From a1a2c9e803ddb489c51df1a1d12db1cc52089248 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:22:49 -0500 Subject: [PATCH 01/11] Limit Layout Invalidation --- .../TextLayoutManager+Layout.swift | 97 ++++++++++++++----- .../TextLine/LineFragmentView.swift | 51 +++++++++- .../CodeEditTextView/TextLine/TextLine.swift | 9 +- .../TextLine/Typesetter/TypesetContext.swift | 2 + .../TextLine/Typesetter/Typesetter.swift | 15 +-- .../Utils/ViewReuseQueue.swift | 4 + 6 files changed, 142 insertions(+), 36 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 074d8fcef..fade6459a 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, @@ -256,4 +293,16 @@ extension TextLayoutManager { 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 + } + + view.frame.origin = CGPoint(x: edgeInsets.left, y: position.yPos + lineFragmentPosition.yPos) + } + return false + } } diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 58a793306..6c3e18820 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -11,6 +11,7 @@ import AppKit open class LineFragmentView: NSView { public weak var lineFragment: LineFragment? public weak var renderer: LineFragmentRenderer? + private var backgroundAnimation: CABasicAnimation? open override var isFlipped: Bool { true @@ -22,10 +23,58 @@ open class LineFragmentView: NSView { open override func hitTest(_ point: NSPoint) -> NSView? { nil } - /// Prepare the view for reuse, clears the line fragment reference. + /// Initialize with random background color animation + public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupBackgroundAnimation() + } + + required public init?(coder: NSCoder) { + super.init(coder: coder) + setupBackgroundAnimation() + } + + /// Setup background animation from random color to clear + private func setupBackgroundAnimation() { + // Ensure the view is layer-backed for animation + self.wantsLayer = true + + // Generate random color + let randomColor = NSColor( + red: CGFloat.random(in: 0...1), + green: CGFloat.random(in: 0...1), + blue: CGFloat.random(in: 0...1), + alpha: 0.3 // Start with some transparency + ) + + // Set initial background color + self.layer?.backgroundColor = randomColor.cgColor + + // Create animation from random color to clear + 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 + + // Apply animation + self.layer?.add(animation, forKey: "backgroundColorAnimation") + + // Set final state + DispatchQueue.main.asyncAfter(deadline: .now() + animation.duration) { + self.layer?.backgroundColor = NSColor.clear.cgColor + } + } + + /// Prepare the view for reuse, clears the line fragment reference and restarts animation. open override func prepareForReuse() { super.prepareForReuse() lineFragment = nil + + // Restart the background animation + setupBackgroundAnimation() } /// Set a new line fragment for this view, updating view size. diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 2eee6f375..9dc85ca8c 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -31,13 +31,11 @@ public final class TextLine: Identifiable, Equatable { /// - Returns: True, if this line has been marked as needing layout using ``TextLine/setNeedsLayout()`` or if the /// line needs to find new line breaks due to a new constraining width. func needsLayout(maxWidth: CGFloat) -> Bool { - needsLayout // Force layout + return needsLayout // Force layout || ( // 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,15 @@ 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 = min(maxWidth, displayData.maxWidth) + self.maxWidth = displayData.maxWidth needsLayout = false } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index e74af78a9..5867d4f1e 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -16,6 +16,7 @@ struct TypesetContext { /// Accumulated generated line fragments. var lines: [TextLineStorage.BuildItem] = [] var maxHeight: CGFloat = 0 + var maxWidth: CGFloat = 0 /// The current fragment typesetting context. var fragmentContext = LineFragmentTypesetContext(start: 0, width: 0.0, height: 0.0, descent: 0.0) @@ -76,6 +77,7 @@ struct TypesetContext { .init(data: fragment, length: currentPosition - fragmentContext.start, height: fragment.scaledHeight) ) maxHeight = max(maxHeight, fragment.scaledHeight) + maxWidth = max(maxWidth, fragment.width) fragmentContext.clear() fragmentContext.start = currentPosition diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 2bebfb69d..55f8a2878 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -33,28 +33,31 @@ final public class Typesetter { public init() { } + /// Performs the typesetting operation, returning the maximum width required for the current layout. + /// - Returns: The maximum width the typeset lines require. public func typeset( _ string: NSAttributedString, documentRange: NSRange, displayData: TextLine.DisplayData, markedRanges: MarkedRanges?, attachments: [AnyTextAttachment] = [] - ) { + ) -> CGFloat { let string = makeString(string: string, markedRanges: markedRanges) lineFragments.removeAll() // Fast path if string.length == 0 || displayData.maxWidth <= 0 { typesetEmptyLine(displayData: displayData, string: string) - return + return 0.0 } - let (lines, maxHeight) = typesetLineFragments( + let (lines, maxSize) = typesetLineFragments( string: string, documentRange: documentRange, displayData: displayData, attachments: attachments ) - lineFragments.build(from: lines, estimatedLineHeight: maxHeight) + lineFragments.build(from: lines, estimatedLineHeight: maxSize.height) + return maxSize.width } private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) -> NSAttributedString { @@ -132,7 +135,7 @@ final public class Typesetter { documentRange: NSRange, displayData: TextLine.DisplayData, attachments: [AnyTextAttachment] - ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) { + ) -> (lines: [TextLineStorage.BuildItem], maxSize: CGSize) { let contentRuns = createContentRuns(string: string, documentRange: documentRange, attachments: attachments) var context = TypesetContext(documentRange: documentRange, displayData: displayData) @@ -155,7 +158,7 @@ final public class Typesetter { context.popCurrentData() } - return (context.lines, context.maxHeight) + return (context.lines, CGSize(width: context.maxWidth, height: context.maxHeight)) } // MARK: - Layout Text Fragments diff --git a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift index 15a65d014..a39d9e029 100644 --- a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift +++ b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift @@ -40,6 +40,10 @@ public class ViewReuseQueue { 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) { From 098a76cb69740b19a76da3ee24ed2741a7c80196 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:20:04 -0500 Subject: [PATCH 02/11] Remove Unnecessary LineFragment Variables --- .../Extensions/NSRange+/NSRange+translate.swift | 14 ++++++++++++++ .../TextLayoutManager+Layout.swift | 15 ++++++++++++--- .../TextLayoutManager+Public.swift | 11 ++++++----- .../TextLine/LineFragment.swift | 7 +------ .../TextLine/LineFragmentRenderer.swift | 4 ++-- .../TextLine/LineFragmentView.swift | 2 +- .../CodeEditTextView/TextLine/TextLine.swift | 1 - .../TextLine/Typesetter/TypesetContext.swift | 7 ------- .../TextLine/Typesetter/Typesetter.swift | 17 ++++++----------- .../TextView/TextView+ScrollToVisible.swift | 9 +++++---- 10 files changed, 47 insertions(+), 40 deletions(-) create mode 100644 Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift 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+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index fade6459a..acf0ea0ae 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -263,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 @@ -281,14 +286,16 @@ 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 @@ -300,7 +307,9 @@ extension TextLayoutManager { 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 6c3e18820..8d7609e83 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -79,7 +79,7 @@ open class LineFragmentView: NSView { /// 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 9dc85ca8c..c97360d92 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -62,7 +62,6 @@ public final class TextLine: Identifiable, Equatable { markedRanges: markedRanges, attachments: attachments ) -// self.maxWidth = min(maxWidth, displayData.maxWidth) self.maxWidth = displayData.maxWidth needsLayout = false } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 5867d4f1e..f5b6ab6df 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -16,7 +16,6 @@ struct TypesetContext { /// Accumulated generated line fragments. var lines: [TextLineStorage.BuildItem] = [] var maxHeight: CGFloat = 0 - var maxWidth: CGFloat = 0 /// The current fragment typesetting context. var fragmentContext = LineFragmentTypesetContext(start: 0, width: 0.0, height: 0.0, descent: 0.0) @@ -62,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, @@ -77,7 +71,6 @@ struct TypesetContext { .init(data: fragment, length: currentPosition - fragmentContext.start, height: fragment.scaledHeight) ) maxHeight = max(maxHeight, fragment.scaledHeight) - maxWidth = max(maxWidth, fragment.width) fragmentContext.clear() fragmentContext.start = currentPosition diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 55f8a2878..b5edb8594 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -33,31 +33,28 @@ final public class Typesetter { public init() { } - /// Performs the typesetting operation, returning the maximum width required for the current layout. - /// - Returns: The maximum width the typeset lines require. public func typeset( _ string: NSAttributedString, documentRange: NSRange, displayData: TextLine.DisplayData, markedRanges: MarkedRanges?, attachments: [AnyTextAttachment] = [] - ) -> CGFloat { + ) { let string = makeString(string: string, markedRanges: markedRanges) lineFragments.removeAll() // Fast path if string.length == 0 || displayData.maxWidth <= 0 { typesetEmptyLine(displayData: displayData, string: string) - return 0.0 + return } - let (lines, maxSize) = typesetLineFragments( + let (lines, maxHeight) = typesetLineFragments( string: string, documentRange: documentRange, displayData: displayData, attachments: attachments ) - lineFragments.build(from: lines, estimatedLineHeight: maxSize.height) - return maxSize.width + lineFragments.build(from: lines, estimatedLineHeight: maxHeight) } private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) -> NSAttributedString { @@ -135,7 +132,7 @@ final public class Typesetter { documentRange: NSRange, displayData: TextLine.DisplayData, attachments: [AnyTextAttachment] - ) -> (lines: [TextLineStorage.BuildItem], maxSize: CGSize) { + ) -> (lines: [TextLineStorage.BuildItem], maxHeight: CGFloat) { let contentRuns = createContentRuns(string: string, documentRange: documentRange, attachments: attachments) var context = TypesetContext(documentRange: documentRange, displayData: displayData) @@ -158,7 +155,7 @@ final public class Typesetter { context.popCurrentData() } - return (context.lines, CGSize(width: context.maxWidth, height: context.maxHeight)) + return (context.lines, context.maxHeight) } // MARK: - Layout Text Fragments @@ -233,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) + } } } From a45e926c9ebbab39739338c1fca1f5fc9e07b07c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:29:30 -0500 Subject: [PATCH 03/11] Put debug mode behind compilation flag --- Sources/CodeEditTextView/TextLine/LineFragmentView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 8d7609e83..17f2dca57 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -11,7 +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 @@ -26,14 +28,13 @@ open class LineFragmentView: NSView { /// Initialize with random background color animation public override init(frame frameRect: NSRect) { super.init(frame: frameRect) - setupBackgroundAnimation() } required public init?(coder: NSCoder) { super.init(coder: coder) - setupBackgroundAnimation() } +#if DEBUG_LINE_INVALIDATION /// Setup background animation from random color to clear private func setupBackgroundAnimation() { // Ensure the view is layer-backed for animation @@ -67,14 +68,16 @@ open class LineFragmentView: NSView { self.layer?.backgroundColor = NSColor.clear.cgColor } } +#endif /// Prepare the view for reuse, clears the line fragment reference and restarts animation. open override func prepareForReuse() { super.prepareForReuse() lineFragment = nil - // Restart the background animation +#if DEBUG_LINE_INVALIDATION setupBackgroundAnimation() +#endif } /// Set a new line fragment for this view, updating view size. From 79cecc9619546da3943552a16d25755e9c9ba4fe Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:36:20 -0500 Subject: [PATCH 04/11] Clean Up --- .../CodeEditTextView/TextLine/LineFragmentView.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 17f2dca57..d5cfc0c65 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -35,23 +35,19 @@ open class LineFragmentView: NSView { } #if DEBUG_LINE_INVALIDATION - /// Setup background animation from random color to clear + /// Setup background animation from random color to clear when this fragment is invalidated. private func setupBackgroundAnimation() { - // Ensure the view is layer-backed for animation self.wantsLayer = true - // Generate random color let randomColor = NSColor( red: CGFloat.random(in: 0...1), green: CGFloat.random(in: 0...1), blue: CGFloat.random(in: 0...1), - alpha: 0.3 // Start with some transparency + alpha: 0.3 ) - // Set initial background color self.layer?.backgroundColor = randomColor.cgColor - // Create animation from random color to clear let animation = CABasicAnimation(keyPath: "backgroundColor") animation.fromValue = randomColor.cgColor animation.toValue = NSColor.clear.cgColor @@ -59,11 +55,8 @@ open class LineFragmentView: NSView { animation.timingFunction = CAMediaTimingFunction(name: .easeOut) animation.fillMode = .forwards animation.isRemovedOnCompletion = false - - // Apply animation self.layer?.add(animation, forKey: "backgroundColorAnimation") - // Set final state DispatchQueue.main.asyncAfter(deadline: .now() + animation.duration) { self.layer?.backgroundColor = NSColor.clear.cgColor } From 214f2a2629551248cc01967ed26df7b2a898fe79 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:34:41 -0500 Subject: [PATCH 05/11] Fix Unnecessary Tests --- .../LayoutManager/TextLayoutManagerTests.swift | 2 +- Tests/CodeEditTextViewTests/TypesetterTests.swift | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 3baab6c3a..7be6536f0 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -220,7 +220,7 @@ struct TextLayoutManagerTests { 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 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) } From 0f2cbbbf008115975093cca23c171ba874f4f3f5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:44:54 -0500 Subject: [PATCH 06/11] Fix End Of Doc Invalidation Bug --- .../TextLayoutManager+Invalidation.swift | 6 ++++++ .../LayoutManager/TextLayoutManagerTests.swift | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) 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/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 7be6536f0..57961e294 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -250,4 +250,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))) + } } From 080f5d6a2dc76b37f2fae3144725bc77f3a2bd28 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:46:58 -0500 Subject: [PATCH 07/11] Document Test Change --- .../LayoutManager/TextLayoutManagerTests.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 57961e294..f40c1b878 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -212,8 +212,11 @@ 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)) From bb359dc049b479f7cc698321a43fb58144ffcd71 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:48:11 -0500 Subject: [PATCH 08/11] Remove Doc --- Sources/CodeEditTextView/TextLine/LineFragmentView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index d5cfc0c65..6f9b824b4 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -25,7 +25,6 @@ open class LineFragmentView: NSView { open override func hitTest(_ point: NSPoint) -> NSView? { nil } - /// Initialize with random background color animation public override init(frame frameRect: NSRect) { super.init(frame: frameRect) } From 743b5daee129ea3c9e9e629e3488e62f55c00b6f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:48:40 -0500 Subject: [PATCH 09/11] Remove Doc --- Sources/CodeEditTextView/TextLine/LineFragmentView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 6f9b824b4..66af42872 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -62,7 +62,6 @@ open class LineFragmentView: NSView { } #endif - /// Prepare the view for reuse, clears the line fragment reference and restarts animation. open override func prepareForReuse() { super.prepareForReuse() lineFragment = nil From 955ac316617ba9c633e3b275a7ff41d89c2a6c33 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:49:09 -0500 Subject: [PATCH 10/11] Remove Unnecessary Return --- Sources/CodeEditTextView/TextLine/TextLine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index c97360d92..b9038a6f3 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -31,7 +31,7 @@ public final class TextLine: Identifiable, Equatable { /// - Returns: True, if this line has been marked as needing layout using ``TextLine/setNeedsLayout()`` or if the /// line needs to find new line breaks due to a new constraining width. func needsLayout(maxWidth: CGFloat) -> Bool { - return needsLayout // Force layout + needsLayout // Force layout || ( // Both max widths we're comparing are finite maxWidth.isFinite From 6a1407be1d346acdb5176fc6d35ca8f45c1be801 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 12:17:11 -0500 Subject: [PATCH 11/11] Slight Reuse Queue Optimization --- Sources/CodeEditTextView/Utils/ViewReuseQueue.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift index a39d9e029..3b85c6aeb 100644 --- a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift +++ b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift @@ -35,6 +35,7 @@ public class ViewReuseQueue { } else { view = queuedViews.popFirst() ?? createView() view.prepareForReuse() + view.isHidden = false usedViews[key] = view } return view @@ -48,11 +49,14 @@ public class ViewReuseQueue { /// - 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.