diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift new file mode 100644 index 000000000..40f6251cc --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift @@ -0,0 +1,14 @@ +// +// NSRange+translate.swift +// CodeEditTextView +// +// Created by Khan Winter on 7/21/25. +// + +import Foundation + +extension NSRange { + func translate(location: Int) -> NSRange { + NSRange(location: self.location + location, length: length) + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift index 24fec8074..6b13819c6 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift @@ -25,6 +25,12 @@ extension TextLayoutManager { linePosition.data.setNeedsLayout() } + // Special case where we've deleted from the very end, `linesInRange` correctly does not return any lines + // So we need to invalidate the last line specifically. + if range.location == textStorage?.length, !lineStorage.isEmpty { + lineStorage.last?.data.setNeedsLayout() + } + layoutView?.needsLayout = true } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 074d8fcef..acf0ea0ae 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -79,7 +79,8 @@ extension TextLayoutManager { let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) let originalHeight = lineStorage.height var usedFragmentIDs = Set() - var forceLayout: Bool = needsLayout + let forceLayout: Bool = needsLayout + var didLayoutChange = false var newVisibleLines: Set = [] var yContentAdjustment: CGFloat = 0 var maxFoundLineWidth = maxLineWidth @@ -95,29 +96,17 @@ extension TextLayoutManager { let wasNotVisible = !visibleLineIds.contains(linePosition.data.id) let lineNotEntirelyLaidOut = linePosition.height != linePosition.data.lineFragments.height - if forceLayout || linePositionNeedsLayout || wasNotVisible || lineNotEntirelyLaidOut { - let lineSize = layoutLine( + defer { newVisibleLines.insert(linePosition.data.id) } + + func fullLineLayout() { + let (yAdjustment, wasLineHeightChanged) = layoutLine( linePosition, + usedFragmentIDs: &usedFragmentIDs, textStorage: textStorage, - layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth), - laidOutFragmentIDs: &usedFragmentIDs + yRange: minY.. 0 { + // Layout happened and this line needs to be moved but not necessarily re-added + let needsFullLayout = updateLineViewPositions(linePosition) + if needsFullLayout { + fullLineLayout() + continue + } + } + // Make sure the used fragment views aren't dequeued. usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id)) } - newVisibleLines.insert(linePosition.data.id) } // Enqueue any lines not used in this layout pass. @@ -171,6 +172,42 @@ extension TextLayoutManager { // MARK: - Layout Single Line + private func layoutLine( + _ linePosition: TextLineStorage.TextLinePosition, + usedFragmentIDs: inout Set, + textStorage: NSTextStorage, + yRange: Range, + maxFoundLineWidth: inout CGFloat + ) -> (CGFloat, wasLineHeightChanged: Bool) { + let lineSize = layoutLineViews( + linePosition, + textStorage: textStorage, + layoutData: LineLayoutData(minY: yRange.lowerBound, maxY: yRange.upperBound, maxWidth: maxLineLayoutWidth), + laidOutFragmentIDs: &usedFragmentIDs + ) + let wasLineHeightChanged = lineSize.height != linePosition.height + var yContentAdjustment: CGFloat = 0.0 + var maxFoundLineWidth = maxFoundLineWidth + + if wasLineHeightChanged { + lineStorage.update( + atOffset: linePosition.range.location, + delta: 0, + deltaHeight: lineSize.height - linePosition.height + ) + + if linePosition.yPos < yRange.lowerBound { + // 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 + } + + return (yContentAdjustment, wasLineHeightChanged) + } + /// Lays out a single text line. /// - Parameters: /// - position: The line position from storage to use for layout. @@ -178,7 +215,7 @@ extension TextLayoutManager { /// - 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( + private func layoutLineViews( _ position: TextLineStorage.TextLinePosition, textStorage: NSTextStorage, layoutData: LineLayoutData, @@ -226,8 +263,13 @@ extension TextLayoutManager { // ) { for lineFragmentPosition in line.lineFragments { let lineFragment = lineFragmentPosition.data + lineFragment.documentRange = lineFragmentPosition.range.translate(location: position.range.location) - layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos) + layoutFragmentView( + inLine: position, + for: lineFragmentPosition, + at: position.yPos + lineFragmentPosition.yPos + ) width = max(width, lineFragment.width) height += lineFragment.scaledHeight @@ -244,16 +286,32 @@ extension TextLayoutManager { /// - lineFragment: The line fragment position to lay out a view for. /// - yPos: The y value at which the line should begin. private func layoutFragmentView( + inLine line: TextLineStorage.TextLinePosition, for lineFragment: TextLineStorage.TextLinePosition, at yPos: CGFloat ) { + let fragmentRange = lineFragment.range.translate(location: line.range.location) let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) { renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() } view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews - view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer) + view.setLineFragment(lineFragment.data, fragmentRange: fragmentRange, renderer: lineFragmentRenderer) view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) layoutView?.addSubview(view, positioned: .below, relativeTo: nil) view.needsDisplay = true } + + private func updateLineViewPositions(_ position: TextLineStorage.TextLinePosition) -> Bool { + let line = position.data + for lineFragmentPosition in line.lineFragments { + guard let view = viewReuseQueue.getView(forKey: lineFragmentPosition.data.id) else { + return true + } + lineFragmentPosition.data.documentRange = lineFragmentPosition.range.translate( + location: position.range.location + ) + view.frame.origin = CGPoint(x: edgeInsets.left, y: position.yPos + lineFragmentPosition.yPos) + } + return false + } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index e05c01c20..b73c17177 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -112,12 +112,13 @@ extension TextLayoutManager { fragmentPosition: TextLineStorage.TextLinePosition, in linePosition: TextLineStorage.TextLinePosition ) -> Int? { - let endPosition = fragmentPosition.data.documentRange.max + let fragmentRange = fragmentPosition.range.translate(location: linePosition.range.location) + let endPosition = fragmentRange.max // If the endPosition is at the end of the line, and the line ends with a line ending character // return the index before the eol. if fragmentPosition.index == linePosition.data.lineFragments.count - 1, - let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentPosition.data.documentRange) ?? "") { + let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") { return endPosition - lineEnding.length } else if fragmentPosition.index != linePosition.data.lineFragments.count - 1 { // If this isn't the last fragment, we want to place the cursor at the offset right before the break @@ -175,7 +176,7 @@ extension TextLayoutManager { guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( atOffset: offset - linePosition.range.location ) else { - return nil + return CGRect(x: edgeInsets.left, y: linePosition.yPos, width: 0, height: linePosition.height) } // Get the *real* length of the character at the offset. If this is a surrogate pair it'll return the correct @@ -190,11 +191,11 @@ extension TextLayoutManager { let minXPos = characterXPosition( in: fragmentPosition.data, - for: realRange.location - fragmentPosition.data.documentRange.location + for: realRange.location - linePosition.range.location - fragmentPosition.range.location ) let maxXPos = characterXPosition( in: fragmentPosition.data, - for: realRange.max - fragmentPosition.data.documentRange.location + for: realRange.max - linePosition.range.location - fragmentPosition.range.location ) return CGRect( diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 6671fb8ef..646bf76ba 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -47,8 +47,7 @@ public final class LineFragment: Identifiable, Equatable { } public let id = UUID() - public let lineRange: NSRange - public let documentRange: NSRange + public var documentRange: NSRange = .notFound public var contents: [FragmentContent] public var width: CGFloat public var height: CGFloat @@ -61,16 +60,12 @@ public final class LineFragment: Identifiable, Equatable { } init( - lineRange: NSRange, - documentRange: NSRange, contents: [FragmentContent], width: CGFloat, height: CGFloat, descent: CGFloat, lineHeightMultiplier: CGFloat ) { - self.lineRange = lineRange - self.documentRange = documentRange self.contents = contents self.width = width self.height = height diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift index 4824e010b..6330d0ee0 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift @@ -122,7 +122,7 @@ public final class LineFragmentRenderer { context: context ) - let range = createTextRange(for: drawingContext) + let range = createTextRange(for: drawingContext).clamped(to: (textStorage.string as NSString).length) let string = (textStorage.string as NSString).substring(with: range) processInvisibleCharacters( @@ -177,7 +177,7 @@ public final class LineFragmentRenderer { guard let style = delegate.invisibleStyle( for: character, at: NSRange(start: range.location + index, end: range.max), - lineRange: drawingContext.lineFragment.lineRange + lineRange: drawingContext.lineFragment.documentRange ) else { return } diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 58a793306..66af42872 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -11,6 +11,9 @@ import AppKit open class LineFragmentView: NSView { public weak var lineFragment: LineFragment? public weak var renderer: LineFragmentRenderer? +#if DEBUG_LINE_INVALIDATION + private var backgroundAnimation: CABasicAnimation? +#endif open override var isFlipped: Bool { true @@ -22,15 +25,55 @@ open class LineFragmentView: NSView { open override func hitTest(_ point: NSPoint) -> NSView? { nil } - /// Prepare the view for reuse, clears the line fragment reference. + public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + required public init?(coder: NSCoder) { + super.init(coder: coder) + } + +#if DEBUG_LINE_INVALIDATION + /// Setup background animation from random color to clear when this fragment is invalidated. + private func setupBackgroundAnimation() { + self.wantsLayer = true + + let randomColor = NSColor( + red: CGFloat.random(in: 0...1), + green: CGFloat.random(in: 0...1), + blue: CGFloat.random(in: 0...1), + alpha: 0.3 + ) + + self.layer?.backgroundColor = randomColor.cgColor + + let animation = CABasicAnimation(keyPath: "backgroundColor") + animation.fromValue = randomColor.cgColor + animation.toValue = NSColor.clear.cgColor + animation.duration = 1.0 + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + animation.fillMode = .forwards + animation.isRemovedOnCompletion = false + self.layer?.add(animation, forKey: "backgroundColorAnimation") + + DispatchQueue.main.asyncAfter(deadline: .now() + animation.duration) { + self.layer?.backgroundColor = NSColor.clear.cgColor + } + } +#endif + open override func prepareForReuse() { super.prepareForReuse() lineFragment = nil + +#if DEBUG_LINE_INVALIDATION + setupBackgroundAnimation() +#endif } /// Set a new line fragment for this view, updating view size. /// - Parameter newFragment: The new fragment to use. - open func setLineFragment(_ newFragment: LineFragment, renderer: LineFragmentRenderer) { + open func setLineFragment(_ newFragment: LineFragment, fragmentRange: NSRange, renderer: LineFragmentRenderer) { self.lineFragment = newFragment self.renderer = renderer self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight) diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 2eee6f375..b9038a6f3 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -36,8 +36,6 @@ public final class TextLine: Identifiable, Equatable { // Both max widths we're comparing are finite maxWidth.isFinite && (self.maxWidth ?? 0.0).isFinite - // We can't use `<` here because we want to calculate layout again if this was previously constrained to a - // small layout size and needs to grow. && maxWidth != (self.maxWidth ?? 0.0) ) } @@ -57,14 +55,14 @@ public final class TextLine: Identifiable, Equatable { attachments: [AnyTextAttachment] ) { let string = stringRef.attributedSubstring(from: range) - self.maxWidth = displayData.maxWidth - typesetter.typeset( + let maxWidth = typesetter.typeset( string, documentRange: range, displayData: displayData, markedRanges: markedRanges, attachments: attachments ) + self.maxWidth = displayData.maxWidth needsLayout = false } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index e74af78a9..f5b6ab6df 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -61,11 +61,6 @@ struct TypesetContext { /// Pop the current fragment state into a new line fragment, and reset the fragment state. mutating func popCurrentData() { let fragment = LineFragment( - lineRange: documentRange, - documentRange: NSRange( - location: fragmentContext.start + documentRange.location, - length: currentPosition - fragmentContext.start - ), contents: fragmentContext.contents, width: fragmentContext.width, height: fragmentContext.height, diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 2bebfb69d..b5edb8594 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -230,8 +230,6 @@ final public class Typesetter { // Insert an empty fragment let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) let fragment = LineFragment( - lineRange: documentRange ?? .zero, - documentRange: NSRange(location: (documentRange ?? .notFound).location, length: 0), contents: [.init(data: .text(line: ctLine), width: 0.0)], width: 0, height: displayData.estimatedLineHeight / displayData.lineHeightMultiplier, diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index c4cf1fa6c..bc5274ed3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -29,10 +29,11 @@ extension TextView { layoutManager.layoutLines() selectionManager.updateSelectionViews() selectionManager.drawSelections(in: visibleRect) - } - if lastFrame != .zero { - scrollView.contentView.scrollToVisible(lastFrame) - scrollView.reflectScrolledClipView(scrollView.contentView) + + if lastFrame != .zero { + scrollView.contentView.scrollToVisible(lastFrame) + scrollView.reflectScrolledClipView(scrollView.contentView) + } } } diff --git a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift index 15a65d014..3b85c6aeb 100644 --- a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift +++ b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift @@ -35,20 +35,28 @@ public class ViewReuseQueue { } else { view = queuedViews.popFirst() ?? createView() view.prepareForReuse() + view.isHidden = false usedViews[key] = view } return view } + public func getView(forKey key: Key) -> View? { + usedViews[key] + } + /// Removes a view for the given key and enqueues it for reuse. /// - Parameter key: The key for the view to reuse. public func enqueueView(forKey key: Key) { guard let view = usedViews[key] else { return } - if queuedViews.count < usedViews.count / 4 { + if queuedViews.count < usedViews.count { queuedViews.append(view) + view.frame = .zero + view.isHidden = true + } else { + view.removeFromSuperviewWithoutNeedingDisplay() } usedViews.removeValue(forKey: key) - view.removeFromSuperviewWithoutNeedingDisplay() } /// Enqueues all views not in the given set. diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 3baab6c3a..f40c1b878 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -212,15 +212,18 @@ struct TextLayoutManagerTests { ) } - /// Inserting a new line should cause layout going down the rest of the screen, because the following lines - /// should have moved their position to accomodate the new line. + /// ~~Inserting a new line should cause layout going down the rest of the screen, because the following lines + /// should have moved their position to accomodate the new line.~~ + /// This is slightly changed now. The layout manager checks if a line actually needs to be typeset again and only + /// invalidates it if it does. Otherwise it moves lines. This test now just checks that the invalidated lines + /// equal the expected invalidated lines. @Test func editsWithNewlinesForceLayoutGoingDownScreen() { layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) textStorage.replaceCharacters(in: NSRange(start: 4, end: 4), with: "Z\n") let expectedLineIds = Array( - layoutManager.lineStorage.linesInRange(NSRange(location: 4, length: 9)) + layoutManager.lineStorage.linesInRange(NSRange(location: 4, length: 4)) ).map { $0.data.id } #expect(layoutManager.needsLayout == false) // No forced layout for entire view @@ -250,4 +253,20 @@ struct TextLayoutManagerTests { } } } + + @Test + func editingEndOfDocumentInvalidatesLastLine() throws { + // Setup a slightly longer final line + textStorage.replaceCharacters(in: NSRange(location: 7, length: 0), with: "EFGH") + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + textStorage.replaceCharacters(in: NSRange(location: 10, length: 1), with: "") + let invalidatedLineIds = layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + let expectedLineIds = Array( + layoutManager.lineStorage.linesInRange(NSRange(location: 6, length: 0)) + ).map { $0.data.id } + + #expect(invalidatedLineIds.isSuperset(of: Set(expectedLineIds))) + } } diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index d671ea6ff..92826c365 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -254,17 +254,14 @@ class TypesetterTests: XCTestCase { XCTAssertEqual(typesetter.lineFragments.count, 3) var fragment = try XCTUnwrap(typesetter.lineFragments.first?.data) - XCTAssertEqual(fragment.documentRange, NSRange(location: 0, length: 1)) XCTAssertEqual(fragment.contents.count, 1) XCTAssertTrue(fragment.contents[0].isText) fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 1)?.data) - XCTAssertEqual(fragment.documentRange, NSRange(location: 1, length: 1)) XCTAssertEqual(fragment.contents.count, 1) XCTAssertFalse(fragment.contents[0].isText) fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 2)?.data) - XCTAssertEqual(fragment.documentRange, NSRange(location: 2, length: 4)) XCTAssertEqual(fragment.contents.count, 1) XCTAssertTrue(fragment.contents[0].isText) }