Skip to content

Commit 616a48d

Browse files
committed
Add Overridable Layout To LayoutManager
1 parent 2a252a0 commit 616a48d

File tree

9 files changed

+216
-42
lines changed

9 files changed

+216
-42
lines changed

Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77

88
import AppKit
99

10+
/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o
11+
/// requiring a reference to the marked text manager.
12+
public struct MarkedRanges {
13+
let ranges: [NSRange]
14+
let attributes: [NSAttributedString.Key: Any]
15+
}
16+
1017
/// Manages marked ranges. Not a public API.
1118
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-
1919
/// All marked ranges being tracked.
2020
private(set) var markedRanges: [NSRange] = []
2121

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,6 @@
88
import Foundation
99
import AppKit
1010

11-
public protocol TextLayoutManagerDelegate: AnyObject {
12-
func layoutManagerHeightDidUpdate(newHeight: CGFloat)
13-
func layoutManagerMaxWidthDidChange(newWidth: CGFloat)
14-
func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any]
15-
func textViewportSize() -> CGSize
16-
func layoutManagerYAdjustment(_ yAdjustment: CGFloat)
17-
18-
var visibleRect: NSRect { get }
19-
}
20-
2111
/// The text layout manager manages laying out lines in a code document.
2212
public class TextLayoutManager: NSObject {
2313
// MARK: - Public Properties
@@ -65,6 +55,8 @@ public class TextLayoutManager: NSObject {
6555
}
6656
}
6757

58+
public weak var renderDelegate: TextLayoutManagerRenderDelegate?
59+
6860
// MARK: - Internal
6961

7062
weak var textStorage: NSTextStorage?
@@ -207,7 +199,7 @@ public class TextLayoutManager: NSObject {
207199
#endif
208200
}
209201

210-
// MARK: - Layout
202+
// MARK: - Layout Lines
211203

212204
/// Lays out all visible lines
213205
func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
@@ -300,6 +292,8 @@ public class TextLayoutManager: NSObject {
300292
needsLayout = false
301293
}
302294

295+
// MARK: - Layout Single Line
296+
303297
/// Lays out a single text line.
304298
/// - Parameters:
305299
/// - position: The line position from storage to use for layout.
@@ -320,13 +314,24 @@ public class TextLayoutManager: NSObject {
320314
)
321315

322316
let line = position.data
323-
line.prepareForDisplay(
324-
displayData: lineDisplayData,
325-
range: position.range,
326-
stringRef: textStorage,
327-
markedRanges: markedTextManager.markedRanges(in: position.range),
328-
breakStrategy: lineBreakStrategy
329-
)
317+
if let renderDelegate {
318+
renderDelegate.prepareForDisplay(
319+
textLine: line,
320+
displayData: lineDisplayData,
321+
range: position.range,
322+
stringRef: textStorage,
323+
markedRanges: markedTextManager.markedRanges(in: position.range),
324+
breakStrategy: lineBreakStrategy
325+
)
326+
} else {
327+
line.prepareForDisplay(
328+
displayData: lineDisplayData,
329+
range: position.range,
330+
stringRef: textStorage,
331+
markedRanges: markedTextManager.markedRanges(in: position.range),
332+
breakStrategy: lineBreakStrategy
333+
)
334+
}
330335

331336
if position.range.isEmpty {
332337
return CGSize(width: 0, height: estimateLineHeight())
@@ -353,6 +358,8 @@ public class TextLayoutManager: NSObject {
353358
return CGSize(width: width, height: height)
354359
}
355360

361+
// MARK: - Layout Fragment
362+
356363
/// Lays out a line fragment view for the given line fragment at the specified y value.
357364
/// - Parameters:
358365
/// - lineFragment: The line fragment position to lay out a view for.
@@ -363,6 +370,7 @@ public class TextLayoutManager: NSObject {
363370
) {
364371
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id)
365372
view.setLineFragment(lineFragment.data)
373+
view.renderDelegate = renderDelegate
366374
view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos)
367375
layoutView?.addSubview(view)
368376
view.needsDisplay = true
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// TextLayoutManagerDelegate.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 4/10/25.
6+
//
7+
8+
import AppKit
9+
10+
public protocol TextLayoutManagerDelegate: AnyObject {
11+
func layoutManagerHeightDidUpdate(newHeight: CGFloat)
12+
func layoutManagerMaxWidthDidChange(newWidth: CGFloat)
13+
func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any]
14+
func textViewportSize() -> CGSize
15+
func layoutManagerYAdjustment(_ yAdjustment: CGFloat)
16+
17+
var visibleRect: NSRect { get }
18+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// TextLayoutManagerLayoutDelegate.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 4/10/25.
6+
//
7+
8+
import AppKit
9+
10+
/// Provide an instance of this class to the ``TextLayoutManager`` to override how the layout manager performs layout
11+
/// and display for text lines and fragments.
12+
///
13+
/// All methods on this protocol are optional, and default to the default behavior.
14+
public protocol TextLayoutManagerRenderDelegate: AnyObject {
15+
func prepareForDisplay( // swiftlint:disable:this function_parameter_count
16+
textLine: TextLine,
17+
displayData: TextLine.DisplayData,
18+
range: NSRange,
19+
stringRef: NSTextStorage,
20+
markedRanges: MarkedRanges?,
21+
breakStrategy: LineBreakStrategy
22+
)
23+
func drawLineFragment(fragment: LineFragment, in context: CGContext)
24+
}
25+
26+
extension TextLayoutManagerRenderDelegate {
27+
func prepareForDisplay( // swiftlint:disable:this function_parameter_count
28+
textLine: TextLine,
29+
displayData: TextLine.DisplayData,
30+
range: NSRange,
31+
stringRef: NSTextStorage,
32+
markedRanges: MarkedRanges?,
33+
breakStrategy: LineBreakStrategy
34+
) {
35+
textLine.prepareForDisplay(
36+
displayData: displayData,
37+
range: range,
38+
stringRef: stringRef,
39+
markedRanges: markedRanges,
40+
breakStrategy: breakStrategy
41+
)
42+
}
43+
44+
func drawLineFragment(fragment: LineFragment, in context: CGContext) {
45+
fragment.draw(in: context, yPos: 0.0)
46+
}
47+
}

Sources/CodeEditTextView/TextLine/LineFragment.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ import CodeEditTextViewObjC
1212
/// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment.
1313
public final class LineFragment: Identifiable, Equatable {
1414
public let id = UUID()
15-
private(set) public var ctLine: CTLine
16-
public let width: CGFloat
17-
public let height: CGFloat
18-
public let descent: CGFloat
19-
public let scaledHeight: CGFloat
15+
public var ctLine: CTLine
16+
public var width: CGFloat
17+
public var height: CGFloat
18+
public var descent: CGFloat
19+
public var scaledHeight: CGFloat
2020

2121
/// The difference between the real text height and the scaled height
2222
public var heightDifference: CGFloat {
2323
scaledHeight - height
2424
}
2525

26-
init(
26+
public init(
2727
ctLine: CTLine,
2828
width: CGFloat,
2929
height: CGFloat,
@@ -81,7 +81,7 @@ public final class LineFragment: Identifiable, Equatable {
8181
/// Calculates the drawing rect for a given range.
8282
/// - Parameter range: The range to calculate the bounds for, relative to the line.
8383
/// - Returns: A rect that contains the text contents in the given range.
84-
func rectFor(range: NSRange) -> CGRect {
84+
public func rectFor(range: NSRange) -> CGRect {
8585
let minXPos = CTLineGetOffsetForStringIndex(ctLine, range.lowerBound, nil)
8686
let maxXPos = CTLineGetOffsetForStringIndex(ctLine, range.upperBound, nil)
8787
return CGRect(

Sources/CodeEditTextView/TextLine/LineFragmentView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import AppKit
1111
final class LineFragmentView: NSView {
1212
private weak var lineFragment: LineFragment?
1313

14+
weak var renderDelegate: TextLayoutManagerRenderDelegate?
15+
1416
override var isFlipped: Bool {
1517
true
1618
}
@@ -39,6 +41,11 @@ final class LineFragmentView: NSView {
3941
guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else {
4042
return
4143
}
42-
lineFragment.draw(in: context, yPos: 0.0)
44+
45+
if let renderDelegate {
46+
renderDelegate.drawLineFragment(fragment: lineFragment, in: context)
47+
} else {
48+
lineFragment.draw(in: context, yPos: 0.0)
49+
}
4350
}
4451
}

Sources/CodeEditTextView/TextLine/TextLine.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public final class TextLine: Identifiable, Equatable {
4545
displayData: DisplayData,
4646
range: NSRange,
4747
stringRef: NSTextStorage,
48-
markedRanges: MarkedTextManager.MarkedRanges?,
48+
markedRanges: MarkedRanges?,
4949
breakStrategy: LineBreakStrategy
5050
) {
5151
let string = stringRef.attributedSubstring(from: range)
@@ -64,7 +64,7 @@ public final class TextLine: Identifiable, Equatable {
6464
}
6565

6666
/// Contains all required data to perform a typeset and layout operation on a text line.
67-
struct DisplayData {
67+
public struct DisplayData {
6868
let maxWidth: CGFloat
6969
let lineHeightMultiplier: CGFloat
7070
let estimatedLineHeight: CGFloat

Sources/CodeEditTextView/TextLine/Typesetter.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,20 @@
88
import Foundation
99
import CoreText
1010

11-
final class Typesetter {
12-
var typesetter: CTTypesetter?
13-
var string: NSAttributedString!
14-
var lineFragments = TextLineStorage<LineFragment>()
11+
final public class Typesetter {
12+
public var typesetter: CTTypesetter?
13+
public var string: NSAttributedString!
14+
public var lineFragments = TextLineStorage<LineFragment>()
1515

1616
// MARK: - Init & Prepare
1717

18-
init() { }
18+
public init() { }
1919

20-
func typeset(
20+
public func typeset(
2121
_ string: NSAttributedString,
2222
displayData: TextLine.DisplayData,
2323
breakStrategy: LineBreakStrategy,
24-
markedRanges: MarkedTextManager.MarkedRanges?
24+
markedRanges: MarkedRanges?
2525
) {
2626
lineFragments.removeAll()
2727
if let markedRanges {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Testing
2+
import AppKit
3+
@testable import CodeEditTextView
4+
5+
class MockRenderDelegate: TextLayoutManagerRenderDelegate {
6+
var prepareForDisplay: ((
7+
_ textLine: TextLine,
8+
_ displayData: TextLine.DisplayData,
9+
_ range: NSRange,
10+
_ stringRef: NSTextStorage,
11+
_ markedRanges: MarkedRanges?,
12+
_ breakStrategy: LineBreakStrategy
13+
) -> Void)?
14+
15+
func prepareForDisplay( // swiftlint:disable:this function_parameter_count
16+
textLine: TextLine,
17+
displayData: TextLine.DisplayData,
18+
range: NSRange,
19+
stringRef: NSTextStorage,
20+
markedRanges: MarkedRanges?,
21+
breakStrategy: LineBreakStrategy
22+
) {
23+
prepareForDisplay?(
24+
textLine,
25+
displayData,
26+
range,
27+
stringRef,
28+
markedRanges,
29+
breakStrategy
30+
) ?? textLine.prepareForDisplay(
31+
displayData: displayData,
32+
range: range,
33+
stringRef: stringRef,
34+
markedRanges: markedRanges,
35+
breakStrategy: breakStrategy
36+
)
37+
}
38+
}
39+
40+
@Suite
41+
@MainActor
42+
struct OverridingLayoutManagerRenderingTests {
43+
let mockDelegate: MockRenderDelegate
44+
let textView: TextView
45+
let textStorage: NSTextStorage
46+
let layoutManager: TextLayoutManager
47+
48+
init() throws {
49+
textView = TextView(string: "A\nB\nC\nD")
50+
textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000)
51+
textStorage = textView.textStorage
52+
layoutManager = try #require(textView.layoutManager)
53+
mockDelegate = MockRenderDelegate()
54+
layoutManager.renderDelegate = mockDelegate
55+
}
56+
57+
@Test
58+
func overriddenLineHeight() {
59+
mockDelegate.prepareForDisplay = { textLine, displayData, range, stringRef, markedRanges, breakStrategy in
60+
textLine.prepareForDisplay(
61+
displayData: displayData,
62+
range: range,
63+
stringRef: stringRef,
64+
markedRanges: markedRanges,
65+
breakStrategy: breakStrategy
66+
)
67+
// Update all text fragments to be height = 2.0
68+
textLine.lineFragments.forEach { fragmentPosition in
69+
let idealHeight: CGFloat = 2.0
70+
textLine.lineFragments.update(
71+
atIndex: fragmentPosition.index,
72+
delta: 0,
73+
deltaHeight: -(fragmentPosition.height - idealHeight)
74+
)
75+
fragmentPosition.data.height = 2.0
76+
fragmentPosition.data.scaledHeight = 2.0
77+
}
78+
}
79+
80+
layoutManager.invalidateLayoutForRect(NSRect(x: 0, y: 0, width: 1000, height: 1000))
81+
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
82+
83+
// 4 lines, each 2px tall
84+
#expect(layoutManager.lineStorage.height == 8.0)
85+
86+
// Edit some text
87+
88+
textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "0\n1\r\n2\r")
89+
90+
#expect(layoutManager.lineCount == 7)
91+
#expect(layoutManager.lineStorage.height == 14.0)
92+
layoutManager.lineStorage.validateInternalState()
93+
}
94+
}

0 commit comments

Comments
 (0)