Skip to content

Commit 6fa3516

Browse files
Invalidation Performance, Rename LineStorage index to offset (#85)
### Description Fixes a few small things: - No longer nukes layout information on every invalidation call. Instead tells the layout view that it needs layout, meaning layout will be performed when the system determines it's necessary. - When doing something like syntax highlighting, this means that layout will be calculated *once* for each invalidated line, rather than once *per color update*. - When editing text, forces a layout pass to ensure edited lines are updated. - Renames a confusing `atIndex` to `atOffset` to avoid potential confusion in the `TextLineStorage` object. - Fix a small missing notification and delegate update in `setTextStorage`. - Moves layout manager layout methods to their own file. ### Related Issues N/A, general cleanup. ### Checklist <!--- Add things that are not yet implemented above --> - [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 N/A
1 parent 2a252a0 commit 6fa3516

11 files changed

+252
-242
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ extension TextLayoutManager: NSTextStorageDelegate {
4545
let insertedStringRange = NSRange(location: editedRange.location, length: editedRange.length - delta)
4646
removeLayoutLinesIn(range: insertedStringRange)
4747
insertNewLines(for: editedRange)
48-
invalidateLayoutForRange(editedRange)
48+
49+
setNeedsLayout()
4950
}
5051

5152
/// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an
@@ -65,10 +66,10 @@ extension TextLayoutManager: NSTextStorageDelegate {
6566
lineStorage.delete(lineAt: nextLine.range.location)
6667
let delta = -intersection.length + nextLine.range.length
6768
if delta != 0 {
68-
lineStorage.update(atIndex: linePosition.range.location, delta: delta, deltaHeight: 0)
69+
lineStorage.update(atOffset: linePosition.range.location, delta: delta, deltaHeight: 0)
6970
}
7071
} else {
71-
lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0)
72+
lineStorage.update(atOffset: linePosition.range.location, delta: -intersection.length, deltaHeight: 0)
7273
}
7374
}
7475
}
@@ -100,7 +101,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
100101
if location == lineStorage.length {
101102
// Insert a new line at the end of the document, need to insert a new line 'cause there's nothing to
102103
// split. Also, append the new text to the last line.
103-
lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0)
104+
lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0)
104105
lineStorage.insert(
105106
line: TextLine(),
106107
atOffset: location + insertedString.length,
@@ -114,7 +115,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
114115
let splitLength = linePosition.range.max - location
115116
let lineDelta = insertedString.length - splitLength // The difference in the line being edited
116117
if lineDelta != 0 {
117-
lineStorage.update(atIndex: location, delta: lineDelta, deltaHeight: 0.0)
118+
lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0)
118119
}
119120

120121
lineStorage.insert(
@@ -125,7 +126,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
125126
)
126127
}
127128
} else {
128-
lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0)
129+
lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0)
129130
}
130131
}
131132
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ extension TextLayoutManager {
1414
for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
1515
linePosition.data.setNeedsLayout()
1616
}
17-
layoutLines()
17+
18+
layoutView?.needsLayout = true
1819
}
1920

2021
/// Invalidates layout for the given range of text.
@@ -24,11 +25,12 @@ extension TextLayoutManager {
2425
linePosition.data.setNeedsLayout()
2526
}
2627

27-
layoutLines()
28+
layoutView?.needsLayout = true
2829
}
2930

3031
public func setNeedsLayout() {
3132
needsLayout = true
3233
visibleLineIds.removeAll(keepingCapacity: true)
34+
layoutView?.needsLayout = true
3335
}
3436
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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+
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,7 @@ extension TextLayoutManager {
121121
return nil
122122
}
123123
if linePosition.data.lineFragments.isEmpty {
124-
let newHeight = preparePositionForDisplay(linePosition)
125-
if linePosition.height != newHeight {
126-
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
127-
}
124+
ensureLayoutUntil(offset)
128125
}
129126

130127
guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
@@ -293,7 +290,7 @@ extension TextLayoutManager {
293290
let height = preparePositionForDisplay(linePosition)
294291
if height != linePosition.height {
295292
lineStorage.update(
296-
atIndex: linePosition.range.location,
293+
atOffset: linePosition.range.location,
297294
delta: 0,
298295
deltaHeight: height - linePosition.height
299296
)

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift

Lines changed: 0 additions & 34 deletions
This file was deleted.

0 commit comments

Comments
 (0)