Skip to content

Commit a1a2c9e

Browse files
committed
Limit Layout Invalidation
1 parent fbb038c commit a1a2c9e

File tree

6 files changed

+142
-36
lines changed

6 files changed

+142
-36
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift

Lines changed: 73 additions & 24 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,
@@ -256,4 +293,16 @@ extension TextLayoutManager {
256293
layoutView?.addSubview(view, positioned: .below, relativeTo: nil)
257294
view.needsDisplay = true
258295
}
296+
297+
private func updateLineViewPositions(_ position: TextLineStorage<TextLine>.TextLinePosition) -> Bool {
298+
let line = position.data
299+
for lineFragmentPosition in line.lineFragments {
300+
guard let view = viewReuseQueue.getView(forKey: lineFragmentPosition.data.id) else {
301+
return true
302+
}
303+
304+
view.frame.origin = CGPoint(x: edgeInsets.left, y: position.yPos + lineFragmentPosition.yPos)
305+
}
306+
return false
307+
}
259308
}

Sources/CodeEditTextView/TextLine/LineFragmentView.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import AppKit
1111
open class LineFragmentView: NSView {
1212
public weak var lineFragment: LineFragment?
1313
public weak var renderer: LineFragmentRenderer?
14+
private var backgroundAnimation: CABasicAnimation?
1415

1516
open override var isFlipped: Bool {
1617
true
@@ -22,10 +23,58 @@ open class LineFragmentView: NSView {
2223

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

25-
/// Prepare the view for reuse, clears the line fragment reference.
26+
/// Initialize with random background color animation
27+
public override init(frame frameRect: NSRect) {
28+
super.init(frame: frameRect)
29+
setupBackgroundAnimation()
30+
}
31+
32+
required public init?(coder: NSCoder) {
33+
super.init(coder: coder)
34+
setupBackgroundAnimation()
35+
}
36+
37+
/// Setup background animation from random color to clear
38+
private func setupBackgroundAnimation() {
39+
// Ensure the view is layer-backed for animation
40+
self.wantsLayer = true
41+
42+
// Generate random color
43+
let randomColor = NSColor(
44+
red: CGFloat.random(in: 0...1),
45+
green: CGFloat.random(in: 0...1),
46+
blue: CGFloat.random(in: 0...1),
47+
alpha: 0.3 // Start with some transparency
48+
)
49+
50+
// Set initial background color
51+
self.layer?.backgroundColor = randomColor.cgColor
52+
53+
// Create animation from random color to clear
54+
let animation = CABasicAnimation(keyPath: "backgroundColor")
55+
animation.fromValue = randomColor.cgColor
56+
animation.toValue = NSColor.clear.cgColor
57+
animation.duration = 1.0
58+
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
59+
animation.fillMode = .forwards
60+
animation.isRemovedOnCompletion = false
61+
62+
// Apply animation
63+
self.layer?.add(animation, forKey: "backgroundColorAnimation")
64+
65+
// Set final state
66+
DispatchQueue.main.asyncAfter(deadline: .now() + animation.duration) {
67+
self.layer?.backgroundColor = NSColor.clear.cgColor
68+
}
69+
}
70+
71+
/// Prepare the view for reuse, clears the line fragment reference and restarts animation.
2672
open override func prepareForReuse() {
2773
super.prepareForReuse()
2874
lineFragment = nil
75+
76+
// Restart the background animation
77+
setupBackgroundAnimation()
2978
}
3079

3180
/// Set a new line fragment for this view, updating view size.

Sources/CodeEditTextView/TextLine/TextLine.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,11 @@ public final class TextLine: Identifiable, Equatable {
3131
/// - Returns: True, if this line has been marked as needing layout using ``TextLine/setNeedsLayout()`` or if the
3232
/// line needs to find new line breaks due to a new constraining width.
3333
func needsLayout(maxWidth: CGFloat) -> Bool {
34-
needsLayout // Force layout
34+
return needsLayout // Force layout
3535
|| (
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,15 @@ 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 = min(maxWidth, displayData.maxWidth)
66+
self.maxWidth = displayData.maxWidth
6867
needsLayout = false
6968
}
7069

Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct TypesetContext {
1616
/// Accumulated generated line fragments.
1717
var lines: [TextLineStorage<LineFragment>.BuildItem] = []
1818
var maxHeight: CGFloat = 0
19+
var maxWidth: CGFloat = 0
1920
/// The current fragment typesetting context.
2021
var fragmentContext = LineFragmentTypesetContext(start: 0, width: 0.0, height: 0.0, descent: 0.0)
2122

@@ -76,6 +77,7 @@ struct TypesetContext {
7677
.init(data: fragment, length: currentPosition - fragmentContext.start, height: fragment.scaledHeight)
7778
)
7879
maxHeight = max(maxHeight, fragment.scaledHeight)
80+
maxWidth = max(maxWidth, fragment.width)
7981

8082
fragmentContext.clear()
8183
fragmentContext.start = currentPosition

Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,28 +33,31 @@ final public class Typesetter {
3333

3434
public init() { }
3535

36+
/// Performs the typesetting operation, returning the maximum width required for the current layout.
37+
/// - Returns: The maximum width the typeset lines require.
3638
public func typeset(
3739
_ string: NSAttributedString,
3840
documentRange: NSRange,
3941
displayData: TextLine.DisplayData,
4042
markedRanges: MarkedRanges?,
4143
attachments: [AnyTextAttachment] = []
42-
) {
44+
) -> CGFloat {
4345
let string = makeString(string: string, markedRanges: markedRanges)
4446
lineFragments.removeAll()
4547

4648
// Fast path
4749
if string.length == 0 || displayData.maxWidth <= 0 {
4850
typesetEmptyLine(displayData: displayData, string: string)
49-
return
51+
return 0.0
5052
}
51-
let (lines, maxHeight) = typesetLineFragments(
53+
let (lines, maxSize) = typesetLineFragments(
5254
string: string,
5355
documentRange: documentRange,
5456
displayData: displayData,
5557
attachments: attachments
5658
)
57-
lineFragments.build(from: lines, estimatedLineHeight: maxHeight)
59+
lineFragments.build(from: lines, estimatedLineHeight: maxSize.height)
60+
return maxSize.width
5861
}
5962

6063
private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) -> NSAttributedString {
@@ -132,7 +135,7 @@ final public class Typesetter {
132135
documentRange: NSRange,
133136
displayData: TextLine.DisplayData,
134137
attachments: [AnyTextAttachment]
135-
) -> (lines: [TextLineStorage<LineFragment>.BuildItem], maxHeight: CGFloat) {
138+
) -> (lines: [TextLineStorage<LineFragment>.BuildItem], maxSize: CGSize) {
136139
let contentRuns = createContentRuns(string: string, documentRange: documentRange, attachments: attachments)
137140
var context = TypesetContext(documentRange: documentRange, displayData: displayData)
138141

@@ -155,7 +158,7 @@ final public class Typesetter {
155158
context.popCurrentData()
156159
}
157160

158-
return (context.lines, context.maxHeight)
161+
return (context.lines, CGSize(width: context.maxWidth, height: context.maxHeight))
159162
}
160163

161164
// MARK: - Layout Text Fragments

Sources/CodeEditTextView/Utils/ViewReuseQueue.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public class ViewReuseQueue<View: NSView, Key: Hashable> {
4040
return view
4141
}
4242

43+
public func getView(forKey key: Key) -> View? {
44+
usedViews[key]
45+
}
46+
4347
/// Removes a view for the given key and enqueues it for reuse.
4448
/// - Parameter key: The key for the view to reuse.
4549
public func enqueueView(forKey key: Key) {

0 commit comments

Comments
 (0)