From d803c7cbc4cb0e22c381d3320d3bcb0c7da1ad00 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:13:52 -0500 Subject: [PATCH 1/2] Invalidation Performance, Rename LineStorage `index` to `offset`, Editing Invalidation --- .../TextLayoutManager+Edits.swift | 13 +- .../TextLayoutManager+Invalidation.swift | 6 +- .../TextLayoutManager+Layout.swift | 210 ++++++++++++++++++ .../TextLayoutManager+Public.swift | 7 +- .../TextLayoutManager+ensureLayout.swift | 34 --- .../TextLayoutManager/TextLayoutManager.swift | 178 +-------------- .../TextLineStorage/TextLineStorage.swift | 16 +- .../TextView/TextView+Layout.swift | 5 + .../TextView/TextView+Lifecycle.swift | 5 - .../TextView/TextView+SetText.swift | 7 + .../TextLayoutLineStorageTests.swift | 10 +- 11 files changed, 249 insertions(+), 242 deletions(-) create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift delete mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index 1c3d97240..192b8a981 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -45,7 +45,8 @@ extension TextLayoutManager: NSTextStorageDelegate { let insertedStringRange = NSRange(location: editedRange.location, length: editedRange.length - delta) removeLayoutLinesIn(range: insertedStringRange) insertNewLines(for: editedRange) - invalidateLayoutForRange(editedRange) + + setNeedsLayout() } /// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an @@ -65,10 +66,10 @@ extension TextLayoutManager: NSTextStorageDelegate { lineStorage.delete(lineAt: nextLine.range.location) let delta = -intersection.length + nextLine.range.length if delta != 0 { - lineStorage.update(atIndex: linePosition.range.location, delta: delta, deltaHeight: 0) + lineStorage.update(atOffset: linePosition.range.location, delta: delta, deltaHeight: 0) } } else { - lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0) + lineStorage.update(atOffset: linePosition.range.location, delta: -intersection.length, deltaHeight: 0) } } } @@ -100,7 +101,7 @@ extension TextLayoutManager: NSTextStorageDelegate { 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) + lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0) lineStorage.insert( line: TextLine(), atOffset: location + insertedString.length, @@ -114,7 +115,7 @@ extension TextLayoutManager: NSTextStorageDelegate { let splitLength = linePosition.range.max - location let lineDelta = insertedString.length - splitLength // The difference in the line being edited if lineDelta != 0 { - lineStorage.update(atIndex: location, delta: lineDelta, deltaHeight: 0.0) + lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0) } lineStorage.insert( @@ -125,7 +126,7 @@ extension TextLayoutManager: NSTextStorageDelegate { ) } } else { - lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) + lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0) } } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift index 6ddb9a305..24fec8074 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift @@ -14,7 +14,8 @@ extension TextLayoutManager { for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) { linePosition.data.setNeedsLayout() } - layoutLines() + + layoutView?.needsLayout = true } /// Invalidates layout for the given range of text. @@ -24,11 +25,12 @@ extension TextLayoutManager { linePosition.data.setNeedsLayout() } - layoutLines() + layoutView?.needsLayout = true } public func setNeedsLayout() { needsLayout = true visibleLineIds.removeAll(keepingCapacity: true) + layoutView?.needsLayout = true } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift new file mode 100644 index 000000000..764b1ec92 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -0,0 +1,210 @@ +// +// TextLayoutManager+ensureLayout.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/7/25. +// + +import AppKit + +extension TextLayoutManager { + /// Contains all data required to perform layout on a text line. + private struct LineLayoutData { + let minY: CGFloat + let maxY: CGFloat + let maxWidth: 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 + var usedFragmentIDs = Set() + var forceLayout: Bool = needsLayout + var newVisibleLines: Set = [] + var yContentAdjustment: CGFloat = 0 + var maxFoundLineWidth = maxLineWidth + + // Layout all lines, fetching lines lazily as they are laid out. + for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy { + guard linePosition.yPos < maxY else { break } + if forceLayout + || linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) + || !visibleLineIds.contains(linePosition.data.id) { + let lineSize = layoutLine( + linePosition, + textStorage: textStorage, + layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth), + laidOutFragmentIDs: &usedFragmentIDs + ) + if lineSize.height != linePosition.height { + lineStorage.update( + atOffset: linePosition.range.location, + delta: 0, + deltaHeight: lineSize.height - linePosition.height + ) + // If we've updated a line's height, force re-layout for the rest of the pass. + forceLayout = true + + if linePosition.yPos < minY { + // Adjust the scroll position by the difference between the new height and old. + yContentAdjustment += lineSize.height - linePosition.height + } + } + if maxFoundLineWidth < lineSize.width { + maxFoundLineWidth = lineSize.width + } + } else { + // Make sure the used fragment views aren't dequeued. + usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id)) + } + 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) + + // Update the visible lines with the new set. + visibleLineIds = newVisibleLines + + // 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. + + if maxFoundLineWidth > maxLineWidth { + maxLineWidth = maxFoundLineWidth + } + + if yContentAdjustment != 0 { + delegate?.layoutManagerYAdjustment(yContentAdjustment) + } + + if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) + } + + needsLayout = false + } + + /// Lays out a single text line. + /// - Parameters: + /// - position: The line position from storage to use for layout. + /// - textStorage: The text storage object to use for text info. + /// - layoutData: The information required to perform layout for the given line. + /// - laidOutFragmentIDs: Updated by this method as line fragments are laid out. + /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line. + private func layoutLine( + _ position: TextLineStorage.TextLinePosition, + textStorage: NSTextStorage, + layoutData: LineLayoutData, + laidOutFragmentIDs: inout Set + ) -> CGSize { + let lineDisplayData = TextLine.DisplayData( + maxWidth: layoutData.maxWidth, + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimateLineHeight() + ) + + let line = position.data + line.prepareForDisplay( + displayData: lineDisplayData, + range: position.range, + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range), + breakStrategy: lineBreakStrategy + ) + + if position.range.isEmpty { + return CGSize(width: 0, height: estimateLineHeight()) + } + + var height: CGFloat = 0 + var width: CGFloat = 0 + let relativeMinY = max(layoutData.minY - position.yPos, 0) + let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) + + for lineFragmentPosition in line.lineFragments.linesStartingAt( + relativeMinY, + until: relativeMaxY + ) { + let lineFragment = lineFragmentPosition.data + + layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos) + + width = max(width, lineFragment.width) + height += lineFragment.scaledHeight + laidOutFragmentIDs.insert(lineFragment.id) + } + + return CGSize(width: width, height: height) + } + + /// Lays out a line fragment view for the given line fragment at the specified y value. + /// - Parameters: + /// - lineFragment: The line fragment position to lay out a view for. + /// - yPos: The y value at which the line should begin. + private func layoutFragmentView( + for lineFragment: TextLineStorage.TextLinePosition, + at yPos: CGFloat + ) { + let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) + view.setLineFragment(lineFragment.data) + view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) + layoutView?.addSubview(view) + view.needsDisplay = true + } + + /// 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. + func preparePositionForDisplay(_ position: TextLineStorage.TextLinePosition) -> CGFloat { + guard let textStorage else { return 0 } + let displayData = TextLine.DisplayData( + maxWidth: maxLineLayoutWidth, + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimateLineHeight() + ) + position.data.prepareForDisplay( + displayData: displayData, + range: position.range, + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range), + breakStrategy: lineBreakStrategy + ) + var height: CGFloat = 0 + for fragmentPosition in position.data.lineFragments { + height += fragmentPosition.data.scaledHeight + } + return height + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 6fa1d9c9e..5c9f14059 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -121,10 +121,7 @@ extension TextLayoutManager { return nil } if linePosition.data.lineFragments.isEmpty { - let newHeight = preparePositionForDisplay(linePosition) - if linePosition.height != newHeight { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } + ensureLayoutUntil(offset) } guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( @@ -293,7 +290,7 @@ extension TextLayoutManager { let height = preparePositionForDisplay(linePosition) if height != linePosition.height { lineStorage.update( - atIndex: linePosition.range.location, + atOffset: linePosition.range.location, delta: 0, deltaHeight: height - linePosition.height ) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift deleted file mode 100644 index e2cf08d15..000000000 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// TextLayoutManager+ensureLayout.swift -// CodeEditTextView -// -// Created by Khan Winter on 4/7/25. -// - -import Foundation - -extension TextLayoutManager { - /// 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, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight() - ) - position.data.prepareForDisplay( - displayData: displayData, - range: position.range, - stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy - ) - var height: CGFloat = 0 - for fragmentPosition in position.data.lineFragments { - height += fragmentPosition.data.scaledHeight - } - return height - } -} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 880c28748..45df87e95 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -84,7 +84,7 @@ public class TextLayoutManager: NSObject { /// Ensures that layout calls are not overlapping, potentially causing layout issues. /// This is used over a lock, as locks in performant code such as this would be detrimental to performance. /// Also only included in debug builds. DO NOT USE for checking if layout is active or not. That is an anti-pattern. - private var isInLayout: Bool = false + var isInLayout: Bool = false #endif weak var layoutView: NSView? @@ -109,13 +109,6 @@ public class TextLayoutManager: NSObject { (delegate?.textViewportSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal } - /// Contains all data required to perform layout on a text line. - private struct LineLayoutData { - let minY: CGFloat - let maxY: CGFloat - let maxWidth: CGFloat - } - // MARK: - Init /// Initialize a text layout manager and prepare it for use. @@ -199,175 +192,6 @@ 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 - var usedFragmentIDs = Set() - var forceLayout: Bool = needsLayout - var newVisibleLines: Set = [] - var yContentAdjustment: CGFloat = 0 - var maxFoundLineWidth = maxLineWidth - - // Layout all lines, fetching lines lazily as they are laid out. - for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy { - guard linePosition.yPos < maxY else { break } - if forceLayout - || linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) - || !visibleLineIds.contains(linePosition.data.id) { - let lineSize = layoutLine( - linePosition, - textStorage: textStorage, - layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth), - laidOutFragmentIDs: &usedFragmentIDs - ) - if lineSize.height != linePosition.height { - lineStorage.update( - atIndex: linePosition.range.location, - delta: 0, - deltaHeight: lineSize.height - linePosition.height - ) - // If we've updated a line's height, force re-layout for the rest of the pass. - forceLayout = true - - if linePosition.yPos < minY { - // Adjust the scroll position by the difference between the new height and old. - yContentAdjustment += lineSize.height - linePosition.height - } - } - if maxFoundLineWidth < lineSize.width { - maxFoundLineWidth = lineSize.width - } - } else { - // Make sure the used fragment views aren't dequeued. - usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id)) - } - 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) - - // Update the visible lines with the new set. - visibleLineIds = newVisibleLines - - // 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. - - if maxFoundLineWidth > maxLineWidth { - maxLineWidth = maxFoundLineWidth - } - - if yContentAdjustment != 0 { - delegate?.layoutManagerYAdjustment(yContentAdjustment) - } - - if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - - needsLayout = false - } - - /// Lays out a single text line. - /// - Parameters: - /// - position: The line position from storage to use for layout. - /// - textStorage: The text storage object to use for text info. - /// - layoutData: The information required to perform layout for the given line. - /// - laidOutFragmentIDs: Updated by this method as line fragments are laid out. - /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line. - private func layoutLine( - _ position: TextLineStorage.TextLinePosition, - textStorage: NSTextStorage, - layoutData: LineLayoutData, - laidOutFragmentIDs: inout Set - ) -> CGSize { - let lineDisplayData = TextLine.DisplayData( - maxWidth: layoutData.maxWidth, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight() - ) - - let line = position.data - line.prepareForDisplay( - displayData: lineDisplayData, - range: position.range, - stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy - ) - - if position.range.isEmpty { - return CGSize(width: 0, height: estimateLineHeight()) - } - - var height: CGFloat = 0 - var width: CGFloat = 0 - let relativeMinY = max(layoutData.minY - position.yPos, 0) - let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) - - for lineFragmentPosition in line.lineFragments.linesStartingAt( - relativeMinY, - until: relativeMaxY - ) { - let lineFragment = lineFragmentPosition.data - - layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos) - - width = max(width, lineFragment.width) - height += lineFragment.scaledHeight - laidOutFragmentIDs.insert(lineFragment.id) - } - - return CGSize(width: width, height: height) - } - - /// Lays out a line fragment view for the given line fragment at the specified y value. - /// - Parameters: - /// - lineFragment: The line fragment position to lay out a view for. - /// - yPos: The y value at which the line should begin. - private func layoutFragmentView( - for lineFragment: TextLineStorage.TextLinePosition, - at yPos: CGFloat - ) { - let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) - view.setLineFragment(lineFragment.data) - view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) - layoutView?.addSubview(view) - view.needsDisplay = true - } - deinit { lineStorage.removeAll() layoutView = nil diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift index 55a78d5cd..fb03d631e 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift @@ -190,26 +190,26 @@ public final class TextLineStorage { /// - Complexity `O(m log n)` where `m` is the number of lines that need to be deleted as a result of this update. /// and `n` is the number of lines stored in the tree. /// - Parameters: - /// - index: The index where the edit began + /// - offset: The offset where the edit began /// - delta: The change in length of the document. Negative for deletes, positive for insertions. /// - deltaHeight: The change in height of the document. - public func update(atIndex index: Int, delta: Int, deltaHeight: CGFloat) { - assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") + public func update(atOffset offset: Int, delta: Int, deltaHeight: CGFloat) { + assert(offset >= 0 && offset <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(offset)") assert(delta != 0 || deltaHeight != 0, "Delta must be non-0") let position: NodePosition? - if index == self.length { // Updates at the end of the document are valid + if offset == self.length { // Updates at the end of the document are valid position = lastNode } else { - position = search(for: index) + position = search(for: offset) } guard let position else { - assertionFailure("No line found at index \(index)") + assertionFailure("No line found at index \(offset)") return } if delta < 0 { assert( - index - position.textPos > delta, - "Delta too large. Deleting \(-delta) from line at position \(index) extends beyond the line's range." + offset - position.textPos > delta, + "Delta too large. Deleting \(-delta) from line at position \(offset) extends beyond the line's range." ) } length += delta diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index a2579c855..c0a700aec 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -8,6 +8,11 @@ import Foundation extension TextView { + override public func layout() { + super.layout() + layoutManager.layoutLines() + } + open override class var isCompatibleWithResponsiveScrolling: Bool { true } diff --git a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift index af07527fe..9befba72a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift @@ -8,11 +8,6 @@ import AppKit extension TextView { - override public func layout() { - layoutManager.layoutLines() - super.layout() - } - override public func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) layoutManager.layoutLines() diff --git a/Sources/CodeEditTextView/TextView/TextView+SetText.swift b/Sources/CodeEditTextView/TextView/TextView+SetText.swift index 7581dbcd7..a3f064857 100644 --- a/Sources/CodeEditTextView/TextView/TextView+SetText.swift +++ b/Sources/CodeEditTextView/TextView/TextView+SetText.swift @@ -27,9 +27,16 @@ extension TextView { textStorage.addAttributes(typingAttributes, range: documentRange) layoutManager.textStorage = textStorage layoutManager.reset() + storageDelegate.addDelegate(layoutManager) selectionManager.textStorage = textStorage selectionManager.setSelectedRanges(selectionManager.textSelections.map { $0.range }) + NotificationCenter.default.post( + Notification( + name: TextSelectionManager.selectionChangedNotification, + object: selectionManager + ) + ) _undoManager?.clearStack() diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift index 1b613199b..30b9376de 100644 --- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift @@ -89,7 +89,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Single Element tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) - tree.update(atIndex: 0, delta: 20, deltaHeight: 5.0) + tree.update(atOffset: 0, delta: 20, deltaHeight: 5.0) XCTAssertEqual(tree.length, 21, "Tree length incorrect") XCTAssertEqual(tree.count, 1, "Tree count incorrect") XCTAssertEqual(tree.height, 6, "Tree height incorrect") @@ -98,7 +98,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Update First tree = createBalancedTree() - tree.update(atIndex: 0, delta: 12, deltaHeight: -0.5) + tree.update(atOffset: 0, delta: 12, deltaHeight: -0.5) XCTAssertEqual(tree.height, 14.5, "Tree height incorrect") XCTAssertEqual(tree.count, 15, "Tree count changed") XCTAssertEqual(tree.length, 132, "Tree length incorrect") @@ -107,7 +107,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Update Last tree = createBalancedTree() - tree.update(atIndex: tree.length - 1, delta: -14, deltaHeight: 1.75) + tree.update(atOffset: tree.length - 1, delta: -14, deltaHeight: 1.75) XCTAssertEqual(tree.height, 16.75, "Tree height incorrect") XCTAssertEqual(tree.count, 15, "Tree count changed") XCTAssertEqual(tree.length, 106, "Tree length incorrect") @@ -116,7 +116,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // Update middle tree = createBalancedTree() - tree.update(atIndex: 45, delta: -9, deltaHeight: 1.0) + tree.update(atOffset: 45, delta: -9, deltaHeight: 1.0) XCTAssertEqual(tree.height, 16.0, "Tree height incorrect") XCTAssertEqual(tree.count, 15, "Tree count changed") XCTAssertEqual(tree.length, 111, "Tree length incorrect") @@ -131,7 +131,7 @@ final class TextLayoutLineStorageTests: XCTestCase { let originalHeight = tree.height let originalCount = tree.count let originalLength = tree.length - tree.update(atIndex: Int.random(in: 0.. Date: Fri, 11 Apr 2025 13:18:24 -0500 Subject: [PATCH 2/2] Linter --- .../CodeEditTextView/TextLineStorage/TextLineStorage.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift index fb03d631e..72b94effd 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift @@ -194,7 +194,10 @@ public final class TextLineStorage { /// - delta: The change in length of the document. Negative for deletes, positive for insertions. /// - deltaHeight: The change in height of the document. public func update(atOffset offset: Int, delta: Int, deltaHeight: CGFloat) { - assert(offset >= 0 && offset <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(offset)") + assert( + offset >= 0 && offset <= self.length, + "Invalid index, expected between 0 and \(self.length). Got \(offset)" + ) assert(delta != 0 || deltaHeight != 0, "Delta must be non-0") let position: NodePosition? if offset == self.length { // Updates at the end of the document are valid