Skip to content

Commit 9ec949e

Browse files
committed
Fix Selection & Cursor Placement, Add Attachment Selection
1 parent b62ae94 commit 9ec949e

File tree

8 files changed

+65
-10
lines changed

8 files changed

+65
-10
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import AppKit
1010
/// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view.
1111
public protocol TextAttachment: AnyObject {
1212
var width: CGFloat { get }
13+
var isSelected: Bool { get set }
1314
func draw(in context: CGContext, rect: NSRect)
1415
}
1516

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Foundation
1515
public final class TextAttachmentManager {
1616
private var orderedAttachments: [AnyTextAttachment] = []
1717
weak var layoutManager: TextLayoutManager?
18+
private var selectionObserver: (any NSObjectProtocol)?
1819

1920
/// Adds a new attachment, keeping `orderedAttachments` sorted by range.location.
2021
/// If two attachments overlap, the layout phase will later ignore the one with the higher start.
@@ -94,7 +95,7 @@ public final class TextAttachmentManager {
9495
/// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`.
9596
public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] {
9697
// Find the first attachment whose end is beyond the start of the query.
97-
guard let startIdx = firstIndex(where: { $0.range.upperBound > range.location }) else {
98+
guard let startIdx = firstIndex(where: { $0.range.upperBound >= range.location }) else {
9899
return []
99100
}
100101

@@ -107,8 +108,8 @@ public final class TextAttachmentManager {
107108
if attachment.range.location >= range.upperBound {
108109
break
109110
}
110-
if attachment.range.intersection(range)?.length ?? 0 > 0,
111-
results.last?.range != attachment.range {
111+
if (attachment.range.intersection(range)?.length ?? 0 > 0 || attachment.range.max == range.location)
112+
&& results.last?.range != attachment.range {
112113
results.append(attachment)
113114
}
114115
idx += 1
@@ -131,6 +132,43 @@ public final class TextAttachmentManager {
131132
}
132133
}
133134
}
135+
136+
/// Set up the attachment manager to listen to selection updates, giving text attachments a chance to respond to
137+
/// selection state.
138+
///
139+
/// This is specifically not in the initializer to prevent a bit of a chicken-and-the-egg situation where the
140+
/// layout manager and selection manager need each other to init.
141+
///
142+
/// - Parameter selectionManager: The selection manager to listen to.
143+
func setUpSelectionListener(for selectionManager: TextSelectionManager) {
144+
if let selectionObserver {
145+
NotificationCenter.default.removeObserver(selectionObserver)
146+
}
147+
148+
selectionObserver = NotificationCenter.default.addObserver(
149+
forName: TextSelectionManager.selectionChangedNotification,
150+
object: selectionManager,
151+
queue: .main
152+
) { [weak self] notification in
153+
guard let selectionManager = notification.object as? TextSelectionManager else {
154+
return
155+
}
156+
let selectedSet = IndexSet(ranges: selectionManager.textSelections.map({ $0.range }))
157+
for attachment in self?.orderedAttachments ?? [] {
158+
let isSelected = selectedSet.contains(integersIn: attachment.range)
159+
if attachment.attachment.isSelected != isSelected {
160+
self?.layoutManager?.invalidateLayoutForRange(attachment.range)
161+
}
162+
attachment.attachment.isSelected = isSelected
163+
}
164+
}
165+
}
166+
167+
deinit {
168+
if let selectionObserver {
169+
NotificationCenter.default.removeObserver(selectionObserver)
170+
}
171+
}
134172
}
135173

136174
private extension TextAttachmentManager {

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ public extension TextLayoutManager {
196196
}
197197

198198
if lastAttachment.range.max > originalPosition.position.range.max,
199-
let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) {
199+
var extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) {
200200
newPosition = TextLineStorage<TextLine>.TextLinePosition(
201201
data: newPosition.data,
202202
range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max),
@@ -207,6 +207,14 @@ public extension TextLayoutManager {
207207
maxIndex = max(maxIndex, extendedLinePosition.index)
208208
}
209209

210+
if firstAttachment.range.location == newPosition.range.location {
211+
minIndex = max(minIndex, 0)
212+
}
213+
214+
if lastAttachment.range.max == newPosition.range.max {
215+
maxIndex = min(maxIndex, lineStorage.count - 1)
216+
}
217+
210218
// Base case, we haven't updated anything
211219
if minIndex...maxIndex == originalPosition.indexRange {
212220
return (newPosition, minIndex...maxIndex)

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public class TextLayoutManager: NSObject {
6969
// MARK: - Internal
7070

7171
weak var textStorage: NSTextStorage?
72-
var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
72+
public var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
7373
var markedTextManager: MarkedTextManager = MarkedTextManager()
7474
let viewReuseQueue: ViewReuseQueue<LineFragmentView, LineFragment.ID> = ViewReuseQueue()
7575
package var visibleLineIds: Set<TextLine.ID> = []

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,15 @@ extension TextSelectionManager {
7272
}
7373

7474
let maxRect: CGRect
75+
let endOfLine = fragmentRange.max <= range.max || range.contains(fragmentRange.max)
76+
let endOfDocument = intersectionRange.max == layoutManager.lineStorage.length
77+
let emptyLine = linePosition.range.isEmpty
78+
7579
// If the selection is at the end of the line, or contains the end of the fragment, and is not the end
7680
// of the document, we select the entire line to the right of the selection point.
77-
if (fragmentRange.max <= range.max || range.contains(fragmentRange.max))
78-
&& intersectionRange.max != layoutManager.lineStorage.length {
81+
// true, !true = false, false
82+
// true, !true = false, true
83+
if endOfLine && !(endOfDocument && !emptyLine) {
7984
maxRect = CGRect(
8085
x: rect.maxX,
8186
y: fragmentPosition.yPos + linePosition.yPos,

Sources/CodeEditTextView/TextView/TextView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,8 @@ public class TextView: NSView, NSTextContent {
346346
selectionManager = setUpSelectionManager()
347347
selectionManager.useSystemCursor = useSystemCursor
348348

349+
layoutManager.attachments.setUpSelectionListener(for: selectionManager)
350+
349351
_undoManager = CEUndoManager(textView: self)
350352

351353
layoutManager.layoutLines()

Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ struct TextLayoutManagerAttachmentsTests {
114114
let height = try #require(layoutManager.textLineForOffset(0)).height
115115
layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 0, end: 3))
116116

117-
// With bug: this the line for offset 3 is > 0 because it wasn't updated for the new attachment.
118-
#expect(layoutManager.textLineForOffset(0)?.height == height)
119-
#expect(layoutManager.textLineForOffset(3)?.height == 0)
117+
// With bug: the line for offset 3 would be the 2nd line (index 1). They should be merged
118+
#expect(layoutManager.textLineForOffset(0)?.index == 0)
119+
#expect(layoutManager.textLineForOffset(3)?.index == 0)
120120
}
121121
}

Tests/CodeEditTextViewTests/TypesetterTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import XCTest
33

44
final class DemoTextAttachment: TextAttachment {
55
var width: CGFloat
6+
var isSelected: Bool = false
67

78
init(width: CGFloat = 100) {
89
self.width = width

0 commit comments

Comments
 (0)