Skip to content

Commit 86b9804

Browse files
UndoManager Fixes (#25)
1 parent 6653c21 commit 86b9804

File tree

5 files changed

+84
-69
lines changed

5 files changed

+84
-69
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// TextLayoutManager+Invalidation.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 2/24/24.
6+
//
7+
8+
import Foundation
9+
10+
extension TextLayoutManager {
11+
/// Invalidates layout for the given rect.
12+
/// - Parameter rect: The rect to invalidate.
13+
public func invalidateLayoutForRect(_ rect: NSRect) {
14+
for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
15+
linePosition.data.setNeedsLayout()
16+
}
17+
layoutLines()
18+
}
19+
20+
/// Invalidates layout for the given range of text.
21+
/// - Parameter range: The range of text to invalidate.
22+
public func invalidateLayoutForRange(_ range: NSRange) {
23+
for linePosition in lineStorage.linesInRange(range) {
24+
linePosition.data.setNeedsLayout()
25+
}
26+
27+
layoutLines()
28+
}
29+
30+
public func setNeedsLayout() {
31+
needsLayout = true
32+
visibleLineIds.removeAll(keepingCapacity: true)
33+
}
34+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// TextLayoutManager+Transaction.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 2/24/24.
6+
//
7+
8+
import Foundation
9+
10+
extension TextLayoutManager {
11+
/// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called.
12+
/// Useful for grouping attribute modifications into one layout pass rather than laying out every update.
13+
///
14+
/// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction
15+
/// group is ended.
16+
///
17+
/// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout
18+
/// manager will never lay out text. If there is a end call without matching a start call an assertionFailure
19+
/// will occur.
20+
public func beginTransaction() {
21+
transactionCounter += 1
22+
}
23+
24+
/// Ends a transaction. When called, the layout manager will layout any necessary lines.
25+
public func endTransaction(forceLayout: Bool = false) {
26+
transactionCounter -= 1
27+
if transactionCounter == 0 {
28+
if forceLayout {
29+
setNeedsLayout()
30+
}
31+
layoutLines()
32+
} else if transactionCounter < 0 {
33+
assertionFailure(
34+
"TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call"
35+
)
36+
}
37+
}
38+
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ public class TextLayoutManager: NSObject {
7171
var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
7272
var markedTextManager: MarkedTextManager = MarkedTextManager()
7373
private let viewReuseQueue: ViewReuseQueue<LineFragmentView, UUID> = ViewReuseQueue()
74-
private var visibleLineIds: Set<TextLine.ID> = []
74+
package var visibleLineIds: Set<TextLine.ID> = []
7575
/// Used to force a complete re-layout using `setNeedsLayout`
76-
private var needsLayout: Bool = false
76+
package var needsLayout: Bool = false
7777

78-
private var transactionCounter: Int = 0
78+
package var transactionCounter: Int = 0
7979
public var isInTransaction: Bool {
8080
transactionCounter > 0
8181
}
@@ -186,59 +186,6 @@ public class TextLayoutManager: NSObject {
186186
/// ``TextLayoutManager/estimateLineHeight()`` is called.
187187
private var _estimateLineHeight: CGFloat?
188188

189-
// MARK: - Invalidation
190-
191-
/// Invalidates layout for the given rect.
192-
/// - Parameter rect: The rect to invalidate.
193-
public func invalidateLayoutForRect(_ rect: NSRect) {
194-
for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
195-
linePosition.data.setNeedsLayout()
196-
}
197-
layoutLines()
198-
}
199-
200-
/// Invalidates layout for the given range of text.
201-
/// - Parameter range: The range of text to invalidate.
202-
public func invalidateLayoutForRange(_ range: NSRange) {
203-
for linePosition in lineStorage.linesInRange(range) {
204-
linePosition.data.setNeedsLayout()
205-
}
206-
207-
layoutLines()
208-
}
209-
210-
public func setNeedsLayout() {
211-
needsLayout = true
212-
visibleLineIds.removeAll(keepingCapacity: true)
213-
}
214-
215-
/// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called.
216-
/// Useful for grouping attribute modifications into one layout pass rather than laying out every update.
217-
///
218-
/// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction
219-
/// group is ended.
220-
///
221-
/// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout
222-
/// manager will never lay out text. If there is a end call without matching a start call an assertionFailure
223-
/// will occur.
224-
public func beginTransaction() {
225-
transactionCounter += 1
226-
}
227-
228-
/// Ends a transaction. When called, the layout manager will layout any necessary lines.
229-
public func endTransaction(forceLayout: Bool = false) {
230-
transactionCounter -= 1
231-
if transactionCounter == 0 {
232-
if forceLayout {
233-
setNeedsLayout()
234-
}
235-
layoutLines()
236-
} else if transactionCounter < 0 {
237-
// swiftlint:disable:next line_length
238-
assertionFailure("TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call")
239-
}
240-
}
241-
242189
// MARK: - Layout
243190

244191
/// Lays out all visible lines

Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,6 @@ extension TextView {
2121
layoutManager.beginTransaction()
2222
textStorage.beginEditing()
2323

24-
var shouldEndGrouping = false
25-
if !(_undoManager?.isGrouping ?? false) {
26-
_undoManager?.beginGrouping()
27-
shouldEndGrouping = true
28-
}
29-
3024
// Can't insert an empty string into an empty range. One must be not empty
3125
for range in ranges.sorted(by: { $0.location > $1.location }) where
3226
(!range.isEmpty || !string.isEmpty) &&
@@ -46,10 +40,6 @@ extension TextView {
4640
delegate?.textView(self, didReplaceContentsIn: range, with: string)
4741
}
4842

49-
if shouldEndGrouping {
50-
_undoManager?.endGrouping()
51-
}
52-
5343
layoutManager.endTransaction()
5444
textStorage.endEditing()
5545
selectionManager.notifyAfterEdit()

Sources/CodeEditTextView/Utils/CEUndoManager.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public class CEUndoManager {
2222
public class DelegatedUndoManager: UndoManager {
2323
weak var parent: CEUndoManager?
2424

25+
public override var isUndoing: Bool { parent?.isUndoing ?? false }
26+
public override var isRedoing: Bool { parent?.isRedoing ?? false }
2527
public override var canUndo: Bool { parent?.canUndo ?? false }
2628
public override var canRedo: Bool { parent?.canRedo ?? false }
2729

@@ -97,9 +99,11 @@ public class CEUndoManager {
9799
}
98100
isUndoing = true
99101
NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self.manager)
102+
textView.textStorage.beginEditing()
100103
for mutation in item.mutations.reversed() {
101104
textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string)
102105
}
106+
textView.textStorage.endEditing()
103107
NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager)
104108
redoStack.append(item)
105109
isUndoing = false
@@ -112,9 +116,11 @@ public class CEUndoManager {
112116
}
113117
isRedoing = true
114118
NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self.manager)
119+
textView.textStorage.beginEditing()
115120
for mutation in item.mutations {
116121
textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string)
117122
}
123+
textView.textStorage.endEditing()
118124
NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager)
119125
undoStack.append(item)
120126
isRedoing = false
@@ -198,7 +204,7 @@ public class CEUndoManager {
198204
// Deleting
199205
return (
200206
lastMutation.mutation.range.location == mutation.mutation.range.max
201-
&& mutation.inverse.string != "\n"
207+
&& LineEnding(line: lastMutation.inverse.string) == nil
202208
)
203209
} else {
204210
// Inserting
@@ -207,14 +213,14 @@ public class CEUndoManager {
207213
// If the last mutation was not whitespace, and the new one is, break the group.
208214
if lastMutation.mutation.string.count < 1024
209215
&& mutation.mutation.string.count < 1024
210-
&& !lastMutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty
216+
&& !lastMutation.mutation.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
211217
&& mutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty {
212218
return false
213219
}
214220

215221
return (
216222
lastMutation.mutation.range.max + 1 == mutation.mutation.range.location
217-
&& mutation.mutation.string != "\n"
223+
&& LineEnding(line: mutation.mutation.string) == nil
218224
)
219225
}
220226
}

0 commit comments

Comments
 (0)