From 616a48d1857a0d9f8df28a851167138c50450096 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Apr 2025 09:52:53 -0500 Subject: [PATCH 01/21] Add Overridable Layout To LayoutManager --- .../MarkedTextManager/MarkedTextManager.swift | 14 +-- .../TextLayoutManager/TextLayoutManager.swift | 44 +++++---- .../TextLayoutManagerDelegate.swift | 18 ++++ .../TextLayoutManagerLayoutDelegate.swift | 47 ++++++++++ .../TextLine/LineFragment.swift | 14 +-- .../TextLine/LineFragmentView.swift | 9 +- .../CodeEditTextView/TextLine/TextLine.swift | 4 +- .../TextLine/Typesetter.swift | 14 +-- ...verridingLayoutManagerRenderingTests.swift | 94 +++++++++++++++++++ 9 files changed, 216 insertions(+), 42 deletions(-) create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerDelegate.swift create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerLayoutDelegate.swift create mode 100644 Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift diff --git a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift index 9c7612913..81c53e564 100644 --- a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift @@ -7,15 +7,15 @@ import AppKit +/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o +/// requiring a reference to the marked text manager. +public struct MarkedRanges { + let ranges: [NSRange] + let attributes: [NSAttributedString.Key: Any] +} + /// Manages marked ranges. Not a public API. class MarkedTextManager { - /// Struct for passing attribute and range information easily down into line fragments, typesetters w/o - /// requiring a reference to the marked text manager. - struct MarkedRanges { - let ranges: [NSRange] - let attributes: [NSAttributedString.Key: Any] - } - /// All marked ranges being tracked. private(set) var markedRanges: [NSRange] = [] diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 880c28748..d3380a0d7 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -8,16 +8,6 @@ import Foundation import AppKit -public protocol TextLayoutManagerDelegate: AnyObject { - func layoutManagerHeightDidUpdate(newHeight: CGFloat) - func layoutManagerMaxWidthDidChange(newWidth: CGFloat) - func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] - func textViewportSize() -> CGSize - func layoutManagerYAdjustment(_ yAdjustment: CGFloat) - - var visibleRect: NSRect { get } -} - /// The text layout manager manages laying out lines in a code document. public class TextLayoutManager: NSObject { // MARK: - Public Properties @@ -65,6 +55,8 @@ public class TextLayoutManager: NSObject { } } + public weak var renderDelegate: TextLayoutManagerRenderDelegate? + // MARK: - Internal weak var textStorage: NSTextStorage? @@ -207,7 +199,7 @@ public class TextLayoutManager: NSObject { #endif } - // MARK: - Layout + // MARK: - Layout Lines /// Lays out all visible lines func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length @@ -300,6 +292,8 @@ public class TextLayoutManager: NSObject { needsLayout = false } + // MARK: - Layout Single Line + /// Lays out a single text line. /// - Parameters: /// - position: The line position from storage to use for layout. @@ -320,13 +314,24 @@ public class TextLayoutManager: NSObject { ) let line = position.data - line.prepareForDisplay( - displayData: lineDisplayData, - range: position.range, - stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy - ) + if let renderDelegate { + renderDelegate.prepareForDisplay( + textLine: line, + displayData: lineDisplayData, + range: position.range, + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range), + breakStrategy: lineBreakStrategy + ) + } else { + 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()) @@ -353,6 +358,8 @@ public class TextLayoutManager: NSObject { return CGSize(width: width, height: height) } + // MARK: - Layout Fragment + /// 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. @@ -363,6 +370,7 @@ public class TextLayoutManager: NSObject { ) { let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) view.setLineFragment(lineFragment.data) + view.renderDelegate = renderDelegate view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) layoutView?.addSubview(view) view.needsDisplay = true diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerDelegate.swift new file mode 100644 index 000000000..b6850b3cc --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerDelegate.swift @@ -0,0 +1,18 @@ +// +// TextLayoutManagerDelegate.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit + +public protocol TextLayoutManagerDelegate: AnyObject { + func layoutManagerHeightDidUpdate(newHeight: CGFloat) + func layoutManagerMaxWidthDidChange(newWidth: CGFloat) + func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] + func textViewportSize() -> CGSize + func layoutManagerYAdjustment(_ yAdjustment: CGFloat) + + var visibleRect: NSRect { get } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerLayoutDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerLayoutDelegate.swift new file mode 100644 index 000000000..c6e1d4070 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerLayoutDelegate.swift @@ -0,0 +1,47 @@ +// +// TextLayoutManagerLayoutDelegate.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit + +/// Provide an instance of this class to the ``TextLayoutManager`` to override how the layout manager performs layout +/// and display for text lines and fragments. +/// +/// All methods on this protocol are optional, and default to the default behavior. +public protocol TextLayoutManagerRenderDelegate: AnyObject { + func prepareForDisplay( // swiftlint:disable:this function_parameter_count + textLine: TextLine, + displayData: TextLine.DisplayData, + range: NSRange, + stringRef: NSTextStorage, + markedRanges: MarkedRanges?, + breakStrategy: LineBreakStrategy + ) + func drawLineFragment(fragment: LineFragment, in context: CGContext) +} + +extension TextLayoutManagerRenderDelegate { + func prepareForDisplay( // swiftlint:disable:this function_parameter_count + textLine: TextLine, + displayData: TextLine.DisplayData, + range: NSRange, + stringRef: NSTextStorage, + markedRanges: MarkedRanges?, + breakStrategy: LineBreakStrategy + ) { + textLine.prepareForDisplay( + displayData: displayData, + range: range, + stringRef: stringRef, + markedRanges: markedRanges, + breakStrategy: breakStrategy + ) + } + + func drawLineFragment(fragment: LineFragment, in context: CGContext) { + fragment.draw(in: context, yPos: 0.0) + } +} diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index f4ace5689..045214fc8 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -12,18 +12,18 @@ import CodeEditTextViewObjC /// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment. public final class LineFragment: Identifiable, Equatable { public let id = UUID() - private(set) public var ctLine: CTLine - public let width: CGFloat - public let height: CGFloat - public let descent: CGFloat - public let scaledHeight: CGFloat + public var ctLine: CTLine + public var width: CGFloat + public var height: CGFloat + public var descent: CGFloat + public var scaledHeight: CGFloat /// The difference between the real text height and the scaled height public var heightDifference: CGFloat { scaledHeight - height } - init( + public init( ctLine: CTLine, width: CGFloat, height: CGFloat, @@ -81,7 +81,7 @@ public final class LineFragment: Identifiable, Equatable { /// Calculates the drawing rect for a given range. /// - Parameter range: The range to calculate the bounds for, relative to the line. /// - Returns: A rect that contains the text contents in the given range. - func rectFor(range: NSRange) -> CGRect { + public func rectFor(range: NSRange) -> CGRect { let minXPos = CTLineGetOffsetForStringIndex(ctLine, range.lowerBound, nil) let maxXPos = CTLineGetOffsetForStringIndex(ctLine, range.upperBound, nil) return CGRect( diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 3536fd4e0..9e3ee228e 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -11,6 +11,8 @@ import AppKit final class LineFragmentView: NSView { private weak var lineFragment: LineFragment? + weak var renderDelegate: TextLayoutManagerRenderDelegate? + override var isFlipped: Bool { true } @@ -39,6 +41,11 @@ final class LineFragmentView: NSView { guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else { return } - lineFragment.draw(in: context, yPos: 0.0) + + if let renderDelegate { + renderDelegate.drawLineFragment(fragment: lineFragment, in: context) + } else { + lineFragment.draw(in: context, yPos: 0.0) + } } } diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 0bce6307b..2b906b34b 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -45,7 +45,7 @@ public final class TextLine: Identifiable, Equatable { displayData: DisplayData, range: NSRange, stringRef: NSTextStorage, - markedRanges: MarkedTextManager.MarkedRanges?, + markedRanges: MarkedRanges?, breakStrategy: LineBreakStrategy ) { let string = stringRef.attributedSubstring(from: range) @@ -64,7 +64,7 @@ public final class TextLine: Identifiable, Equatable { } /// Contains all required data to perform a typeset and layout operation on a text line. - struct DisplayData { + public struct DisplayData { let maxWidth: CGFloat let lineHeightMultiplier: CGFloat let estimatedLineHeight: CGFloat diff --git a/Sources/CodeEditTextView/TextLine/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter.swift index f05c69c75..df46bd193 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter.swift @@ -8,20 +8,20 @@ import Foundation import CoreText -final class Typesetter { - var typesetter: CTTypesetter? - var string: NSAttributedString! - var lineFragments = TextLineStorage() +final public class Typesetter { + public var typesetter: CTTypesetter? + public var string: NSAttributedString! + public var lineFragments = TextLineStorage() // MARK: - Init & Prepare - init() { } + public init() { } - func typeset( + public func typeset( _ string: NSAttributedString, displayData: TextLine.DisplayData, breakStrategy: LineBreakStrategy, - markedRanges: MarkedTextManager.MarkedRanges? + markedRanges: MarkedRanges? ) { lineFragments.removeAll() if let markedRanges { diff --git a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift new file mode 100644 index 000000000..dfb16e319 --- /dev/null +++ b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift @@ -0,0 +1,94 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +class MockRenderDelegate: TextLayoutManagerRenderDelegate { + var prepareForDisplay: (( + _ textLine: TextLine, + _ displayData: TextLine.DisplayData, + _ range: NSRange, + _ stringRef: NSTextStorage, + _ markedRanges: MarkedRanges?, + _ breakStrategy: LineBreakStrategy + ) -> Void)? + + func prepareForDisplay( // swiftlint:disable:this function_parameter_count + textLine: TextLine, + displayData: TextLine.DisplayData, + range: NSRange, + stringRef: NSTextStorage, + markedRanges: MarkedRanges?, + breakStrategy: LineBreakStrategy + ) { + prepareForDisplay?( + textLine, + displayData, + range, + stringRef, + markedRanges, + breakStrategy + ) ?? textLine.prepareForDisplay( + displayData: displayData, + range: range, + stringRef: stringRef, + markedRanges: markedRanges, + breakStrategy: breakStrategy + ) + } +} + +@Suite +@MainActor +struct OverridingLayoutManagerRenderingTests { + let mockDelegate: MockRenderDelegate + let textView: TextView + let textStorage: NSTextStorage + let layoutManager: TextLayoutManager + + 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) + mockDelegate = MockRenderDelegate() + layoutManager.renderDelegate = mockDelegate + } + + @Test + func overriddenLineHeight() { + mockDelegate.prepareForDisplay = { textLine, displayData, range, stringRef, markedRanges, breakStrategy in + textLine.prepareForDisplay( + displayData: displayData, + range: range, + stringRef: stringRef, + markedRanges: markedRanges, + breakStrategy: breakStrategy + ) + // Update all text fragments to be height = 2.0 + textLine.lineFragments.forEach { fragmentPosition in + let idealHeight: CGFloat = 2.0 + textLine.lineFragments.update( + atIndex: fragmentPosition.index, + delta: 0, + deltaHeight: -(fragmentPosition.height - idealHeight) + ) + fragmentPosition.data.height = 2.0 + fragmentPosition.data.scaledHeight = 2.0 + } + } + + layoutManager.invalidateLayoutForRect(NSRect(x: 0, y: 0, width: 1000, height: 1000)) + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + // 4 lines, each 2px tall + #expect(layoutManager.lineStorage.height == 8.0) + + // Edit some text + + textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "0\n1\r\n2\r") + + #expect(layoutManager.lineCount == 7) + #expect(layoutManager.lineStorage.height == 14.0) + layoutManager.lineStorage.validateInternalState() + } +} From 244d59ac564d1c1e733323139afa71c3f9db6151 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Apr 2025 09:55:10 -0500 Subject: [PATCH 02/21] Move Layout To Own File --- .../TextLayoutManager+Layout.swift | 195 ++++++++++++++++++ .../TextLayoutManager/TextLayoutManager.swift | 189 +---------------- 2 files changed, 197 insertions(+), 187 deletions(-) create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift new file mode 100644 index 000000000..2ad428dc2 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -0,0 +1,195 @@ +// +// File.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit + +extension TextLayoutManager { + /// 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 Lines + + /// 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 + } + + // MARK: - Layout Single Line + + /// 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 + if let renderDelegate { + renderDelegate.prepareForDisplay( + textLine: line, + displayData: lineDisplayData, + range: position.range, + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range), + breakStrategy: lineBreakStrategy + ) + } else { + 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) + } + + // MARK: - Layout Fragment + + /// 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.renderDelegate = renderDelegate + view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) + layoutView?.addSubview(view) + view.needsDisplay = true + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index d3380a0d7..5994b30a8 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -76,7 +76,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? @@ -102,7 +102,7 @@ public class TextLayoutManager: NSObject { } /// Contains all data required to perform layout on a text line. - private struct LineLayoutData { + struct LineLayoutData { let minY: CGFloat let maxY: CGFloat let maxWidth: CGFloat @@ -191,191 +191,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 Lines - - /// 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 - } - - // MARK: - Layout Single Line - - /// 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 - if let renderDelegate { - renderDelegate.prepareForDisplay( - textLine: line, - displayData: lineDisplayData, - range: position.range, - stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range), - breakStrategy: lineBreakStrategy - ) - } else { - 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) - } - - // MARK: - Layout Fragment - - /// 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.renderDelegate = renderDelegate - view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) - layoutView?.addSubview(view) - view.needsDisplay = true - } - deinit { lineStorage.removeAll() layoutView = nil From c59aef78fdf97f4a1e7e2de87e7debd51d5e1766 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Apr 2025 09:56:39 -0500 Subject: [PATCH 03/21] File Rename --- ...LayoutDelegate.swift => TextLayoutManagerRenderDelegate.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/CodeEditTextView/TextLayoutManager/{TextLayoutManagerLayoutDelegate.swift => TextLayoutManagerRenderDelegate.swift} (100%) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerLayoutDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift similarity index 100% rename from Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerLayoutDelegate.swift rename to Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift From a37a6aebd05cb8cd6e4e47a90e0b3eee7a81d294 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Apr 2025 09:56:42 -0500 Subject: [PATCH 04/21] Update TextLayoutManagerRenderDelegate.swift --- .../TextLayoutManager/TextLayoutManagerRenderDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift index c6e1d4070..a960a9f91 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -1,5 +1,5 @@ // -// TextLayoutManagerLayoutDelegate.swift +// TextLayoutManagerRenderDelegate.swift // CodeEditTextView // // Created by Khan Winter on 4/10/25. From 726b1ecfd4287c3b570caa30ca947029bf4d2ca5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:11:17 -0500 Subject: [PATCH 05/21] Move Away from 'drawing' Towards Subclassing `LineFragmentView` --- .../Extensions/NSRange+/NSRange+init.swift | 2 +- .../TextLayoutManager+Layout.swift | 34 +++++++++++++++++-- .../TextLayoutManager+ensureLayout.swift | 34 ------------------- .../TextLayoutManager/TextLayoutManager.swift | 6 ++-- .../TextLayoutManagerRenderDelegate.swift | 9 ++--- .../TextLine/LineFragment.swift | 5 ++- .../TextLine/LineFragmentView.swift | 24 +++++-------- .../CodeEditTextView/TextLine/TextLine.swift | 15 +++++--- .../TextLine/Typesetter.swift | 6 ++++ .../TextView/TextView+Layout.swift | 5 +++ .../TextView/TextView+Lifecycle.swift | 5 --- .../Utils/ViewReuseQueue.swift | 8 +++-- 12 files changed, 81 insertions(+), 72 deletions(-) delete mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift index a9dc211a2..0c8c8e6a0 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift @@ -7,7 +7,7 @@ import Foundation -extension NSRange { +public extension NSRange { @inline(__always) init(start: Int, end: Int) { self.init(location: start, length: end - start) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 2ad428dc2..900699de0 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -19,7 +19,9 @@ extension TextLayoutManager { // MARK: - Layout Lines /// Lays out all visible lines - func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length + /// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this + /// is not the way to do so. This should only be called when macOS performs layout. + public func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length assertNotInLayout() guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, @@ -185,11 +187,37 @@ extension TextLayoutManager { for lineFragment: TextLineStorage.TextLinePosition, at yPos: CGFloat ) { - let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) + let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) { + renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() + } + view.translatesAutoresizingMaskIntoConstraints = false view.setLineFragment(lineFragment.data) - view.renderDelegate = renderDelegate 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. + 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+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 5994b30a8..e2235c8d8 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -117,18 +117,20 @@ public class TextLayoutManager: NSObject { /// - wrapLines: Set to true to wrap lines to the visible editor width. /// - textView: The view to layout text fragments in. /// - delegate: A delegate for the layout manager. - init( + public init( textStorage: NSTextStorage, lineHeightMultiplier: CGFloat, wrapLines: Bool, textView: NSView, - delegate: TextLayoutManagerDelegate? + delegate: TextLayoutManagerDelegate?, + renderDelegate: TextLayoutManagerRenderDelegate? = nil ) { self.textStorage = textStorage self.lineHeightMultiplier = lineHeightMultiplier self.wrapLines = wrapLines self.layoutView = textView self.delegate = delegate + self.renderDelegate = renderDelegate super.init() prepareTextLines() } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift index a960a9f91..4af2a0643 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -20,10 +20,11 @@ public protocol TextLayoutManagerRenderDelegate: AnyObject { markedRanges: MarkedRanges?, breakStrategy: LineBreakStrategy ) - func drawLineFragment(fragment: LineFragment, in context: CGContext) + + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView } -extension TextLayoutManagerRenderDelegate { +public extension TextLayoutManagerRenderDelegate { func prepareForDisplay( // swiftlint:disable:this function_parameter_count textLine: TextLine, displayData: TextLine.DisplayData, @@ -41,7 +42,7 @@ extension TextLayoutManagerRenderDelegate { ) } - func drawLineFragment(fragment: LineFragment, in context: CGContext) { - fragment.draw(in: context, yPos: 0.0) + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { + LineFragmentView() } } diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 045214fc8..5c24145ac 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -12,6 +12,7 @@ import CodeEditTextViewObjC /// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment. public final class LineFragment: Identifiable, Equatable { public let id = UUID() + public let documentRange: NSRange public var ctLine: CTLine public var width: CGFloat public var height: CGFloat @@ -23,13 +24,15 @@ public final class LineFragment: Identifiable, Equatable { scaledHeight - height } - public init( + init( + documentRange: NSRange, ctLine: CTLine, width: CGFloat, height: CGFloat, descent: CGFloat, lineHeightMultiplier: CGFloat ) { + self.documentRange = documentRange self.ctLine = ctLine self.width = width self.height = height diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 9e3ee228e..89b9141dc 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -8,44 +8,38 @@ import AppKit /// Displays a line fragment. -final class LineFragmentView: NSView { - private weak var lineFragment: LineFragment? +open class LineFragmentView: NSView { + public weak var lineFragment: LineFragment? - weak var renderDelegate: TextLayoutManagerRenderDelegate? - - override var isFlipped: Bool { + open override var isFlipped: Bool { true } - override var isOpaque: Bool { + open override var isOpaque: Bool { false } - override func hitTest(_ point: NSPoint) -> NSView? { nil } + open override func hitTest(_ point: NSPoint) -> NSView? { nil } /// Prepare the view for reuse, clears the line fragment reference. - override func prepareForReuse() { + open override func prepareForReuse() { super.prepareForReuse() lineFragment = nil } /// Set a new line fragment for this view, updating view size. /// - Parameter newFragment: The new fragment to use. - public func setLineFragment(_ newFragment: LineFragment) { + open func setLineFragment(_ newFragment: LineFragment) { self.lineFragment = newFragment self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight) } /// Draws the line fragment in the graphics context. - override func draw(_ dirtyRect: NSRect) { + open override func draw(_ dirtyRect: NSRect) { guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else { return } - if let renderDelegate { - renderDelegate.drawLineFragment(fragment: lineFragment, in: context) - } else { - lineFragment.draw(in: context, yPos: 0.0) - } + lineFragment.draw(in: context, yPos: 0.0) } } diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 2b906b34b..410848108 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -41,7 +41,7 @@ public final class TextLine: Identifiable, Equatable { /// - stringRef: A reference to the string storage for the document. /// - markedRanges: Any marked ranges in the line. /// - breakStrategy: Determines how line breaks are calculated. - func prepareForDisplay( + public func prepareForDisplay( displayData: DisplayData, range: NSRange, stringRef: NSTextStorage, @@ -52,6 +52,7 @@ public final class TextLine: Identifiable, Equatable { self.maxWidth = displayData.maxWidth typesetter.typeset( string, + documentRange: range, displayData: displayData, breakStrategy: breakStrategy, markedRanges: markedRanges @@ -65,8 +66,14 @@ public final class TextLine: Identifiable, Equatable { /// Contains all required data to perform a typeset and layout operation on a text line. public struct DisplayData { - let maxWidth: CGFloat - let lineHeightMultiplier: CGFloat - let estimatedLineHeight: CGFloat + public let maxWidth: CGFloat + public let lineHeightMultiplier: CGFloat + public let estimatedLineHeight: CGFloat + + public init(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, estimatedLineHeight: CGFloat) { + self.maxWidth = maxWidth + self.lineHeightMultiplier = lineHeightMultiplier + self.estimatedLineHeight = estimatedLineHeight + } } } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter.swift index df46bd193..0a0afdb44 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter.swift @@ -11,6 +11,7 @@ import CoreText final public class Typesetter { public var typesetter: CTTypesetter? public var string: NSAttributedString! + public var documentRange: NSRange? public var lineFragments = TextLineStorage() // MARK: - Init & Prepare @@ -19,10 +20,12 @@ final public class Typesetter { public func typeset( _ string: NSAttributedString, + documentRange: NSRange, displayData: TextLine.DisplayData, breakStrategy: LineBreakStrategy, markedRanges: MarkedRanges? ) { + self.documentRange = documentRange lineFragments.removeAll() if let markedRanges { let mutableString = NSMutableAttributedString(attributedString: string) @@ -62,6 +65,7 @@ final public class Typesetter { // Insert an empty fragment let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) let fragment = LineFragment( + documentRange: NSRange(location: (documentRange ?? .notFound).location, length: 0), ctLine: ctLine, width: 0, height: estimatedLineHeight/lineHeightMultiplier, @@ -107,7 +111,9 @@ final public class Typesetter { var leading: CGFloat = 0 let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading)) let height = ascent + descent + leading + let range = NSRange(location: (documentRange ?? .notFound).location + range.location, length: range.length) return LineFragment( + documentRange: range, ctLine: ctLine, width: width, height: height, diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index a2579c855..e969793f8 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() { + layoutManager.layoutLines() + super.layout() + } + 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/Utils/ViewReuseQueue.swift b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift index c969c573c..15a65d014 100644 --- a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift +++ b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift @@ -24,14 +24,16 @@ public class ViewReuseQueue { /// If there was no view dequeued for the given key, the returned view will either be a view queued for reuse or a /// new view object. /// - /// - Parameter key: The key for the view to find. + /// - Parameters: + /// - key: The key for the view to find. + /// - createView: A callback that is called to create a new instance of the queued view types. /// - Returns: A view for the given key. - public func getOrCreateView(forKey key: Key) -> View { + public func getOrCreateView(forKey key: Key, createView: () -> View) -> View { let view: View if let usedView = usedViews[key] { view = usedView } else { - view = queuedViews.popFirst() ?? View() + view = queuedViews.popFirst() ?? createView() view.prepareForReuse() usedViews[key] = view } 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 06/21] 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 07/21] 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 From 4974ac6cfff5c4c3285b0fac48948a532f82474e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:05:23 -0500 Subject: [PATCH 08/21] Public Some more Variables, Delegate Estimate Line Height --- .../TextLayoutManager/TextLayoutManager.swift | 12 ++++++++---- .../TextLayoutManagerRenderDelegate.swift | 6 ++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 40bfbe293..319b53bc4 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -92,12 +92,12 @@ public class TextLayoutManager: NSObject { /// The maximum width available to lay out lines in, used to determine how much space is available for laying out /// lines. Evals to `.greatestFiniteMagnitude` when ``wrapLines`` is `false`. - var maxLineLayoutWidth: CGFloat { + public var maxLineLayoutWidth: CGFloat { wrapLines ? wrapLinesWidth : .greatestFiniteMagnitude } /// The width of the space available to draw text fragments when wrapping lines. - var wrapLinesWidth: CGFloat { + public var wrapLinesWidth: CGFloat { (delegate?.textViewportSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal } @@ -169,6 +169,9 @@ public class TextLayoutManager: NSObject { public func estimateLineHeight() -> CGFloat { if let _estimateLineHeight { return _estimateLineHeight + } else if let estimate = renderDelegate?.estimatedLineHeight() { + _estimateLineHeight = estimate + return estimate } else { let string = NSAttributedString(string: "0", attributes: delegate?.layoutManagerTypingAttributes() ?? [:]) let typesetter = CTTypesetterCreateWithAttributedString(string) @@ -177,8 +180,9 @@ public class TextLayoutManager: NSObject { var descent: CGFloat = 0 var leading: CGFloat = 0 CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading) - _estimateLineHeight = (ascent + descent + leading) * lineHeightMultiplier - return _estimateLineHeight! + let height = (ascent + descent + leading) * lineHeightMultiplier + _estimateLineHeight = height + return height } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift index 4af2a0643..e100a86de 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -21,6 +21,8 @@ public protocol TextLayoutManagerRenderDelegate: AnyObject { breakStrategy: LineBreakStrategy ) + func estimatedLineHeight() -> CGFloat? + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView } @@ -42,6 +44,10 @@ public extension TextLayoutManagerRenderDelegate { ) } + func estimatedLineHeight() -> CGFloat? { + nil + } + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { LineFragmentView() } From ee7024decc442867bb6e98954cf54c3eecd3b366 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 16 Apr 2025 13:31:10 -0500 Subject: [PATCH 09/21] Very important Performance Improvements --- .../TextLayoutManager/TextLayoutManager+Layout.swift | 11 +++++++---- Sources/CodeEditTextView/TextLine/TextLine.swift | 9 ++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index cad679b5c..34bd483cf 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -92,14 +92,19 @@ extension TextLayoutManager { #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 + // The delegate methods below may call another layout pass, make sure we don't send it into a loop of forced + // layout. + needsLayout = false + + // Commit the view tree changes we just made. + CATransaction.commit() + // 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. @@ -114,8 +119,6 @@ extension TextLayoutManager { if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) } - - needsLayout = false } // MARK: - Layout Single Line diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 410848108..a06d7920b 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -31,7 +31,14 @@ 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 || maxWidth != self.maxWidth + needsLayout // Force layout + || ( + // Both max widths we're comparing are finite + maxWidth.isFinite + && (self.maxWidth ?? 0.0).isFinite + // The new max width is less than the required space, so we need to layout again. + && maxWidth < (self.maxWidth ?? 0.0) + ) } /// Prepares the line for display, generating all potential line breaks and calculating the real height of the line. From b1450b618c1ef97fa60dc2015723af4b777d0023 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:12:26 -0500 Subject: [PATCH 10/21] Remembered --- Sources/CodeEditTextView/TextLine/TextLine.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index a06d7920b..9e1ac6289 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -36,8 +36,9 @@ public final class TextLine: Identifiable, Equatable { // Both max widths we're comparing are finite maxWidth.isFinite && (self.maxWidth ?? 0.0).isFinite - // The new max width is less than the required space, so we need to layout again. - && maxWidth < (self.maxWidth ?? 0.0) + // 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) ) } From 120e6fc0ecb532effb364cee0c2e596b4170a822 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 09:57:28 -0500 Subject: [PATCH 11/21] Add One Case To Layout Invalidation, Selection Drawing Uses LayoutManager --- .../TextLayoutManager+Layout.swift | 11 ++++-- .../TextLayoutManager+Public.swift | 38 ++++++++++++++----- .../TextLayoutManagerRenderDelegate.swift | 6 +++ .../TextLine/LineFragment.swift | 13 ++++++- .../TextSelectionManager+Draw.swift | 2 +- .../TextSelectionManager+FillRects.swift | 2 +- .../TextSelectionManager.swift | 2 +- .../TextView/DraggingTextRenderer.swift | 4 +- 8 files changed, 59 insertions(+), 19 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 34bd483cf..627a0affb 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -55,10 +55,13 @@ extension TextLayoutManager { // 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) { + guard linePosition.yPos < maxY else { continue } + // Three ways to determine if a line needs to be re-calculated. + let changedWidth = linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) + let wasNotVisible = !visibleLineIds.contains(linePosition.data.id) + let lineNotEntirelyLaidOut = linePosition.height != linePosition.data.lineFragments.height + + if forceLayout || changedWidth || wasNotVisible || lineNotEntirelyLaidOut { let lineSize = layoutLine( linePosition, textStorage: textStorage, diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 5c9f14059..120cf8b85 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -137,15 +137,13 @@ extension TextLayoutManager { : (textStorage?.string as? NSString)?.rangeOfComposedCharacterSequence(at: offset) ?? NSRange(location: offset, length: 0) - let minXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRange.location - linePosition.range.location, // CTLines have the same relative range as the line - nil + let minXPos = characterXPosition( + in: fragmentPosition.data, + for: realRange.location - linePosition.range.location ) - let maxXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - realRange.max - linePosition.range.location, - nil + let maxXPos = characterXPosition( + in: fragmentPosition.data, + for: realRange.max - linePosition.range.location ) return CGRect( @@ -187,7 +185,7 @@ extension TextLayoutManager { var rects: [CGRect] = [] for fragmentPosition in line.data.lineFragments.linesInRange(relativeRange) { guard let intersectingRange = fragmentPosition.range.intersection(relativeRange) else { continue } - let fragmentRect = fragmentPosition.data.rectFor(range: intersectingRange) + let fragmentRect = characterRect(in: fragmentPosition.data, for: intersectingRange) guard fragmentRect.width > 0 else { continue } rects.append( CGRect( @@ -270,6 +268,28 @@ extension TextLayoutManager { return nil } + // MARK: - Line Fragment Rects + + /// Finds the x position of the offset in the string the fragment represents. + /// - Parameters: + /// - lineFragment: The line fragment to calculate for. + /// - offset: The offset, relative to the start of the *line*. + /// - Returns: The x position of the character in the drawn line, from the left. + public func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { + renderDelegate?.characterXPosition(in: lineFragment, for: offset) ?? lineFragment._xPos(for: offset) + } + + public func characterRect(in lineFragment: LineFragment, for range: NSRange) -> CGRect { + let minXPos = characterXPosition(in: lineFragment, for: range.lowerBound) + let maxXPos = characterXPosition(in: lineFragment, for: range.upperBound) + return CGRect( + x: minXPos, + y: 0, + width: maxXPos - minXPos, + height: lineFragment.scaledHeight + ).pixelAligned + } + // MARK: - Ensure Layout /// Forces layout calculation for all lines up to and including the given offset. diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift index e100a86de..6e366d3c5 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -24,6 +24,8 @@ public protocol TextLayoutManagerRenderDelegate: AnyObject { func estimatedLineHeight() -> CGFloat? func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView + + func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat } public extension TextLayoutManagerRenderDelegate { @@ -51,4 +53,8 @@ public extension TextLayoutManagerRenderDelegate { func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { LineFragmentView() } + + func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { + lineFragment._xPos(for: offset) + } } diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 5c24145ac..923848ab1 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -47,7 +47,17 @@ public final class LineFragment: Identifiable, Equatable { /// Finds the x position of the offset in the string the fragment represents. /// - Parameter offset: The offset, relative to the start of the *line*. /// - Returns: The x position of the character in the drawn line, from the left. - public func xPos(for offset: Int) -> CGFloat { + @available(*, deprecated, renamed: "layoutManager.characterXPosition(in:)", message: "Moved to layout manager") + public func xPos(for offset: Int) -> CGFloat { _xPos(for: offset) } + + /// Finds the x position of the offset in the string the fragment represents. + /// + /// Underscored, because although this needs to be accessible outside this class, the relevant layout manager method + /// should be used. + /// + /// - Parameter offset: The offset, relative to the start of the *line*. + /// - Returns: The x position of the character in the drawn line, from the left. + func _xPos(for offset: Int) -> CGFloat { return CTLineGetOffsetForStringIndex(ctLine, offset, nil) } @@ -84,6 +94,7 @@ public final class LineFragment: Identifiable, Equatable { /// Calculates the drawing rect for a given range. /// - Parameter range: The range to calculate the bounds for, relative to the line. /// - Returns: A rect that contains the text contents in the given range. + @available(*, deprecated, renamed: "layoutManager.characterRect(in:)", message: "Moved to layout manager") public func rectFor(range: NSRange) -> CGRect { let minXPos = CTLineGetOffsetForStringIndex(ctLine, range.lowerBound, nil) let maxXPos = CTLineGetOffsetForStringIndex(ctLine, range.upperBound, nil) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift index e79832fdc..45907d6c1 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift @@ -10,7 +10,7 @@ import AppKit extension TextSelectionManager { /// Draws line backgrounds and selection rects for each selection in the given rect. /// - Parameter rect: The rect to draw in. - func drawSelections(in rect: NSRect) { + public func drawSelections(in rect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() var highlightedLines: Set = [] diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index 204e09ebb..666f9b711 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -18,7 +18,7 @@ extension TextSelectionManager { /// - Returns: An array of rects that the selection overlaps. func getFillRects(in rect: NSRect, for textSelection: TextSelection) -> [CGRect] { guard let layoutManager, - let range = textSelection.range.intersection(textView?.visibleTextRange ?? .zero) else { + let range = textSelection.range.intersection(delegate?.visibleTextRange ?? .zero) else { return [] } diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 9df5922bd..28e8801a2 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -52,7 +52,7 @@ public class TextSelectionManager: NSObject { weak var delegate: TextSelectionManagerDelegate? var cursorTimer: CursorTimer - init( + public init( layoutManager: TextLayoutManager, textStorage: NSTextStorage, textView: TextView?, diff --git a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift index 03fc773e4..b8680f443 100644 --- a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift +++ b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift @@ -81,7 +81,7 @@ class DraggingTextRenderer: NSView { // Clear text that's not selected if fragmentRange.contains(selectedRange.lowerBound) { let relativeOffset = selectedRange.lowerBound - line.range.lowerBound - let selectionXPos = fragment.data.xPos(for: relativeOffset) + let selectionXPos = layoutManager.characterXPosition(in: fragment.data, for: relativeOffset) context.clear( CGRect( x: 0.0, @@ -94,7 +94,7 @@ class DraggingTextRenderer: NSView { if fragmentRange.contains(selectedRange.upperBound) { let relativeOffset = selectedRange.upperBound - line.range.lowerBound - let selectionXPos = fragment.data.xPos(for: relativeOffset) + let selectionXPos = layoutManager.characterXPosition(in: fragment.data, for: relativeOffset) context.clear( CGRect( x: selectionXPos, From ca32b4c1fc49895e32a1535d877df90cfad4ad7c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:24:26 -0500 Subject: [PATCH 12/21] Document the layout routine --- .../TextLayoutManager+Layout.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 627a0affb..261484111 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -26,6 +26,47 @@ extension TextLayoutManager { // MARK: - Layout Lines /// Lays out all visible lines + /// + /// ## Overview Of The Layout Routine + /// + /// The basic premise of this method is that it loops over all lines in the given rect (defaults to the visible + /// rect), checks if the line needs a layout calculation, and performs layout on the line if it does. + /// + /// The thing that makes this layout method so fast is the second point, checking if a line needs layout. To + /// determine if a line needs a layout pass, the layout manager can check three things: + /// - **1** Was the line laid out under the assumption of a different maximum layout width? + /// Eg: If wrapping is toggled, and a line was initially long but now needs to be broken, this triggers that + /// layout pass. + /// - **2** Was the line previously not visible? This is determined by keeping a set of visible line IDs. If the + /// line does not appear in that set, we can assume it was previously off screen and may need layout. + /// - **3** Was the line entirely laid out? We break up lines into line fragments. When we do layout, we determine + /// all line fragments but don't necessarily place them all in the view. This checks if all line fragments have + /// been placed in the view. If not, we need to place them. + /// + /// Once it has been determined that a line needs layout, we perform layout by recalculating it's line fragments, + /// removing all old line fragment views, and creating new ones for the line. + /// + /// ## Laziness + /// + /// At the end of the layout pass, we clean up any old lines by updating the set of visible line IDs and fragment + /// IDs. Any IDs that no longer appear in those sets are removed to save resources. This facilitates the text view's + /// ability to only render text that is visible and saves tons of resources (similar to the lazy loading of + /// collection or table views). + /// + /// The other important lazy attribute is the line iteration. Line iteration is done lazily. As we iterate + /// through lines and potentially update their heights, the next line is only queried for *after* the updates are + /// finished. + /// + /// ## Reentry + /// + /// An important thing to note is that this method cannot be reentered. If a layout pass is begun while a layout + /// pass is already ongoing, internal data structures will be broken. In debug builds, this is checked with a simple + /// boolean and assertion. + /// + /// To help ensure this property, all view modifications are done in a `CATransaction`. This ensures that only after + /// we're done inserting and removing line fragment views, does macOS call `layout` on any related views. Otherwise, + /// we may cause a layout pass when a line fragment view is inserted and cause a reentrance in this method. + /// /// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this /// is not the way to do so. This should only be called when macOS performs layout. public func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length From 537cf7415175e2a0f68e73723e44629a1bd3efb7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:31:47 -0500 Subject: [PATCH 13/21] Move Struct, Update Tests --- .../MarkedTextManager/MarkedRanges.swift | 15 +++++++++++++++ .../MarkedTextManager/MarkedTextManager.swift | 7 ------- .../OverridingLayoutManagerRenderingTests.swift | 3 ++- Tests/CodeEditTextViewTests/TypesetterTests.swift | 6 ++++++ 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift diff --git a/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift new file mode 100644 index 000000000..8e25e8af1 --- /dev/null +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift @@ -0,0 +1,15 @@ +// +// MarkedRanges.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/17/25. +// + +import AppKit + +/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o +/// requiring a reference to the marked text manager. +public struct MarkedRanges { + let ranges: [NSRange] + let attributes: [NSAttributedString.Key: Any] +} diff --git a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift index 81c53e564..5270dce8d 100644 --- a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift @@ -7,13 +7,6 @@ import AppKit -/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o -/// requiring a reference to the marked text manager. -public struct MarkedRanges { - let ranges: [NSRange] - let attributes: [NSAttributedString.Key: Any] -} - /// Manages marked ranges. Not a public API. class MarkedTextManager { /// All marked ranges being tracked. diff --git a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift index dfb16e319..ecb61b205 100644 --- a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift +++ b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift @@ -68,7 +68,7 @@ struct OverridingLayoutManagerRenderingTests { textLine.lineFragments.forEach { fragmentPosition in let idealHeight: CGFloat = 2.0 textLine.lineFragments.update( - atIndex: fragmentPosition.index, + atOffset: fragmentPosition.index, delta: 0, deltaHeight: -(fragmentPosition.height - idealHeight) ) @@ -86,6 +86,7 @@ struct OverridingLayoutManagerRenderingTests { // Edit some text textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "0\n1\r\n2\r") + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) #expect(layoutManager.lineCount == 7) #expect(layoutManager.lineStorage.height == 14.0) diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index 07954a7cf..3b62e8fbe 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -11,6 +11,7 @@ class TypesetterTests: XCTestCase { let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\n"), + documentRange: NSRange(location: 0, length: 9), displayData: unlimitedLineWidthDisplayData, breakStrategy: .word, markedRanges: nil @@ -20,6 +21,7 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "testline\n"), + documentRange: NSRange(location: 0, length: 9), displayData: unlimitedLineWidthDisplayData, breakStrategy: .character, markedRanges: nil @@ -32,6 +34,7 @@ class TypesetterTests: XCTestCase { let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\r"), + documentRange: NSRange(location: 0, length: 9), displayData: unlimitedLineWidthDisplayData, breakStrategy: .word, markedRanges: nil @@ -41,6 +44,7 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "testline\r"), + documentRange: NSRange(location: 0, length: 9), displayData: unlimitedLineWidthDisplayData, breakStrategy: .character, markedRanges: nil @@ -53,6 +57,7 @@ class TypesetterTests: XCTestCase { let typesetter = Typesetter() typesetter.typeset( NSAttributedString(string: "testline\r\n"), + documentRange: NSRange(location: 0, length: 10), displayData: unlimitedLineWidthDisplayData, breakStrategy: .word, markedRanges: nil @@ -62,6 +67,7 @@ class TypesetterTests: XCTestCase { typesetter.typeset( NSAttributedString(string: "testline\r\n"), + documentRange: NSRange(location: 0, length: 10), displayData: unlimitedLineWidthDisplayData, breakStrategy: .character, markedRanges: nil From 9296067599df773ef0d182cc20fe32e52ca747b7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:11:39 -0500 Subject: [PATCH 14/21] Add Test --- .../TextLayoutManager/TextLayoutManager.swift | 9 ++++++++- ...verridingLayoutManagerRenderingTests.swift | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 319b53bc4..0b65efb58 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -55,7 +55,14 @@ public class TextLayoutManager: NSObject { } } - public weak var renderDelegate: TextLayoutManagerRenderDelegate? + public weak var renderDelegate: TextLayoutManagerRenderDelegate? { + didSet { + // Rebuild using potentially overridden behavior. + _estimateLineHeight = nil + lineStorage.removeAll() + prepareTextLines() + } + } // MARK: - Internal diff --git a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift index ecb61b205..85a8eb68e 100644 --- a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift +++ b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift @@ -12,6 +12,8 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate { _ breakStrategy: LineBreakStrategy ) -> Void)? + var estimatedLineHeightOverride: (() -> CGFloat)? + func prepareForDisplay( // swiftlint:disable:this function_parameter_count textLine: TextLine, displayData: TextLine.DisplayData, @@ -35,6 +37,10 @@ class MockRenderDelegate: TextLayoutManagerRenderDelegate { breakStrategy: breakStrategy ) } + + func estimatedLineHeight() -> CGFloat? { + estimatedLineHeightOverride?() + } } @Suite @@ -92,4 +98,17 @@ struct OverridingLayoutManagerRenderingTests { #expect(layoutManager.lineStorage.height == 14.0) layoutManager.lineStorage.validateInternalState() } + + @Test + func overriddenEstimatedLineHeight() { + // The layout manager should use the estimation from the render delegate, not the font size. + mockDelegate.estimatedLineHeightOverride = { + 1.0 + } + + layoutManager.renderDelegate = mockDelegate + + #expect(layoutManager.estimateLineHeight() == 1.0) + #expect(layoutManager.estimatedHeight() == 4.0) // 4 lines, each 1 high + } } From e58a83637d9edf0435e582fe0e690038814b7eb9 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:39:36 -0500 Subject: [PATCH 15/21] Remove Methods Destroying Layout Information --- .../TextLayoutManager+Layout.swift | 24 ------------- .../TextLayoutManager+Public.swift | 36 ------------------- 2 files changed, 60 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 261484111..a06f36163 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -250,28 +250,4 @@ extension TextLayoutManager { 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 120cf8b85..c79b8b5f5 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -120,9 +120,6 @@ extension TextLayoutManager { guard let linePosition = lineStorage.getLine(atOffset: offset) else { return nil } - if linePosition.data.lineFragments.isEmpty { - ensureLayoutUntil(offset) - } guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( atOffset: offset - linePosition.range.location @@ -160,7 +157,6 @@ 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] { - ensureLayoutUntil(range.max) return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } } @@ -289,36 +285,4 @@ extension TextLayoutManager { height: lineFragment.scaledHeight ).pixelAligned } - - // MARK: - Ensure Layout - - /// Forces layout calculation for all lines up to and including the given offset. - /// - Parameter offset: The offset to ensure layout until. - public func ensureLayoutUntil(_ offset: Int) { - guard let linePosition = lineStorage.getLine(atOffset: offset), - let visibleRect = delegate?.visibleRect, - visibleRect.maxY < linePosition.yPos + linePosition.height, - let startingLinePosition = lineStorage.getLine(atPosition: visibleRect.minY) - else { - return - } - let originalHeight = lineStorage.height - - for linePosition in lineStorage.linesInRange( - NSRange(start: startingLinePosition.range.location, end: linePosition.range.max) - ) { - let height = preparePositionForDisplay(linePosition) - if height != linePosition.height { - lineStorage.update( - atOffset: linePosition.range.location, - delta: 0, - deltaHeight: height - linePosition.height - ) - } - } - - if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } - } } From 17b5d9842e6615404e335865592bad7de5917081 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:11:34 -0500 Subject: [PATCH 16/21] Update MarkedRanges.swift Co-authored-by: Tom Ludwig --- Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift index 8e25e8af1..3cfcdbafd 100644 --- a/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift @@ -7,7 +7,7 @@ import AppKit -/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o +/// Struct for passing attribute and range information easily down into line fragments, typesetters without /// requiring a reference to the marked text manager. public struct MarkedRanges { let ranges: [NSRange] From 047d827a76a06d8e26630783b80dab19bf41ad93 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:11:52 -0500 Subject: [PATCH 17/21] Update TextLayoutManager+Layout.swift Co-authored-by: Tom Ludwig --- .../TextLayoutManager/TextLayoutManager+Layout.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index a06f36163..2d25e976e 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -59,7 +59,7 @@ extension TextLayoutManager { /// /// ## Reentry /// - /// An important thing to note is that this method cannot be reentered. If a layout pass is begun while a layout + /// An important thing to note is that this method cannot be reentered. If a layout pass has begun while a layout /// pass is already ongoing, internal data structures will be broken. In debug builds, this is checked with a simple /// boolean and assertion. /// From 8db24b1d74b500e44afc8a7149003c94d41067c7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:12:05 -0500 Subject: [PATCH 18/21] Update TextLayoutManager+Layout.swift Co-authored-by: Tom Ludwig --- .../TextLayoutManager/TextLayoutManager+Layout.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 2d25e976e..d614247f5 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -63,9 +63,9 @@ extension TextLayoutManager { /// pass is already ongoing, internal data structures will be broken. In debug builds, this is checked with a simple /// boolean and assertion. /// - /// To help ensure this property, all view modifications are done in a `CATransaction`. This ensures that only after - /// we're done inserting and removing line fragment views, does macOS call `layout` on any related views. Otherwise, - /// we may cause a layout pass when a line fragment view is inserted and cause a reentrance in this method. + /// To help ensure this property, all view modifications are performed within a `CATransaction`. This guarantees that macOS calls + /// `layout` on any related views only after we’ve finished inserting and removing line fragment views. Otherwise, + /// inserting a line fragment view could trigger a layout pass prematurely and cause this method to re-enter. /// /// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this /// is not the way to do so. This should only be called when macOS performs layout. From 8692f41236b3af3d6a4b00bb952df8a6961e4ef7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 09:48:58 -0500 Subject: [PATCH 19/21] Use a Lock --- .../TextLayoutManager+Layout.swift | 37 ++++++------------- .../TextLayoutManager/TextLayoutManager.swift | 7 +--- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index d614247f5..f0d9d7aa5 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -15,14 +15,6 @@ extension TextLayoutManager { 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 Lines /// Lays out all visible lines @@ -63,14 +55,14 @@ extension TextLayoutManager { /// pass is already ongoing, internal data structures will be broken. In debug builds, this is checked with a simple /// boolean and assertion. /// - /// To help ensure this property, all view modifications are performed within a `CATransaction`. This guarantees that macOS calls - /// `layout` on any related views only after we’ve finished inserting and removing line fragment views. Otherwise, - /// inserting a line fragment view could trigger a layout pass prematurely and cause this method to re-enter. - /// + /// To help ensure this property, all view modifications are performed within a `CATransaction`. This guarantees + /// that macOS calls `layout` on any related views only after we’ve finished inserting and removing line fragment + /// views. Otherwise, inserting a line fragment view could trigger a layout pass prematurely and cause this method + /// to re-enter. /// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this /// is not the way to do so. This should only be called when macOS performs layout. public func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length - assertNotInLayout() + layoutLock.lock() guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { @@ -81,9 +73,6 @@ extension TextLayoutManager { // 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) @@ -133,9 +122,6 @@ extension TextLayoutManager { newVisibleLines.insert(linePosition.data.id) } -#if DEBUG - isInLayout = false -#endif // Enqueue any lines not used in this layout pass. viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs) @@ -149,9 +135,6 @@ extension TextLayoutManager { // Commit the view tree changes we just made. CATransaction.commit() - // 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 } @@ -163,6 +146,7 @@ extension TextLayoutManager { if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) } + layoutLock.unlock() } // MARK: - Layout Single Line @@ -215,10 +199,11 @@ extension TextLayoutManager { 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 - ) { +// for lineFragmentPosition in line.lineFragments.linesStartingAt( +// relativeMinY, +// until: relativeMaxY +// ) { + for lineFragmentPosition in line.lineFragments { let lineFragment = lineFragmentPosition.data layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 0b65efb58..7cf7b0428 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -78,13 +78,10 @@ public class TextLayoutManager: NSObject { public var isInTransaction: Bool { transactionCounter > 0 } - #if DEBUG + /// Guard variable for an assertion check in debug builds. /// 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. - var isInLayout: Bool = false - #endif + var layoutLock: NSLock = NSLock() weak var layoutView: NSView? From fad4ea41ed0fffe7b7902159e0a067a1c384604b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 09:54:16 -0500 Subject: [PATCH 20/21] Grammar --- .../TextLayoutManager/TextLayoutManager+Layout.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index f0d9d7aa5..1b5d83f45 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -27,8 +27,8 @@ extension TextLayoutManager { /// The thing that makes this layout method so fast is the second point, checking if a line needs layout. To /// determine if a line needs a layout pass, the layout manager can check three things: /// - **1** Was the line laid out under the assumption of a different maximum layout width? - /// Eg: If wrapping is toggled, and a line was initially long but now needs to be broken, this triggers that - /// layout pass. + /// For instance, if a line was previously broken by the line wrapping setting, it won’t need to wrap once the + /// line wrapping is disabled. This will detect that, and cause the lines to be recalculated. /// - **2** Was the line previously not visible? This is determined by keeping a set of visible line IDs. If the /// line does not appear in that set, we can assume it was previously off screen and may need layout. /// - **3** Was the line entirely laid out? We break up lines into line fragments. When we do layout, we determine From ef1548bfc57d05511a31ba5f3bc62cb981c03991 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 09:58:27 -0500 Subject: [PATCH 21/21] More Conservative Locking --- .../TextLayoutManager/TextLayoutManager+Layout.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 1b5d83f45..b90c18b46 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -62,7 +62,6 @@ extension TextLayoutManager { /// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this /// is not the way to do so. This should only be called when macOS performs layout. public func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length - layoutLock.lock() guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { @@ -73,6 +72,7 @@ extension TextLayoutManager { // tree modifications caused by this method are atomic, so macOS won't call `layout` while we're already doing // that CATransaction.begin() + layoutLock.lock() let minY = max(visibleRect.minY - verticalLayoutPadding, 0) let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) @@ -133,6 +133,7 @@ extension TextLayoutManager { needsLayout = false // Commit the view tree changes we just made. + layoutLock.unlock() CATransaction.commit() if maxFoundLineWidth > maxLineWidth { @@ -146,7 +147,6 @@ extension TextLayoutManager { if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) } - layoutLock.unlock() } // MARK: - Layout Single Line