Skip to content

Commit af713a3

Browse files
committed
Handle Attachments In Layout Manager Iterator
1 parent cd1cfd4 commit af713a3

File tree

10 files changed

+161
-29
lines changed

10 files changed

+161
-29
lines changed

Sources/CodeEditTextView/Extensions/CTTypesetter+SuggestLineBreak.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ extension CTTypesetter {
7676
constrainingWidth: CGFloat
7777
) -> Int {
7878
var breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth)
79-
8079
let isBreakAtEndOfString = breakIndex >= string.length
8180

8281
let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string)

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public struct TextAttachmentBox: Equatable {
1515
attachment.width
1616
}
1717

18-
public static func ==(_ lhs: TextAttachmentBox, _ rhs: TextAttachmentBox) -> Bool {
18+
public static func == (_ lhs: TextAttachmentBox, _ rhs: TextAttachmentBox) -> Bool {
1919
lhs.range == rhs.range && lhs.attachment === rhs.attachment
2020
}
2121
}

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,28 @@ public final class TextAttachmentManager {
2323
let box = TextAttachmentBox(range: range, attachment: attachment)
2424
let insertIndex = findInsertionIndex(for: range.location)
2525
orderedAttachments.insert(box, at: insertIndex)
26+
layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach {
27+
layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height)
28+
}
2629
layoutManager?.invalidateLayoutForRange(range)
2730
}
2831

2932
public func remove(atOffset offset: Int) {
3033
let index = findInsertionIndex(for: offset)
3134

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 {
35+
guard index < orderedAttachments.count && orderedAttachments[index].range.location == offset else {
3836
assertionFailure("No attachment found at offset \(offset)")
37+
return
3938
}
39+
40+
let attachment = orderedAttachments.remove(at: index)
41+
layoutManager?.invalidateLayoutForRange(attachment.range)
4042
}
4143

42-
/// Finds attachments for the given line range, and returns them as an array.
44+
/// Finds attachments starting in the given line range, and returns them as an array.
4345
/// Returned attachment's ranges will be relative to the _document_, not the line.
4446
/// - Complexity: `O(n log(n))`, ideally `O(log(n))`
45-
public func attachments(in range: NSRange) -> [TextAttachmentBox] {
47+
public func attachments(startingIn range: NSRange) -> [TextAttachmentBox] {
4648
var results: [TextAttachmentBox] = []
4749
var idx = findInsertionIndex(for: range.location)
4850
while idx < orderedAttachments.count {
@@ -52,10 +54,43 @@ public final class TextAttachmentManager {
5254
break
5355
}
5456
if range.contains(loc) {
57+
if let lastResult = results.last, !lastResult.range.contains(box.range.location) {
58+
results.append(box)
59+
} else if results.isEmpty {
60+
results.append(box)
61+
}
62+
}
63+
idx += 1
64+
}
65+
return results
66+
}
67+
68+
/// Returns all attachments whose ranges overlap the given query range.
69+
///
70+
/// - Parameter query: The `NSRange` to test for overlap.
71+
/// - Returns: An array of `TextAttachmentBox` instances whose ranges intersect `query`.
72+
func attachments(overlapping query: NSRange) -> [TextAttachmentBox] {
73+
// Find the first attachment whose end is beyond the start of the query.
74+
guard let startIdx = firstIndex(where: { $0.range.upperBound > query.location }) else {
75+
return []
76+
}
77+
78+
var results: [TextAttachmentBox] = []
79+
var idx = startIdx
80+
81+
// Collect every subsequent attachment that truly overlaps the query.
82+
while idx < orderedAttachments.count {
83+
let box = orderedAttachments[idx]
84+
if box.range.location >= query.upperBound {
85+
break
86+
}
87+
if NSIntersectionRange(box.range, query).length > 0,
88+
results.last?.range != box.range {
5589
results.append(box)
5690
}
5791
idx += 1
5892
}
93+
5994
return results
6095
}
6196
}
@@ -77,4 +112,21 @@ private extension TextAttachmentManager {
77112
}
78113
return low
79114
}
115+
116+
/// Finds the first index that matches a callback.
117+
/// - Parameter predicate: The query predicate.
118+
/// - Returns: The first index that matches the given predicate.
119+
func firstIndex(where predicate: (TextAttachmentBox) -> Bool) -> Int? {
120+
var low = 0
121+
var high = orderedAttachments.count
122+
while low < high {
123+
let mid = (low + high) / 2
124+
if predicate(orderedAttachments[mid]) {
125+
high = mid
126+
} else {
127+
low = mid + 1
128+
}
129+
}
130+
return low < orderedAttachments.count ? low : nil
131+
}
80132
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Edits.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ extension TextLayoutManager: NSTextStorageDelegate {
4646
removeLayoutLinesIn(range: insertedStringRange)
4747
insertNewLines(for: editedRange)
4848

49-
setNeedsLayout()
49+
attachments.attachments(overlapping: insertedStringRange).forEach { attachment in
50+
attachments.remove(atOffset: attachment.range.location)
51+
}
52+
53+
invalidateLayoutForRange(insertedStringRange)
5054
}
5155

5256
/// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,82 @@ public extension TextLayoutManager {
2121
width: 0,
2222
height: estimatedHeight()
2323
)
24-
return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage)
24+
return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), layoutManager: self)
2525
}
2626

2727
/// Iterate over all lines in the y position range.
2828
/// - Parameters:
2929
/// - minY: The minimum y position to begin at.
3030
/// - maxY: The maximum y position to iterate to.
3131
/// - Returns: An iterator that will iterate through all text lines in the y position range.
32-
func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorage<TextLine>.TextLineStorageYIterator {
33-
lineStorage.linesStartingAt(minY, until: maxY)
32+
func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> Iterator {
33+
Iterator(minY: minY, maxY: maxY, layoutManager: self)
3434
}
3535

3636
struct Iterator: LazySequenceProtocol, IteratorProtocol {
37-
private var storageIterator: TextLineStorage<TextLine>.TextLineStorageYIterator
37+
typealias TextLinePosition = TextLineStorage<TextLine>.TextLinePosition
3838

39-
init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage<TextLine>) {
40-
storageIterator = storage.linesStartingAt(minY, until: maxY)
39+
private weak var layoutManager: TextLayoutManager?
40+
private let minY: CGFloat
41+
private let maxY: CGFloat
42+
private var currentPosition: TextLinePosition?
43+
44+
init(minY: CGFloat, maxY: CGFloat, layoutManager: TextLayoutManager) {
45+
self.minY = minY
46+
self.maxY = maxY
47+
self.layoutManager = layoutManager
4148
}
4249

4350
public mutating func next() -> TextLineStorage<TextLine>.TextLinePosition? {
44-
storageIterator.next()
51+
// Determine the 'visible' line at the next position. This iterator may skip lines that are covered by
52+
// attachments, so we use the line position's range to get the next position. Once we have the position,
53+
// we'll create a new one that reflects what we actually want to display.
54+
// Eg for the following setup:
55+
// Line 1
56+
// Line[ 2 <- Attachment start
57+
// Line 3
58+
// Line] 4 <- Attachment end
59+
// The iterator will first return the line 1 position, then, line 2 is queried but has an attachment.
60+
// So, we extend the line until the end of the attachment (line 4), and return the position extended that
61+
// far.
62+
// This retains information line line index and position in the text storage.
63+
64+
if let currentPosition {
65+
guard let nextPosition = layoutManager?.lineStorage.getLine(
66+
atOffset: currentPosition.range.max + 1
67+
), nextPosition.yPos < maxY else {
68+
return nil
69+
}
70+
self.currentPosition = determineVisiblePosition(for: nextPosition)
71+
return self.currentPosition
72+
} else if let position = layoutManager?.lineStorage.getLine(atPosition: minY) {
73+
currentPosition = determineVisiblePosition(for: position)
74+
return currentPosition
75+
}
76+
77+
return nil
78+
}
79+
80+
private func determineVisiblePosition(for originalPosition: TextLinePosition) -> TextLinePosition {
81+
guard let attachment = layoutManager?.attachments.attachments(startingIn: originalPosition.range).last,
82+
attachment.range.max > originalPosition.range.max else {
83+
// No change, either no attachments or attachment doesn't span multiple lines.
84+
return originalPosition
85+
}
86+
87+
guard let extendedLinePosition = layoutManager?.lineStorage.getLine(atOffset: attachment.range.max) else {
88+
return originalPosition
89+
}
90+
91+
let newPosition = TextLinePosition(
92+
data: originalPosition.data,
93+
range: NSRange(start: originalPosition.range.location, end: extendedLinePosition.range.max),
94+
yPos: originalPosition.yPos,
95+
height: originalPosition.height,
96+
index: originalPosition.index
97+
)
98+
99+
return determineVisiblePosition(for: newPosition)
45100
}
46101
}
47102
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ extension TextLayoutManager {
8484
var maxFoundLineWidth = maxLineWidth
8585

8686
// Layout all lines, fetching lines lazily as they are laid out.
87-
for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy {
87+
for linePosition in linesStartingAt(minY, until: maxY).lazy {
8888
guard linePosition.yPos < maxY else { continue }
8989
// Three ways to determine if a line needs to be re-calculated.
9090
let changedWidth = linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth)
@@ -179,7 +179,7 @@ extension TextLayoutManager {
179179
stringRef: textStorage,
180180
markedRanges: markedTextManager.markedRanges(in: position.range),
181181
breakStrategy: lineBreakStrategy,
182-
attachments: attachments.attachments(in: position.range)
182+
attachments: attachments.attachments(startingIn: position.range)
183183
)
184184
} else {
185185
line.prepareForDisplay(
@@ -188,7 +188,7 @@ extension TextLayoutManager {
188188
stringRef: textStorage,
189189
markedRanges: markedTextManager.markedRanges(in: position.range),
190190
breakStrategy: lineBreakStrategy,
191-
attachments: attachments.attachments(in: position.range)
191+
attachments: attachments.attachments(startingIn: position.range)
192192
)
193193
}
194194

Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ final public class Typesetter {
5050
attachments: attachments
5151
)
5252
lineFragments.build(from: lines, estimatedLineHeight: maxHeight)
53+
5354
}
5455

5556
private func makeString(string: NSAttributedString, markedRanges: MarkedRanges?) {
@@ -162,9 +163,9 @@ final public class Typesetter {
162163
let lineBreak = typesetter.suggestLineBreak(
163164
using: string,
164165
strategy: breakStrategy,
165-
startingOffset: context.currentPosition,
166+
startingOffset: context.currentPosition - range.location,
166167
constrainingWidth: displayData.maxWidth - context.fragmentContext.width
167-
) - range.location
168+
)
168169

169170
let typesetData = typesetLine(
170171
typesetter: typesetter,
@@ -181,7 +182,7 @@ final public class Typesetter {
181182
// Amend the current line data to include this line, popping the current line afterwards
182183
context.appendText(lineBreak: lineBreak, typesetData: typesetData)
183184

184-
if lineBreak != range.length {
185+
if context.currentPosition != range.max {
185186
context.popCurrentData()
186187
}
187188
}

Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ extension TextView {
1414
}
1515

1616
override public func viewWillMove(toSuperview newSuperview: NSView?) {
17-
guard let scrollView = enclosingScrollView else {
17+
super.viewWillMove(toSuperview: newSuperview)
18+
guard let clipView = newSuperview as? NSClipView,
19+
let scrollView = enclosingScrollView ?? clipView.enclosingScrollView else {
1820
return
1921
}
2022

Sources/CodeEditTextView/TextView/TextView+Menu.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ extension TextView {
3333
}
3434

3535
@objc func buh() {
36-
layoutManager.attachments.add(Buh(), for: selectedRange())
36+
if layoutManager.attachments.attachments(
37+
startingIn: selectedRange()
38+
).first?.range.location == selectedRange().location {
39+
layoutManager.attachments.remove(atOffset: selectedRange().location)
40+
} else {
41+
layoutManager.attachments.add(Buh(), for: selectedRange())
42+
}
3743
}
3844
}

Sources/CodeEditTextView/TextView/TextView+Setup.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ extension TextView {
2828
}
2929

3030
func setUpScrollListeners(scrollView: NSScrollView) {
31-
NotificationCenter.default.removeObserver(self, name: NSScrollView.willStartLiveScrollNotification, object: nil)
32-
NotificationCenter.default.removeObserver(self, name: NSScrollView.didEndLiveScrollNotification, object: nil)
33-
3431
NotificationCenter.default.addObserver(
3532
self,
3633
selector: #selector(scrollViewWillStartScroll),
@@ -44,6 +41,22 @@ extension TextView {
4441
name: NSScrollView.didEndLiveScrollNotification,
4542
object: scrollView
4643
)
44+
45+
NotificationCenter.default.addObserver(
46+
forName: NSView.boundsDidChangeNotification,
47+
object: scrollView.contentView,
48+
queue: .main
49+
) { [weak self] _ in
50+
self?.updatedViewport(self?.visibleRect ?? .zero)
51+
}
52+
53+
NotificationCenter.default.addObserver(
54+
forName: NSView.frameDidChangeNotification,
55+
object: scrollView.contentView,
56+
queue: .main
57+
) { [weak self] _ in
58+
self?.updatedViewport(self?.visibleRect ?? .zero)
59+
}
4760
}
4861

4962
@objc func scrollViewWillStartScroll() {

0 commit comments

Comments
 (0)