From f1276bdcec5f270cee83b72fb38837e2ea9f8cf8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:53:11 -0500 Subject: [PATCH 1/8] Move Text Layout, Selection Edits to Storage Delegates --- .../TextLayoutManager+Edits.swift | 94 +++++++++++-------- .../TextLayoutManager+Public.swift | 11 ++- .../TextLayoutManager+Transaction.swift | 38 -------- .../TextLayoutManager+ensureLayout.swift | 7 +- .../TextLayoutManager/TextLayoutManager.swift | 14 +-- .../TextSelectionManager+Update.swift | 30 +++--- .../TextView/TextView+ReplaceCharacters.swift | 4 - .../CodeEditTextView/TextView/TextView.swift | 3 +- .../Utils/CEUndoManager.swift | 10 +- 9 files changed, 96 insertions(+), 115 deletions(-) delete mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index 8219bf162..968e7389d 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -10,17 +10,50 @@ import AppKit // MARK: - Edits extension TextLayoutManager: NSTextStorageDelegate { - /// Notifies the layout manager of an edit. + /// Receives edit notifications from the text storage and updates internal data structures to stay in sync with + /// text content. /// - /// Used by the `TextView` to tell the layout manager about any edits that will happen. - /// Use this to keep the layout manager's line storage in sync with the text storage. + /// If the changes are only attribute changes, this method invalidates layout for the edited range and returns. /// - /// - Parameters: - /// - range: The range of the edit. - /// - string: The string to replace in the given range. - public func willReplaceCharactersInRange(range: NSRange, with string: String) { + /// Otherwise, any lines that were removed or replaced by the edit are first removed from the text line layout + /// storage. Then, any new lines are inserted into the same storage. + /// + /// For instance, if inserting a newline this method will: + /// - Remove no lines (none were replaced) + /// - Update the current line's range to contain the newline character. + /// - Insert a new line after the current line. + /// + /// If a selection containing a newline is deleted and replaced with two more newlines this method will: + /// - Delete the original line. + /// - Insert two lines. + /// + /// - Note: This method *does not* cause a layout calculation. If a method is finding `NaN` values for line + /// fragments, ensure `layout` or `ensureLayoutUntil` are called on the subject ranges. + public func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { + if editedMask.contains(.editedAttributes) && delta == 0 { + invalidateLayoutForRange(editedRange) + } + return + } + + let insertedStringRange = NSRange(location: editedRange.location, length: editedRange.length - delta) + removeLayoutLinesIn(range: insertedStringRange) + insertNewLines(for: editedRange) + invalidateLayoutForRange(editedRange) + } + + /// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an + /// edit. + /// - Parameter range: The range that was deleted. + private func removeLayoutLinesIn(range: NSRange) { // Loop through each line being replaced in reverse, updating and removing where necessary. - for linePosition in lineStorage.linesInRange(range).reversed() { + for linePosition in lineStorage.linesInRange(range).reversed() { // Two cases: Updated line, deleted line entirely guard let intersection = linePosition.range.intersection(range), !intersection.isEmpty else { continue } if intersection == linePosition.range && linePosition.range.max != lineStorage.length { @@ -38,25 +71,24 @@ extension TextLayoutManager: NSTextStorageDelegate { lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0) } } - + } + + /// Inserts any newly inserted lines into the line layout storage. Exits early if the range is empty. + /// - Parameter range: The range of the string that was inserted into the text storage. + private func insertNewLines(for range: NSRange) { + guard !range.isEmpty, let string = textStorage?.substring(from: range) as? NSString else { return } // Loop through each line being inserted, inserting & splitting where necessary - if !string.isEmpty { - var index = 0 - while let nextLine = (string as NSString).getNextLine(startingAt: index) { - let lineRange = NSRange(start: index, end: nextLine.max) - applyLineInsert((string as NSString).substring(with: lineRange) as NSString, at: range.location + index) - index = nextLine.max - } + var index = 0 + while let nextLine = string.getNextLine(startingAt: index) { + let lineRange = NSRange(start: index, end: nextLine.max) + applyLineInsert(string.substring(with: lineRange) as NSString, at: range.location + index) + index = nextLine.max + } - if index < (string as NSString).length { - // Get the last line. - applyLineInsert( - (string as NSString).substring(from: index) as NSString, - at: range.location + index - ) - } + if index < string.length { + // Get the last line. + applyLineInsert(string.substring(from: index) as NSString, at: range.location + index) } - setNeedsLayout() } /// Applies a line insert to the internal line storage tree. @@ -96,18 +128,4 @@ extension TextLayoutManager: NSTextStorageDelegate { lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) } } - - /// This method is to simplify keeping the layout manager in sync with attribute changes in the storage object. - /// This does not handle cases where characters have been inserted or removed from the storage. - /// For that, see the `willPerformEdit` method. - public func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - if editedMask.contains(.editedAttributes) && delta == 0 { - invalidateLayoutForRange(editedRange) - } - } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index f53e14f85..07cbcb00e 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -121,7 +121,7 @@ extension TextLayoutManager { return nil } if linePosition.data.lineFragments.isEmpty { - let newHeight = ensureLayoutFor(position: linePosition) + let newHeight = preparePositionForDisplay(linePosition) if linePosition.height != newHeight { delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) } @@ -165,7 +165,8 @@ extension TextLayoutManager { /// - line: The line to calculate rects for. /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. public func rectsFor(range: NSRange) -> [CGRect] { - lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } + ensureLayoutUntil(range.max) + return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } } /// Calculates all text bounding rects that intersect with a given range, with a given line position. @@ -176,6 +177,8 @@ extension TextLayoutManager { private func rectsFor(range: NSRange, in line: borrowing TextLineStorage.TextLinePosition) -> [CGRect] { guard let textStorage = (textStorage?.string as? NSString) else { return [] } + _ = preparePositionForDisplay(line) + // Don't make rects in between characters let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: range.lowerBound) let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: range.upperBound - 1) @@ -239,6 +242,8 @@ extension TextLayoutManager { // Combine the points in clockwise order let points = leftSidePoints + rightSidePoints + guard points.allSatisfy({ $0.x.isFinite && $0.y.isFinite }) else { return nil } + // Close the path if let firstPoint = points.first { return NSBezierPath.smoothPath(points + [firstPoint], radius: cornerRadius) @@ -286,7 +291,7 @@ extension TextLayoutManager { for linePosition in lineStorage.linesInRange( NSRange(start: startingLinePosition.range.location, end: linePosition.range.max) ) { - let height = ensureLayoutFor(position: linePosition) + let height = preparePositionForDisplay(linePosition) if height != linePosition.height { lineStorage.update( atIndex: linePosition.range.location, diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift deleted file mode 100644 index c160bfd57..000000000 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Transaction.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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+ensureLayout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift index e0e5fa07d..e2cf08d15 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift @@ -8,9 +8,10 @@ import Foundation extension TextLayoutManager { - /// Forces layout calculation for all lines up to and including the given offset. - /// - Parameter offset: The offset to ensure layout until. - package func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { + /// Invalidates and prepares a line position for display. + /// - Parameter position: The line position to prepare. + /// - Returns: The height of the newly laid out line and all it's fragments. + package func preparePositionForDisplay(_ position: TextLineStorage.TextLinePosition) -> CGFloat { guard let textStorage else { return 0 } let displayData = TextLine.DisplayData( maxWidth: maxLineLayoutWidth, diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 3e042225c..bf8782df9 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -211,15 +211,15 @@ public class TextLayoutManager: NSObject { /// Lays out all visible lines func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length - assertNotInLayout() +// assertNotInLayout() guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { return } - #if DEBUG - isInLayout = true - #endif +// #if DEBUG +// isInLayout = true +// #endif let minY = max(visibleRect.minY - verticalLayoutPadding, 0) let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) let originalHeight = lineStorage.height @@ -271,9 +271,9 @@ public class TextLayoutManager: NSObject { // Update the visible lines with the new set. visibleLineIds = newVisibleLines - #if DEBUG - isInLayout = false - #endif +// #if DEBUG +// isInLayout = false +// #endif // These are fine to update outside of `isInLayout` as our internal data structures are finalized at this point // so laying out again won't break our line storage or visible line. diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift index 8ea0c7490..384aaba4b 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift @@ -6,22 +6,24 @@ // import Foundation +import AppKit + +extension TextSelectionManager: NSTextStorageDelegate { + public func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { return } -extension TextSelectionManager { - public func didReplaceCharacters(in range: NSRange, replacementLength: Int) { - let delta = replacementLength == 0 ? -range.length : replacementLength for textSelection in self.textSelections { - if textSelection.range.location > range.max { - textSelection.range.location = max(0, textSelection.range.location + delta) + // If the text selection is ahead of the edited range, move it back by the range's length + if textSelection.range.location > editedRange.max { + textSelection.range.location += delta textSelection.range.length = 0 - } else if textSelection.range.intersection(range) != nil - || textSelection.range == range - || (textSelection.range.isEmpty && textSelection.range.location == range.max) { - if replacementLength > 0 { - textSelection.range.location = range.location + replacementLength - } else { - textSelection.range.location = range.location - } + } else if textSelection.range.intersection(editedRange) != nil { + textSelection.range.location = editedRange.max textSelection.range.length = 0 } else { textSelection.range.length = 0 @@ -37,6 +39,8 @@ extension TextSelectionManager { allRanges.insert(selection.range) } } + + notifyAfterEdit() } func notifyAfterEdit() { diff --git a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift index 06949b21a..b18c80f7a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift @@ -16,7 +16,6 @@ extension TextView { public func replaceCharacters(in ranges: [NSRange], with string: String) { guard isEditable else { return } NotificationCenter.default.post(name: Self.textWillChangeNotification, object: self) - layoutManager.beginTransaction() textStorage.beginEditing() // Can't insert an empty string into an empty range. One must be not empty @@ -25,7 +24,6 @@ extension TextView { (delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) { delegate?.textView(self, willReplaceContentsIn: range, with: string) - layoutManager.willReplaceCharactersInRange(range: range, with: string) _undoManager?.registerMutation( TextMutation(string: string as String, range: range, limit: textStorage.length) ) @@ -33,13 +31,11 @@ extension TextView { in: range, with: NSAttributedString(string: string, attributes: typingAttributes) ) - selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length) delegate?.textView(self, didReplaceContentsIn: range, with: string) } textStorage.endEditing() - layoutManager.endTransaction() selectionManager.notifyAfterEdit() NotificationCenter.default.post(name: Self.textDidChangeNotification, object: self) diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 85b2e11b0..73e08b52a 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -58,7 +58,6 @@ public class TextView: NSView, NSTextContent { textStorage.string } set { - layoutManager.willReplaceCharactersInRange(range: documentRange, with: newValue) textStorage.setAttributedString(NSAttributedString(string: newValue, attributes: typingAttributes)) } } @@ -339,8 +338,10 @@ public class TextView: NSView, NSTextContent { layoutManager = setUpLayoutManager(lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines) storageDelegate.addDelegate(layoutManager) + selectionManager = setUpSelectionManager() selectionManager.useSystemCursor = useSystemCursor + storageDelegate.addDelegate(selectionManager) _undoManager = CEUndoManager(textView: self) diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index c716d49b4..0aaaf842f 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -173,19 +173,13 @@ public class CEUndoManager { /// Groups all incoming mutations. public func beginUndoGrouping() { - guard !isGrouping else { - assertionFailure("UndoManager already in a group. Call `beginUndoGrouping` before this can be called.") - return - } + guard !isGrouping else { return } isGrouping = true } /// Stops grouping all incoming mutations. public func endUndoGrouping() { - guard isGrouping else { - assertionFailure("UndoManager not in a group. Call `endUndoGrouping` before this can be called.") - return - } + guard isGrouping else { return } isGrouping = false } From c7ecbde1a071a6d778121a1d78ac8c28cec66e92 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:59:04 -0500 Subject: [PATCH 2/8] Add `TextLayoutManager` Tests --- .../TextLayoutManager+Edits.swift | 4 +- .../TextLayoutManagerTests.swift | 102 ++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index 968e7389d..1c3d97240 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -72,7 +72,7 @@ extension TextLayoutManager: NSTextStorageDelegate { } } } - + /// Inserts any newly inserted lines into the line layout storage. Exits early if the range is empty. /// - Parameter range: The range of the string that was inserted into the text storage. private func insertNewLines(for range: NSRange) { @@ -97,7 +97,7 @@ extension TextLayoutManager: NSTextStorageDelegate { /// - location: The location the string is being inserted into. private func applyLineInsert(_ insertedString: NSString, at location: Int) { if LineEnding(line: insertedString as String) != nil { - if location == textStorage?.length ?? 0 { + if location == lineStorage.length { // Insert a new line at the end of the document, need to insert a new line 'cause there's nothing to // split. Also, append the new text to the last line. lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) diff --git a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift new file mode 100644 index 000000000..164869250 --- /dev/null +++ b/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift @@ -0,0 +1,102 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +extension TextLineStorage { + /// Validate that the internal tree is intact and correct. + /// + /// Ensures that: + /// - All lines can be queried by their index starting from `0`. + /// - All lines can be found by iterating `y` positions. + func validateInternalState() { + func validateLines(_ lines: [TextLineStorage.TextLinePosition]) { + var _lastLine: TextLineStorage.TextLinePosition? + for line in lines { + guard let lastLine = _lastLine else { + #expect(line.index == 0) + _lastLine = line + return + } + + #expect(line.index == lastLine.index + 1) + #expect(line.yPos >= lastLine.yPos + lastLine.height) + #expect(line.range.location == lastLine.range.max + 1) + _lastLine = line + } + } + + let linesUsingIndex = (0.. Date: Wed, 9 Apr 2025 10:19:40 -0500 Subject: [PATCH 3/8] Fix `rectsFor(range:line:)` Removing Layout Info --- .../TextLayoutManager/TextLayoutManager+Public.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 07cbcb00e..6ba0c0b5e 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -177,8 +177,6 @@ extension TextLayoutManager { private func rectsFor(range: NSRange, in line: borrowing TextLineStorage.TextLinePosition) -> [CGRect] { guard let textStorage = (textStorage?.string as? NSString) else { return [] } - _ = preparePositionForDisplay(line) - // Don't make rects in between characters let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: range.lowerBound) let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: range.upperBound - 1) From c45d93e61ba2a681294ca619a00314fde0f73a2b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:19:53 -0500 Subject: [PATCH 4/8] Revert SelectionManager Changes --- .../TextSelectionManager+Update.swift | 30 +++++++++---------- .../TextView/TextView+ReplaceCharacters.swift | 1 + .../CodeEditTextView/TextView/TextView.swift | 1 - 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift index 384aaba4b..338e166cb 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift @@ -8,22 +8,22 @@ import Foundation import AppKit -extension TextSelectionManager: NSTextStorageDelegate { - public func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - guard editedMask.contains(.editedCharacters) else { return } - +extension TextSelectionManager { + public func didReplaceCharacters(in range: NSRange, replacementLength: Int) { + let delta = replacementLength == 0 ? -range.length : replacementLength for textSelection in self.textSelections { - // If the text selection is ahead of the edited range, move it back by the range's length - if textSelection.range.location > editedRange.max { - textSelection.range.location += delta + if textSelection.range.location > range.max { + textSelection.range.location = max(0, textSelection.range.location + delta) + textSelection.range.length = 0 - } else if textSelection.range.intersection(editedRange) != nil { - textSelection.range.location = editedRange.max + } else if textSelection.range.intersection(range) != nil + || textSelection.range == range + || (textSelection.range.isEmpty && textSelection.range.location == range.max) { + if replacementLength > 0 { + textSelection.range.location = range.location + replacementLength + } else { + textSelection.range.location = range.location + } textSelection.range.length = 0 } else { textSelection.range.length = 0 @@ -39,8 +39,6 @@ extension TextSelectionManager: NSTextStorageDelegate { allRanges.insert(selection.range) } } - - notifyAfterEdit() } func notifyAfterEdit() { diff --git a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift index b18c80f7a..951e9977e 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift @@ -31,6 +31,7 @@ extension TextView { in: range, with: NSAttributedString(string: string, attributes: typingAttributes) ) + selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length) delegate?.textView(self, didReplaceContentsIn: range, with: string) } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 73e08b52a..9b0337f9e 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -341,7 +341,6 @@ public class TextView: NSView, NSTextContent { selectionManager = setUpSelectionManager() selectionManager.useSystemCursor = useSystemCursor - storageDelegate.addDelegate(selectionManager) _undoManager = CEUndoManager(textView: self) From 7b56c7c376547d4f257604c40b0307631201d335 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:42:13 -0500 Subject: [PATCH 5/8] Add Invalidated Layout Test, Delegate Editing Test, Remove Layout Manager Assertion --- .../TextLayoutManager/TextLayoutManager.swift | 16 ------- .../EmphasisManagerTests.swift | 3 +- .../TextLayoutManagerTests.swift | 31 +++++++++++++ .../CodeEditTextViewTests/TextViewTests.swift | 43 +++++++++++++++++++ 4 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 Tests/CodeEditTextViewTests/TextViewTests.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index bf8782df9..b281c0ce3 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -201,25 +201,13 @@ public class TextLayoutManager: NSObject { // MARK: - Layout - /// Asserts that the caller is not in an active layout pass. - /// See docs on ``isInLayout`` for more details. - private func assertNotInLayout() { - #if DEBUG // This is redundant, but it keeps the flag debug-only too which helps prevent misuse. - assert(!isInLayout, "layoutLines called while already in a layout pass. This is a programmer error.") - #endif - } - /// Lays out all visible lines func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length -// assertNotInLayout() guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { return } -// #if DEBUG -// isInLayout = true -// #endif let minY = max(visibleRect.minY - verticalLayoutPadding, 0) let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) let originalHeight = lineStorage.height @@ -271,10 +259,6 @@ public class TextLayoutManager: NSObject { // Update the visible lines with the new set. visibleLineIds = newVisibleLines -// #if DEBUG -// isInLayout = false -// #endif - // These are fine to update outside of `isInLayout` as our internal data structures are finalized at this point // so laying out again won't break our line storage or visible line. diff --git a/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift index a921f1051..4cdf8468b 100644 --- a/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift +++ b/Tests/CodeEditTextViewTests/EmphasisManagerTests.swift @@ -4,7 +4,8 @@ import Foundation @Suite() struct EmphasisManagerTests { - @Test() @MainActor + @Test() + @MainActor func testFlashEmphasisLayersNotLeaked() { // Ensure layers are not leaked when switching from flash emphasis to any other emphasis type. let textView = TextView(string: "Lorem Ipsum") diff --git a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift index 164869250..6eb1d97c7 100644 --- a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift @@ -42,6 +42,7 @@ struct TextLayoutManagerTests { init() throws { textView = TextView(string: "A\nB\nC\nD") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) textStorage = textView.textStorage layoutManager = try #require(textView.layoutManager) } @@ -99,4 +100,34 @@ struct TextLayoutManagerTests { #expect(layoutManager.lineStorage.length == textStorage.length) layoutManager.lineStorage.validateInternalState() } + + /// # 04/09/25 + /// This ensures that getting line rect info does not invalidate layout. The issue was previously caused by a call to + /// ``TextLayoutManager/preparePositionForDisplay``. + @Test + func getRectsDoesNotRemoveLayoutInfo() { + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + let lineFragmentIDs = Set( + layoutManager.lineStorage + .linesInRange(NSRange(location: 0, length: 7)) + .flatMap(\.data.lineFragments) + .map(\.data.id) + ) + + _ = layoutManager.rectsFor(range: NSRange(start: 0, end: 7)) + + #expect( + layoutManager.lineStorage.linesInRange(NSRange(location: 0, length: 7)).allSatisfy({ position in + !position.data.lineFragments.isEmpty + }) + ) + let afterLineFragmentIDs = Set( + layoutManager.lineStorage + .linesInRange(NSRange(location: 0, length: 7)) + .flatMap(\.data.lineFragments) + .map(\.data.id) + ) + #expect(lineFragmentIDs == afterLineFragmentIDs, "Line fragments were invalidated by `rectsFor(range:)` call.") + layoutManager.lineStorage.validateInternalState() + } } diff --git a/Tests/CodeEditTextViewTests/TextViewTests.swift b/Tests/CodeEditTextViewTests/TextViewTests.swift new file mode 100644 index 000000000..da5056d27 --- /dev/null +++ b/Tests/CodeEditTextViewTests/TextViewTests.swift @@ -0,0 +1,43 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +@Suite +@MainActor +struct TextViewTests { + class MockDelegate: TextViewDelegate { + var shouldReplaceContents: ((_ textView: TextView, _ range: NSRange, _ string: String) -> Bool)? + + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { + shouldReplaceContents?(textView, range, string) ?? true + } + } + + let textView: TextView + let delegate: MockDelegate + + init() { + textView = TextView(string: "Lorem Ipsum") + delegate = MockDelegate() + textView.delegate = delegate + } + + @Test + func delegateChangesText() { + var hasReplaced = false + delegate.shouldReplaceContents = { textView, _, _ -> Bool in + if !hasReplaced { + hasReplaced.toggle() + textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: " World ") + } + + return true + } + + textView.replaceCharacters(in: NSRange(location: 0, length: 0), with: "Hello") + + #expect(textView.string == "Hello World Lorem Ipsum") + // available in test module + textView.layoutManager.lineStorage.validateInternalState() + } +} From 13a455ef64453e65c7fe0138963e5eec7188e5ca Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:44:57 -0500 Subject: [PATCH 6/8] Lint Error --- Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift index 6eb1d97c7..1dcc9a7dd 100644 --- a/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/TextLayoutManagerTests.swift @@ -102,8 +102,8 @@ struct TextLayoutManagerTests { } /// # 04/09/25 - /// This ensures that getting line rect info does not invalidate layout. The issue was previously caused by a call to - /// ``TextLayoutManager/preparePositionForDisplay``. + /// This ensures that getting line rect info does not invalidate layout. The issue was previously caused by a + /// call to ``TextLayoutManager/preparePositionForDisplay``. @Test func getRectsDoesNotRemoveLayoutInfo() { layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) From 49aee7fb42c215231d6b348e937b817c0cc25e09 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:51:43 -0500 Subject: [PATCH 7/8] Revert Final Changes in Selection Update --- .../TextSelectionManager/TextSelectionManager+Update.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift index 338e166cb..8ea0c7490 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Update.swift @@ -6,7 +6,6 @@ // import Foundation -import AppKit extension TextSelectionManager { public func didReplaceCharacters(in range: NSRange, replacementLength: Int) { @@ -14,7 +13,6 @@ extension TextSelectionManager { for textSelection in self.textSelections { if textSelection.range.location > range.max { textSelection.range.location = max(0, textSelection.range.location + delta) - textSelection.range.length = 0 } else if textSelection.range.intersection(range) != nil || textSelection.range == range From 21b7e346323436fec3c438c3c26136b2fcc0bd61 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 9 Apr 2025 11:27:45 -0500 Subject: [PATCH 8/8] Assert When Layout Is Recursively Called --- .../TextLayoutManager+Public.swift | 1 + .../TextLayoutManager/TextLayoutManager.swift | 23 +++++++++++++++++++ .../TextSelectionManager.swift | 7 +++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 6ba0c0b5e..6fa1d9c9e 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -191,6 +191,7 @@ extension TextLayoutManager { for fragmentPosition in line.data.lineFragments.linesInRange(relativeRange) { guard let intersectingRange = fragmentPosition.range.intersection(relativeRange) else { continue } let fragmentRect = fragmentPosition.data.rectFor(range: intersectingRange) + guard fragmentRect.width > 0 else { continue } rects.append( CGRect( x: fragmentRect.minX + edgeInsets.left, diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index b281c0ce3..880c28748 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -199,15 +199,33 @@ public class TextLayoutManager: NSObject { /// ``TextLayoutManager/estimateLineHeight()`` is called. private var _estimateLineHeight: CGFloat? + /// Asserts that the caller is not in an active layout pass. + /// See docs on ``isInLayout`` for more details. + private func assertNotInLayout() { + #if DEBUG // This is redundant, but it keeps the flag debug-only too which helps prevent misuse. + assert(!isInLayout, "layoutLines called while already in a layout pass. This is a programmer error.") + #endif + } + // MARK: - Layout /// Lays out all visible lines func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length + assertNotInLayout() guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { return } + + // The macOS may call `layout` on the textView while we're laying out fragment views. This ensures the view + // tree modifications caused by this method are atomic, so macOS won't call `layout` while we're already doing + // that + CATransaction.begin() + #if DEBUG + isInLayout = true + #endif + let minY = max(visibleRect.minY - verticalLayoutPadding, 0) let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) let originalHeight = lineStorage.height @@ -253,6 +271,11 @@ public class TextLayoutManager: NSObject { newVisibleLines.insert(linePosition.data.id) } + #if DEBUG + isInLayout = false + #endif + CATransaction.commit() + // Enqueue any lines not used in this layout pass. viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 453fcac87..a56c73a68 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -86,6 +86,8 @@ public class TextSelectionManager: NSObject { /// Set the selected ranges to new ranges. Overrides any existing selections. /// - Parameter range: The selected ranges to set. public func setSelectedRanges(_ ranges: [NSRange]) { + let oldRanges = textSelections.map(\.range) + textSelections.forEach { $0.view?.removeFromSuperview() } // Remove duplicates, invalid ranges, update suggested X position. textSelections = Set(ranges) @@ -99,8 +101,11 @@ public class TextSelectionManager: NSObject { return selection } updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) delegate?.setNeedsDisplay() + + if oldRanges != textSelections.map(\.range) { + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + } } /// Append a new selected range to the existing ones.