Skip to content

Commit e7f1580

Browse files
Layout Invalidation Performance (#118)
### Description Made the layout manager *move* lines that weren't invalidated instead of re-typesetting them. Also made the reuse queue just make views tiny and hide them instead of removing them from the view hierarchy, and queue more views. After these changes, layout in the source editor goes from taking up *36% of the CPU* time while editing, to taking up **5% of main thread CPU time**. - Added a new debug mode for visualizing line fragment invalidation. - Adjusted the layout pass to avoid typesetting lines that don't need layout. - Made a distinction between 'forced layout' via `setNeedsLayout` and 'continued' layout where a line previously scanned in the layout pass was updated. - Due to that, I was able to check if a line fragment actually needed typesetting or just potentially needed to have it's position adjusted. - Added a new method to update a line's view's positions during layout. - Removed the unnecessary `lineRange` variable on the `LineFragment` class. - Adjusted the use of `documentRange` on the `LineFragment` class. It's now updated during layout, simplifying various methods. ### Related Issues * N/A ### 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 Before: https://github.com/user-attachments/assets/05027712-5690-4970-b1ab-e0b4fe9553ec After: https://github.com/user-attachments/assets/36350ea1-66d8-43d0-a676-1bb770a733d7
1 parent fbb038c commit e7f1580

File tree

14 files changed

+197
-64
lines changed

14 files changed

+197
-64
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//
2+
// NSRange+translate.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 7/21/25.
6+
//
7+
8+
import Foundation
9+
10+
extension NSRange {
11+
func translate(location: Int) -> NSRange {
12+
NSRange(location: self.location + location, length: length)
13+
}
14+
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ extension TextLayoutManager {
2525
linePosition.data.setNeedsLayout()
2626
}
2727

28+
// Special case where we've deleted from the very end, `linesInRange` correctly does not return any lines
29+
// So we need to invalidate the last line specifically.
30+
if range.location == textStorage?.length, !lineStorage.isEmpty {
31+
lineStorage.last?.data.setNeedsLayout()
32+
}
33+
2834
layoutView?.needsLayout = true
2935
}
3036

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ extension TextLayoutManager {
7979
let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0)
8080
let originalHeight = lineStorage.height
8181
var usedFragmentIDs = Set<LineFragment.ID>()
82-
var forceLayout: Bool = needsLayout
82+
let forceLayout: Bool = needsLayout
83+
var didLayoutChange = false
8384
var newVisibleLines: Set<TextLine.ID> = []
8485
var yContentAdjustment: CGFloat = 0
8586
var maxFoundLineWidth = maxLineWidth
@@ -95,29 +96,17 @@ extension TextLayoutManager {
9596
let wasNotVisible = !visibleLineIds.contains(linePosition.data.id)
9697
let lineNotEntirelyLaidOut = linePosition.height != linePosition.data.lineFragments.height
9798

98-
if forceLayout || linePositionNeedsLayout || wasNotVisible || lineNotEntirelyLaidOut {
99-
let lineSize = layoutLine(
99+
defer { newVisibleLines.insert(linePosition.data.id) }
100+
101+
func fullLineLayout() {
102+
let (yAdjustment, wasLineHeightChanged) = layoutLine(
100103
linePosition,
104+
usedFragmentIDs: &usedFragmentIDs,
101105
textStorage: textStorage,
102-
layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth),
103-
laidOutFragmentIDs: &usedFragmentIDs
106+
yRange: minY..<maxY,
107+
maxFoundLineWidth: &maxFoundLineWidth
104108
)
105-
let wasLineHeightChanged = lineSize.height != linePosition.height
106-
if wasLineHeightChanged {
107-
lineStorage.update(
108-
atOffset: linePosition.range.location,
109-
delta: 0,
110-
deltaHeight: lineSize.height - linePosition.height
111-
)
112-
113-
if linePosition.yPos < minY {
114-
// Adjust the scroll position by the difference between the new height and old.
115-
yContentAdjustment += lineSize.height - linePosition.height
116-
}
117-
}
118-
if maxFoundLineWidth < lineSize.width {
119-
maxFoundLineWidth = lineSize.width
120-
}
109+
yContentAdjustment += yAdjustment
121110
#if DEBUG
122111
laidOutLines.insert(linePosition.data.id)
123112
#endif
@@ -128,12 +117,24 @@ extension TextLayoutManager {
128117
// - New lines being inserted & Lines being deleted (lineNotEntirelyLaidOut)
129118
// - Line updated for width change (wasLineHeightChanged)
130119

131-
forceLayout = forceLayout || wasLineHeightChanged || lineNotEntirelyLaidOut
120+
didLayoutChange = didLayoutChange || wasLineHeightChanged || lineNotEntirelyLaidOut
121+
}
122+
123+
if forceLayout || linePositionNeedsLayout || wasNotVisible || lineNotEntirelyLaidOut {
124+
fullLineLayout()
132125
} else {
126+
if didLayoutChange || yContentAdjustment > 0 {
127+
// Layout happened and this line needs to be moved but not necessarily re-added
128+
let needsFullLayout = updateLineViewPositions(linePosition)
129+
if needsFullLayout {
130+
fullLineLayout()
131+
continue
132+
}
133+
}
134+
133135
// Make sure the used fragment views aren't dequeued.
134136
usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id))
135137
}
136-
newVisibleLines.insert(linePosition.data.id)
137138
}
138139

139140
// Enqueue any lines not used in this layout pass.
@@ -171,14 +172,50 @@ extension TextLayoutManager {
171172

172173
// MARK: - Layout Single Line
173174

175+
private func layoutLine(
176+
_ linePosition: TextLineStorage<TextLine>.TextLinePosition,
177+
usedFragmentIDs: inout Set<LineFragment.ID>,
178+
textStorage: NSTextStorage,
179+
yRange: Range<CGFloat>,
180+
maxFoundLineWidth: inout CGFloat
181+
) -> (CGFloat, wasLineHeightChanged: Bool) {
182+
let lineSize = layoutLineViews(
183+
linePosition,
184+
textStorage: textStorage,
185+
layoutData: LineLayoutData(minY: yRange.lowerBound, maxY: yRange.upperBound, maxWidth: maxLineLayoutWidth),
186+
laidOutFragmentIDs: &usedFragmentIDs
187+
)
188+
let wasLineHeightChanged = lineSize.height != linePosition.height
189+
var yContentAdjustment: CGFloat = 0.0
190+
var maxFoundLineWidth = maxFoundLineWidth
191+
192+
if wasLineHeightChanged {
193+
lineStorage.update(
194+
atOffset: linePosition.range.location,
195+
delta: 0,
196+
deltaHeight: lineSize.height - linePosition.height
197+
)
198+
199+
if linePosition.yPos < yRange.lowerBound {
200+
// Adjust the scroll position by the difference between the new height and old.
201+
yContentAdjustment += lineSize.height - linePosition.height
202+
}
203+
}
204+
if maxFoundLineWidth < lineSize.width {
205+
maxFoundLineWidth = lineSize.width
206+
}
207+
208+
return (yContentAdjustment, wasLineHeightChanged)
209+
}
210+
174211
/// Lays out a single text line.
175212
/// - Parameters:
176213
/// - position: The line position from storage to use for layout.
177214
/// - textStorage: The text storage object to use for text info.
178215
/// - layoutData: The information required to perform layout for the given line.
179216
/// - laidOutFragmentIDs: Updated by this method as line fragments are laid out.
180217
/// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line.
181-
private func layoutLine(
218+
private func layoutLineViews(
182219
_ position: TextLineStorage<TextLine>.TextLinePosition,
183220
textStorage: NSTextStorage,
184221
layoutData: LineLayoutData,
@@ -226,8 +263,13 @@ extension TextLayoutManager {
226263
// ) {
227264
for lineFragmentPosition in line.lineFragments {
228265
let lineFragment = lineFragmentPosition.data
266+
lineFragment.documentRange = lineFragmentPosition.range.translate(location: position.range.location)
229267

230-
layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos)
268+
layoutFragmentView(
269+
inLine: position,
270+
for: lineFragmentPosition,
271+
at: position.yPos + lineFragmentPosition.yPos
272+
)
231273

232274
width = max(width, lineFragment.width)
233275
height += lineFragment.scaledHeight
@@ -244,16 +286,32 @@ extension TextLayoutManager {
244286
/// - lineFragment: The line fragment position to lay out a view for.
245287
/// - yPos: The y value at which the line should begin.
246288
private func layoutFragmentView(
289+
inLine line: TextLineStorage<TextLine>.TextLinePosition,
247290
for lineFragment: TextLineStorage<LineFragment>.TextLinePosition,
248291
at yPos: CGFloat
249292
) {
293+
let fragmentRange = lineFragment.range.translate(location: line.range.location)
250294
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) {
251295
renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView()
252296
}
253297
view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews
254-
view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer)
298+
view.setLineFragment(lineFragment.data, fragmentRange: fragmentRange, renderer: lineFragmentRenderer)
255299
view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos)
256300
layoutView?.addSubview(view, positioned: .below, relativeTo: nil)
257301
view.needsDisplay = true
258302
}
303+
304+
private func updateLineViewPositions(_ position: TextLineStorage<TextLine>.TextLinePosition) -> Bool {
305+
let line = position.data
306+
for lineFragmentPosition in line.lineFragments {
307+
guard let view = viewReuseQueue.getView(forKey: lineFragmentPosition.data.id) else {
308+
return true
309+
}
310+
lineFragmentPosition.data.documentRange = lineFragmentPosition.range.translate(
311+
location: position.range.location
312+
)
313+
view.frame.origin = CGPoint(x: edgeInsets.left, y: position.yPos + lineFragmentPosition.yPos)
314+
}
315+
return false
316+
}
259317
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,13 @@ extension TextLayoutManager {
112112
fragmentPosition: TextLineStorage<LineFragment>.TextLinePosition,
113113
in linePosition: TextLineStorage<TextLine>.TextLinePosition
114114
) -> Int? {
115-
let endPosition = fragmentPosition.data.documentRange.max
115+
let fragmentRange = fragmentPosition.range.translate(location: linePosition.range.location)
116+
let endPosition = fragmentRange.max
116117

117118
// If the endPosition is at the end of the line, and the line ends with a line ending character
118119
// return the index before the eol.
119120
if fragmentPosition.index == linePosition.data.lineFragments.count - 1,
120-
let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentPosition.data.documentRange) ?? "") {
121+
let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") {
121122
return endPosition - lineEnding.length
122123
} else if fragmentPosition.index != linePosition.data.lineFragments.count - 1 {
123124
// If this isn't the last fragment, we want to place the cursor at the offset right before the break
@@ -175,7 +176,7 @@ extension TextLayoutManager {
175176
guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
176177
atOffset: offset - linePosition.range.location
177178
) else {
178-
return nil
179+
return CGRect(x: edgeInsets.left, y: linePosition.yPos, width: 0, height: linePosition.height)
179180
}
180181

181182
// Get the *real* length of the character at the offset. If this is a surrogate pair it'll return the correct
@@ -190,11 +191,11 @@ extension TextLayoutManager {
190191

191192
let minXPos = characterXPosition(
192193
in: fragmentPosition.data,
193-
for: realRange.location - fragmentPosition.data.documentRange.location
194+
for: realRange.location - linePosition.range.location - fragmentPosition.range.location
194195
)
195196
let maxXPos = characterXPosition(
196197
in: fragmentPosition.data,
197-
for: realRange.max - fragmentPosition.data.documentRange.location
198+
for: realRange.max - linePosition.range.location - fragmentPosition.range.location
198199
)
199200

200201
return CGRect(

Sources/CodeEditTextView/TextLine/LineFragment.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ public final class LineFragment: Identifiable, Equatable {
4747
}
4848

4949
public let id = UUID()
50-
public let lineRange: NSRange
51-
public let documentRange: NSRange
50+
public var documentRange: NSRange = .notFound
5251
public var contents: [FragmentContent]
5352
public var width: CGFloat
5453
public var height: CGFloat
@@ -61,16 +60,12 @@ public final class LineFragment: Identifiable, Equatable {
6160
}
6261

6362
init(
64-
lineRange: NSRange,
65-
documentRange: NSRange,
6663
contents: [FragmentContent],
6764
width: CGFloat,
6865
height: CGFloat,
6966
descent: CGFloat,
7067
lineHeightMultiplier: CGFloat
7168
) {
72-
self.lineRange = lineRange
73-
self.documentRange = documentRange
7469
self.contents = contents
7570
self.width = width
7671
self.height = height

Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ public final class LineFragmentRenderer {
122122
context: context
123123
)
124124

125-
let range = createTextRange(for: drawingContext)
125+
let range = createTextRange(for: drawingContext).clamped(to: (textStorage.string as NSString).length)
126126
let string = (textStorage.string as NSString).substring(with: range)
127127

128128
processInvisibleCharacters(
@@ -177,7 +177,7 @@ public final class LineFragmentRenderer {
177177
guard let style = delegate.invisibleStyle(
178178
for: character,
179179
at: NSRange(start: range.location + index, end: range.max),
180-
lineRange: drawingContext.lineFragment.lineRange
180+
lineRange: drawingContext.lineFragment.documentRange
181181
) else {
182182
return
183183
}

Sources/CodeEditTextView/TextLine/LineFragmentView.swift

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import AppKit
1111
open class LineFragmentView: NSView {
1212
public weak var lineFragment: LineFragment?
1313
public weak var renderer: LineFragmentRenderer?
14+
#if DEBUG_LINE_INVALIDATION
15+
private var backgroundAnimation: CABasicAnimation?
16+
#endif
1417

1518
open override var isFlipped: Bool {
1619
true
@@ -22,15 +25,55 @@ open class LineFragmentView: NSView {
2225

2326
open override func hitTest(_ point: NSPoint) -> NSView? { nil }
2427

25-
/// Prepare the view for reuse, clears the line fragment reference.
28+
public override init(frame frameRect: NSRect) {
29+
super.init(frame: frameRect)
30+
}
31+
32+
required public init?(coder: NSCoder) {
33+
super.init(coder: coder)
34+
}
35+
36+
#if DEBUG_LINE_INVALIDATION
37+
/// Setup background animation from random color to clear when this fragment is invalidated.
38+
private func setupBackgroundAnimation() {
39+
self.wantsLayer = true
40+
41+
let randomColor = NSColor(
42+
red: CGFloat.random(in: 0...1),
43+
green: CGFloat.random(in: 0...1),
44+
blue: CGFloat.random(in: 0...1),
45+
alpha: 0.3
46+
)
47+
48+
self.layer?.backgroundColor = randomColor.cgColor
49+
50+
let animation = CABasicAnimation(keyPath: "backgroundColor")
51+
animation.fromValue = randomColor.cgColor
52+
animation.toValue = NSColor.clear.cgColor
53+
animation.duration = 1.0
54+
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
55+
animation.fillMode = .forwards
56+
animation.isRemovedOnCompletion = false
57+
self.layer?.add(animation, forKey: "backgroundColorAnimation")
58+
59+
DispatchQueue.main.asyncAfter(deadline: .now() + animation.duration) {
60+
self.layer?.backgroundColor = NSColor.clear.cgColor
61+
}
62+
}
63+
#endif
64+
2665
open override func prepareForReuse() {
2766
super.prepareForReuse()
2867
lineFragment = nil
68+
69+
#if DEBUG_LINE_INVALIDATION
70+
setupBackgroundAnimation()
71+
#endif
2972
}
3073

3174
/// Set a new line fragment for this view, updating view size.
3275
/// - Parameter newFragment: The new fragment to use.
33-
open func setLineFragment(_ newFragment: LineFragment, renderer: LineFragmentRenderer) {
76+
open func setLineFragment(_ newFragment: LineFragment, fragmentRange: NSRange, renderer: LineFragmentRenderer) {
3477
self.lineFragment = newFragment
3578
self.renderer = renderer
3679
self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight)

Sources/CodeEditTextView/TextLine/TextLine.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ public final class TextLine: Identifiable, Equatable {
3636
// Both max widths we're comparing are finite
3737
maxWidth.isFinite
3838
&& (self.maxWidth ?? 0.0).isFinite
39-
// We can't use `<` here because we want to calculate layout again if this was previously constrained to a
40-
// small layout size and needs to grow.
4139
&& maxWidth != (self.maxWidth ?? 0.0)
4240
)
4341
}
@@ -57,14 +55,14 @@ public final class TextLine: Identifiable, Equatable {
5755
attachments: [AnyTextAttachment]
5856
) {
5957
let string = stringRef.attributedSubstring(from: range)
60-
self.maxWidth = displayData.maxWidth
61-
typesetter.typeset(
58+
let maxWidth = typesetter.typeset(
6259
string,
6360
documentRange: range,
6461
displayData: displayData,
6562
markedRanges: markedRanges,
6663
attachments: attachments
6764
)
65+
self.maxWidth = displayData.maxWidth
6866
needsLayout = false
6967
}
7068

Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,6 @@ struct TypesetContext {
6161
/// Pop the current fragment state into a new line fragment, and reset the fragment state.
6262
mutating func popCurrentData() {
6363
let fragment = LineFragment(
64-
lineRange: documentRange,
65-
documentRange: NSRange(
66-
location: fragmentContext.start + documentRange.location,
67-
length: currentPosition - fragmentContext.start
68-
),
6964
contents: fragmentContext.contents,
7065
width: fragmentContext.width,
7166
height: fragmentContext.height,

0 commit comments

Comments
 (0)