Skip to content

Commit f1276bd

Browse files
committed
Move Text Layout, Selection Edits to Storage Delegates
1 parent 82bc832 commit f1276bd

File tree

9 files changed

+96
-115
lines changed

9 files changed

+96
-115
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,50 @@ import AppKit
1010
// MARK: - Edits
1111

1212
extension TextLayoutManager: NSTextStorageDelegate {
13-
/// Notifies the layout manager of an edit.
13+
/// Receives edit notifications from the text storage and updates internal data structures to stay in sync with
14+
/// text content.
1415
///
15-
/// Used by the `TextView` to tell the layout manager about any edits that will happen.
16-
/// Use this to keep the layout manager's line storage in sync with the text storage.
16+
/// If the changes are only attribute changes, this method invalidates layout for the edited range and returns.
1717
///
18-
/// - Parameters:
19-
/// - range: The range of the edit.
20-
/// - string: The string to replace in the given range.
21-
public func willReplaceCharactersInRange(range: NSRange, with string: String) {
18+
/// Otherwise, any lines that were removed or replaced by the edit are first removed from the text line layout
19+
/// storage. Then, any new lines are inserted into the same storage.
20+
///
21+
/// For instance, if inserting a newline this method will:
22+
/// - Remove no lines (none were replaced)
23+
/// - Update the current line's range to contain the newline character.
24+
/// - Insert a new line after the current line.
25+
///
26+
/// If a selection containing a newline is deleted and replaced with two more newlines this method will:
27+
/// - Delete the original line.
28+
/// - Insert two lines.
29+
///
30+
/// - Note: This method *does not* cause a layout calculation. If a method is finding `NaN` values for line
31+
/// fragments, ensure `layout` or `ensureLayoutUntil` are called on the subject ranges.
32+
public func textStorage(
33+
_ textStorage: NSTextStorage,
34+
didProcessEditing editedMask: NSTextStorageEditActions,
35+
range editedRange: NSRange,
36+
changeInLength delta: Int
37+
) {
38+
guard editedMask.contains(.editedCharacters) else {
39+
if editedMask.contains(.editedAttributes) && delta == 0 {
40+
invalidateLayoutForRange(editedRange)
41+
}
42+
return
43+
}
44+
45+
let insertedStringRange = NSRange(location: editedRange.location, length: editedRange.length - delta)
46+
removeLayoutLinesIn(range: insertedStringRange)
47+
insertNewLines(for: editedRange)
48+
invalidateLayoutForRange(editedRange)
49+
}
50+
51+
/// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an
52+
/// edit.
53+
/// - Parameter range: The range that was deleted.
54+
private func removeLayoutLinesIn(range: NSRange) {
2255
// Loop through each line being replaced in reverse, updating and removing where necessary.
23-
for linePosition in lineStorage.linesInRange(range).reversed() {
56+
for linePosition in lineStorage.linesInRange(range).reversed() {
2457
// Two cases: Updated line, deleted line entirely
2558
guard let intersection = linePosition.range.intersection(range), !intersection.isEmpty else { continue }
2659
if intersection == linePosition.range && linePosition.range.max != lineStorage.length {
@@ -38,25 +71,24 @@ extension TextLayoutManager: NSTextStorageDelegate {
3871
lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0)
3972
}
4073
}
41-
74+
}
75+
76+
/// Inserts any newly inserted lines into the line layout storage. Exits early if the range is empty.
77+
/// - Parameter range: The range of the string that was inserted into the text storage.
78+
private func insertNewLines(for range: NSRange) {
79+
guard !range.isEmpty, let string = textStorage?.substring(from: range) as? NSString else { return }
4280
// Loop through each line being inserted, inserting & splitting where necessary
43-
if !string.isEmpty {
44-
var index = 0
45-
while let nextLine = (string as NSString).getNextLine(startingAt: index) {
46-
let lineRange = NSRange(start: index, end: nextLine.max)
47-
applyLineInsert((string as NSString).substring(with: lineRange) as NSString, at: range.location + index)
48-
index = nextLine.max
49-
}
81+
var index = 0
82+
while let nextLine = string.getNextLine(startingAt: index) {
83+
let lineRange = NSRange(start: index, end: nextLine.max)
84+
applyLineInsert(string.substring(with: lineRange) as NSString, at: range.location + index)
85+
index = nextLine.max
86+
}
5087

51-
if index < (string as NSString).length {
52-
// Get the last line.
53-
applyLineInsert(
54-
(string as NSString).substring(from: index) as NSString,
55-
at: range.location + index
56-
)
57-
}
88+
if index < string.length {
89+
// Get the last line.
90+
applyLineInsert(string.substring(from: index) as NSString, at: range.location + index)
5891
}
59-
setNeedsLayout()
6092
}
6193

6294
/// Applies a line insert to the internal line storage tree.
@@ -96,18 +128,4 @@ extension TextLayoutManager: NSTextStorageDelegate {
96128
lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0)
97129
}
98130
}
99-
100-
/// This method is to simplify keeping the layout manager in sync with attribute changes in the storage object.
101-
/// This does not handle cases where characters have been inserted or removed from the storage.
102-
/// For that, see the `willPerformEdit` method.
103-
public func textStorage(
104-
_ textStorage: NSTextStorage,
105-
didProcessEditing editedMask: NSTextStorageEditActions,
106-
range editedRange: NSRange,
107-
changeInLength delta: Int
108-
) {
109-
if editedMask.contains(.editedAttributes) && delta == 0 {
110-
invalidateLayoutForRange(editedRange)
111-
}
112-
}
113131
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ extension TextLayoutManager {
121121
return nil
122122
}
123123
if linePosition.data.lineFragments.isEmpty {
124-
let newHeight = ensureLayoutFor(position: linePosition)
124+
let newHeight = preparePositionForDisplay(linePosition)
125125
if linePosition.height != newHeight {
126126
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
127127
}
@@ -165,7 +165,8 @@ extension TextLayoutManager {
165165
/// - line: The line to calculate rects for.
166166
/// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range.
167167
public func rectsFor(range: NSRange) -> [CGRect] {
168-
lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) }
168+
ensureLayoutUntil(range.max)
169+
return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) }
169170
}
170171

171172
/// Calculates all text bounding rects that intersect with a given range, with a given line position.
@@ -176,6 +177,8 @@ extension TextLayoutManager {
176177
private func rectsFor(range: NSRange, in line: borrowing TextLineStorage<TextLine>.TextLinePosition) -> [CGRect] {
177178
guard let textStorage = (textStorage?.string as? NSString) else { return [] }
178179

180+
_ = preparePositionForDisplay(line)
181+
179182
// Don't make rects in between characters
180183
let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: range.lowerBound)
181184
let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: range.upperBound - 1)
@@ -239,6 +242,8 @@ extension TextLayoutManager {
239242
// Combine the points in clockwise order
240243
let points = leftSidePoints + rightSidePoints
241244

245+
guard points.allSatisfy({ $0.x.isFinite && $0.y.isFinite }) else { return nil }
246+
242247
// Close the path
243248
if let firstPoint = points.first {
244249
return NSBezierPath.smoothPath(points + [firstPoint], radius: cornerRadius)
@@ -286,7 +291,7 @@ extension TextLayoutManager {
286291
for linePosition in lineStorage.linesInRange(
287292
NSRange(start: startingLinePosition.range.location, end: linePosition.range.max)
288293
) {
289-
let height = ensureLayoutFor(position: linePosition)
294+
let height = preparePositionForDisplay(linePosition)
290295
if height != linePosition.height {
291296
lineStorage.update(
292297
atIndex: linePosition.range.location,

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift

Lines changed: 0 additions & 38 deletions
This file was deleted.

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
import Foundation
99

1010
extension TextLayoutManager {
11-
/// Forces layout calculation for all lines up to and including the given offset.
12-
/// - Parameter offset: The offset to ensure layout until.
13-
package func ensureLayoutFor(position: TextLineStorage<TextLine>.TextLinePosition) -> CGFloat {
11+
/// Invalidates and prepares a line position for display.
12+
/// - Parameter position: The line position to prepare.
13+
/// - Returns: The height of the newly laid out line and all it's fragments.
14+
package func preparePositionForDisplay(_ position: TextLineStorage<TextLine>.TextLinePosition) -> CGFloat {
1415
guard let textStorage else { return 0 }
1516
let displayData = TextLine.DisplayData(
1617
maxWidth: maxLineLayoutWidth,

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,15 @@ public class TextLayoutManager: NSObject {
211211

212212
/// Lays out all visible lines
213213
func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
214-
assertNotInLayout()
214+
// assertNotInLayout()
215215
guard let visibleRect = rect ?? delegate?.visibleRect,
216216
!isInTransaction,
217217
let textStorage else {
218218
return
219219
}
220-
#if DEBUG
221-
isInLayout = true
222-
#endif
220+
// #if DEBUG
221+
// isInLayout = true
222+
// #endif
223223
let minY = max(visibleRect.minY - verticalLayoutPadding, 0)
224224
let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0)
225225
let originalHeight = lineStorage.height
@@ -271,9 +271,9 @@ public class TextLayoutManager: NSObject {
271271
// Update the visible lines with the new set.
272272
visibleLineIds = newVisibleLines
273273

274-
#if DEBUG
275-
isInLayout = false
276-
#endif
274+
// #if DEBUG
275+
// isInLayout = false
276+
// #endif
277277

278278
// These are fine to update outside of `isInLayout` as our internal data structures are finalized at this point
279279
// so laying out again won't break our line storage or visible line.

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,24 @@
66
//
77

88
import Foundation
9+
import AppKit
10+
11+
extension TextSelectionManager: NSTextStorageDelegate {
12+
public func textStorage(
13+
_ textStorage: NSTextStorage,
14+
didProcessEditing editedMask: NSTextStorageEditActions,
15+
range editedRange: NSRange,
16+
changeInLength delta: Int
17+
) {
18+
guard editedMask.contains(.editedCharacters) else { return }
919

10-
extension TextSelectionManager {
11-
public func didReplaceCharacters(in range: NSRange, replacementLength: Int) {
12-
let delta = replacementLength == 0 ? -range.length : replacementLength
1320
for textSelection in self.textSelections {
14-
if textSelection.range.location > range.max {
15-
textSelection.range.location = max(0, textSelection.range.location + delta)
21+
// If the text selection is ahead of the edited range, move it back by the range's length
22+
if textSelection.range.location > editedRange.max {
23+
textSelection.range.location += delta
1624
textSelection.range.length = 0
17-
} else if textSelection.range.intersection(range) != nil
18-
|| textSelection.range == range
19-
|| (textSelection.range.isEmpty && textSelection.range.location == range.max) {
20-
if replacementLength > 0 {
21-
textSelection.range.location = range.location + replacementLength
22-
} else {
23-
textSelection.range.location = range.location
24-
}
25+
} else if textSelection.range.intersection(editedRange) != nil {
26+
textSelection.range.location = editedRange.max
2527
textSelection.range.length = 0
2628
} else {
2729
textSelection.range.length = 0
@@ -37,6 +39,8 @@ extension TextSelectionManager {
3739
allRanges.insert(selection.range)
3840
}
3941
}
42+
43+
notifyAfterEdit()
4044
}
4145

4246
func notifyAfterEdit() {

Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ extension TextView {
1616
public func replaceCharacters(in ranges: [NSRange], with string: String) {
1717
guard isEditable else { return }
1818
NotificationCenter.default.post(name: Self.textWillChangeNotification, object: self)
19-
layoutManager.beginTransaction()
2019
textStorage.beginEditing()
2120

2221
// Can't insert an empty string into an empty range. One must be not empty
@@ -25,21 +24,18 @@ extension TextView {
2524
(delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) {
2625
delegate?.textView(self, willReplaceContentsIn: range, with: string)
2726

28-
layoutManager.willReplaceCharactersInRange(range: range, with: string)
2927
_undoManager?.registerMutation(
3028
TextMutation(string: string as String, range: range, limit: textStorage.length)
3129
)
3230
textStorage.replaceCharacters(
3331
in: range,
3432
with: NSAttributedString(string: string, attributes: typingAttributes)
3533
)
36-
selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length)
3734

3835
delegate?.textView(self, didReplaceContentsIn: range, with: string)
3936
}
4037

4138
textStorage.endEditing()
42-
layoutManager.endTransaction()
4339
selectionManager.notifyAfterEdit()
4440
NotificationCenter.default.post(name: Self.textDidChangeNotification, object: self)
4541

Sources/CodeEditTextView/TextView/TextView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ public class TextView: NSView, NSTextContent {
5858
textStorage.string
5959
}
6060
set {
61-
layoutManager.willReplaceCharactersInRange(range: documentRange, with: newValue)
6261
textStorage.setAttributedString(NSAttributedString(string: newValue, attributes: typingAttributes))
6362
}
6463
}
@@ -339,8 +338,10 @@ public class TextView: NSView, NSTextContent {
339338

340339
layoutManager = setUpLayoutManager(lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines)
341340
storageDelegate.addDelegate(layoutManager)
341+
342342
selectionManager = setUpSelectionManager()
343343
selectionManager.useSystemCursor = useSystemCursor
344+
storageDelegate.addDelegate(selectionManager)
344345

345346
_undoManager = CEUndoManager(textView: self)
346347

Sources/CodeEditTextView/Utils/CEUndoManager.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,19 +173,13 @@ public class CEUndoManager {
173173

174174
/// Groups all incoming mutations.
175175
public func beginUndoGrouping() {
176-
guard !isGrouping else {
177-
assertionFailure("UndoManager already in a group. Call `beginUndoGrouping` before this can be called.")
178-
return
179-
}
176+
guard !isGrouping else { return }
180177
isGrouping = true
181178
}
182179

183180
/// Stops grouping all incoming mutations.
184181
public func endUndoGrouping() {
185-
guard isGrouping else {
186-
assertionFailure("UndoManager not in a group. Call `endUndoGrouping` before this can be called.")
187-
return
188-
}
182+
guard isGrouping else { return }
189183
isGrouping = false
190184
}
191185

0 commit comments

Comments
 (0)