From 86b980464bcb67693e2053283c7a99bdc6f358bc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 1 Mar 2024 12:50:17 -0600 Subject: [PATCH] UndoManager Fixes (#25) --- .../TextLayoutManager+Invalidation.swift | 34 +++++++++++ .../TextLayoutManager+Transaction.swift | 38 ++++++++++++ .../TextLayoutManager/TextLayoutManager.swift | 59 +------------------ .../TextView/TextView+ReplaceCharacters.swift | 10 ---- .../Utils/CEUndoManager.swift | 12 +++- 5 files changed, 84 insertions(+), 69 deletions(-) create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift new file mode 100644 index 00000000..6ddb9a30 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift @@ -0,0 +1,34 @@ +// +// TextLayoutManager+Invalidation.swift +// CodeEditTextView +// +// Created by Khan Winter on 2/24/24. +// + +import Foundation + +extension TextLayoutManager { + /// Invalidates layout for the given rect. + /// - Parameter rect: The rect to invalidate. + public func invalidateLayoutForRect(_ rect: NSRect) { + for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) { + linePosition.data.setNeedsLayout() + } + layoutLines() + } + + /// Invalidates layout for the given range of text. + /// - Parameter range: The range of text to invalidate. + public func invalidateLayoutForRange(_ range: NSRange) { + for linePosition in lineStorage.linesInRange(range) { + linePosition.data.setNeedsLayout() + } + + layoutLines() + } + + public func setNeedsLayout() { + needsLayout = true + visibleLineIds.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift new file mode 100644 index 00000000..c160bfd5 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift @@ -0,0 +1,38 @@ +// +// TextLayoutManager+Transaction.swift +// CodeEditTextView +// +// Created by Khan Winter on 2/24/24. +// + +import Foundation + +extension TextLayoutManager { + /// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called. + /// Useful for grouping attribute modifications into one layout pass rather than laying out every update. + /// + /// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction + /// group is ended. + /// + /// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout + /// manager will never lay out text. If there is a end call without matching a start call an assertionFailure + /// will occur. + public func beginTransaction() { + transactionCounter += 1 + } + + /// Ends a transaction. When called, the layout manager will layout any necessary lines. + public func endTransaction(forceLayout: Bool = false) { + transactionCounter -= 1 + if transactionCounter == 0 { + if forceLayout { + setNeedsLayout() + } + layoutLines() + } else if transactionCounter < 0 { + assertionFailure( + "TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call" + ) + } + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 11ce033d..6de10a04 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -71,11 +71,11 @@ public class TextLayoutManager: NSObject { var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() - private var visibleLineIds: Set = [] + package var visibleLineIds: Set = [] /// Used to force a complete re-layout using `setNeedsLayout` - private var needsLayout: Bool = false + package var needsLayout: Bool = false - private var transactionCounter: Int = 0 + package var transactionCounter: Int = 0 public var isInTransaction: Bool { transactionCounter > 0 } @@ -186,59 +186,6 @@ public class TextLayoutManager: NSObject { /// ``TextLayoutManager/estimateLineHeight()`` is called. private var _estimateLineHeight: CGFloat? - // MARK: - Invalidation - - /// Invalidates layout for the given rect. - /// - Parameter rect: The rect to invalidate. - public func invalidateLayoutForRect(_ rect: NSRect) { - for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) { - linePosition.data.setNeedsLayout() - } - layoutLines() - } - - /// Invalidates layout for the given range of text. - /// - Parameter range: The range of text to invalidate. - public func invalidateLayoutForRange(_ range: NSRange) { - for linePosition in lineStorage.linesInRange(range) { - linePosition.data.setNeedsLayout() - } - - layoutLines() - } - - public func setNeedsLayout() { - needsLayout = true - visibleLineIds.removeAll(keepingCapacity: true) - } - - /// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called. - /// Useful for grouping attribute modifications into one layout pass rather than laying out every update. - /// - /// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction - /// group is ended. - /// - /// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout - /// manager will never lay out text. If there is a end call without matching a start call an assertionFailure - /// will occur. - public func beginTransaction() { - transactionCounter += 1 - } - - /// Ends a transaction. When called, the layout manager will layout any necessary lines. - public func endTransaction(forceLayout: Bool = false) { - transactionCounter -= 1 - if transactionCounter == 0 { - if forceLayout { - setNeedsLayout() - } - layoutLines() - } else if transactionCounter < 0 { - // swiftlint:disable:next line_length - assertionFailure("TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call") - } - } - // MARK: - Layout /// Lays out all visible lines diff --git a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift index 333f41a2..0aa7f6a9 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift @@ -21,12 +21,6 @@ extension TextView { layoutManager.beginTransaction() textStorage.beginEditing() - var shouldEndGrouping = false - if !(_undoManager?.isGrouping ?? false) { - _undoManager?.beginGrouping() - shouldEndGrouping = true - } - // Can't insert an empty string into an empty range. One must be not empty for range in ranges.sorted(by: { $0.location > $1.location }) where (!range.isEmpty || !string.isEmpty) && @@ -46,10 +40,6 @@ extension TextView { delegate?.textView(self, didReplaceContentsIn: range, with: string) } - if shouldEndGrouping { - _undoManager?.endGrouping() - } - layoutManager.endTransaction() textStorage.endEditing() selectionManager.notifyAfterEdit() diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index 86eca088..e9ce1223 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -22,6 +22,8 @@ public class CEUndoManager { public class DelegatedUndoManager: UndoManager { weak var parent: CEUndoManager? + public override var isUndoing: Bool { parent?.isUndoing ?? false } + public override var isRedoing: Bool { parent?.isRedoing ?? false } public override var canUndo: Bool { parent?.canUndo ?? false } public override var canRedo: Bool { parent?.canRedo ?? false } @@ -97,9 +99,11 @@ public class CEUndoManager { } isUndoing = true NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self.manager) + textView.textStorage.beginEditing() for mutation in item.mutations.reversed() { textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string) } + textView.textStorage.endEditing() NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager) redoStack.append(item) isUndoing = false @@ -112,9 +116,11 @@ public class CEUndoManager { } isRedoing = true NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self.manager) + textView.textStorage.beginEditing() for mutation in item.mutations { textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string) } + textView.textStorage.endEditing() NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager) undoStack.append(item) isRedoing = false @@ -198,7 +204,7 @@ public class CEUndoManager { // Deleting return ( lastMutation.mutation.range.location == mutation.mutation.range.max - && mutation.inverse.string != "\n" + && LineEnding(line: lastMutation.inverse.string) == nil ) } else { // Inserting @@ -207,14 +213,14 @@ public class CEUndoManager { // If the last mutation was not whitespace, and the new one is, break the group. if lastMutation.mutation.string.count < 1024 && mutation.mutation.string.count < 1024 - && !lastMutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty + && !lastMutation.mutation.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && mutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty { return false } return ( lastMutation.mutation.range.max + 1 == mutation.mutation.range.location - && mutation.mutation.string != "\n" + && LineEnding(line: mutation.mutation.string) == nil ) } }