From 74580517365b0fbd08a669ce5cb21ec65a1bd520 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 19 Apr 2026 08:40:05 -0700 Subject: [PATCH 1/2] View zones, line decorations, selection style updates, bug fixes --- .../Decorations/LineDecoration.swift | 74 +++++ .../Decorations/LineDecorationManager.swift | 216 ++++++++++++++ .../TextLayoutManager+Decorations.swift | 92 ++++++ .../TextLayoutManager+Edits.swift | 84 ++++-- .../TextLayoutManager+Invalidation.swift | 15 +- .../TextLayoutManager+Layout.swift | 97 ++++++- .../TextLayoutManager+Public.swift | 86 +++++- .../TextLayoutManager/TextLayoutManager.swift | 17 ++ .../TextLineStorage/TextLineStorage.swift | 13 +- .../TextSelectionManager+Draw.swift | 14 +- .../TextSelectionManager.swift | 2 +- .../TextView/TextView+Layout.swift | 34 +++ .../CodeEditTextView/TextView/TextView.swift | 8 + .../Utils/LineHighlightDrawing.swift | 92 ++++++ .../CodeEditTextView/ViewZones/ViewZone.swift | 56 ++++ .../ViewZones/ViewZoneManager.swift | 270 ++++++++++++++++++ 16 files changed, 1108 insertions(+), 62 deletions(-) create mode 100644 Sources/CodeEditTextView/Decorations/LineDecoration.swift create mode 100644 Sources/CodeEditTextView/Decorations/LineDecorationManager.swift create mode 100644 Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Decorations.swift create mode 100644 Sources/CodeEditTextView/Utils/LineHighlightDrawing.swift create mode 100644 Sources/CodeEditTextView/ViewZones/ViewZone.swift create mode 100644 Sources/CodeEditTextView/ViewZones/ViewZoneManager.swift diff --git a/Sources/CodeEditTextView/Decorations/LineDecoration.swift b/Sources/CodeEditTextView/Decorations/LineDecoration.swift new file mode 100644 index 000000000..a04a00771 --- /dev/null +++ b/Sources/CodeEditTextView/Decorations/LineDecoration.swift @@ -0,0 +1,74 @@ +// +// LineDecoration.swift +// CodeEditTextView +// +// Created by Abe Malla on 4/12/26. +// + +import AppKit + +/// A line decoration represents a visual adornment applied to one or more lines of text in the editor. +/// +/// Line decorations can add background colors to entire lines (e.g. for error highlights, current line +/// highlighting, search match line highlights), glyph margin decorations (e.g. breakpoint indicators), +/// or line-level CSS-like styling. +/// +/// Decorations are identified by a unique ID and can be added/removed dynamically. They track +/// with text edits via their stickiness behavior. +/// +/// Modeled after VSCode's `IModelDecoration` / `IModelDecorationOptions`. +public struct LineDecoration: Identifiable { + /// Unique identifier for this decoration. + public let id: UUID + + /// The range of line indices (0-based) this decoration applies to. + public var lineRange: ClosedRange + + /// The visual style of this decoration. + public var style: Style + + /// How the decoration range behaves when text is edited at its boundaries. + public var stickiness: Stickiness + + /// An optional tag for grouping decorations (e.g. "error", "search-result", "current-line"). + /// Used for bulk removal of decorations by group. + public var group: String? + + /// The type of visual decoration. + public enum Style { + /// A background color applied to the entire line(s). + /// Used for current line highlight, error line backgrounds, search match highlights, etc. + case lineBackground(color: NSColor) + + /// An overview ruler decoration — a small colored mark on the scrollbar. + /// Used to show the position of errors, search results, and other items in the scrollbar. + case overviewRuler(color: NSColor) + + /// A custom drawing callback for the line background. + /// The closure receives the context, the rect for the line, and the line index. + case custom(draw: (CGContext, CGRect, Int) -> Void) + } + + /// Controls how the decoration range adjusts when text is edited at its boundaries. + public enum Stickiness { + /// The range does not grow when text is typed at its edges. + case neverGrowsWhenTypingAtEdges + /// The range grows on the right when text is typed at its right edge. + case growsOnlyWhenTypingAfter + /// The range always grows when text is typed at either edge. + case alwaysGrowsWhenTypingAtEdges + } + + public init( + lineRange: ClosedRange, + style: Style, + stickiness: Stickiness = .growsOnlyWhenTypingAfter, + group: String? = nil + ) { + self.id = UUID() + self.lineRange = lineRange + self.style = style + self.stickiness = stickiness + self.group = group + } +} diff --git a/Sources/CodeEditTextView/Decorations/LineDecorationManager.swift b/Sources/CodeEditTextView/Decorations/LineDecorationManager.swift new file mode 100644 index 000000000..9b23b38a4 --- /dev/null +++ b/Sources/CodeEditTextView/Decorations/LineDecorationManager.swift @@ -0,0 +1,216 @@ +// +// LineDecorationManager.swift +// CodeEditTextView +// +// Created by Abe Malla on 4/12/26. +// + +import AppKit + +/// Manages line decorations for the text view. +/// +/// Provides O(log n) lookup of decorations by line number using a sorted structure. +/// Decorations are grouped by line for efficient rendering during the layout pass. +/// +/// Modeled after VSCode's `EditorDecorations` and `DecorationsOverviewRuler`. +public final class LineDecorationManager { + /// Callback invoked when decorations change. Set by the layout manager to trigger relayout. + public var onDecorationsChanged: (() -> Void)? + + /// All decorations, keyed by ID for O(1) removal. + private var decorationsById: [UUID: LineDecoration] = [:] + + /// Cached sorted array of decorations, sorted by lineRange.lowerBound for binary search. + /// Invalidated and rebuilt lazily. + private var sortedDecorations: [LineDecoration]? + + /// The number of managed decorations. + public var count: Int { decorationsById.count } + + public init() {} + + // MARK: - Mutation + + /// Add a decoration and return its ID. + @discardableResult + public func addDecoration(_ decoration: LineDecoration) -> UUID { + decorationsById[decoration.id] = decoration + invalidateCache() + onDecorationsChanged?() + return decoration.id + } + + /// Remove a decoration by ID. + public func removeDecoration(id: UUID) { + guard decorationsById.removeValue(forKey: id) != nil else { return } + invalidateCache() + onDecorationsChanged?() + } + + /// Remove all decorations with a given group tag. + public func removeDecorations(group: String) { + let idsToRemove = decorationsById.values.filter { $0.group == group }.map(\.id) + guard !idsToRemove.isEmpty else { return } + for id in idsToRemove { + decorationsById.removeValue(forKey: id) + } + invalidateCache() + onDecorationsChanged?() + } + + /// Remove all decorations. + public func removeAllDecorations() { + guard !decorationsById.isEmpty else { return } + decorationsById.removeAll() + invalidateCache() + onDecorationsChanged?() + } + + /// Update the line range of an existing decoration. + public func updateDecorationRange(id: UUID, lineRange: ClosedRange) { + guard decorationsById[id] != nil else { return } + decorationsById[id]!.lineRange = lineRange + invalidateCache() + onDecorationsChanged?() + } + + /// Update the style of an existing decoration. + public func updateDecorationStyle(id: UUID, style: LineDecoration.Style) { + guard decorationsById[id] != nil else { return } + decorationsById[id]!.style = style + invalidateCache() + onDecorationsChanged?() + } + + // MARK: - Query + + /// Returns all decorations that overlap with the given line range. + /// Uses binary search for the starting position, then scans forward. + public func decorations(inLineRange range: ClosedRange) -> [LineDecoration] { + let sorted = ensureSorted() + guard !sorted.isEmpty else { return [] } + + // Binary search: find first decoration whose lineRange.upperBound >= range.lowerBound + var lo = 0 + var hi = sorted.count + while lo < hi { + let mid = (lo + hi) / 2 + if sorted[mid].lineRange.upperBound < range.lowerBound { + lo = mid + 1 + } else { + hi = mid + } + } + + var result: [LineDecoration] = [] + for idx in lo.. range.upperBound { + break + } + // Check overlap + if dec.lineRange.overlaps(range) { + result.append(dec) + } + } + return result + } + + /// Returns all decorations that affect a single line index. + public func decorations(forLine line: Int) -> [LineDecoration] { + decorations(inLineRange: line...line) + } + + /// Returns all decorations. + public func allDecorations() -> [LineDecoration] { + Array(decorationsById.values) + } + + // MARK: - Edit Tracking + + /// Adjusts decoration line ranges after a text edit. + /// + /// - Parameters: + /// - editLineStart: The first line affected by the edit (0-based). + /// - oldLineCount: The number of lines that were in the edited range before the edit. + /// - newLineCount: The number of lines now in the edited range after the edit. + public func adjustForEdit(editLineStart: Int, oldLineCount: Int, newLineCount: Int) { + let delta = newLineCount - oldLineCount + guard delta != 0 else { return } + + let editLineEnd = editLineStart + oldLineCount - 1 + var changed = false + var idsToRemove: [UUID] = [] + + for (id, var decoration) in decorationsById { + let lower = decoration.lineRange.lowerBound + let upper = decoration.lineRange.upperBound + + if upper < editLineStart { + // Decoration entirely before the edit — unaffected + continue + } else if lower > editLineEnd { + // Decoration entirely after the edit — shift by delta + decoration.lineRange = (lower + delta)...(upper + delta) + decorationsById[id] = decoration + changed = true + } else { + // Decoration overlaps the edit region + if delta < 0 { + // Lines were deleted + let newLower = min(lower, editLineStart) + let newUpper = max(upper + delta, editLineStart + newLineCount - 1) + if newUpper < newLower { + // Decoration range collapsed entirely — remove + idsToRemove.append(id) + } else { + decoration.lineRange = newLower...newUpper + decorationsById[id] = decoration + } + changed = true + } else { + // Lines were inserted — expand or shift based on stickiness + switch decoration.stickiness { + case .neverGrowsWhenTypingAtEdges: + if lower >= editLineStart && upper <= editLineEnd { + // Entirely within — shift + decoration.lineRange = lower...(upper + delta) + } + case .growsOnlyWhenTypingAfter: + let newUpper = upper + delta + decoration.lineRange = lower...newUpper + case .alwaysGrowsWhenTypingAtEdges: + let newUpper = upper + delta + decoration.lineRange = lower...newUpper + } + decorationsById[id] = decoration + changed = true + } + } + } + + for id in idsToRemove { + decorationsById.removeValue(forKey: id) + } + + if changed { + invalidateCache() + } + } + + // MARK: - Private + + private func invalidateCache() { + sortedDecorations = nil + } + + private func ensureSorted() -> [LineDecoration] { + if let cached = sortedDecorations { + return cached + } + let sorted = decorationsById.values.sorted { $0.lineRange.lowerBound < $1.lineRange.lowerBound } + sortedDecorations = sorted + return sorted + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Decorations.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Decorations.swift new file mode 100644 index 000000000..848ea688b --- /dev/null +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Decorations.swift @@ -0,0 +1,92 @@ +// +// TextLayoutManager+Decorations.swift +// CodeEditTextView +// +// Created by Abe Malla on 4/12/26. +// + +import AppKit + +extension TextLayoutManager { + /// Draws line decoration backgrounds for lines visible in the given dirty rect. + /// + /// Called during the view's `draw(_:)` pass, before text and selections are drawn, so that + /// line backgrounds appear as an underlay. + /// + /// - Parameter dirtyRect: The rect being drawn, in the text view's coordinate space. + public func drawLineDecorations(in dirtyRect: NSRect) { + guard lineDecorations.count > 0 else { return } + guard let context = NSGraphicsContext.current?.cgContext else { return } + + // Determine the visible line range from the dirty rect. + let minY = max(dirtyRect.minY, 0) + let maxY = dirtyRect.maxY + + guard let firstLine = textLineForPosition(minY), + let lastLine = textLineForPosition(maxY) else { + return + } + + let visibleLineRange = firstLine.index...lastLine.index + let decorations = lineDecorations.decorations(inLineRange: visibleLineRange) + guard !decorations.isEmpty else { return } + + let viewportWidth = delegate?.textViewportSize().width ?? layoutView?.bounds.width ?? 0 + let hasViewZones = !viewZones.zones.isEmpty + let padding = LineHighlightDrawing.horizontalPadding + + for decoration in decorations { + // Clamp the decoration range to visible lines. + let clampedLower = max(decoration.lineRange.lowerBound, visibleLineRange.lowerBound) + let clampedUpper = min(decoration.lineRange.upperBound, visibleLineRange.upperBound) + + // Build a single rect spanning all lines in this decoration range + guard let firstLinePos = lineStorage.getLine(atIndex: clampedLower), + let lastLinePos = lineStorage.getLine(atIndex: clampedUpper) else { + continue + } + + let firstWhitespace = hasViewZones + ? viewZones.whitespaceHeightBeforeLine(clampedLower) : 0.0 + let lastWhitespace = hasViewZones + ? viewZones.whitespaceHeightBeforeLine(clampedUpper) : 0.0 + + let spanY = firstLinePos.yPos + firstWhitespace + let spanMaxY = lastLinePos.yPos + lastWhitespace + lastLinePos.height + let spanRect = CGRect( + x: edgeInsets.left + padding, + y: spanY, + width: viewportWidth - edgeInsets.left - edgeInsets.right - 2 * padding, + height: spanMaxY - spanY + ) + + switch decoration.style { + case .lineBackground(let color): + LineHighlightDrawing.fillRoundedRect( + spanRect, color: color.cgColor, in: context + ) + + case .custom(let drawFunc): + for lineIndex in clampedLower...clampedUpper { + guard let linePosition = lineStorage.getLine(atIndex: lineIndex) else { continue } + let whitespaceOffset = hasViewZones + ? viewZones.whitespaceHeightBeforeLine(lineIndex) : 0.0 + let lineY = linePosition.yPos + whitespaceOffset + let lineRect = CGRect( + x: 0, + y: lineY, + width: viewportWidth, + height: linePosition.height + ) + context.saveGState() + drawFunc(context, lineRect, lineIndex) + context.restoreGState() + } + + case .overviewRuler: + // Drawn by the minimap/scrollbar, not in the main text view draw pass. + break + } + } + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift index b3d7d11bc..725e4ab14 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -42,10 +42,33 @@ extension TextLayoutManager: NSTextStorageDelegate { return } + let lineCountBefore = lineStorage.count + let insertedStringRange = NSRange(location: editedRange.location, length: editedRange.length - delta) removeLayoutLinesIn(range: insertedStringRange) insertNewLines(for: editedRange) + // Adjust view zone line numbers if the line count changed. + let lineCountAfter = lineStorage.count + let lineDelta = lineCountAfter - lineCountBefore + if lineDelta != 0, !viewZones.zones.isEmpty { + // Find which line the edit started at. + let editLine = lineStorage.getLine(atOffset: editedRange.location)?.index ?? 0 + viewZones.adjustLineNumbers(afterLine: editLine, delta: lineDelta) + } + + // Adjust line decoration ranges if the line count changed. + if lineDelta != 0, lineDecorations.count > 0 { + let editLine = lineStorage.getLine(atOffset: editedRange.location)?.index ?? 0 + let oldLineCount = lineCountBefore > 0 ? max(1, abs(lineDelta)) : 1 + let newLineCount = oldLineCount + lineDelta + lineDecorations.adjustForEdit( + editLineStart: editLine, + oldLineCount: oldLineCount, + newLineCount: newLineCount + ) + } + attachments.textUpdated(atOffset: editedRange.location, delta: delta) invalidateLayoutForRange(insertedStringRange) @@ -55,23 +78,39 @@ extension TextLayoutManager: NSTextStorageDelegate { /// edit. /// - Parameter range: The range that was deleted. private func removeLayoutLinesIn(range: NSRange) { - // Loop through each line being replaced in reverse, updating and removing where necessary. - for linePosition in lineStorage.linesInRange(range).reversed() { - // Two cases: Updated line, deleted line entirely - guard let intersection = linePosition.range.intersection(range), !intersection.isEmpty else { continue } - if intersection == linePosition.range && linePosition.range.max != lineStorage.length { - // Delete line - lineStorage.delete(lineAt: linePosition.range.location) - } else if intersection.max == linePosition.range.max, - let nextLine = lineStorage.getLine(atOffset: linePosition.range.max) { - // Need to merge line with one after it after updating this line to remove the end of the line + // Collect all line positions in the range first, snapshotting their indices in reverse order. + // We use indices rather than offsets because after each tree mutation the offsets shift, but re-fetching + // by index is stable (we process in reverse order so lower indices remain valid). + let linePositions = Array(lineStorage.linesInRange(range).reversed()) + guard !linePositions.isEmpty else { return } + + for linePosition in linePositions { + // Re-fetch the line by index to get a fresh position after prior mutations. + guard let freshPosition = lineStorage.getLine(atIndex: linePosition.index) else { continue } + guard let intersection = freshPosition.range.intersection(range), !intersection.isEmpty else { continue } + + if intersection == freshPosition.range && freshPosition.range.max != lineStorage.length { + // Delete line entirely + lineStorage.delete(lineAt: freshPosition.range.location) + } else if intersection.max == freshPosition.range.max, + let nextLine = lineStorage.getLine(atOffset: freshPosition.range.max) { + // Need to merge line with one after it after updating this line to remove the end of the line. + // Capture merge delta before mutating the tree. + let mergeDelta = -intersection.length + nextLine.range.length lineStorage.delete(lineAt: nextLine.range.location) - let delta = -intersection.length + nextLine.range.length - if delta != 0 { - lineStorage.update(atOffset: linePosition.range.location, delta: delta, deltaHeight: 0) + if mergeDelta != 0 { + lineStorage.update( + atOffset: freshPosition.range.location, + delta: mergeDelta, + deltaHeight: 0 + ) } } else { - lineStorage.update(atOffset: linePosition.range.location, delta: -intersection.length, deltaHeight: 0) + lineStorage.update( + atOffset: freshPosition.range.location, + delta: -intersection.length, + deltaHeight: 0 + ) } } } @@ -111,21 +150,26 @@ extension TextLayoutManager: NSTextStorageDelegate { height: estimateLineHeight() ) } else { - // Need to split the line inserting into and create a new line with the split section of the line + // Need to split the line inserting into and create a new line with the split section of the line. guard let linePosition = lineStorage.getLine(atOffset: location) else { return } - let splitLocation = location + insertedString.length + // Capture splitLength before any tree mutations so it uses the fresh linePosition. let splitLength = linePosition.range.max - location let lineDelta = insertedString.length - splitLength // The difference in the line being edited - if lineDelta != 0 { - lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0) - } + // First, insert the new split line. We do this before updating the current line to avoid + // using stale values after tree rebalancing. lineStorage.insert( line: TextLine(), - atOffset: splitLocation, + atOffset: linePosition.range.max, length: splitLength, height: estimateLineHeight() ) + + // Now update the current line's length. The inserted string replaces the portion that was + // split off, so the net delta on this line is `insertedString.length - splitLength`. + if lineDelta != 0 { + lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0) + } } } else { 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 6b13819c6..b95b83465 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift @@ -21,8 +21,19 @@ extension TextLayoutManager { /// Invalidates layout for the given range of text. /// - Parameter range: The range of text to invalidate. public func invalidateLayoutForRange(_ range: NSRange) { - for linePosition in lineStorage.linesInRange(range) { - linePosition.data.setNeedsLayout() + if range.isEmpty { + // For zero-length ranges (e.g. cursor position after insert/delete at a point), invalidate the line + // containing the location. + if let linePosition = lineStorage.getLine(atOffset: range.location) { + linePosition.data.setNeedsLayout() + } else if !lineStorage.isEmpty { + // If we can't find a line at the offset (e.g. offset == length), invalidate the last line. + lineStorage.last?.data.setNeedsLayout() + } + } else { + for linePosition in lineStorage.linesInRange(range) { + linePosition.data.setNeedsLayout() + } } // Special case where we've deleted from the very end, `linesInRange` correctly does not return any lines diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index acf0ea0ae..a8882a5ec 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -77,20 +77,61 @@ extension TextLayoutManager { let minY = max(visibleRect.minY - verticalLayoutPadding, 0) let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) - let originalHeight = lineStorage.height + let originalHeight = lineStorage.height + viewZones.totalHeight var usedFragmentIDs = Set() let forceLayout: Bool = needsLayout var didLayoutChange = false var newVisibleLines: Set = [] var yContentAdjustment: CGFloat = 0 var maxFoundLineWidth = maxLineWidth + let hasViewZones = !viewZones.zones.isEmpty + + // When view zones exist, line storage Y positions don't include whitespace. + // We subtract total whitespace from minY for a conservative lower bound on which lines to iterate, + // and keep maxY as-is (since line storage Y is always <= document Y). + let queryMinY = hasViewZones ? max(minY - viewZones.totalHeight, 0) : minY + let queryMaxY = maxY #if DEBUG var laidOutLines: Set = [] #endif + // Track which view zone views are still in use so we can remove stale ones. + var usedZoneIDs = Set() + // Layout all lines, fetching lines lazily as they are laid out. - for linePosition in linesStartingAt(minY, until: maxY).lazy { - guard linePosition.yPos < maxY else { continue } + for linePosition in linesStartingAt(queryMinY, until: queryMaxY).lazy { + // Compute the document Y (with view zone offsets) for visibility checks. + let whitespaceOffset = hasViewZones + ? viewZones.whitespaceHeightBeforeLine(linePosition.index) + : 0 + let documentYPos = linePosition.yPos + whitespaceOffset + + guard documentYPos < maxY else { continue } + + // Layout any view zones that appear before this line. + if hasViewZones { + let zonesBeforeLine = viewZones.zones(afterLine: linePosition.index - 1) + for zone in zonesBeforeLine where zone.afterLineNumber == linePosition.index - 1 + || (linePosition.index == 0 && zone.afterLineNumber == 0) { + // Compute the zone's document Y position: it sits between the previous line and this line. + let zoneDocY: CGFloat + if zone.afterLineNumber <= 0 { + zoneDocY = viewZones.whitespaceHeightBeforeLine(0) + - zone.heightInPoints // This zone is part of the whitespace before line 0 + } else { + // Zone sits after the line it follows + if let prevLine = lineStorage.getLine(atIndex: zone.afterLineNumber - 1) { + let prevWhitespace = viewZones.whitespaceHeightBeforeLine(zone.afterLineNumber - 1) + zoneDocY = prevLine.yPos + prevLine.height + prevWhitespace + } else { + zoneDocY = documentYPos - zone.heightInPoints + } + } + layoutViewZone(zone, at: zoneDocY) + usedZoneIDs.insert(zone.id) + } + } + // Three ways to determine if a line needs to be re-calculated. let linePositionNeedsLayout = linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) let wasNotVisible = !visibleLineIds.contains(linePosition.data.id) @@ -101,6 +142,7 @@ extension TextLayoutManager { func fullLineLayout() { let (yAdjustment, wasLineHeightChanged) = layoutLine( linePosition, + whitespaceOffset: whitespaceOffset, usedFragmentIDs: &usedFragmentIDs, textStorage: textStorage, yRange: minY.. 0 { // Layout happened and this line needs to be moved but not necessarily re-added - let needsFullLayout = updateLineViewPositions(linePosition) + let needsFullLayout = updateLineViewPositions(linePosition, whitespaceOffset: whitespaceOffset) if needsFullLayout { fullLineLayout() continue @@ -137,6 +179,13 @@ extension TextLayoutManager { } } + // Remove view zone views that are no longer in the viewport. + if hasViewZones { + for zone in viewZones.zones where !usedZoneIDs.contains(zone.id) { + zone.view?.removeFromSuperview() + } + } + // Enqueue any lines not used in this layout pass. viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs) @@ -159,8 +208,9 @@ extension TextLayoutManager { delegate?.layoutManagerYAdjustment(yContentAdjustment) } - if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { - delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) + if originalHeight != lineStorage.height + viewZones.totalHeight + || layoutView?.frame.size.height != lineStorage.height + viewZones.totalHeight { + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height + viewZones.totalHeight) } #if DEBUG @@ -174,6 +224,7 @@ extension TextLayoutManager { private func layoutLine( _ linePosition: TextLineStorage.TextLinePosition, + whitespaceOffset: CGFloat = 0, usedFragmentIDs: inout Set, textStorage: NSTextStorage, yRange: Range, @@ -181,6 +232,7 @@ extension TextLayoutManager { ) -> (CGFloat, wasLineHeightChanged: Bool) { let lineSize = layoutLineViews( linePosition, + whitespaceOffset: whitespaceOffset, textStorage: textStorage, layoutData: LineLayoutData(minY: yRange.lowerBound, maxY: yRange.upperBound, maxWidth: maxLineLayoutWidth), laidOutFragmentIDs: &usedFragmentIDs @@ -217,6 +269,7 @@ extension TextLayoutManager { /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line. private func layoutLineViews( _ position: TextLineStorage.TextLinePosition, + whitespaceOffset: CGFloat = 0, textStorage: NSTextStorage, layoutData: LineLayoutData, laidOutFragmentIDs: inout Set @@ -254,13 +307,7 @@ extension TextLayoutManager { 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 -// ) { for lineFragmentPosition in line.lineFragments { let lineFragment = lineFragmentPosition.data lineFragment.documentRange = lineFragmentPosition.range.translate(location: position.range.location) @@ -268,7 +315,7 @@ extension TextLayoutManager { layoutFragmentView( inLine: position, for: lineFragmentPosition, - at: position.yPos + lineFragmentPosition.yPos + at: position.yPos + whitespaceOffset + lineFragmentPosition.yPos ) width = max(width, lineFragment.width) @@ -281,6 +328,20 @@ extension TextLayoutManager { // MARK: - Layout Fragment + /// Positions a view zone's view at the given document y position. + /// - Parameters: + /// - zone: The view zone to layout. + /// - yPos: The y position in document coordinates where the zone should be placed. + private func layoutViewZone(_ zone: ViewZone, at yPos: CGFloat) { + guard let zoneView = zone.view else { return } + zoneView.translatesAutoresizingMaskIntoConstraints = true + let width = (delegate?.textViewportSize().width ?? layoutView?.bounds.width) ?? 0 + zoneView.frame = CGRect(x: 0, y: yPos, width: width, height: zone.heightInPoints) + if zoneView.superview !== layoutView { + layoutView?.addSubview(zoneView, positioned: .above, relativeTo: nil) + } + } + /// 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. @@ -301,7 +362,10 @@ extension TextLayoutManager { view.needsDisplay = true } - private func updateLineViewPositions(_ position: TextLineStorage.TextLinePosition) -> Bool { + private func updateLineViewPositions( + _ position: TextLineStorage.TextLinePosition, + whitespaceOffset: CGFloat = 0 + ) -> Bool { let line = position.data for lineFragmentPosition in line.lineFragments { guard let view = viewReuseQueue.getView(forKey: lineFragmentPosition.data.id) else { @@ -310,7 +374,10 @@ extension TextLayoutManager { lineFragmentPosition.data.documentRange = lineFragmentPosition.range.translate( location: position.range.location ) - view.frame.origin = CGPoint(x: edgeInsets.left, y: position.yPos + lineFragmentPosition.yPos) + view.frame.origin = CGPoint( + x: edgeInsets.left, + y: position.yPos + whitespaceOffset + lineFragmentPosition.yPos + ) } return false } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index b73c17177..4de85589d 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -11,13 +11,52 @@ extension TextLayoutManager { // MARK: - Estimate public func estimatedHeight() -> CGFloat { - max(lineStorage.height, estimateLineHeight()) + max(lineStorage.height + viewZones.totalHeight, estimateLineHeight()) } public func estimatedWidth() -> CGFloat { maxLineWidth + edgeInsets.horizontal } + // MARK: - View Zone Coordinate Translation + + /// Converts a document Y position (which includes view zone whitespace) to a line-storage Y position + /// (which does not include view zone whitespace). + /// + /// This is needed because the line storage tree stores raw line heights without knowledge of view zones. + /// When the user clicks at a document Y position or when layout needs to determine which line is at a + /// given visual position, the view zone whitespace must be subtracted. + /// + /// - Parameter documentY: The Y position in the full document coordinate space (includes view zones). + /// - Returns: The Y position in line-storage coordinate space (excludes view zones). + func documentYToLineStorageY(_ documentY: CGFloat) -> CGFloat { + guard !viewZones.zones.isEmpty else { return documentY } + + var adjustedY = documentY + var lastWhitespace: CGFloat = 0 + + // Iterate to convergence (usually 1-2 iterations) + for _ in 0..<10 { + // For the current adjustedY, find which line it would fall on without whitespace + guard let linePosition = lineStorage.getLine(atPosition: adjustedY) else { break } + let whitespace = viewZones.whitespaceHeightBeforeLine(linePosition.index) + if abs(whitespace - lastWhitespace) < 0.5 { break } + lastWhitespace = whitespace + adjustedY = documentY - whitespace + } + + return adjustedY + } + + /// Converts a line-storage Y position to a document Y position by adding view zone whitespace. + /// + /// - Parameter lineStorageY: The Y position in line-storage coordinate space. + /// - Parameter lineIndex: The line index, used to determine how much whitespace precedes this position. + /// - Returns: The Y position in document coordinate space (includes view zones). + func lineStorageYToDocumentY(_ lineStorageY: CGFloat, lineIndex: Int) -> CGFloat { + lineStorageY + viewZones.whitespaceHeightBeforeLine(lineIndex) + } + // MARK: - Text Lines /// Finds a text line for the given y position relative to the text view. @@ -29,7 +68,8 @@ extension TextLayoutManager { /// - Parameter posY: The y position to find a line for. /// - Returns: A text line position, if a line could be found at the given y position. public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? { - determineVisiblePosition(for: lineStorage.getLine(atPosition: posY))?.position + let adjustedY = documentYToLineStorageY(posY) + return determineVisiblePosition(for: lineStorage.getLine(atPosition: adjustedY))?.position } /// Finds a text line for a given text offset. @@ -69,14 +109,15 @@ extension TextLayoutManager { guard point.y <= estimatedHeight() else { // End position is a special case. return textStorage?.length } - guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atPosition: point.y))?.position, + let adjustedPoint = CGPoint(x: point.x, y: documentYToLineStorageY(point.y)) + guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atPosition: adjustedPoint.y))?.position, let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( - atPosition: point.y - linePosition.yPos + atPosition: adjustedPoint.y - linePosition.yPos ) else { return nil } - return textOffsetAtPoint(point, fragmentPosition: fragmentPosition, linePosition: linePosition) + return textOffsetAtPoint(adjustedPoint, fragmentPosition: fragmentPosition, linePosition: linePosition) } func textOffsetAtPoint( @@ -173,10 +214,16 @@ extension TextLayoutManager { guard let linePosition = determineVisiblePosition(for: lineStorage.getLine(atOffset: offset))?.position else { return nil } + let whitespaceOffset = viewZones.whitespaceHeightBeforeLine(linePosition.index) guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( atOffset: offset - linePosition.range.location ) else { - return CGRect(x: edgeInsets.left, y: linePosition.yPos, width: 0, height: linePosition.height) + return CGRect( + x: edgeInsets.left, + y: linePosition.yPos + whitespaceOffset, + width: 0, + height: linePosition.height + ) } // Get the *real* length of the character at the offset. If this is a surrogate pair it'll return the correct @@ -200,7 +247,7 @@ extension TextLayoutManager { return CGRect( x: minXPos + edgeInsets.left, - y: linePosition.yPos + fragmentPosition.yPos, + y: linePosition.yPos + whitespaceOffset + fragmentPosition.yPos, width: maxXPos - minXPos, height: fragmentPosition.data.scaledHeight ) @@ -223,9 +270,17 @@ extension TextLayoutManager { private func rectsFor(range: NSRange, in line: borrowing TextLineStorage.TextLinePosition) -> [CGRect] { guard let textStorage = (textStorage?.string as? NSString) else { return [] } + let storageLength = textStorage.length + guard range.lowerBound < storageLength, range.upperBound > 0 else { return [] } + + // Clamp to valid text storage bounds before character sequence lookup + let clampedLower = min(range.lowerBound, storageLength - 1) + let clampedUpper = min(range.upperBound - 1, storageLength - 1) + guard clampedUpper >= clampedLower else { return [] } + // Don't make rects in between characters - let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: range.lowerBound) - let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: range.upperBound - 1) + let realRangeStart = textStorage.rangeOfComposedCharacterSequence(at: clampedLower) + let realRangeEnd = textStorage.rangeOfComposedCharacterSequence(at: clampedUpper) // Fragments are relative to the line let relativeRange = NSRange( @@ -233,6 +288,7 @@ extension TextLayoutManager { end: realRangeEnd.upperBound - line.range.location ) + let whitespaceOffset = viewZones.whitespaceHeightBeforeLine(line.index) var rects: [CGRect] = [] for fragmentPosition in line.data.lineFragments.linesInRange(relativeRange) { guard let intersectingRange = fragmentPosition.range.intersection(relativeRange) else { continue } @@ -241,7 +297,7 @@ extension TextLayoutManager { rects.append( CGRect( x: fragmentRect.minX + edgeInsets.left, - y: fragmentPosition.yPos + line.yPos, + y: fragmentPosition.yPos + line.yPos + whitespaceOffset, width: fragmentRect.width, height: fragmentRect.height ) @@ -301,11 +357,17 @@ extension TextLayoutManager { /// - Returns: A CGRect if it could be created. private func rectForEndOffset() -> CGRect? { if let last = lineStorage.last { + let whitespaceOffset = viewZones.whitespaceHeightBeforeLine(last.index) if last.range.isEmpty { // Return a 0-width rect at the end of the last line. - return CGRect(x: edgeInsets.left, y: last.yPos, width: 0, height: last.height) + return CGRect( + x: edgeInsets.left, + y: last.yPos + whitespaceOffset, + width: 0, + height: last.height + ) } else if let rect = rectForOffset(last.range.max - 1) { - return CGRect(x: rect.maxX, y: rect.minY, width: 0, height: rect.height) + return CGRect(x: rect.maxX, y: rect.minY, width: 0, height: rect.height) } } else if lineStorage.isEmpty { // Text is empty, create a new rect with estimated height at the origin diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 0a8b57ffd..71de023e4 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -66,6 +66,16 @@ public class TextLayoutManager: NSObject { public let attachments: TextAttachmentManager = TextAttachmentManager() + /// Manages view zones — horizontal bands of space inserted between lines of text. + /// View zones are used to display UI elements between lines, such as reference counts, + /// merge conflict buttons, inline diffs, and other features that need vertical space + /// in the document flow. + public let viewZones: ViewZoneManager = ViewZoneManager() + + /// Manages line decorations — visual adornments applied to lines such as backgrounds, glyph margin + /// indicators, and overview ruler marks. + public let lineDecorations: LineDecorationManager = LineDecorationManager() + public weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? { didSet { lineFragmentRenderer.invisibleCharacterDelegate = invisibleCharacterDelegate @@ -148,6 +158,12 @@ public class TextLayoutManager: NSObject { super.init() prepareTextLines() attachments.layoutManager = self + viewZones.onZonesChanged = { [weak self] in + self?.setNeedsLayout() + } + lineDecorations.onDecorationsChanged = { [weak self] in + self?.layoutView?.needsDisplay = true + } } /// Prepares the layout manager for use. @@ -187,6 +203,7 @@ public class TextLayoutManager: NSObject { maxLineWidth = 0 markedTextManager.removeAll() lineFragmentRenderer.textStorage = textStorage + lineDecorations.removeAllDecorations() prepareTextLines() setNeedsLayout() } diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift index f944fae30..449d3c747 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift @@ -150,6 +150,7 @@ public final class TextLineStorage { /// - Parameter position: The position to fetch for. /// - Returns: A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. public func getLine(atPosition posY: CGFloat) -> TextLinePosition? { + guard posY >= 0 else { return first } guard posY < height else { return last } @@ -159,7 +160,7 @@ public final class TextLineStorage { var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 var currentIndex: Int = root?.leftSubtreeCount ?? 0 while let node = currentNode { - // If index is in the range [currentOffset..= currentYPosition && posY < currentYPosition + node.height { return TextLinePosition( data: node.data, @@ -173,13 +174,12 @@ public final class TextLineStorage { currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) - } else if node.leftSubtreeHeight < posY { + } else { + // posY >= currentYPosition + node.height, so the target line is after this node. currentNode = node.right currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) - } else { - currentNode = nil } } @@ -350,13 +350,12 @@ private extension TextLineStorage { currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) - } else if node.leftSubtreeOffset < offset { + } else { + // offset >= currentOffset + node.length, so the target is after this node. currentNode = node.right currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) - } else { - currentNode = nil } } return nil diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift index b24dab062..4281b396d 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift @@ -50,19 +50,23 @@ extension TextSelectionManager { highlightedLines.insert(linePosition.data.id) context.saveGState() - let insetXPos = max(rect.minX, edgeInsets.left) - let maxWidth = (textView?.frame.width ?? 0) - insetXPos - edgeInsets.right + let padding = LineHighlightDrawing.horizontalPadding + let insetXPos = max(rect.minX, edgeInsets.left) + padding + let maxWidth = (textView?.frame.width ?? 0) - insetXPos - edgeInsets.right - padding let selectionRect = CGRect( x: insetXPos, y: linePosition.yPos, - width: min(rect.width, maxWidth), + width: min(rect.width - 2 * padding, maxWidth), height: linePosition.height ).pixelAligned if selectionRect.intersects(rect) { - context.setFillColor(selectedLineBackgroundColor.cgColor) - context.fill(selectionRect) + LineHighlightDrawing.fillRoundedRect( + selectionRect, + color: selectedLineBackgroundColor.cgColor, + in: context + ) } context.restoreGState() } diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index f05168629..39dd66a70 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -139,7 +139,7 @@ public class TextSelectionManager: NSObject { /// Update all selection cursors. Placing them in the correct position for each text selection and /// optionally reseting the blink timer. - func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) { + public func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) { guard textView?.isFirstResponder ?? false else { return } var didUpdate: Bool = false diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 2fef2aa1b..895791e30 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -10,6 +10,39 @@ import Foundation extension TextView { override public func layout() { super.layout() + + // let currentWidth = layoutManager.wrapLinesWidth + // let widthChanged = currentWidth != lastKnownLayoutWidth + // lastKnownLayoutWidth = currentWidth + + // // When the only change is the available layout width (e.g. a split-view panel animating open/close), + // // every animation frame would otherwise re-lay out all visible lines because `maxLineLayoutWidth` changed. + // // Coalesce these into a single deferred pass after the width stabilises instead. + // // + // // Conditions for deferral: + // // • wrap-lines is enabled (non-wrapping text uses .greatestFiniteMagnitude which never changes) + // // • the width changed but no explicit content/layout invalidation is pending + // // • there are already visible lines (avoids delaying the very first render) + // if layoutManager.wrapLines + // && widthChanged + // && !layoutManager.needsLayout + // && !layoutManager.visibleLineIds.isEmpty { + // pendingWidthRelayout?.cancel() + // let task = DispatchWorkItem { [weak self] in + // self?.pendingWidthRelayout = nil + // self?.layoutManager.layoutLines() + // self?.selectionManager.updateSelectionViews(skipTimerReset: true) + // } + // pendingWidthRelayout = task + // // 50 ms coalesces all frames of a typical panel animation (~200 ms) + // // while still being imperceptible after the animation completes. + // DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: task) + // } else { + // pendingWidthRelayout?.cancel() + // pendingWidthRelayout = nil + // layoutManager.layoutLines() + // selectionManager.updateSelectionViews(skipTimerReset: true) + // } layoutManager.layoutLines() selectionManager.updateSelectionViews(skipTimerReset: true) } @@ -28,6 +61,7 @@ extension TextView { if isSelectable { selectionManager.drawSelections(in: dirtyRect) } + layoutManager.drawLineDecorations(in: dirtyRect) emphasisManager?.updateLayerBackgrounds() } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 14ed3914e..3d914a521 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -258,6 +258,14 @@ open class TextView: NSView, NSTextContent { var isFirstResponder: Bool = false + // Tracks the layout width from the most recent `layout()` call. Used in ``layout()`` to detect resize-only + // passes (e.g. during a split-view panel animation) and debounce the resulting full text relayout. + // var lastKnownLayoutWidth: CGFloat = 0 + + // A pending work item that performs a deferred `layoutLines()` after the view width stabilises. + // Cancelled and replaced whenever a new width-change-only `layout()` call arrives. + // var pendingWidthRelayout: DispatchWorkItem? + /// When dragging to create a selection, these enable us to scroll the view as the user drags outside the view's /// bounds. var mouseDragAnchor: CGPoint? diff --git a/Sources/CodeEditTextView/Utils/LineHighlightDrawing.swift b/Sources/CodeEditTextView/Utils/LineHighlightDrawing.swift new file mode 100644 index 000000000..5ee68dc48 --- /dev/null +++ b/Sources/CodeEditTextView/Utils/LineHighlightDrawing.swift @@ -0,0 +1,92 @@ +// +// LineHighlightDrawing.swift +// CodeEditTextView +// +// Created by Abe Malla on 4/12/26. +// + +import CoreGraphics + +/// Shared configuration and drawing utilities for rounded line highlight backgrounds. +/// +/// Used by both `TextSelectionManager` (current line highlight) and `TextLayoutManager` (line decoration +/// backgrounds) to ensure consistent appearance and a single place to tune padding/corner radius. +public enum LineHighlightDrawing { + /// Horizontal inset from the edges of the drawing area. + public static let horizontalPadding: CGFloat = 4.0 + + /// Corner radius for the rounded rect. + public static let cornerRadius: CGFloat = 4.0 + + /// Fills a rounded-rect line background in the given context. + /// + /// - Parameters: + /// - rect: The rect to fill (should already account for padding). + /// - color: The fill color. + /// - context: The Core Graphics context to draw into. + /// - cornerRadius: Override for the corner radius. Defaults to ``cornerRadius``. + public static func fillRoundedRect( + _ rect: CGRect, + color: CGColor, + in context: CGContext, + cornerRadius: CGFloat = Self.cornerRadius + ) { + let path = CGPath( + roundedRect: rect, + cornerWidth: cornerRadius, + cornerHeight: cornerRadius, + transform: nil + ) + context.setFillColor(color) + context.addPath(path) + context.fillPath() + } + + /// Fills a rect with only the leading (left) corners rounded. Used by the gutter view to match the + /// text view's fully-rounded highlight on the trailing side. + /// + /// - Parameters: + /// - rect: The rect to fill. + /// - color: The fill color. + /// - context: The Core Graphics context to draw into. + /// - cornerRadius: Override for the corner radius. Defaults to ``cornerRadius``. + public static func fillLeadingRoundedRect( + _ rect: CGRect, + color: CGColor, + in context: CGContext, + cornerRadius: CGFloat = Self.cornerRadius + ) { + let path = CGMutablePath() + // Start at top-left rounded corner + path.move(to: CGPoint(x: rect.minX + cornerRadius, y: rect.minY)) + // Top edge → straight right to trailing edge (no rounding) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + // Right edge → straight down + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + // Bottom edge → straight left to bottom-left corner + path.addLine(to: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY)) + // Bottom-left rounded corner + path.addArc( + center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius), + radius: cornerRadius, + startAngle: .pi / 2, + endAngle: .pi, + clockwise: false + ) + // Left edge → straight up to top-left corner + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius)) + // Top-left rounded corner + path.addArc( + center: CGPoint(x: rect.minX + cornerRadius, y: rect.minY + cornerRadius), + radius: cornerRadius, + startAngle: .pi, + endAngle: 3 * .pi / 2, + clockwise: false + ) + path.closeSubpath() + + context.setFillColor(color) + context.addPath(path) + context.fillPath() + } +} diff --git a/Sources/CodeEditTextView/ViewZones/ViewZone.swift b/Sources/CodeEditTextView/ViewZones/ViewZone.swift new file mode 100644 index 000000000..a2effbf00 --- /dev/null +++ b/Sources/CodeEditTextView/ViewZones/ViewZone.swift @@ -0,0 +1,56 @@ +// +// ViewZone.swift +// CodeEditTextView +// +// Created by Abe Malla on 4/12/26. +// + +import AppKit + +/// A view zone represents a horizontal band of space inserted between two lines of text in the editor. +/// View zones are used to display UI elements between lines, such as reference counts, +/// merge conflict action buttons, inline diff views, inline chat, and other features that need to +/// occupy space in the flow of the document without being part of the text content. +/// +/// View zones are managed by ``ViewZoneManager`` and are integrated into the layout system via +/// ``TextLayoutManager``. They affect vertical positioning of all lines below the zone. +/// +/// Modeled after VSCode's `IViewZone` system. +public struct ViewZone: Identifiable { + /// Unique identifier for this view zone. + public let id: UUID + + /// The line number after which this zone appears. + /// A value of `0` means the zone appears before the first line. + /// Must be >= 0 and <= the number of lines in the document. + public var afterLineNumber: Int + + /// The height of the zone in points. + public var heightInPoints: CGFloat + + /// An optional view to display in the zone. If `nil`, the zone is blank whitespace. + /// The view is managed by the caller; ``ViewZoneManager`` positions it but does not retain it strongly. + public weak var view: NSView? + + /// Ordinal used to break ties when multiple zones are placed after the same line. + /// Lower ordinals are placed first (closer to the line above). Defaults to `0`. + public var ordinal: Int + + /// If `true`, mouse events on the zone's view are suppressed. Defaults to `false`. + public var suppressMouseDown: Bool + + public init( + afterLineNumber: Int, + heightInPoints: CGFloat, + view: NSView? = nil, + ordinal: Int = 0, + suppressMouseDown: Bool = false + ) { + self.id = UUID() + self.afterLineNumber = afterLineNumber + self.heightInPoints = heightInPoints + self.view = view + self.ordinal = ordinal + self.suppressMouseDown = suppressMouseDown + } +} diff --git a/Sources/CodeEditTextView/ViewZones/ViewZoneManager.swift b/Sources/CodeEditTextView/ViewZones/ViewZoneManager.swift new file mode 100644 index 000000000..beb5ad1fa --- /dev/null +++ b/Sources/CodeEditTextView/ViewZones/ViewZoneManager.swift @@ -0,0 +1,270 @@ +// +// ViewZoneManager.swift +// CodeEditTextView +// +// Created by Abe Malla on 4/12/26. +// + +import AppKit + +/// Manages view zones — horizontal bands of space inserted between lines of text. +/// +/// The manager maintains a sorted array of ``ViewZone`` entries and computes prefix sums of their heights +/// for O(log n) position lookups. This is modeled after VSCode's `LinesLayout` whitespace system. +/// +/// ## Usage +/// +/// ```swift +/// let zoneID = viewZoneManager.addZone(ViewZone(afterLineNumber: 5, heightInPoints: 30, view: myButton)) +/// // Later: +/// viewZoneManager.removeZone(id: zoneID) +/// ``` +/// +/// ## Layout Integration +/// +/// The ``TextLayoutManager`` queries the view zone manager during layout to: +/// 1. Compute the extra vertical offset for lines due to view zones above them. +/// 2. Position zone views between lines during the layout pass. +/// 3. Adjust content height to include all view zone heights. +/// +/// ## Performance +/// +/// - Zone lookup by line: O(log n) via binary search +/// - Prefix sum recomputation: O(n) but only when zones change (lazy invalidation) +/// - Zone iteration for a viewport: O(k) where k is the number of zones in the viewport +public final class ViewZoneManager { + // MARK: - Storage + + /// All zones, sorted by `(afterLineNumber, ordinal)`. Kept sorted on mutation. + public private(set) var zones: [ViewZone] = [] + + /// Prefix sums of zone heights. `prefixSums[i]` is the cumulative height of zones `0.. Void)? + + // MARK: - Public API + + /// Adds a view zone. Returns the zone's ID for later removal or update. + @discardableResult + public func addZone(_ zone: ViewZone) -> UUID { + let insertIndex = insertionIndex(for: zone) + zones.insert(zone, at: insertIndex) + invalidatePrefixSums(from: insertIndex) + onZonesChanged?() + return zone.id + } + + /// Removes a view zone by its ID. No-op if the ID is not found. + public func removeZone(id: UUID) { + guard let index = zones.firstIndex(where: { $0.id == id }) else { return } + if let view = zones[index].view { + view.removeFromSuperview() + } + zones.remove(at: index) + invalidatePrefixSums(from: index) + onZonesChanged?() + } + + /// Updates the height of a zone. Triggers re-layout. + public func updateZoneHeight(id: UUID, newHeight: CGFloat) { + guard let index = zones.firstIndex(where: { $0.id == id }) else { return } + zones[index].heightInPoints = newHeight + invalidatePrefixSums(from: index) + onZonesChanged?() + } + + /// Updates the line number a zone appears after. Triggers re-layout. + /// The zone is re-sorted to maintain order. + public func updateZoneLineNumber(id: UUID, newLineNumber: Int) { + guard let index = zones.firstIndex(where: { $0.id == id }) else { return } + var zone = zones[index] + zone.afterLineNumber = newLineNumber + zones.remove(at: index) + let newIndex = insertionIndex(for: zone) + zones.insert(zone, at: newIndex) + invalidatePrefixSums(from: min(index, newIndex)) + onZonesChanged?() + } + + /// Removes all view zones. + public func removeAll() { + for zone in zones { + zone.view?.removeFromSuperview() + } + zones.removeAll() + prefixSums.removeAll() + prefixSumValidIndex = -1 + onZonesChanged?() + } + + // MARK: - Layout Queries + + /// Returns the cumulative height of all view zones that appear after lines `<= lineNumber`. + /// In other words, the extra vertical space above line `lineNumber + 1` due to view zones. + /// + /// - Parameter lineNumber: The line number (0-indexed). + /// - Returns: The total whitespace height inserted at or before this line. + public func whitespaceHeightBeforeLine(_ lineNumber: Int) -> CGFloat { + guard !zones.isEmpty else { return 0 } + ensurePrefixSums() + + let searchLine = lineNumber + var lo = 0 + var hi = zones.count - 1 + var result = -1 + while lo <= hi { + let mid = lo + (hi - lo) / 2 + if zones[mid].afterLineNumber < searchLine { + result = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + + if result < 0 { return 0 } + return prefixSums[result] + } + + /// Returns all view zones that are visible in the given y-position range, accounting for line positions. + /// + /// - Parameters: + /// - minY: The minimum y position (in document coordinates). + /// - maxY: The maximum y position (in document coordinates). + /// - lineYPosition: A closure that returns the y position of a given line index (0-based). The y position + /// should be the position *without* view zone offsets (raw line position from the line storage). + /// - Returns: An array of tuples containing the zone, its computed y position, and its index in the zones array. + public func zonesInViewport( + minY: CGFloat, + maxY: CGFloat, + lineYPosition: (Int) -> CGFloat? + ) -> [(zone: ViewZone, yPosition: CGFloat, index: Int)] { + guard !zones.isEmpty else { return [] } + ensurePrefixSums() + + var result: [(zone: ViewZone, yPosition: CGFloat, index: Int)] = [] + + for (index, zone) in zones.enumerated() { + // The zone's y-position is after the line it follows, plus the whitespace of all prior zones. + let lineEndY: CGFloat + if zone.afterLineNumber <= 0 { + lineEndY = 0 + } else if let lineY = lineYPosition(zone.afterLineNumber - 1) { + lineEndY = lineY + } else { + continue + } + + let priorWhitespace: CGFloat = index > 0 ? prefixSums[index - 1] : 0 + let zoneY = lineEndY + priorWhitespace + + if zoneY + zone.heightInPoints < minY { continue } + if zoneY > maxY { break } + + result.append((zone: zone, yPosition: zoneY, index: index)) + } + + return result + } + + /// Returns all view zones after a given line number. + /// - Parameter lineNumber: The line number to query zones after. + /// - Returns: Zones that appear after the given line number. + public func zones(afterLine lineNumber: Int) -> [ViewZone] { + guard !zones.isEmpty else { return [] } + + var lo = 0 + var hi = zones.count + while lo < hi { + let mid = lo + (hi - lo) / 2 + if zones[mid].afterLineNumber < lineNumber { + lo = mid + 1 + } else { + hi = mid + } + } + var result: [ViewZone] = [] + while lo < zones.count && zones[lo].afterLineNumber == lineNumber { + result.append(zones[lo]) + lo += 1 + } + return result + } + + /// Adjusts zone line numbers after a text edit. + /// + /// When lines are inserted or deleted, zones after the edit point need their `afterLineNumber` updated. + /// + /// - Parameters: + /// - afterLine: The line number after which the edit occurred. + /// - delta: The number of lines inserted (positive) or deleted (negative). + public func adjustLineNumbers(afterLine: Int, delta: Int) { + guard delta != 0 else { return } + var didChange = false + for index in 0.. afterLine { + zones[index].afterLineNumber = max(0, zones[index].afterLineNumber + delta) + didChange = true + } + } + if didChange { + // Re-sort in case adjustments changed ordering + zones.sort { ($0.afterLineNumber, $0.ordinal) < ($1.afterLineNumber, $1.ordinal) } + invalidatePrefixSums(from: 0) + } + } + + // MARK: - Private + + /// Returns the insertion index to maintain sort order for the given zone. + private func insertionIndex(for zone: ViewZone) -> Int { + var lo = 0 + var hi = zones.count + while lo < hi { + let mid = lo + (hi - lo) / 2 + let existing = zones[mid] + if (existing.afterLineNumber, existing.ordinal) < (zone.afterLineNumber, zone.ordinal) { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + /// Invalidates prefix sums from the given index onward. + private func invalidatePrefixSums(from index: Int) { + prefixSumValidIndex = min(prefixSumValidIndex, index - 1) + } + + /// Ensures prefix sums are fully computed up to the end of the zones array. + private func ensurePrefixSums() { + guard !zones.isEmpty else { return } + + if prefixSums.count != zones.count { + prefixSums = Array(repeating: 0, count: zones.count) + prefixSumValidIndex = -1 + } + + let startIndex = prefixSumValidIndex + 1 + guard startIndex < zones.count else { return } + + for i in startIndex.. 0 ? prefixSums[i - 1] : 0 + prefixSums[i] = previousSum + zones[i].heightInPoints + } + prefixSumValidIndex = zones.count - 1 + } +} From 93b1428c8142e0f46e1dc38512b2ca1837957ce6 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 19 Apr 2026 18:41:27 -0700 Subject: [PATCH 2/2] TextLineStorage optimization --- .../TextLineStorage+Iterator.swift | 179 +++- .../TextLineStorage+Node.swift | 365 +++++-- .../TextLineStorage+Structs.swift | 38 +- .../TextLineStorage/TextLineStorage.swift | 952 +++++++++++------- .../TextLayoutLineStorageTests.swift | 115 +-- 5 files changed, 1054 insertions(+), 595 deletions(-) diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift index c79d4adcb..4a7b16884 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift @@ -9,8 +9,9 @@ import Foundation /// # Dev Note /// -/// For these iterators, prefer `.getLine(atIndex: )` for finding the next item in the iteration. -/// Using plain indexes instead of y positions or ranges has led to far fewer edge cases. +/// All iterators use in-order successor walks (`successor(handle)`) for O(1) +/// amortized advancement. Each iterator tracks the current `NodeHandle` plus running +/// offset/yPos/index counters that are bumped by the current node's length/height on each step. public extension TextLineStorage { /// Iterate over all lines overlapping a range of `y` positions. Positions in the middle of line contents will /// return that line. @@ -31,91 +32,163 @@ public extension TextLineStorage { struct TextLineStorageYIterator: LazySequenceProtocol, IteratorProtocol { private let storage: TextLineStorage - private let minY: CGFloat private let maxY: CGFloat - private var currentPosition: TextLinePosition? + private var currentHandle: NodeHandle + private var textPos: Int + private var yPos: CGFloat + private var index: Int - init(storage: TextLineStorage, minY: CGFloat, maxY: CGFloat, currentPosition: TextLinePosition? = nil) { + init(storage: TextLineStorage, minY: CGFloat, maxY: CGFloat) { self.storage = storage - self.minY = minY self.maxY = maxY - self.currentPosition = currentPosition + // Seed from a Y-position search to find the first overlapping node. + if let pos = storage.search(forYPosition: Swift.max(minY, 0)) { + self.currentHandle = pos.handle + self.textPos = pos.textPos + self.yPos = pos.yPos + self.index = pos.index + } else { + self.currentHandle = Int32.min + self.textPos = 0 + self.yPos = 0 + self.index = 0 + } } public mutating func next() -> TextLinePosition? { - if let currentPosition { - guard let nextPosition = storage.getLine(atIndex: currentPosition.index + 1), - nextPosition.yPos < maxY else { - return nil - } - self.currentPosition = nextPosition - return nextPosition - } else if let nextPosition = storage.getLine(atPosition: minY) { - self.currentPosition = nextPosition - return nextPosition - } else { - return nil - } + guard currentHandle != Int32.min else { return nil } + let ptr = storage.nodesPtr + let node = ptr + Int(currentHandle) + let nodeLength = node.pointee.length + let nodeHeight = node.pointee.height + + guard yPos < maxY else { return nil } + + let result = TextLinePosition( + data: node.pointee.data, + range: NSRange(location: textPos, length: nodeLength), + yPos: yPos, + height: nodeHeight, + index: index + ) + + // Advance to successor + let nextHandle = storage.successor(currentHandle) + textPos += nodeLength + yPos += nodeHeight + index += 1 + currentHandle = nextHandle + + return result } } struct TextLineStorageRangeIterator: LazySequenceProtocol, IteratorProtocol { private let storage: TextLineStorage private let range: NSRange - private var currentPosition: TextLinePosition? + private var currentHandle: NodeHandle + private var textPos: Int + private var yPos: CGFloat + private var index: Int - init(storage: TextLineStorage, range: NSRange, currentPosition: TextLinePosition? = nil) { + init(storage: TextLineStorage, range: NSRange) { self.storage = storage self.range = range - self.currentPosition = currentPosition + // Seed from an offset search. + if let pos = storage.search(for: range.location) { + self.currentHandle = pos.handle + self.textPos = pos.textPos + self.yPos = pos.yPos + self.index = pos.index + } else { + self.currentHandle = Int32.min + self.textPos = 0 + self.yPos = 0 + self.index = 0 + } } public mutating func next() -> TextLinePosition? { - if let currentPosition { - guard currentPosition.range.max < range.max, - let nextPosition = storage.getLine(atIndex: currentPosition.index + 1) else { - return nil - } - self.currentPosition = nextPosition - return nextPosition - } else if let nextPosition = storage.getLine(atOffset: range.location) { - self.currentPosition = nextPosition - return nextPosition - } else { - return nil - } + guard currentHandle != Int32.min else { return nil } + let ptr = storage.nodesPtr + let node = ptr + Int(currentHandle) + let nodeLength = node.pointee.length + let nodeHeight = node.pointee.height + + // Stop if we've passed the end of the requested range. + guard textPos < range.location + range.length else { return nil } + + let result = TextLinePosition( + data: node.pointee.data, + range: NSRange(location: textPos, length: nodeLength), + yPos: yPos, + height: nodeHeight, + index: index + ) + + // Advance to successor + let nextHandle = storage.successor(currentHandle) + textPos += nodeLength + yPos += nodeHeight + index += 1 + currentHandle = nextHandle + + return result } } } extension TextLineStorage: LazySequenceProtocol { public func makeIterator() -> TextLineStorageIterator { - TextLineStorageIterator(storage: self, currentPosition: nil) + TextLineStorageIterator(storage: self) } public struct TextLineStorageIterator: IteratorProtocol { private let storage: TextLineStorage - private var currentPosition: TextLinePosition? + private var currentHandle: NodeHandle + private var textPos: Int + private var yPos: CGFloat + private var index: Int - init(storage: TextLineStorage, currentPosition: TextLinePosition? = nil) { + init(storage: TextLineStorage) { self.storage = storage - self.currentPosition = currentPosition + // Seed at the leftmost (first) node. + if storage.rootHandle != Int32.min { + self.currentHandle = storage.minimum(storage.rootHandle) + self.textPos = 0 + self.yPos = 0 + self.index = 0 + } else { + self.currentHandle = Int32.min + self.textPos = 0 + self.yPos = 0 + self.index = 0 + } } public mutating func next() -> TextLinePosition? { - if let currentPosition { - guard currentPosition.range.max < storage.length, - let nextPosition = storage.getLine(atIndex: currentPosition.index + 1) else { - return nil - } - self.currentPosition = nextPosition - return nextPosition - } else if let nextPosition = storage.getLine(atOffset: 0) { - self.currentPosition = nextPosition - return nextPosition - } else { - return nil - } + guard currentHandle != Int32.min else { return nil } + let ptr = storage.nodesPtr + let node = ptr + Int(currentHandle) + let nodeLength = node.pointee.length + let nodeHeight = node.pointee.height + + let result = TextLinePosition( + data: node.pointee.data, + range: NSRange(location: textPos, length: nodeLength), + yPos: yPos, + height: nodeHeight, + index: index + ) + + // Advance to successor + let nextHandle = storage.successor(currentHandle) + textPos += nodeLength + yPos += nodeHeight + index += 1 + currentHandle = nextHandle + + return result } } } diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Node.swift index eb6c4ca94..2c823b68c 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Node.swift @@ -7,68 +7,53 @@ import Foundation +// MARK: - Arena-backed node storage +// +// Nodes live in a single raw `UnsafeMutablePointer` buffer owned by `TextLineStorage`. +// Parent/left/right are 32-bit indices (`NodeHandle`) into that buffer, not class +// references. +// +// The subscript uses `unsafeAddress`/`unsafeMutableAddress` addressors so +// `self[h].field` projects directly to the field and does NOT copy the ~72-byte node +// on every read. A by-value subscript would cost 72 bytes per tree-level; with the +// addressor we only load the fields we touch. +// +// `Int32.min` is the nil handle sentinel. Freed slots go on `freeList` and are reused +// on the next `allocNode`; we don't deinitialize on free — the slot keeps its old +// `Data` until assignment drops it on reuse or `removeAll`/`deinit` tears it down. + extension TextLineStorage { - func isRightChild(_ node: Node) -> Bool { - node.parent?.right === node - } + @usableFromInline + typealias NodeHandle = Int32 - func isLeftChild(_ node: Node) -> Bool { - node.parent?.left === node - } + @inlinable + static var nilHandle: NodeHandle { Int32.min } - /// Transplants a node with another node. - /// - /// ``` - /// [a] - /// [u]_/ \_[b] - /// [c]_/ \_[v] - /// - /// call: transplant(u, v) - /// - /// [a] - /// [v]_/ \_[b] - /// [c]_/ - /// - /// ``` - /// - Note: Leaves the task of updating tree metadata to the caller. - /// - Parameters: - /// - nodeU: The node to replace. - /// - nodeV: The node to insert in place of `nodeU` - func transplant(_ nodeU: borrowing Node, with nodeV: Node?) { - if nodeU.parent == nil { - root = nodeV - } else if isLeftChild(nodeU) { - nodeU.parent?.left = nodeV - } else { - nodeU.parent?.right = nodeV - } - nodeV?.parent = nodeU.parent - } - - enum Color { + @usableFromInline + enum Color: UInt8 { case red case black } - final class Node { - // The length of the text line - var length: Int - // The height of this text line - var height: CGFloat - var data: NodeData - - // The offset in characters of the entire left subtree - var leftSubtreeOffset: Int - // The sum of the height of the nodes in the left subtree - var leftSubtreeHeight: CGFloat - // The number of nodes in the left subtree - var leftSubtreeCount: Int - - var left: Node? - var right: Node? - unowned var parent: Node? - var color: Color + /// A tree node. Value type — lives in `TextLineStorage.nodesPtr`. + /// + /// Fields are laid out with the hot traversal metadata first to improve packing. + /// Size for `Data == TextLine` is ~72 bytes, comfortably small enough to prefetch. + @usableFromInline + struct Node { + // Hot traversal metadata (read on every tree walk) + @usableFromInline var leftSubtreeOffset: Int + @usableFromInline var leftSubtreeHeight: CGFloat + @usableFromInline var leftSubtreeCount: Int + @usableFromInline var length: Int + @usableFromInline var height: CGFloat + @usableFromInline var left: NodeHandle + @usableFromInline var right: NodeHandle + @usableFromInline var parent: NodeHandle + @usableFromInline var color: Color + @usableFromInline var data: NodeData + @inlinable init( length: Int, data: NodeData, @@ -76,9 +61,9 @@ extension TextLineStorage { leftSubtreeHeight: CGFloat, leftSubtreeCount: Int, height: CGFloat, - left: Node? = nil, - right: Node? = nil, - parent: Node? = nil, + left: NodeHandle = Int32.min, + right: NodeHandle = Int32.min, + parent: NodeHandle = Int32.min, color: Color ) { self.length = length @@ -93,7 +78,8 @@ extension TextLineStorage { self.color = color } - convenience init(length: Int, data: NodeData, height: CGFloat) { + @inlinable + init(length: Int, data: NodeData, height: CGFloat) { self.init( length: length, data: data, @@ -104,53 +90,244 @@ extension TextLineStorage { color: .black ) } + } +} + +// MARK: - Arena / handle access + +extension TextLineStorage { + /// Access a node by handle. Addressor-based — `self[h].field` projects directly to + /// the field with no copy of the 72-byte `Node`. Preconditions: handle refers to a + /// live slot (not `Int32.min`, not currently in the free list). + @inlinable + subscript(_ handle: NodeHandle) -> Node { + @_transparent unsafeAddress { + return UnsafePointer(nodesPtr.advanced(by: Int(handle))) + } + @_transparent unsafeMutableAddress { + return nodesPtr.advanced(by: Int(handle)) + } + } - func sibling() -> Node? { - if parent?.left === self { - return parent?.right - } else { - return parent?.left - } + /// Safe read — returns nil for the nil sentinel. Copies the node by value; only use + /// for test/inspection paths. + @inlinable + func nodeOrNil(_ handle: NodeHandle) -> Node? { + guard handle != Int32.min else { return nil } + return nodesPtr.advanced(by: Int(handle)).pointee + } + + /// Ensure the arena can hold at least `required` slots. Grows geometrically. + @inlinable + func ensureNodeCapacity(_ required: Int) { + guard required > nodesCapacity else { return } + let newCap = Swift.max(required, Swift.max(16, nodesCapacity * 2)) + let newPtr = UnsafeMutablePointer>.allocate(capacity: newCap) + if nodesCount > 0 { + newPtr.moveInitialize(from: nodesPtr, count: nodesCount) } + nodesPtr.deallocate() + nodesPtr = newPtr + nodesCapacity = newCap + } - func minimum() -> Node { - if let left { - return left.minimum() - } else { - return self - } + /// Allocate a new node in the arena. Reuses a freed slot when available. + @inlinable + func allocNode( + length: Int, + data: Data, + height: CGFloat, + leftSubtreeOffset: Int = 0, + leftSubtreeHeight: CGFloat = 0, + leftSubtreeCount: Int = 0, + color: Color + ) -> NodeHandle { + let node = Node( + length: length, + data: data, + leftSubtreeOffset: leftSubtreeOffset, + leftSubtreeHeight: leftSubtreeHeight, + leftSubtreeCount: leftSubtreeCount, + height: height, + color: color + ) + if let reused = freeList.popLast() { + // Slot is still initialized (we don't deinitialize on free); assignment + // destroys the old value and stores the new one. + nodesPtr.advanced(by: Int(reused)).pointee = node + return reused } + ensureNodeCapacity(nodesCount + 1) + nodesPtr.advanced(by: nodesCount).initialize(to: node) + let handle = NodeHandle(nodesCount) + nodesCount += 1 + return handle + } - func maximum() -> Node { - if let right { - return right.maximum() - } else { - return self - } + /// Return a node's slot to the free list. The slot's `Data` reference is kept alive + /// until the slot is reused (assignment drops it) or the storage is cleared. + @inlinable + func freeNode(_ handle: NodeHandle) { + freeList.append(handle) + } + + /// For tests: read-only node view. + @usableFromInline + func node(for handle: NodeHandle) -> Node? { + nodeOrNil(handle) + } +} + +// MARK: - Test-friendly navigation +// +// `NodeRef` wraps `(storage, handle)` so tests can chain `.root?.right?.left?.length` +// the same way they did when nodes were classes. This is `@usableFromInline` rather +// than `public` — the production layout path goes through handles directly and should +// never allocate these wrappers. + +extension TextLineStorage { + @usableFromInline + struct NodeRef { + @usableFromInline let storage: TextLineStorage + @usableFromInline let handle: NodeHandle + + @usableFromInline + init(storage: TextLineStorage, handle: NodeHandle) { + self.storage = storage + self.handle = handle + } + + @usableFromInline var left: NodeRef? { + let h = storage[handle].left + return h == Int32.min ? nil : NodeRef(storage: storage, handle: h) + } + + @usableFromInline var right: NodeRef? { + let h = storage[handle].right + return h == Int32.min ? nil : NodeRef(storage: storage, handle: h) } - func getSuccessor() -> Node? { - // If node has right child: successor is the min of this right tree - if let right { - return right.minimum() + @usableFromInline var parent: NodeRef? { + let h = storage[handle].parent + return h == Int32.min ? nil : NodeRef(storage: storage, handle: h) + } + + @usableFromInline var length: Int { storage[handle].length } + @usableFromInline var height: CGFloat { storage[handle].height } + @usableFromInline var color: Color { storage[handle].color } + @usableFromInline var leftSubtreeOffset: Int { storage[handle].leftSubtreeOffset } + @usableFromInline var leftSubtreeHeight: CGFloat { storage[handle].leftSubtreeHeight } + @usableFromInline var leftSubtreeCount: Int { storage[handle].leftSubtreeCount } + @usableFromInline var data: Data { storage[handle].data } + } + + /// Root node as a chainable reference. `nil` when the tree is empty. + @usableFromInline + var root: NodeRef? { + rootHandle == Int32.min ? nil : NodeRef(storage: self, handle: rootHandle) + } +} + +// MARK: - Tree helpers (handle-based) +// +// All helpers hoist `nodesPtr` into a local `ptr` and traverse with direct pointer +// arithmetic. Using `self[handle].field` here would force the compiler to reload +// `self.nodesPtr` (a class-property load) on every field access inside the loop; the +// hoisted pointer guarantees a single load per call and lets the loop body compile to +// a tight sequence of indexed loads. + +extension TextLineStorage { + @inlinable + func isRightChild(_ handle: NodeHandle) -> Bool { + let ptr = nodesPtr + let parent = (ptr + Int(handle)).pointee.parent + guard parent != Int32.min else { return false } + return (ptr + Int(parent)).pointee.right == handle + } + + @inlinable + func isLeftChild(_ handle: NodeHandle) -> Bool { + let ptr = nodesPtr + let parent = (ptr + Int(handle)).pointee.parent + guard parent != Int32.min else { return false } + return (ptr + Int(parent)).pointee.left == handle + } + + /// Transplants one node with another. Meta (left/parent updates at parent nodes) + /// is left to the caller — matches the original behavior. + @inlinable + func transplant(_ nodeU: NodeHandle, with nodeV: NodeHandle) { + let ptr = nodesPtr + let parentU = (ptr + Int(nodeU)).pointee.parent + if parentU == Int32.min { + rootHandle = nodeV + } else { + let parentNode = ptr + Int(parentU) + if parentNode.pointee.left == nodeU { + parentNode.pointee.left = nodeV } else { - // Else go upward until node is a left child - var currentNode = self - var parent = currentNode.parent - while currentNode.parent?.right === currentNode { - if let parent = parent { - currentNode = parent - } - parent = currentNode.parent - } - return parent + parentNode.pointee.right = nodeV } } + if nodeV != Int32.min { + (ptr + Int(nodeV)).pointee.parent = parentU + } + } - deinit { - left = nil - right = nil - parent = nil + /// Sibling of `handle` (nil if no parent, or parent has only this child). + @inlinable + func sibling(_ handle: NodeHandle) -> NodeHandle { + let ptr = nodesPtr + let parent = (ptr + Int(handle)).pointee.parent + guard parent != Int32.min else { return Int32.min } + let parentNode = ptr + Int(parent) + let left = parentNode.pointee.left + if left == handle { + return parentNode.pointee.right + } else { + return left + } + } + + /// Leftmost descendant of `handle` (inclusive). Iterative — avoids deep recursion + /// blowing the stack on long left spines. + @inlinable + func minimum(_ handle: NodeHandle) -> NodeHandle { + let ptr = nodesPtr + var current = handle + while true { + let left = (ptr + Int(current)).pointee.left + if left == Int32.min { return current } + current = left + } + } + + /// Rightmost descendant of `handle` (inclusive). Iterative. + @inlinable + func maximum(_ handle: NodeHandle) -> NodeHandle { + let ptr = nodesPtr + var current = handle + while true { + let right = (ptr + Int(current)).pointee.right + if right == Int32.min { return current } + current = right + } + } + + /// In-order successor. Iterative. + @inlinable + func successor(_ handle: NodeHandle) -> NodeHandle { + let ptr = nodesPtr + let right = (ptr + Int(handle)).pointee.right + if right != Int32.min { + return minimum(right) + } + var current = handle + var parent = (ptr + Int(current)).pointee.parent + while parent != Int32.min && (ptr + Int(parent)).pointee.right == current { + current = parent + parent = (ptr + Int(current)).pointee.parent } + return parent } } diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift index b022a9b1c..7acc5068d 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Structs.swift @@ -9,6 +9,7 @@ import Foundation extension TextLineStorage where Data: Identifiable { public struct TextLinePosition { + @usableFromInline init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) { self.data = data self.range = range @@ -17,11 +18,12 @@ extension TextLineStorage where Data: Identifiable { self.index = index } + @usableFromInline init(position: NodePosition) { - self.data = position.node.data - self.range = NSRange(location: position.textPos, length: position.node.length) + self.data = position.data + self.range = NSRange(location: position.textPos, length: position.length) self.yPos = position.yPos - self.height = position.node.height + self.height = position.height self.index = position.index } @@ -37,26 +39,46 @@ extension TextLineStorage where Data: Identifiable { public let index: Int } + /// Internal result type for tree searches. Carries the handle for mutation plus a + /// snapshot of the node's user-facing fields so callers that only need to *read* + /// the node don't have to go back through the arena subscript. + @usableFromInline struct NodePosition { - /// The node storing information and the data stored at the position. - let node: Node + let handle: NodeHandle + let data: Data + let length: Int + let height: CGFloat /// The y position of the data, on a top down y axis let yPos: CGFloat /// The location of the node in the document let textPos: Int /// The index of the node in the document. let index: Int + + @usableFromInline + init(handle: NodeHandle, data: Data, length: Int, height: CGFloat, yPos: CGFloat, textPos: Int, index: Int) { + self.handle = handle + self.data = data + self.length = length + self.height = height + self.yPos = yPos + self.textPos = textPos + self.index = index + } } + @usableFromInline struct NodeSubtreeMetadata { let height: CGFloat let offset: Int let count: Int + @usableFromInline static var zero: NodeSubtreeMetadata { NodeSubtreeMetadata(height: 0, offset: 0, count: 0) } + @usableFromInline static func + (lhs: NodeSubtreeMetadata, rhs: NodeSubtreeMetadata) -> NodeSubtreeMetadata { NodeSubtreeMetadata( height: lhs.height + rhs.height, @@ -70,5 +92,11 @@ extension TextLineStorage where Data: Identifiable { public let data: Data public let length: Int public let height: CGFloat? + + public init(data: Data, length: Int, height: CGFloat?) { + self.data = data + self.length = length + self.height = height + } } } diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift index 449d3c747..4e91f8f67 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage.swift @@ -7,20 +7,36 @@ import Foundation -// Disabling the file length here due to the fact that we want to keep certain methods private even to this package. -// Specifically, all rotation methods, fixup methods, and internal search methods must be kept private. // swiftlint:disable file_length -// There is some ugly `Unmanaged` code in this class. This is due to the fact that Swift often has a hard time -// optimizing retain/release calls for object trees. For instance, the `metaFixup` method has a lot of retain/release -// calls to each node/parent as we do a little walk up the tree. +// MARK: - Performance notes // -// Using Unmanaged references resulted in a -15% decrease (0.667s -> 0.563s) in the -// TextLayoutLineStorageTests.test_insertPerformance benchmark when first changed to use Unmanaged. +// `TextLineStorage` is a red-black tree keyed on document offset and index, with an +// auxiliary Y-position metric. It is the hottest data structure in the text engine — +// the layout loop walks it per-frame to find visible lines, selection drawing hits it +// once per selection-fragment, and every edit mutates it. // -// See: -// - https://github.com/apple/swift/blob/main/docs/OptimizationTips.rst#unsafe-code -// - https://forums.swift.org/t/improving-linked-list-performance-swift-release-and-swift-retain-overhead/17205 +// Nodes live in a raw `UnsafeMutablePointer>` buffer owned by this class. +// Parent/left/right are 32-bit indices into that buffer, not class references. +// +// Why raw pointer and not `ContiguousArray>`? Array's subscript getter +// returns by value, so `self[h].field` would copy the whole 72-byte node on every +// read. The subscript in `TextLineStorage+Node.swift` uses +// `unsafeAddress`/`unsafeMutableAddress` addressors over the raw buffer, which project +// directly to the field with no copy. +// +// Hot traversal functions (search, getLine, rotate, fixups) hoist `nodesPtr` into a +// local `ptr` and use `(ptr + Int(h)).pointee.field` directly. This is deliberate: +// without it, every `self[h].field` has to re-load `self.nodesPtr` (a class-property +// load) before the pointer math, and the compiler cannot reliably CSE those loads +// across a loop of 20+ iterations. Writes in the same loops can stay on the subscript +// (`self[h].field = x`) since the `_modify`/mutable-addressor path is already +// in-place and the loss from re-loading `nodesPtr` on a handful of writes is small. +// +// All tree methods take `NodeHandle` (Int32); `Int32.min` is the nil sentinel. Freed +// slots go on `freeList` and are reused on the next `allocNode`. We don't deinitialize +// on free — the slot keeps its `Data` until assignment drops it on reuse or +// `removeAll`/`deinit` tears down the occupied range. /// Implements a red-black tree for efficiently editing, storing and retrieving lines of text in a document. public final class TextLineStorage { @@ -30,7 +46,24 @@ public final class TextLineStorage { case none } - var root: Node? + // MARK: - Arena state + + @usableFromInline + internal var nodesPtr: UnsafeMutablePointer> + + @usableFromInline + internal var nodesCapacity: Int + + @usableFromInline + internal var nodesCount: Int = 0 + + @usableFromInline + internal var freeList: [NodeHandle] = [] + + @usableFromInline + internal var rootHandle: NodeHandle = Int32.min + + // MARK: - Public state /// The number of characters in the storage object. private(set) public var length: Int = 0 @@ -42,24 +75,64 @@ public final class TextLineStorage { public var height: CGFloat = 0 public var first: TextLinePosition? { - guard count > 0, let position = search(forIndex: 0) else { return nil } - return TextLinePosition(position: position) + guard rootHandle != Int32.min else { return nil } + let h = minimum(rootHandle) + let node = nodesPtr + Int(h) + return TextLinePosition( + data: node.pointee.data, + range: NSRange(location: 0, length: node.pointee.length), + yPos: 0, + height: node.pointee.height, + index: 0 + ) } public var last: TextLinePosition? { - guard count > 0, let position = search(forIndex: count - 1) else { return nil } - return TextLinePosition(position: position) + guard rootHandle != Int32.min else { return nil } + let h = maximum(rootHandle) + let node = nodesPtr + Int(h) + return TextLinePosition( + data: node.pointee.data, + range: NSRange(location: length - node.pointee.length, length: node.pointee.length), + yPos: height - node.pointee.height, + height: node.pointee.height, + index: count - 1 + ) } - private var lastNode: NodePosition? { - guard count > 0, let position = search(forIndex: count - 1) else { return nil } - return position + @usableFromInline + var lastNode: NodePosition? { + guard rootHandle != Int32.min else { return nil } + let h = maximum(rootHandle) + let node = nodesPtr + Int(h) + return NodePosition( + handle: h, + data: node.pointee.data, + length: node.pointee.length, + height: node.pointee.height, + yPos: height - node.pointee.height, + textPos: length - node.pointee.length, + index: count - 1 + ) } - public init() { } + public init() { + self.nodesCapacity = 16 + self.nodesPtr = UnsafeMutablePointer>.allocate(capacity: 16) + } + + deinit { + if nodesCount > 0 { + nodesPtr.deinitialize(count: nodesCount) + } + nodesPtr.deallocate() + } - init(root: Node, count: Int, length: Int, height: CGFloat) { - self.root = root + /// Test-only state injection for tree tests that need a specific hand-crafted shape + /// the public API can't produce (RB-tree inserts always rebalance). Internal so it + /// doesn't leak to external consumers; `@testable import` exposes it to test code. + @usableFromInline + internal func _unsafeSetCounters(count: Int, length: Int, height: CGFloat) { self.count = count self.length = length self.height = height @@ -69,11 +142,6 @@ public final class TextLineStorage { /// Inserts a new line for the given range. /// - Complexity: `O(log n)` where `n` is the number of lines in the storage object. - /// - Parameters: - /// - line: The text line to insert - /// - index: The offset to insert the line at. - /// - length: The length of the new line. - /// - height: The height of the new line. public func insert(line: Data, atOffset index: Int, length: Int, height: CGFloat) { assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") defer { @@ -82,124 +150,86 @@ public final class TextLineStorage { self.height += height } - let insertedNode = Node(length: length, data: line, height: height) - guard root != nil else { - root = insertedNode + // Empty tree — first insert becomes the root. + guard rootHandle != Int32.min else { + rootHandle = allocNode(length: length, data: line, height: height, color: .black) return } - insertedNode.color = .red - var currentNode: Unmanaged> = Unmanaged>.passUnretained(root!) - var shouldContinue = true - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - while shouldContinue { - let node = currentNode.takeUnretainedValue() + let inserted = allocNode(length: length, data: line, height: height, color: .red) + // `allocNode` may have grown the buffer — read `nodesPtr` AFTER it. + let ptr = nodesPtr + + // Walk down to the correct parent position, tracking the target offset. + var currentHandle = rootHandle + var currentOffset = (ptr + Int(rootHandle)).pointee.leftSubtreeOffset + while true { + let node = ptr + Int(currentHandle) if currentOffset >= index { - if node.left != nil { - currentNode = Unmanaged>.passUnretained(node.left!) - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) + let leftHandle = node.pointee.left + if leftHandle != Int32.min { + currentOffset += (ptr + Int(leftHandle)).pointee.leftSubtreeOffset + - node.pointee.leftSubtreeOffset + currentHandle = leftHandle } else { - node.left = insertedNode - insertedNode.parent = node - shouldContinue = false + node.pointee.left = inserted + (ptr + Int(inserted)).pointee.parent = currentHandle + break } } else { - if node.right != nil { - currentNode = Unmanaged>.passUnretained(node.right!) - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) + let rightHandle = node.pointee.right + if rightHandle != Int32.min { + currentOffset += node.pointee.length + + (ptr + Int(rightHandle)).pointee.leftSubtreeOffset + currentHandle = rightHandle } else { - node.right = insertedNode - insertedNode.parent = node - shouldContinue = false + node.pointee.right = inserted + (ptr + Int(inserted)).pointee.parent = currentHandle + break } } } + let insertedNode = ptr + Int(inserted) metaFixup( - startingAt: insertedNode, - delta: insertedNode.length, - deltaHeight: insertedNode.height, + startingAt: inserted, + delta: insertedNode.pointee.length, + deltaHeight: insertedNode.pointee.height, nodeAction: .inserted ) - insertFixup(node: insertedNode) + insertFixup(handle: inserted) } /// Fetches a line for the given offset. - /// /// - Complexity: `O(log n)` - /// - Parameter offset: The offset to fetch for. - /// - Returns:A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. + @inlinable public func getLine(atOffset offset: Int) -> TextLinePosition? { - guard let nodePosition = search(for: offset) else { return nil } - return TextLinePosition(position: nodePosition) + guard let position = search(for: offset) else { return nil } + return TextLinePosition(position: position) } /// Fetches a line for the given index. - /// /// - Complexity: `O(log n)` - /// - Parameter index: The index to fetch for. - /// - Returns: A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. + @inlinable public func getLine(atIndex index: Int) -> TextLinePosition? { - guard let nodePosition = search(forIndex: index) else { return nil } - return TextLinePosition(position: nodePosition) + guard let position = search(forIndex: index) else { return nil } + return TextLinePosition(position: position) } /// Fetches a line for the given `y` value. - /// /// - Complexity: `O(log n)` - /// - Parameter position: The position to fetch for. - /// - Returns: A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. + @inlinable public func getLine(atPosition posY: CGFloat) -> TextLinePosition? { guard posY >= 0 else { return first } guard posY < height else { return last } - - var currentNode = root - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 - var currentIndex: Int = root?.leftSubtreeCount ?? 0 - while let node = currentNode { - // If posY is in the range [currentYPosition..= currentYPosition && posY < currentYPosition + node.height { - return TextLinePosition( - data: node.data, - range: NSRange(location: currentOffset, length: node.length), - yPos: currentYPosition, - height: node.height, - index: currentIndex - ) - } else if currentYPosition > posY { - currentNode = node.left - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) - currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) - currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) - } else { - // posY >= currentYPosition + node.height, so the target line is after this node. - currentNode = node.right - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) - currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) - currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) - } - } - - return nil + guard let position = search(forYPosition: posY) else { return nil } + return TextLinePosition(position: position) } /// Applies a length change at the given index. - /// - /// If a character was deleted, delta should be negative. - /// The `index` parameter should represent where the edit began. - /// - /// Lines will be deleted if the delta is both negative and encompasses the entire line. - /// - /// If the delta goes beyond the line's range, an error will be thrown. - /// - 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: - /// - 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. + /// - Complexity `O(log n)`. public func update(atOffset offset: Int, delta: Int, deltaHeight: CGFloat) { assert( offset >= 0 && offset <= self.length, @@ -224,34 +254,36 @@ public final class TextLineStorage { } length += delta height += deltaHeight - position.node.length += delta - position.node.height += deltaHeight - metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight) + let node = nodesPtr + Int(position.handle) + node.pointee.length += delta + node.pointee.height += deltaHeight + metaFixup(startingAt: position.handle, delta: delta, deltaHeight: deltaHeight) } /// Deletes the line containing the given index. - /// - /// Will exit silently if a line could not be found for the given index, and throw an assertion error if the index - /// is out of bounds. - /// - Parameter index: The index to delete a line at. public func delete(lineAt index: Int) { assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") guard count > 1 else { removeAll() return } - guard let node = search(for: index)?.node else { + guard let position = search(for: index) else { assertionFailure("Failed to find node for index: \(index)") return } count -= 1 - length -= node.length - height -= node.height - deleteNode(node) + length -= position.length + height -= position.height + deleteNode(position.handle) } public func removeAll() { - root = nil + if nodesCount > 0 { + nodesPtr.deinitialize(count: nodesCount) + } + nodesCount = 0 + freeList.removeAll(keepingCapacity: true) + rootHandle = Int32.min count = 0 length = 0 height = 0 @@ -259,315 +291,487 @@ public final class TextLineStorage { /// Efficiently builds the tree from the given array of lines. /// - Note: Calls ``TextLineStorage/removeAll()`` before building. - /// - Parameter lines: The lines to use to build the tree. public func build(from lines: borrowing [BuildItem], estimatedLineHeight: CGFloat) { removeAll() - root = build(lines: lines, estimatedLineHeight: estimatedLineHeight, left: 0, right: lines.count, parent: nil).0 + // Reserve capacity up-front — one allocation for the full arena. + ensureNodeCapacity(lines.count) + let (handle, _, _, _) = buildSubtree( + lines: lines, + estimatedLineHeight: estimatedLineHeight, + left: 0, + right: lines.count, + parent: Int32.min + ) + rootHandle = handle count = lines.count } + // swiftlint:disable large_tuple /// Recursively builds a subtree given an array of sorted lines, and a left and right indexes. - /// - Parameters: - /// - lines: The lines to use to build the subtree. - /// - estimatedLineHeight: An estimated line height to add to the allocated nodes. - /// - left: The left index to use. - /// - right: The right index to use. - /// - parent: The parent of the subtree, `nil` if this is the root. - /// - Returns: A node, if available, along with it's subtree's height and offset. - private func build( + /// - Returns: (rootHandle, offsetSum, heightSum, count) for the built subtree. + private func buildSubtree( lines: borrowing [BuildItem], estimatedLineHeight: CGFloat, left: Int, right: Int, - parent: Node? - ) -> (Node?, Int?, CGFloat?, Int) { // swiftlint:disable:this large_tuple - guard left < right else { return (nil, nil, nil, 0) } - let mid = left + (right - left)/2 - let node = Node( + parent: NodeHandle + ) -> (NodeHandle, Int, CGFloat, Int) { + guard left < right else { return (Int32.min, 0, 0, 0) } + let mid = left + (right - left) / 2 + + let handle = allocNode( length: lines[mid].length, data: lines[mid].data, - leftSubtreeOffset: 0, - leftSubtreeHeight: 0, - leftSubtreeCount: 0, height: lines[mid].height ?? estimatedLineHeight, color: .black ) - node.parent = parent + self[handle].parent = parent - let (left, leftOffset, leftHeight, leftCount) = build( + let (leftHandle, leftOffset, leftHeight, leftCount) = buildSubtree( lines: lines, estimatedLineHeight: estimatedLineHeight, left: left, right: mid, - parent: node + parent: handle ) - let (right, rightOffset, rightHeight, rightCount) = build( + let (rightHandle, rightOffset, rightHeight, rightCount) = buildSubtree( lines: lines, estimatedLineHeight: estimatedLineHeight, left: mid + 1, right: right, - parent: node + parent: handle ) - node.left = left - node.right = right - if node.left == nil && node.right == nil { - node.color = .red + // `allocNode` may have grown the buffer during the recursive calls — re-read. + let node = nodesPtr + Int(handle) + node.pointee.left = leftHandle + node.pointee.right = rightHandle + + // Leaves are red; internal nodes black. Same coloring as the original. + if leftHandle == Int32.min && rightHandle == Int32.min { + node.pointee.color = .red } - length += node.length - height += node.height - node.leftSubtreeOffset = leftOffset ?? 0 - node.leftSubtreeHeight = leftHeight ?? 0 - node.leftSubtreeCount = leftCount + length += node.pointee.length + height += node.pointee.height + node.pointee.leftSubtreeOffset = leftOffset + node.pointee.leftSubtreeHeight = leftHeight + node.pointee.leftSubtreeCount = leftCount return ( - node, - node.length + (leftOffset ?? 0) + (rightOffset ?? 0), - node.height + (leftHeight ?? 0) + (rightHeight ?? 0), + handle, + node.pointee.length + leftOffset + rightOffset, + node.pointee.height + leftHeight + rightHeight, 1 + leftCount + rightCount ) } } -private extension TextLineStorage { - // MARK: - Search +// MARK: - Search +extension TextLineStorage { /// Searches for the given offset. - /// - Parameter offset: The offset to look for in the document. - /// - Returns: A tuple containing a node if it was found, and the offset of the node in the document. + @inlinable func search(for offset: Int) -> NodePosition? { - var currentNode = root - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 - var currentIndex: Int = root?.leftSubtreeCount ?? 0 - while let node = currentNode { - // If index is in the range [currentOffset..= currentOffset && offset < currentOffset + node.length) { - return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset, index: currentIndex) + guard rootHandle != Int32.min else { return nil } + let ptr = nodesPtr + let rootNode = ptr + Int(rootHandle) + var currentHandle = rootHandle + var currentOffset = rootNode.pointee.leftSubtreeOffset + var currentYPosition = rootNode.pointee.leftSubtreeHeight + var currentIndex = rootNode.pointee.leftSubtreeCount + while currentHandle != Int32.min { + let node = ptr + Int(currentHandle) + let nodeLength = node.pointee.length + if offset == currentOffset || (offset >= currentOffset && offset < currentOffset + nodeLength) { + return NodePosition( + handle: currentHandle, + data: node.pointee.data, + length: nodeLength, + height: node.pointee.height, + yPos: currentYPosition, + textPos: currentOffset, + index: currentIndex + ) } else if currentOffset > offset { - currentNode = node.left - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) - currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) - currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) + let left = node.pointee.left + if left == Int32.min { return nil } + let leftNode = ptr + Int(left) + currentOffset += leftNode.pointee.leftSubtreeOffset - node.pointee.leftSubtreeOffset + currentYPosition += leftNode.pointee.leftSubtreeHeight - node.pointee.leftSubtreeHeight + currentIndex += leftNode.pointee.leftSubtreeCount - node.pointee.leftSubtreeCount + currentHandle = left } else { - // offset >= currentOffset + node.length, so the target is after this node. - currentNode = node.right - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) - currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) - currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) + let right = node.pointee.right + if right == Int32.min { return nil } + let rightNode = ptr + Int(right) + currentOffset += nodeLength + rightNode.pointee.leftSubtreeOffset + currentYPosition += node.pointee.height + rightNode.pointee.leftSubtreeHeight + currentIndex += 1 + rightNode.pointee.leftSubtreeCount + currentHandle = right } } return nil } /// Searches for the given index. - /// - Parameter index: The index to look for in the document. - /// - Returns: A tuple containing a node if it was found, and the offset of the node in the document. + @inlinable func search(forIndex index: Int) -> NodePosition? { - var currentNode = root - var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 - var currentIndex: Int = root?.leftSubtreeCount ?? 0 - while let node = currentNode { + guard rootHandle != Int32.min else { return nil } + let ptr = nodesPtr + let rootNode = ptr + Int(rootHandle) + var currentHandle = rootHandle + var currentOffset = rootNode.pointee.leftSubtreeOffset + var currentYPosition = rootNode.pointee.leftSubtreeHeight + var currentIndex = rootNode.pointee.leftSubtreeCount + while currentHandle != Int32.min { + let node = ptr + Int(currentHandle) if index == currentIndex { - return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset, index: currentIndex) + return NodePosition( + handle: currentHandle, + data: node.pointee.data, + length: node.pointee.length, + height: node.pointee.height, + yPos: currentYPosition, + textPos: currentOffset, + index: currentIndex + ) } else if currentIndex > index { - currentNode = node.left - currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) - currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0) - currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0) + let left = node.pointee.left + if left == Int32.min { return nil } + let leftNode = ptr + Int(left) + currentOffset += leftNode.pointee.leftSubtreeOffset - node.pointee.leftSubtreeOffset + currentYPosition += leftNode.pointee.leftSubtreeHeight - node.pointee.leftSubtreeHeight + currentIndex += leftNode.pointee.leftSubtreeCount - node.pointee.leftSubtreeCount + currentHandle = left + } else { + let right = node.pointee.right + if right == Int32.min { return nil } + let rightNode = ptr + Int(right) + currentOffset += node.pointee.length + rightNode.pointee.leftSubtreeOffset + currentYPosition += node.pointee.height + rightNode.pointee.leftSubtreeHeight + currentIndex += 1 + rightNode.pointee.leftSubtreeCount + currentHandle = right + } + } + return nil + } + + /// Searches for the node containing the given y position. + @inlinable + func search(forYPosition posY: CGFloat) -> NodePosition? { + guard rootHandle != Int32.min else { return nil } + let ptr = nodesPtr + let rootNode = ptr + Int(rootHandle) + var currentHandle = rootHandle + var currentOffset = rootNode.pointee.leftSubtreeOffset + var currentYPosition = rootNode.pointee.leftSubtreeHeight + var currentIndex = rootNode.pointee.leftSubtreeCount + while currentHandle != Int32.min { + let node = ptr + Int(currentHandle) + let nodeHeight = node.pointee.height + if posY >= currentYPosition && posY < currentYPosition + nodeHeight { + return NodePosition( + handle: currentHandle, + data: node.pointee.data, + length: node.pointee.length, + height: nodeHeight, + yPos: currentYPosition, + textPos: currentOffset, + index: currentIndex + ) + } else if currentYPosition > posY { + let left = node.pointee.left + if left == Int32.min { return nil } + let leftNode = ptr + Int(left) + currentOffset += leftNode.pointee.leftSubtreeOffset - node.pointee.leftSubtreeOffset + currentYPosition += leftNode.pointee.leftSubtreeHeight - node.pointee.leftSubtreeHeight + currentIndex += leftNode.pointee.leftSubtreeCount - node.pointee.leftSubtreeCount + currentHandle = left } else { - currentNode = node.right - currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) - currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) - currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) + let right = node.pointee.right + if right == Int32.min { return nil } + let rightNode = ptr + Int(right) + currentOffset += node.pointee.length + rightNode.pointee.leftSubtreeOffset + currentYPosition += nodeHeight + rightNode.pointee.leftSubtreeHeight + currentIndex += 1 + rightNode.pointee.leftSubtreeCount + currentHandle = right } } return nil } +} - // MARK: - Delete +// MARK: - Delete - /// A basic RB-Tree node removal with specialization for node metadata. - /// - Parameter nodeZ: The node to remove. - func deleteNode(_ nodeZ: Node) { - metaFixup(startingAt: nodeZ, delta: -nodeZ.length, deltaHeight: -nodeZ.height, nodeAction: .deleted) +private extension TextLineStorage { + /// Basic RB-Tree node removal with specialization for node metadata. + func deleteNode(_ nodeZ: NodeHandle) { + let ptr = nodesPtr + let zNode = ptr + Int(nodeZ) + + metaFixup( + startingAt: nodeZ, + delta: -zNode.pointee.length, + deltaHeight: -zNode.pointee.height, + nodeAction: .deleted + ) var nodeY = nodeZ - var nodeX: Node? - var originalColor = nodeY.color + var nodeX: NodeHandle = Int32.min + var originalColor = zNode.pointee.color + + let zLeft = zNode.pointee.left + let zRight = zNode.pointee.right - if nodeZ.left == nil || nodeZ.right == nil { - nodeX = nodeZ.right ?? nodeZ.left + if zLeft == Int32.min || zRight == Int32.min { + nodeX = zRight != Int32.min ? zRight : zLeft transplant(nodeZ, with: nodeX) } else { - nodeY = nodeZ.right!.minimum() + nodeY = minimum(zRight) + let yNode = ptr + Int(nodeY) + + // Remove nodeY from its original position. + metaFixup( + startingAt: nodeY, + delta: -yNode.pointee.length, + deltaHeight: -yNode.pointee.height, + nodeAction: .deleted + ) - // Delete nodeY from it's original place in the tree. - metaFixup(startingAt: nodeY, delta: -nodeY.length, deltaHeight: -nodeY.height, nodeAction: .deleted) + originalColor = yNode.pointee.color + nodeX = yNode.pointee.right - originalColor = nodeY.color - nodeX = nodeY.right - if nodeY.parent === nodeZ { - nodeX?.parent = nodeY + if yNode.pointee.parent == nodeZ { + if nodeX != Int32.min { + (ptr + Int(nodeX)).pointee.parent = nodeY + } } else { - transplant(nodeY, with: nodeY.right) - - nodeY.right?.leftSubtreeCount += nodeY.leftSubtreeCount - nodeY.right?.leftSubtreeHeight += nodeY.leftSubtreeHeight - nodeY.right?.leftSubtreeOffset += nodeY.leftSubtreeOffset + transplant(nodeY, with: yNode.pointee.right) + + let yRight = yNode.pointee.right + if yRight != Int32.min { + let yRightNode = ptr + Int(yRight) + yRightNode.pointee.leftSubtreeCount += yNode.pointee.leftSubtreeCount + yRightNode.pointee.leftSubtreeHeight += yNode.pointee.leftSubtreeHeight + yRightNode.pointee.leftSubtreeOffset += yNode.pointee.leftSubtreeOffset + } - nodeY.right = nodeZ.right - nodeY.right?.parent = nodeY + let newRight = zNode.pointee.right + yNode.pointee.right = newRight + if newRight != Int32.min { + (ptr + Int(newRight)).pointee.parent = nodeY + } } + transplant(nodeZ, with: nodeY) - nodeY.left = nodeZ.left - nodeY.left?.parent = nodeY - nodeY.color = nodeZ.color - nodeY.leftSubtreeCount = nodeZ.leftSubtreeCount - nodeY.leftSubtreeHeight = nodeZ.leftSubtreeHeight - nodeY.leftSubtreeOffset = nodeZ.leftSubtreeOffset - - // We've inserted nodeY again into a new spot. Update tree meta - metaFixup(startingAt: nodeY, delta: nodeY.length, deltaHeight: nodeY.height, nodeAction: .inserted) + let newLeft = zNode.pointee.left + yNode.pointee.left = newLeft + if newLeft != Int32.min { + (ptr + Int(newLeft)).pointee.parent = nodeY + } + yNode.pointee.color = zNode.pointee.color + yNode.pointee.leftSubtreeCount = zNode.pointee.leftSubtreeCount + yNode.pointee.leftSubtreeHeight = zNode.pointee.leftSubtreeHeight + yNode.pointee.leftSubtreeOffset = zNode.pointee.leftSubtreeOffset + + // nodeY re-inserted — bump metadata for its new position. + metaFixup( + startingAt: nodeY, + delta: yNode.pointee.length, + deltaHeight: yNode.pointee.height, + nodeAction: .inserted + ) } - if originalColor == .black, let nodeX { - deleteFixup(node: nodeX) + if originalColor == .black && nodeX != Int32.min { + deleteFixup(handle: nodeX) } + + // Return the removed slot to the free list. + freeNode(nodeZ) } +} - // MARK: - Fixup - - func insertFixup(node: Node) { - var nextNode: Node? = node - while var nodeX = nextNode, nodeX !== root, let nodeXParent = nodeX.parent, nodeXParent.color == .red { - let nodeY = nodeXParent.sibling() - if isLeftChild(nodeXParent) { - if nodeY?.color == .red { - nodeXParent.color = .black - nodeY?.color = .black - nodeX.parent?.parent?.color = .red - nextNode = nodeX.parent?.parent - } else { - if isRightChild(nodeX) { - nodeX = nodeXParent - leftRotate(node: nodeX) - } +// MARK: - Fixup - nodeX.parent?.color = .black - nodeX.parent?.parent?.color = .red - if let grandparent = nodeX.parent?.parent { - rightRotate(node: grandparent) - } +private extension TextLineStorage { + func insertFixup(handle: NodeHandle) { + let ptr = nodesPtr + var nodeX = handle + while nodeX != rootHandle { + let parent = (ptr + Int(nodeX)).pointee.parent + if parent == Int32.min || (ptr + Int(parent)).pointee.color != .red { break } + + let parentNode = ptr + Int(parent) + let grandparent = parentNode.pointee.parent + if grandparent == Int32.min { break } + + let gpNode = ptr + Int(grandparent) + let parentIsLeft = gpNode.pointee.left == parent + let uncle = parentIsLeft ? gpNode.pointee.right : gpNode.pointee.left + + if uncle != Int32.min && (ptr + Int(uncle)).pointee.color == .red { + parentNode.pointee.color = .black + (ptr + Int(uncle)).pointee.color = .black + gpNode.pointee.color = .red + nodeX = grandparent + continue + } + + if parentIsLeft { + if parentNode.pointee.right == nodeX { + nodeX = parent + leftRotate(handle: nodeX) + } + let pAfter = (ptr + Int(nodeX)).pointee.parent + (ptr + Int(pAfter)).pointee.color = .black + let gpAfter = (ptr + Int(pAfter)).pointee.parent + if gpAfter != Int32.min { + (ptr + Int(gpAfter)).pointee.color = .red + rightRotate(handle: gpAfter) } } else { - if nodeY?.color == .red { - nodeXParent.color = .black - nodeY?.color = .black - nodeX.parent?.parent?.color = .red - nextNode = nodeX.parent?.parent - } else { - if isLeftChild(nodeX) { - nodeX = nodeXParent - rightRotate(node: nodeX) - } - - nodeX.parent?.color = .black - nodeX.parent?.parent?.color = .red - if let grandparent = nodeX.parent?.parent { - leftRotate(node: grandparent) - } + if parentNode.pointee.left == nodeX { + nodeX = parent + rightRotate(handle: nodeX) + } + let pAfter = (ptr + Int(nodeX)).pointee.parent + (ptr + Int(pAfter)).pointee.color = .black + let gpAfter = (ptr + Int(pAfter)).pointee.parent + if gpAfter != Int32.min { + (ptr + Int(gpAfter)).pointee.color = .red + leftRotate(handle: gpAfter) } } } - root?.color = .black + if rootHandle != Int32.min { + (ptr + Int(rootHandle)).pointee.color = .black + } } - func deleteFixup(node: Node) { - var nodeX: Node? = node - while let node = nodeX, node !== root, node.color == .black { - var sibling = node.sibling() - if sibling?.color == .red { - sibling?.color = .black - node.parent?.color = .red - if isLeftChild(node) { - leftRotate(node: node) - } else { - rightRotate(node: node) + // swiftlint:disable cyclomatic_complexity + func deleteFixup(handle: NodeHandle) { + let ptr = nodesPtr + var nodeX = handle + while nodeX != rootHandle && (ptr + Int(nodeX)).pointee.color == .black { + var siblingHandle = sibling(nodeX) + if siblingHandle != Int32.min && (ptr + Int(siblingHandle)).pointee.color == .red { + (ptr + Int(siblingHandle)).pointee.color = .black + let parent = (ptr + Int(nodeX)).pointee.parent + if parent != Int32.min { + (ptr + Int(parent)).pointee.color = .red + if isLeftChild(nodeX) { + leftRotate(handle: nodeX) + } else { + rightRotate(handle: nodeX) + } } - sibling = node.sibling() + siblingHandle = sibling(nodeX) } - if sibling?.left?.color == .black && sibling?.right?.color == .black { - sibling?.color = .red - nodeX = node.parent + let sibLeft = siblingHandle != Int32.min ? (ptr + Int(siblingHandle)).pointee.left : Int32.min + let sibRight = siblingHandle != Int32.min ? (ptr + Int(siblingHandle)).pointee.right : Int32.min + let sibLeftBlack = sibLeft == Int32.min || (ptr + Int(sibLeft)).pointee.color == .black + let sibRightBlack = sibRight == Int32.min || (ptr + Int(sibRight)).pointee.color == .black + + if sibLeftBlack && sibRightBlack { + if siblingHandle != Int32.min { + (ptr + Int(siblingHandle)).pointee.color = .red + } + let parent = (ptr + Int(nodeX)).pointee.parent + if parent == Int32.min { break } + nodeX = parent } else { - if isLeftChild(node) { - if sibling?.right?.color == .black { - sibling?.left?.color = .black - sibling?.color = .red - if let sibling { - rightRotate(node: sibling) + if isLeftChild(nodeX) { + if sibRightBlack { + if sibLeft != Int32.min { + (ptr + Int(sibLeft)).pointee.color = .black + } + if siblingHandle != Int32.min { + (ptr + Int(siblingHandle)).pointee.color = .red + rightRotate(handle: siblingHandle) + } + let parent = (ptr + Int(nodeX)).pointee.parent + siblingHandle = parent != Int32.min ? (ptr + Int(parent)).pointee.right : Int32.min + } + let parent = (ptr + Int(nodeX)).pointee.parent + let parentColor: Color = parent != Int32.min ? (ptr + Int(parent)).pointee.color : .black + if siblingHandle != Int32.min { + let sibNode = ptr + Int(siblingHandle) + sibNode.pointee.color = parentColor + if sibNode.pointee.right != Int32.min { + (ptr + Int(sibNode.pointee.right)).pointee.color = .black } - sibling = node.parent?.right } - sibling?.color = node.parent?.color ?? .black - node.parent?.color = .black - sibling?.right?.color = .black - leftRotate(node: node) - nodeX = root + if parent != Int32.min { + (ptr + Int(parent)).pointee.color = .black + } + leftRotate(handle: nodeX) + nodeX = rootHandle } else { - if sibling?.left?.color == .black { - sibling?.left?.color = .black - sibling?.color = .red - if let sibling { - leftRotate(node: sibling) + if sibLeftBlack { + if sibRight != Int32.min { + (ptr + Int(sibRight)).pointee.color = .black + } + if siblingHandle != Int32.min { + (ptr + Int(siblingHandle)).pointee.color = .red + leftRotate(handle: siblingHandle) + } + let parent = (ptr + Int(nodeX)).pointee.parent + siblingHandle = parent != Int32.min ? (ptr + Int(parent)).pointee.left : Int32.min + } + let parent = (ptr + Int(nodeX)).pointee.parent + let parentColor: Color = parent != Int32.min ? (ptr + Int(parent)).pointee.color : .black + if siblingHandle != Int32.min { + let sibNode = ptr + Int(siblingHandle) + sibNode.pointee.color = parentColor + if sibNode.pointee.left != Int32.min { + (ptr + Int(sibNode.pointee.left)).pointee.color = .black } - sibling = node.parent?.left } - sibling?.color = node.parent?.color ?? .black - node.parent?.color = .black - sibling?.left?.color = .black - rightRotate(node: node) - nodeX = root + if parent != Int32.min { + (ptr + Int(parent)).pointee.color = .black + } + rightRotate(handle: nodeX) + nodeX = rootHandle } } } - nodeX?.color = .black + if nodeX != Int32.min { + (ptr + Int(nodeX)).pointee.color = .black + } } - /// Walk up the tree, updating any `leftSubtree` metadata. + /// Walk up the tree, updating any `leftSubtree` metadata. Hoisted-pointer version — + /// this is called on every insert/delete/update, and was previously (with the class + /// Node implementation) the hottest function in the file. private func metaFixup( - startingAt node: borrowing Node, + startingAt handle: NodeHandle, delta: Int, deltaHeight: CGFloat, nodeAction: MetaFixupAction = .none ) { - guard node.parent != nil, root != nil else { return } - let rootRef = Unmanaged>.passUnretained(root!) - var ref = Unmanaged>.passUnretained(node) - while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), - ref.takeUnretainedValue() !== rootRef.takeUnretainedValue() { - if node.left === ref.takeUnretainedValue() { - node.leftSubtreeOffset += delta - node.leftSubtreeHeight += deltaHeight + let ptr = nodesPtr + var child = handle + var parent = (ptr + Int(child)).pointee.parent + while parent != Int32.min { + let parentNode = ptr + Int(parent) + if parentNode.pointee.left == child { + parentNode.pointee.leftSubtreeOffset += delta + parentNode.pointee.leftSubtreeHeight += deltaHeight switch nodeAction { case .inserted: - node.leftSubtreeCount += 1 + parentNode.pointee.leftSubtreeCount += 1 case .deleted: - node.leftSubtreeCount -= 1 + parentNode.pointee.leftSubtreeCount -= 1 case .none: break } } - if node.parent != nil { - ref = Unmanaged.passUnretained(node) - } else { - return - } + child = parent + parent = parentNode.pointee.parent } } } @@ -575,66 +779,88 @@ private extension TextLineStorage { // MARK: - Rotations private extension TextLineStorage { - func rightRotate(node: Node) { - rotate(node: node, left: false) + func rightRotate(handle: NodeHandle) { + rotate(handle: handle, left: false) } - func leftRotate(node: Node) { - rotate(node: node, left: true) + func leftRotate(handle: NodeHandle) { + rotate(handle: handle, left: true) } - func rotate(node: Node, left: Bool) { - var nodeY: Node? + func rotate(handle: NodeHandle, left: Bool) { + let ptr = nodesPtr + let hNode = ptr + Int(handle) + var nodeY: NodeHandle if left { - nodeY = node.right - guard nodeY != nil else { return } - nodeY?.leftSubtreeOffset += node.leftSubtreeOffset + node.length - nodeY?.leftSubtreeHeight += node.leftSubtreeHeight + node.height - nodeY?.leftSubtreeCount += node.leftSubtreeCount + 1 - node.right = nodeY?.left - node.right?.parent = node + nodeY = hNode.pointee.right + guard nodeY != Int32.min else { return } + let yNode = ptr + Int(nodeY) + yNode.pointee.leftSubtreeOffset += hNode.pointee.leftSubtreeOffset + hNode.pointee.length + yNode.pointee.leftSubtreeHeight += hNode.pointee.leftSubtreeHeight + hNode.pointee.height + yNode.pointee.leftSubtreeCount += hNode.pointee.leftSubtreeCount + 1 + + let yLeft = yNode.pointee.left + hNode.pointee.right = yLeft + if yLeft != Int32.min { + (ptr + Int(yLeft)).pointee.parent = handle + } } else { - nodeY = node.left - guard nodeY != nil else { return } - node.left = nodeY?.right - node.left?.parent = node + nodeY = hNode.pointee.left + guard nodeY != Int32.min else { return } + + let yRight = (ptr + Int(nodeY)).pointee.right + hNode.pointee.left = yRight + if yRight != Int32.min { + (ptr + Int(yRight)).pointee.parent = handle + } } - nodeY?.parent = node.parent - if node.parent == nil { - if let node = nodeY { - root = node + let yNode = ptr + Int(nodeY) + let originalParent = hNode.pointee.parent + yNode.pointee.parent = originalParent + if originalParent == Int32.min { + rootHandle = nodeY + } else { + let opNode = ptr + Int(originalParent) + if opNode.pointee.left == handle { + opNode.pointee.left = nodeY + } else if opNode.pointee.right == handle { + opNode.pointee.right = nodeY } - } else if isLeftChild(node) { - node.parent?.left = nodeY - } else if isRightChild(node) { - node.parent?.right = nodeY } if left { - nodeY?.left = node + yNode.pointee.left = handle } else { - nodeY?.right = node - let metadata = getSubtreeMeta(startingAt: node.left) - node.leftSubtreeOffset = metadata.offset - node.leftSubtreeHeight = metadata.height - node.leftSubtreeCount = metadata.count + yNode.pointee.right = handle + // After a right rotation, `handle`'s new left subtree is what used to be + // nodeY's right subtree — recompute left-subtree metadata from that root. + let meta = subtreeMeta(rootedAt: hNode.pointee.left) + hNode.pointee.leftSubtreeOffset = meta.offset + hNode.pointee.leftSubtreeHeight = meta.height + hNode.pointee.leftSubtreeCount = meta.count } - node.parent = nodeY + hNode.pointee.parent = nodeY } - /// Finds the correct subtree metadata starting at a node. - /// - Complexity: `O(log n)` where `n` is the number of nodes in the tree. - /// - Parameter node: The node to start finding metadata for. - /// - Returns: The metadata representing the entire subtree including `node`. - func getSubtreeMeta(startingAt node: Node?) -> NodeSubtreeMetadata { - guard let node else { return .zero } - return NodeSubtreeMetadata( - height: node.height + node.leftSubtreeHeight, - offset: node.length + node.leftSubtreeOffset, - count: 1 + node.leftSubtreeCount - ) + getSubtreeMeta(startingAt: node.right) + /// Total (length, height, count) of the subtree rooted at `handle`, inclusive. + /// Iterative — walks the right spine, accumulating each node's left-subtree meta + /// plus the node itself. `O(log n)` on a balanced tree. + func subtreeMeta(rootedAt handle: NodeHandle) -> NodeSubtreeMetadata { + let ptr = nodesPtr + var current = handle + var offset = 0 + var heightSum: CGFloat = 0 + var count = 0 + while current != Int32.min { + let node = ptr + Int(current) + offset += node.pointee.leftSubtreeOffset + node.pointee.length + heightSum += node.pointee.leftSubtreeHeight + node.pointee.height + count += node.pointee.leftSubtreeCount + 1 + current = node.pointee.right + } + return NodeSubtreeMetadata(height: heightSum, offset: offset, count: count) } } diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift index 5316f1638..4cd33dae4 100644 --- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift @@ -34,7 +34,7 @@ final class TextLayoutLineStorageTests: XCTestCase { // swiftlint:disable:this t /// Recursively checks that the given tree has the correct metadata everywhere. /// - Parameter tree: The tree to check. fileprivate func assertTreeMetadataCorrect(_ tree: TextLineStorage) throws { - func checkChildren(_ node: TextLineStorage.Node?) -> ChildData { + func checkChildren(_ node: TextLineStorage.NodeRef?) -> ChildData { guard let node else { return ChildData(length: 0, count: 0, height: 0.0) } let leftSubtreeData = checkChildren(node.left) let rightSubtreeData = checkChildren(node.right) @@ -280,7 +280,6 @@ final class TextLayoutLineStorageTests: XCTestCase { // swiftlint:disable:this t func test_transplantWithExistingLeftNodes() throws { // swiftlint:disable:this function_body_length typealias Storage = TextLineStorage - typealias Node = TextLineStorage.Node // Test that when transplanting a node with no left nodes, with a node with left nodes, that // the resulting tree has valid 'left_' metadata // 1 @@ -293,92 +292,48 @@ final class TextLayoutLineStorageTests: XCTestCase { // swiftlint:disable:this t // | | // 5 6 - let node5 = Node( - length: 5, - data: UUID(), - leftSubtreeOffset: 0, - leftSubtreeHeight: 0, - leftSubtreeCount: 0, - height: 1, - left: nil, - right: nil, - parent: nil, - color: .black - ) - - let node6 = Node( - length: 6, - data: UUID(), - leftSubtreeOffset: 0, - leftSubtreeHeight: 0, - leftSubtreeCount: 0, - height: 1, - left: nil, - right: nil, - parent: nil, - color: .black - ) + let storage = Storage() - let node4 = Node( - length: 4, - data: UUID(), - leftSubtreeOffset: 5, - leftSubtreeHeight: 1, - leftSubtreeCount: 1, // node5 is on the left - height: 1, - left: node5, - right: node6, - parent: nil, + // Build the arena manually so we can assert behavior on a specific tree shape — + // RB-tree rebalancing would otherwise pick its own structure on natural inserts. + let h5 = storage.allocNode(length: 5, data: UUID(), height: 1, color: .black) + let h6 = storage.allocNode(length: 6, data: UUID(), height: 1, color: .black) + let h4 = storage.allocNode( + length: 4, data: UUID(), height: 1, + leftSubtreeOffset: 5, leftSubtreeHeight: 1, leftSubtreeCount: 1, color: .black ) - node5.parent = node4 - node6.parent = node4 - - let node3 = Node( - length: 3, - data: UUID(), - leftSubtreeOffset: 0, - leftSubtreeHeight: 0, - leftSubtreeCount: 0, - height: 1, - left: nil, - right: node4, - parent: nil, + storage[h4].left = h5 + storage[h4].right = h6 + storage[h5].parent = h4 + storage[h6].parent = h4 + + let h3 = storage.allocNode(length: 3, data: UUID(), height: 1, color: .black) + storage[h3].right = h4 + storage[h4].parent = h3 + + let h2 = storage.allocNode( + length: 2, data: UUID(), height: 1, + leftSubtreeOffset: 18, leftSubtreeHeight: 4, leftSubtreeCount: 4, color: .black ) - node4.parent = node3 - - let node2 = Node( - length: 2, - data: UUID(), - leftSubtreeOffset: 18, - leftSubtreeHeight: 4, - leftSubtreeCount: 4, // node3 is on the left - height: 1, - left: node3, - right: nil, - parent: nil, - color: .black - ) - node3.parent = node2 - - let node7 = Node(length: 7, data: UUID(), height: 1) - - let node1 = Node( - length: 1, - data: UUID(), - leftSubtreeOffset: 7, - leftSubtreeHeight: 1, - leftSubtreeCount: 1, - height: 1, - left: node7, - right: node2, - parent: nil, + storage[h2].left = h3 + storage[h3].parent = h2 + + let h7 = storage.allocNode(length: 7, data: UUID(), height: 1, color: .black) + + let h1 = storage.allocNode( + length: 1, data: UUID(), height: 1, + leftSubtreeOffset: 7, leftSubtreeHeight: 1, leftSubtreeCount: 1, color: .black ) - node2.parent = node1 + storage[h1].left = h7 + storage[h1].right = h2 + storage[h7].parent = h1 + storage[h2].parent = h1 - let storage = Storage(root: node1, count: 7, length: 28, height: 7) + storage.rootHandle = h1 + storage._unsafeSetCounters(count: 7, length: 28, height: 7) storage.delete(lineAt: 7) // Delete the root