Skip to content

Commit cd1cfd4

Browse files
committed
Introduce Layout Manager API
1 parent 5807b02 commit cd1cfd4

File tree

14 files changed

+199
-49
lines changed

14 files changed

+199
-49
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1620"
4+
version = "1.7">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES"
8+
buildArchitectures = "Automatic">
9+
<BuildActionEntries>
10+
<BuildActionEntry
11+
buildForTesting = "YES"
12+
buildForRunning = "YES"
13+
buildForProfiling = "YES"
14+
buildForArchiving = "YES"
15+
buildForAnalyzing = "YES">
16+
<BuildableReference
17+
BuildableIdentifier = "primary"
18+
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
19+
BuildableName = "CodeEditTextViewExample.app"
20+
BlueprintName = "CodeEditTextViewExample"
21+
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
22+
</BuildableReference>
23+
</BuildActionEntry>
24+
</BuildActionEntries>
25+
</BuildAction>
26+
<TestAction
27+
buildConfiguration = "Debug"
28+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30+
shouldUseLaunchSchemeArgsEnv = "YES"
31+
shouldAutocreateTestPlan = "YES">
32+
</TestAction>
33+
<LaunchAction
34+
buildConfiguration = "Debug"
35+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
36+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
37+
launchStyle = "0"
38+
useCustomWorkingDirectory = "NO"
39+
ignoresPersistentStateOnLaunch = "NO"
40+
debugDocumentVersioning = "YES"
41+
debugServiceExtension = "internal"
42+
allowLocationSimulation = "YES">
43+
<BuildableProductRunnable
44+
runnableDebuggingMode = "0">
45+
<BuildableReference
46+
BuildableIdentifier = "primary"
47+
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
48+
BuildableName = "CodeEditTextViewExample.app"
49+
BlueprintName = "CodeEditTextViewExample"
50+
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
51+
</BuildableReference>
52+
</BuildableProductRunnable>
53+
</LaunchAction>
54+
<ProfileAction
55+
buildConfiguration = "Release"
56+
shouldUseLaunchSchemeArgsEnv = "YES"
57+
savedToolIdentifier = ""
58+
useCustomWorkingDirectory = "NO"
59+
debugDocumentVersioning = "YES">
60+
<BuildableProductRunnable
61+
runnableDebuggingMode = "0">
62+
<BuildableReference
63+
BuildableIdentifier = "primary"
64+
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
65+
BuildableName = "CodeEditTextViewExample.app"
66+
BlueprintName = "CodeEditTextViewExample"
67+
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
68+
</BuildableReference>
69+
</BuildableProductRunnable>
70+
</ProfileAction>
71+
<AnalyzeAction
72+
buildConfiguration = "Debug">
73+
</AnalyzeAction>
74+
<ArchiveAction
75+
buildConfiguration = "Release"
76+
revealArchiveInOrganizer = "YES">
77+
</ArchiveAction>
78+
</Scheme>

Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ struct CodeEditTextViewExampleDocument: FileDocument, @unchecked Sendable {
3030
options: [.characterEncoding: NSUTF8StringEncoding, .fileType: NSAttributedString.DocumentType.plain],
3131
documentAttributes: nil
3232
)
33-
print(String(decoding: data, as: UTF8.self), text.string)
3433
}
3534

3635
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {

Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// File.swift
2+
// CTTypesetter+SuggestLineBreak.swift
33
// CodeEditTextView
44
//
55
// Created by Khan Winter on 4/24/25.
@@ -126,5 +126,4 @@ extension CTTypesetter {
126126

127127
return substring == LineEnding.carriageReturnLineFeed.rawValue
128128
}
129-
130129
}

Sources/CodeEditTextView/TextLayoutManager/TextAttachmentManager.swift

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

Sources/CodeEditTextView/TextLine/TextAttachment.swift renamed to Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift

File renamed without changes.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//
2+
// TextAttachmentManager.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 4/24/25.
6+
//
7+
8+
import Foundation
9+
10+
/// Manages a set of attachments for the layout manager, provides methods for efficiently finding attachments for a
11+
/// line range.
12+
///
13+
/// If two attachments are overlapping, the one placed further along in the document will be
14+
/// ignored when laying out attachments.
15+
public final class TextAttachmentManager {
16+
private var orderedAttachments: [TextAttachmentBox] = []
17+
weak var layoutManager: TextLayoutManager?
18+
19+
/// Adds a new attachment box, keeping `orderedAttachments` sorted by range.location.
20+
/// If two attachments overlap, the layout phase will later ignore the one with the higher start.
21+
/// - Complexity: `O(n log(n))` due to array insertion. Could be improved with a binary tree.
22+
public func add(_ attachment: any TextAttachment, for range: NSRange) {
23+
let box = TextAttachmentBox(range: range, attachment: attachment)
24+
let insertIndex = findInsertionIndex(for: range.location)
25+
orderedAttachments.insert(box, at: insertIndex)
26+
layoutManager?.invalidateLayoutForRange(range)
27+
}
28+
29+
public func remove(atOffset offset: Int) {
30+
let index = findInsertionIndex(for: offset)
31+
32+
// Check if the attachment at this index starts exactly at the offset
33+
if index < orderedAttachments.count,
34+
orderedAttachments[index].range.location == offset {
35+
let invalidatedRange = orderedAttachments.remove(at: index).range
36+
layoutManager?.invalidateLayoutForRange(invalidatedRange)
37+
} else {
38+
assertionFailure("No attachment found at offset \(offset)")
39+
}
40+
}
41+
42+
/// Finds attachments for the given line range, and returns them as an array.
43+
/// Returned attachment's ranges will be relative to the _document_, not the line.
44+
/// - Complexity: `O(n log(n))`, ideally `O(log(n))`
45+
public func attachments(in range: NSRange) -> [TextAttachmentBox] {
46+
var results: [TextAttachmentBox] = []
47+
var idx = findInsertionIndex(for: range.location)
48+
while idx < orderedAttachments.count {
49+
let box = orderedAttachments[idx]
50+
let loc = box.range.location
51+
if loc >= range.upperBound {
52+
break
53+
}
54+
if range.contains(loc) {
55+
results.append(box)
56+
}
57+
idx += 1
58+
}
59+
return results
60+
}
61+
}
62+
63+
private extension TextAttachmentManager {
64+
/// Returns the index in `orderedAttachments` at which an attachment with
65+
/// `range.location == location` should be inserted to keep the array sorted.
66+
/// (Lower‐bound search.)
67+
func findInsertionIndex(for location: Int) -> Int {
68+
var low = 0
69+
var high = orderedAttachments.count
70+
while low < high {
71+
let mid = (low + high) / 2
72+
if orderedAttachments[mid].range.location < location {
73+
low = mid + 1
74+
} else {
75+
high = mid
76+
}
77+
}
78+
return low
79+
}
80+
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,17 @@ extension TextLayoutManager {
178178
range: position.range,
179179
stringRef: textStorage,
180180
markedRanges: markedTextManager.markedRanges(in: position.range),
181-
breakStrategy: lineBreakStrategy
181+
breakStrategy: lineBreakStrategy,
182+
attachments: attachments.attachments(in: position.range)
182183
)
183184
} else {
184185
line.prepareForDisplay(
185186
displayData: lineDisplayData,
186187
range: position.range,
187188
stringRef: textStorage,
188189
markedRanges: markedTextManager.markedRanges(in: position.range),
189-
breakStrategy: lineBreakStrategy
190+
breakStrategy: lineBreakStrategy,
191+
attachments: attachments.attachments(in: position.range)
190192
)
191193
}
192194

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,12 @@ extension TextLayoutManager {
8080
if fragment.width == 0 {
8181
return linePosition.range.location + fragmentPosition.range.location
8282
} else if fragment.width < point.x - edgeInsets.left {
83-
let fragmentRange = fragment.documentRange
84-
let endPosition = linePosition.range.location + fragmentRange.location + fragmentRange.length
83+
let endPosition = fragment.documentRange.max
8584

8685
// If the endPosition is at the end of the line, and the line ends with a line ending character
8786
// return the index before the eol.
8887
if endPosition == linePosition.range.max,
89-
let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") {
88+
let lineEnding = LineEnding(line: textStorage?.substring(from: fragment.documentRange) ?? "") {
9089
return endPosition - lineEnding.length
9190
} else {
9291
return endPosition

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ public class TextLayoutManager: NSObject {
6464
}
6565
}
6666

67+
public var attachments: TextAttachmentManager = TextAttachmentManager()
68+
6769
// MARK: - Internal
6870

6971
weak var textStorage: NSTextStorage?
@@ -130,6 +132,7 @@ public class TextLayoutManager: NSObject {
130132
self.renderDelegate = renderDelegate
131133
super.init()
132134
prepareTextLines()
135+
attachments.layoutManager = self
133136
}
134137

135138
/// Prepares the layout manager for use.

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManagerRenderDelegate.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public protocol TextLayoutManagerRenderDelegate: AnyObject {
1818
range: NSRange,
1919
stringRef: NSTextStorage,
2020
markedRanges: MarkedRanges?,
21-
breakStrategy: LineBreakStrategy
21+
breakStrategy: LineBreakStrategy,
22+
attachments: [TextAttachmentBox]
2223
)
2324

2425
func estimatedLineHeight() -> CGFloat?
@@ -35,14 +36,16 @@ public extension TextLayoutManagerRenderDelegate {
3536
range: NSRange,
3637
stringRef: NSTextStorage,
3738
markedRanges: MarkedRanges?,
38-
breakStrategy: LineBreakStrategy
39+
breakStrategy: LineBreakStrategy,
40+
attachments: [TextAttachmentBox]
3941
) {
4042
textLine.prepareForDisplay(
4143
displayData: displayData,
4244
range: range,
4345
stringRef: stringRef,
4446
markedRanges: markedRanges,
45-
breakStrategy: breakStrategy
47+
breakStrategy: breakStrategy,
48+
attachments: attachments
4649
)
4750
}
4851

0 commit comments

Comments
 (0)