Skip to content

Commit 2603ff1

Browse files
Introduce Override Layout Behavior API (#84)
### Description Introduces an API to override how text is laid out by the layout manager. An API consumer can implement an object conforming to the `TextLayoutManagerRenderDelegate` protocol to provide custom behavior. Introduces tests for new APIs. Specific changes: - Created a new `TextLayoutManagerRenderDelegate` which contains optional methods to override layout behavior. All methods are optional, and default to the default implementation. - Updates the layout manager to use these methods where relevant. - Estimated height, line height. - Finding `x` position in a line fragment. - Creating a view to render a line fragment (`LineFragmentView` by default, but the render delegate can give a custom one). - Makes lots of variables and methods public that weren't previously. Since we're allowing using the layout manager outside the context of a text view, these are necessarily public. - `LineFragmentView` is now an `open` class to allow for overriding methods. - Two methods on `LineFragment` have been marked as depreciated (`xPos` and `rectFor`). These were moved to the `TextLayoutManger` to ensure that the new render delegate can inject it's own behavior for both those methods if available. - `TextSelectionManager` - `draw` method is now public, for drawing selections in a custom context. - Updates some methods that were calculating drawing rects to use the layout manager information, rather than text view information. This allows the selection manager's behavior to reflect overridden layout manager behavior. - Documented exactly what `layoutLines` does in `TextLayoutManager`. - Removed two misleading methods. `ensureLayoutUntil` was used once, and it was not being used correctly. `preparePositionForDisplay` was only used by that method. Both of these methods destroyed layout information without recourse, sometimes causing text to just disappear despite a perfectly valid layout pass. - `ensureLayoutUntil` could be added back, but needs to be implemented in a way that's additive, rather than removing layout information. - Removing these changes didn't effect the ability to jump to scroll positions, or emphasize text ranges. ### Related Issues * CodeEditApp/CodeEditSourceEditor#33 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots
1 parent 7d24b80 commit 2603ff1

19 files changed

+429
-171
lines changed

Sources/CodeEditTextView/Extensions/NSRange+/NSRange+init.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import Foundation
99

10-
extension NSRange {
10+
public extension NSRange {
1111
@inline(__always)
1212
init(start: Int, end: Int) {
1313
self.init(location: start, length: end - start)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// MarkedRanges.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 4/17/25.
6+
//
7+
8+
import AppKit
9+
10+
/// Struct for passing attribute and range information easily down into line fragments, typesetters without
11+
/// requiring a reference to the marked text manager.
12+
public struct MarkedRanges {
13+
let ranges: [NSRange]
14+
let attributes: [NSAttributedString.Key: Any]
15+
}

Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,6 @@ import AppKit
99

1010
/// Manages marked ranges. Not a public API.
1111
class MarkedTextManager {
12-
/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o
13-
/// requiring a reference to the marked text manager.
14-
struct MarkedRanges {
15-
let ranges: [NSRange]
16-
let attributes: [NSAttributedString.Key: Any]
17-
}
18-
1912
/// All marked ranges being tracked.
2013
private(set) var markedRanges: [NSRange] = []
2114

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift

Lines changed: 91 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,53 @@ extension TextLayoutManager {
1515
let maxWidth: CGFloat
1616
}
1717

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
18+
// MARK: - Layout Lines
2719

2820
/// Lays out all visible lines
29-
func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
30-
assertNotInLayout()
21+
///
22+
/// ## Overview Of The Layout Routine
23+
///
24+
/// The basic premise of this method is that it loops over all lines in the given rect (defaults to the visible
25+
/// rect), checks if the line needs a layout calculation, and performs layout on the line if it does.
26+
///
27+
/// The thing that makes this layout method so fast is the second point, checking if a line needs layout. To
28+
/// determine if a line needs a layout pass, the layout manager can check three things:
29+
/// - **1** Was the line laid out under the assumption of a different maximum layout width?
30+
/// For instance, if a line was previously broken by the line wrapping setting, it won’t need to wrap once the
31+
/// line wrapping is disabled. This will detect that, and cause the lines to be recalculated.
32+
/// - **2** Was the line previously not visible? This is determined by keeping a set of visible line IDs. If the
33+
/// line does not appear in that set, we can assume it was previously off screen and may need layout.
34+
/// - **3** Was the line entirely laid out? We break up lines into line fragments. When we do layout, we determine
35+
/// all line fragments but don't necessarily place them all in the view. This checks if all line fragments have
36+
/// been placed in the view. If not, we need to place them.
37+
///
38+
/// Once it has been determined that a line needs layout, we perform layout by recalculating it's line fragments,
39+
/// removing all old line fragment views, and creating new ones for the line.
40+
///
41+
/// ## Laziness
42+
///
43+
/// At the end of the layout pass, we clean up any old lines by updating the set of visible line IDs and fragment
44+
/// IDs. Any IDs that no longer appear in those sets are removed to save resources. This facilitates the text view's
45+
/// ability to only render text that is visible and saves tons of resources (similar to the lazy loading of
46+
/// collection or table views).
47+
///
48+
/// The other important lazy attribute is the line iteration. Line iteration is done lazily. As we iterate
49+
/// through lines and potentially update their heights, the next line is only queried for *after* the updates are
50+
/// finished.
51+
///
52+
/// ## Reentry
53+
///
54+
/// An important thing to note is that this method cannot be reentered. If a layout pass has begun while a layout
55+
/// pass is already ongoing, internal data structures will be broken. In debug builds, this is checked with a simple
56+
/// boolean and assertion.
57+
///
58+
/// To help ensure this property, all view modifications are performed within a `CATransaction`. This guarantees
59+
/// that macOS calls `layout` on any related views only after we’ve finished inserting and removing line fragment
60+
/// views. Otherwise, inserting a line fragment view could trigger a layout pass prematurely and cause this method
61+
/// to re-enter.
62+
/// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this
63+
/// is not the way to do so. This should only be called when macOS performs layout.
64+
public func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
3165
guard let visibleRect = rect ?? delegate?.visibleRect,
3266
!isInTransaction,
3367
let textStorage else {
@@ -38,9 +72,7 @@ extension TextLayoutManager {
3872
// tree modifications caused by this method are atomic, so macOS won't call `layout` while we're already doing
3973
// that
4074
CATransaction.begin()
41-
#if DEBUG
42-
isInLayout = true
43-
#endif
75+
layoutLock.lock()
4476

4577
let minY = max(visibleRect.minY - verticalLayoutPadding, 0)
4678
let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0)
@@ -53,10 +85,13 @@ extension TextLayoutManager {
5385

5486
// Layout all lines, fetching lines lazily as they are laid out.
5587
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) {
88+
guard linePosition.yPos < maxY else { continue }
89+
// Three ways to determine if a line needs to be re-calculated.
90+
let changedWidth = linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth)
91+
let wasNotVisible = !visibleLineIds.contains(linePosition.data.id)
92+
let lineNotEntirelyLaidOut = linePosition.height != linePosition.data.lineFragments.height
93+
94+
if forceLayout || changedWidth || wasNotVisible || lineNotEntirelyLaidOut {
6095
let lineSize = layoutLine(
6196
linePosition,
6297
textStorage: textStorage,
@@ -87,19 +122,19 @@ extension TextLayoutManager {
87122
newVisibleLines.insert(linePosition.data.id)
88123
}
89124

90-
#if DEBUG
91-
isInLayout = false
92-
#endif
93-
CATransaction.commit()
94-
95125
// Enqueue any lines not used in this layout pass.
96126
viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs)
97127

98128
// Update the visible lines with the new set.
99129
visibleLineIds = newVisibleLines
100130

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.
131+
// The delegate methods below may call another layout pass, make sure we don't send it into a loop of forced
132+
// layout.
133+
needsLayout = false
134+
135+
// Commit the view tree changes we just made.
136+
layoutLock.unlock()
137+
CATransaction.commit()
103138

104139
if maxFoundLineWidth > maxLineWidth {
105140
maxLineWidth = maxFoundLineWidth
@@ -112,10 +147,10 @@ extension TextLayoutManager {
112147
if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
113148
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
114149
}
115-
116-
needsLayout = false
117150
}
118151

152+
// MARK: - Layout Single Line
153+
119154
/// Lays out a single text line.
120155
/// - Parameters:
121156
/// - position: The line position from storage to use for layout.
@@ -136,13 +171,24 @@ extension TextLayoutManager {
136171
)
137172

138173
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-
)
174+
if let renderDelegate {
175+
renderDelegate.prepareForDisplay(
176+
textLine: line,
177+
displayData: lineDisplayData,
178+
range: position.range,
179+
stringRef: textStorage,
180+
markedRanges: markedTextManager.markedRanges(in: position.range),
181+
breakStrategy: lineBreakStrategy
182+
)
183+
} else {
184+
line.prepareForDisplay(
185+
displayData: lineDisplayData,
186+
range: position.range,
187+
stringRef: textStorage,
188+
markedRanges: markedTextManager.markedRanges(in: position.range),
189+
breakStrategy: lineBreakStrategy
190+
)
191+
}
146192

147193
if position.range.isEmpty {
148194
return CGSize(width: 0, height: estimateLineHeight())
@@ -153,10 +199,11 @@ extension TextLayoutManager {
153199
let relativeMinY = max(layoutData.minY - position.yPos, 0)
154200
let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY)
155201

156-
for lineFragmentPosition in line.lineFragments.linesStartingAt(
157-
relativeMinY,
158-
until: relativeMaxY
159-
) {
202+
// for lineFragmentPosition in line.lineFragments.linesStartingAt(
203+
// relativeMinY,
204+
// until: relativeMaxY
205+
// ) {
206+
for lineFragmentPosition in line.lineFragments {
160207
let lineFragment = lineFragmentPosition.data
161208

162209
layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos)
@@ -169,6 +216,8 @@ extension TextLayoutManager {
169216
return CGSize(width: width, height: height)
170217
}
171218

219+
// MARK: - Layout Fragment
220+
172221
/// Lays out a line fragment view for the given line fragment at the specified y value.
173222
/// - Parameters:
174223
/// - lineFragment: The line fragment position to lay out a view for.
@@ -177,34 +226,13 @@ extension TextLayoutManager {
177226
for lineFragment: TextLineStorage<LineFragment>.TextLinePosition,
178227
at yPos: CGFloat
179228
) {
180-
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id)
229+
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) {
230+
renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView()
231+
}
232+
view.translatesAutoresizingMaskIntoConstraints = false
181233
view.setLineFragment(lineFragment.data)
182234
view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos)
183235
layoutView?.addSubview(view)
184236
view.needsDisplay = true
185237
}
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-
}
210238
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 26 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,6 @@ extension TextLayoutManager {
120120
guard let linePosition = lineStorage.getLine(atOffset: offset) else {
121121
return nil
122122
}
123-
if linePosition.data.lineFragments.isEmpty {
124-
ensureLayoutUntil(offset)
125-
}
126123

127124
guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
128125
atOffset: offset - linePosition.range.location
@@ -137,15 +134,13 @@ extension TextLayoutManager {
137134
: (textStorage?.string as? NSString)?.rangeOfComposedCharacterSequence(at: offset)
138135
?? NSRange(location: offset, length: 0)
139136

140-
let minXPos = CTLineGetOffsetForStringIndex(
141-
fragmentPosition.data.ctLine,
142-
realRange.location - linePosition.range.location, // CTLines have the same relative range as the line
143-
nil
137+
let minXPos = characterXPosition(
138+
in: fragmentPosition.data,
139+
for: realRange.location - linePosition.range.location
144140
)
145-
let maxXPos = CTLineGetOffsetForStringIndex(
146-
fragmentPosition.data.ctLine,
147-
realRange.max - linePosition.range.location,
148-
nil
141+
let maxXPos = characterXPosition(
142+
in: fragmentPosition.data,
143+
for: realRange.max - linePosition.range.location
149144
)
150145

151146
return CGRect(
@@ -162,7 +157,6 @@ extension TextLayoutManager {
162157
/// - line: The line to calculate rects for.
163158
/// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range.
164159
public func rectsFor(range: NSRange) -> [CGRect] {
165-
ensureLayoutUntil(range.max)
166160
return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) }
167161
}
168162

@@ -187,7 +181,7 @@ extension TextLayoutManager {
187181
var rects: [CGRect] = []
188182
for fragmentPosition in line.data.lineFragments.linesInRange(relativeRange) {
189183
guard let intersectingRange = fragmentPosition.range.intersection(relativeRange) else { continue }
190-
let fragmentRect = fragmentPosition.data.rectFor(range: intersectingRange)
184+
let fragmentRect = characterRect(in: fragmentPosition.data, for: intersectingRange)
191185
guard fragmentRect.width > 0 else { continue }
192186
rects.append(
193187
CGRect(
@@ -270,35 +264,25 @@ extension TextLayoutManager {
270264
return nil
271265
}
272266

273-
// MARK: - Ensure Layout
274-
275-
/// Forces layout calculation for all lines up to and including the given offset.
276-
/// - Parameter offset: The offset to ensure layout until.
277-
public func ensureLayoutUntil(_ offset: Int) {
278-
guard let linePosition = lineStorage.getLine(atOffset: offset),
279-
let visibleRect = delegate?.visibleRect,
280-
visibleRect.maxY < linePosition.yPos + linePosition.height,
281-
let startingLinePosition = lineStorage.getLine(atPosition: visibleRect.minY)
282-
else {
283-
return
284-
}
285-
let originalHeight = lineStorage.height
286-
287-
for linePosition in lineStorage.linesInRange(
288-
NSRange(start: startingLinePosition.range.location, end: linePosition.range.max)
289-
) {
290-
let height = preparePositionForDisplay(linePosition)
291-
if height != linePosition.height {
292-
lineStorage.update(
293-
atOffset: linePosition.range.location,
294-
delta: 0,
295-
deltaHeight: height - linePosition.height
296-
)
297-
}
298-
}
267+
// MARK: - Line Fragment Rects
299268

300-
if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
301-
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
302-
}
269+
/// Finds the x position of the offset in the string the fragment represents.
270+
/// - Parameters:
271+
/// - lineFragment: The line fragment to calculate for.
272+
/// - offset: The offset, relative to the start of the *line*.
273+
/// - Returns: The x position of the character in the drawn line, from the left.
274+
public func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat {
275+
renderDelegate?.characterXPosition(in: lineFragment, for: offset) ?? lineFragment._xPos(for: offset)
276+
}
277+
278+
public func characterRect(in lineFragment: LineFragment, for range: NSRange) -> CGRect {
279+
let minXPos = characterXPosition(in: lineFragment, for: range.lowerBound)
280+
let maxXPos = characterXPosition(in: lineFragment, for: range.upperBound)
281+
return CGRect(
282+
x: minXPos,
283+
y: 0,
284+
width: maxXPos - minXPos,
285+
height: lineFragment.scaledHeight
286+
).pixelAligned
303287
}
304288
}

0 commit comments

Comments
 (0)