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/MarkedTextManager/MarkedRanges.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift new file mode 100644 index 000000000..3cfcdbafd --- /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 without +/// 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 9c7612913..5270dce8d 100644 --- a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift @@ -9,13 +9,6 @@ import AppKit /// 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+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 764b1ec92..b90c18b46 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -15,19 +15,53 @@ 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 + // MARK: - Layout Lines /// Lays out all visible lines - func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length - assertNotInLayout() + /// + /// ## 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? + /// 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 + /// 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 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. + /// + /// 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 guard let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { @@ -38,9 +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() -#if DEBUG - isInLayout = true -#endif + layoutLock.lock() let minY = max(visibleRect.minY - verticalLayoutPadding, 0) let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) @@ -53,10 +85,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, @@ -87,19 +122,19 @@ extension TextLayoutManager { 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. + // 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. + layoutLock.unlock() + CATransaction.commit() if maxFoundLineWidth > maxLineWidth { maxLineWidth = maxFoundLineWidth @@ -112,10 +147,10 @@ extension TextLayoutManager { 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. @@ -136,13 +171,24 @@ extension TextLayoutManager { ) 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()) @@ -153,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) @@ -169,6 +216,8 @@ extension TextLayoutManager { 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. @@ -177,34 +226,13 @@ 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.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 5c9f14059..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 @@ -137,15 +134,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( @@ -162,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) } } @@ -187,7 +181,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,35 +264,25 @@ extension TextLayoutManager { return nil } - // 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 - ) - } - } + // MARK: - Line Fragment Rects - if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) - } + /// 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 } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 45df87e95..7cf7b0428 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,15 @@ public class TextLayoutManager: NSObject { } } + public weak var renderDelegate: TextLayoutManagerRenderDelegate? { + didSet { + // Rebuild using potentially overridden behavior. + _estimateLineHeight = nil + lineStorage.removeAll() + prepareTextLines() + } + } + // MARK: - Internal weak var textStorage: NSTextStorage? @@ -79,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? @@ -100,12 +96,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 } @@ -118,18 +114,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() } @@ -175,6 +173,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) @@ -183,8 +184,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/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/TextLayoutManagerRenderDelegate.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift new file mode 100644 index 000000000..6e366d3c5 --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift @@ -0,0 +1,60 @@ +// +// TextLayoutManagerRenderDelegate.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 estimatedLineHeight() -> CGFloat? + + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView + + func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat +} + +public 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 estimatedLineHeight() -> CGFloat? { + nil + } + + 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 f4ace5689..923848ab1 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -12,11 +12,12 @@ 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 let documentRange: NSRange + 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 { @@ -24,12 +25,14 @@ public final class LineFragment: Identifiable, Equatable { } 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 @@ -44,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) } @@ -81,7 +94,8 @@ 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 { + @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) return CGRect( diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 3536fd4e0..89b9141dc 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -8,37 +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? - 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 } + lineFragment.draw(in: context, yPos: 0.0) } } diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 0bce6307b..9e1ac6289 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -31,7 +31,15 @@ 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 + // 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) + ) } /// Prepares the line for display, generating all potential line breaks and calculating the real height of the line. @@ -41,17 +49,18 @@ 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, - markedRanges: MarkedTextManager.MarkedRanges?, + markedRanges: MarkedRanges?, breakStrategy: LineBreakStrategy ) { let string = stringRef.attributedSubstring(from: range) self.maxWidth = displayData.maxWidth typesetter.typeset( string, + documentRange: range, displayData: displayData, breakStrategy: breakStrategy, markedRanges: markedRanges @@ -64,9 +73,15 @@ public final class TextLine: Identifiable, Equatable { } /// Contains all required data to perform a typeset and layout operation on a text line. - struct DisplayData { - let maxWidth: CGFloat - let lineHeightMultiplier: CGFloat - let estimatedLineHeight: CGFloat + public struct DisplayData { + 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 f05c69c75..0a0afdb44 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter.swift @@ -8,21 +8,24 @@ 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 documentRange: NSRange? + public var lineFragments = TextLineStorage() // MARK: - Init & Prepare - init() { } + public init() { } - func typeset( + public func typeset( _ string: NSAttributedString, + documentRange: NSRange, displayData: TextLine.DisplayData, breakStrategy: LineBreakStrategy, - markedRanges: MarkedTextManager.MarkedRanges? + markedRanges: MarkedRanges? ) { + self.documentRange = documentRange lineFragments.removeAll() if let markedRanges { let mutableString = NSMutableAttributedString(attributedString: string) @@ -62,6 +65,7 @@ final 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 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/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, 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 } diff --git a/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift new file mode 100644 index 000000000..85a8eb68e --- /dev/null +++ b/Tests/CodeEditTextViewTests/OverridingLayoutManagerRenderingTests.swift @@ -0,0 +1,114 @@ +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)? + + var estimatedLineHeightOverride: (() -> CGFloat)? + + 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 + ) + } + + func estimatedLineHeight() -> CGFloat? { + estimatedLineHeightOverride?() + } +} + +@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( + atOffset: 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") + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + #expect(layoutManager.lineCount == 7) + #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 + } +} 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