|
| 1 | +// |
| 2 | +// TextLayoutManager+ensureLayout.swift |
| 3 | +// CodeEditTextView |
| 4 | +// |
| 5 | +// Created by Khan Winter on 4/7/25. |
| 6 | +// |
| 7 | + |
| 8 | +import AppKit |
| 9 | + |
| 10 | +extension TextLayoutManager { |
| 11 | + /// Contains all data required to perform layout on a text line. |
| 12 | + private struct LineLayoutData { |
| 13 | + let minY: CGFloat |
| 14 | + let maxY: CGFloat |
| 15 | + let maxWidth: CGFloat |
| 16 | + } |
| 17 | + |
| 18 | + /// Asserts that the caller is not in an active layout pass. |
| 19 | + /// See docs on ``isInLayout`` for more details. |
| 20 | + private func assertNotInLayout() { |
| 21 | +#if DEBUG // This is redundant, but it keeps the flag debug-only too which helps prevent misuse. |
| 22 | + assert(!isInLayout, "layoutLines called while already in a layout pass. This is a programmer error.") |
| 23 | +#endif |
| 24 | + } |
| 25 | + |
| 26 | + // MARK: - Layout |
| 27 | + |
| 28 | + /// Lays out all visible lines |
| 29 | + func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length |
| 30 | + assertNotInLayout() |
| 31 | + guard let visibleRect = rect ?? delegate?.visibleRect, |
| 32 | + !isInTransaction, |
| 33 | + let textStorage else { |
| 34 | + return |
| 35 | + } |
| 36 | + |
| 37 | + // The macOS may call `layout` on the textView while we're laying out fragment views. This ensures the view |
| 38 | + // tree modifications caused by this method are atomic, so macOS won't call `layout` while we're already doing |
| 39 | + // that |
| 40 | + CATransaction.begin() |
| 41 | +#if DEBUG |
| 42 | + isInLayout = true |
| 43 | +#endif |
| 44 | + |
| 45 | + let minY = max(visibleRect.minY - verticalLayoutPadding, 0) |
| 46 | + let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) |
| 47 | + let originalHeight = lineStorage.height |
| 48 | + var usedFragmentIDs = Set<UUID>() |
| 49 | + var forceLayout: Bool = needsLayout |
| 50 | + var newVisibleLines: Set<TextLine.ID> = [] |
| 51 | + var yContentAdjustment: CGFloat = 0 |
| 52 | + var maxFoundLineWidth = maxLineWidth |
| 53 | + |
| 54 | + // Layout all lines, fetching lines lazily as they are laid out. |
| 55 | + for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy { |
| 56 | + guard linePosition.yPos < maxY else { break } |
| 57 | + if forceLayout |
| 58 | + || linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) |
| 59 | + || !visibleLineIds.contains(linePosition.data.id) { |
| 60 | + let lineSize = layoutLine( |
| 61 | + linePosition, |
| 62 | + textStorage: textStorage, |
| 63 | + layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth), |
| 64 | + laidOutFragmentIDs: &usedFragmentIDs |
| 65 | + ) |
| 66 | + if lineSize.height != linePosition.height { |
| 67 | + lineStorage.update( |
| 68 | + atOffset: linePosition.range.location, |
| 69 | + delta: 0, |
| 70 | + deltaHeight: lineSize.height - linePosition.height |
| 71 | + ) |
| 72 | + // If we've updated a line's height, force re-layout for the rest of the pass. |
| 73 | + forceLayout = true |
| 74 | + |
| 75 | + if linePosition.yPos < minY { |
| 76 | + // Adjust the scroll position by the difference between the new height and old. |
| 77 | + yContentAdjustment += lineSize.height - linePosition.height |
| 78 | + } |
| 79 | + } |
| 80 | + if maxFoundLineWidth < lineSize.width { |
| 81 | + maxFoundLineWidth = lineSize.width |
| 82 | + } |
| 83 | + } else { |
| 84 | + // Make sure the used fragment views aren't dequeued. |
| 85 | + usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id)) |
| 86 | + } |
| 87 | + newVisibleLines.insert(linePosition.data.id) |
| 88 | + } |
| 89 | + |
| 90 | +#if DEBUG |
| 91 | + isInLayout = false |
| 92 | +#endif |
| 93 | + CATransaction.commit() |
| 94 | + |
| 95 | + // Enqueue any lines not used in this layout pass. |
| 96 | + viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs) |
| 97 | + |
| 98 | + // Update the visible lines with the new set. |
| 99 | + visibleLineIds = newVisibleLines |
| 100 | + |
| 101 | + // These are fine to update outside of `isInLayout` as our internal data structures are finalized at this point |
| 102 | + // so laying out again won't break our line storage or visible line. |
| 103 | + |
| 104 | + if maxFoundLineWidth > maxLineWidth { |
| 105 | + maxLineWidth = maxFoundLineWidth |
| 106 | + } |
| 107 | + |
| 108 | + if yContentAdjustment != 0 { |
| 109 | + delegate?.layoutManagerYAdjustment(yContentAdjustment) |
| 110 | + } |
| 111 | + |
| 112 | + if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { |
| 113 | + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) |
| 114 | + } |
| 115 | + |
| 116 | + needsLayout = false |
| 117 | + } |
| 118 | + |
| 119 | + /// Lays out a single text line. |
| 120 | + /// - Parameters: |
| 121 | + /// - position: The line position from storage to use for layout. |
| 122 | + /// - textStorage: The text storage object to use for text info. |
| 123 | + /// - layoutData: The information required to perform layout for the given line. |
| 124 | + /// - laidOutFragmentIDs: Updated by this method as line fragments are laid out. |
| 125 | + /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line. |
| 126 | + private func layoutLine( |
| 127 | + _ position: TextLineStorage<TextLine>.TextLinePosition, |
| 128 | + textStorage: NSTextStorage, |
| 129 | + layoutData: LineLayoutData, |
| 130 | + laidOutFragmentIDs: inout Set<UUID> |
| 131 | + ) -> CGSize { |
| 132 | + let lineDisplayData = TextLine.DisplayData( |
| 133 | + maxWidth: layoutData.maxWidth, |
| 134 | + lineHeightMultiplier: lineHeightMultiplier, |
| 135 | + estimatedLineHeight: estimateLineHeight() |
| 136 | + ) |
| 137 | + |
| 138 | + let line = position.data |
| 139 | + line.prepareForDisplay( |
| 140 | + displayData: lineDisplayData, |
| 141 | + range: position.range, |
| 142 | + stringRef: textStorage, |
| 143 | + markedRanges: markedTextManager.markedRanges(in: position.range), |
| 144 | + breakStrategy: lineBreakStrategy |
| 145 | + ) |
| 146 | + |
| 147 | + if position.range.isEmpty { |
| 148 | + return CGSize(width: 0, height: estimateLineHeight()) |
| 149 | + } |
| 150 | + |
| 151 | + var height: CGFloat = 0 |
| 152 | + var width: CGFloat = 0 |
| 153 | + let relativeMinY = max(layoutData.minY - position.yPos, 0) |
| 154 | + let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) |
| 155 | + |
| 156 | + for lineFragmentPosition in line.lineFragments.linesStartingAt( |
| 157 | + relativeMinY, |
| 158 | + until: relativeMaxY |
| 159 | + ) { |
| 160 | + let lineFragment = lineFragmentPosition.data |
| 161 | + |
| 162 | + layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos) |
| 163 | + |
| 164 | + width = max(width, lineFragment.width) |
| 165 | + height += lineFragment.scaledHeight |
| 166 | + laidOutFragmentIDs.insert(lineFragment.id) |
| 167 | + } |
| 168 | + |
| 169 | + return CGSize(width: width, height: height) |
| 170 | + } |
| 171 | + |
| 172 | + /// Lays out a line fragment view for the given line fragment at the specified y value. |
| 173 | + /// - Parameters: |
| 174 | + /// - lineFragment: The line fragment position to lay out a view for. |
| 175 | + /// - yPos: The y value at which the line should begin. |
| 176 | + private func layoutFragmentView( |
| 177 | + for lineFragment: TextLineStorage<LineFragment>.TextLinePosition, |
| 178 | + at yPos: CGFloat |
| 179 | + ) { |
| 180 | + let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) |
| 181 | + view.setLineFragment(lineFragment.data) |
| 182 | + view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) |
| 183 | + layoutView?.addSubview(view) |
| 184 | + view.needsDisplay = true |
| 185 | + } |
| 186 | + |
| 187 | + /// Invalidates and prepares a line position for display. |
| 188 | + /// - Parameter position: The line position to prepare. |
| 189 | + /// - Returns: The height of the newly laid out line and all it's fragments. |
| 190 | + func preparePositionForDisplay(_ position: TextLineStorage<TextLine>.TextLinePosition) -> CGFloat { |
| 191 | + guard let textStorage else { return 0 } |
| 192 | + let displayData = TextLine.DisplayData( |
| 193 | + maxWidth: maxLineLayoutWidth, |
| 194 | + lineHeightMultiplier: lineHeightMultiplier, |
| 195 | + estimatedLineHeight: estimateLineHeight() |
| 196 | + ) |
| 197 | + position.data.prepareForDisplay( |
| 198 | + displayData: displayData, |
| 199 | + range: position.range, |
| 200 | + stringRef: textStorage, |
| 201 | + markedRanges: markedTextManager.markedRanges(in: position.range), |
| 202 | + breakStrategy: lineBreakStrategy |
| 203 | + ) |
| 204 | + var height: CGFloat = 0 |
| 205 | + for fragmentPosition in position.data.lineFragments { |
| 206 | + height += fragmentPosition.data.scaledHeight |
| 207 | + } |
| 208 | + return height |
| 209 | + } |
| 210 | +} |
0 commit comments