Skip to content

Commit 2508635

Browse files
Merge Trailing Line on Attachments, Select Attachments (#98)
### Description Fixes a bug with text attachments that ended on the newline character in a line. Eg: ``` A B ``` Text attachment added for range (0..<2) should result in: ``` [Attachment]B ``` Right now it results in the following visible lines. ``` [Attachment]B ``` Also introduces the ability for text attachments to respond to being selected and draw their contents differently. ### Related Issues * CodeEditApp/CodeEditSourceEditor#43 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots With new behavior: https://github.com/user-attachments/assets/3cf665ab-1a9a-4de0-a835-9248d955ab5a
1 parent c045ffc commit 2508635

File tree

10 files changed

+93
-11
lines changed

10 files changed

+93
-11
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift

Lines changed: 3 additions & 2 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

@@ -18,8 +19,8 @@ public protocol TextAttachment: AnyObject {
1819
/// This type cannot be initialized outside of `CodeEditTextView`, but will be received when interrogating
1920
/// the ``TextAttachmentManager``.
2021
public struct AnyTextAttachment: Equatable {
21-
var range: NSRange
22-
let attachment: any TextAttachment
22+
package(set) public var range: NSRange
23+
public let attachment: any TextAttachment
2324

2425
var width: CGFloat {
2526
attachment.width

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift

Lines changed: 58 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.
@@ -23,11 +24,28 @@ public final class TextAttachmentManager {
2324
let attachment = AnyTextAttachment(range: range, attachment: attachment)
2425
let insertIndex = findInsertionIndex(for: range.location)
2526
orderedAttachments.insert(attachment, at: insertIndex)
27+
28+
// This is ugly, but if our attachment meets the end of the next line, we need to merge that line with this
29+
// one.
30+
var getNextOne = false
2631
layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach {
2732
if $0.height != 0 {
2833
layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height)
2934
}
35+
36+
// Only do this if it's not the end of the document
37+
if range.max == $0.range.max && range.max != layoutManager?.lineStorage.length {
38+
getNextOne = true
39+
}
40+
}
41+
42+
if getNextOne,
43+
let trailingLine = layoutManager?.lineStorage.getLine(atOffset: range.max),
44+
trailingLine.height != 0 {
45+
// Update the one trailing line.
46+
layoutManager?.lineStorage.update(atOffset: range.max, delta: 0, deltaHeight: -trailingLine.height)
3047
}
48+
3149
layoutManager?.setNeedsLayout()
3250
}
3351

@@ -77,7 +95,7 @@ public final class TextAttachmentManager {
7795
/// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`.
7896
public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] {
7997
// Find the first attachment whose end is beyond the start of the query.
80-
guard let startIdx = firstIndex(where: { $0.range.upperBound > range.location }) else {
98+
guard let startIdx = firstIndex(where: { $0.range.upperBound >= range.location }) else {
8199
return []
82100
}
83101

@@ -90,8 +108,8 @@ public final class TextAttachmentManager {
90108
if attachment.range.location >= range.upperBound {
91109
break
92110
}
93-
if attachment.range.intersection(range)?.length ?? 0 > 0,
94-
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 {
95113
results.append(attachment)
96114
}
97115
idx += 1
@@ -114,6 +132,43 @@ public final class TextAttachmentManager {
114132
}
115133
}
116134
}
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+
}
117172
}
118173

119174
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+Layout.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ extension TextLayoutManager {
250250
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) {
251251
renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView()
252252
}
253-
view.translatesAutoresizingMaskIntoConstraints = false
253+
view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews
254254
view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer)
255255
view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos)
256256
layoutView?.addSubview(view, positioned: .below, relativeTo: nil)

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ extension TextLayoutManager {
203203
/// - line: The line to calculate rects for.
204204
/// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range.
205205
public func rectsFor(range: NSRange) -> [CGRect] {
206-
return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) }
206+
return linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) }
207207
}
208208

209209
/// Calculates all text bounding rects that intersect with a given range, with a given line position.

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

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

7878
weak var textStorage: NSTextStorage?
79-
var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
79+
public var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
8080
var markedTextManager: MarkedTextManager = MarkedTextManager()
8181
let viewReuseQueue: ViewReuseQueue<LineFragmentView, LineFragment.ID> = ViewReuseQueue()
8282
let lineFragmentRenderer: LineFragmentRenderer

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,14 @@ struct TextLayoutManagerAttachmentsTests {
108108
// Line "5" is from the trailing newline. That shows up as an empty line in the view.
109109
#expect(lines.map { $0.index } == [0, 4])
110110
}
111+
112+
@Test
113+
func addingAttachmentThatMeetsEndOfLineMergesNextLine() throws {
114+
let height = try #require(layoutManager.textLineForOffset(0)).height
115+
layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 0, end: 3))
116+
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)
120+
}
111121
}

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)