From 8dc31c310eb27bc8efba3e6c3e222b303b023757 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:08:41 -0500 Subject: [PATCH 1/5] Merge Trailing Line when Adding Attachment --- .../TextAttachments/TextAttachment.swift | 4 ++-- .../TextAttachments/TextAttachmentManager.swift | 17 +++++++++++++++++ .../TextLayoutManager+Layout.swift | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index 61ca777f2..3bdf331be 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -18,8 +18,8 @@ public protocol TextAttachment: AnyObject { /// This type cannot be initialized outside of `CodeEditTextView`, but will be received when interrogating /// the ``TextAttachmentManager``. public struct AnyTextAttachment: Equatable { - var range: NSRange - let attachment: any TextAttachment + package(set) public var range: NSRange + public let attachment: any TextAttachment var width: CGFloat { attachment.width diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index 5bad2de1e..aed0f8584 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -23,11 +23,28 @@ public final class TextAttachmentManager { let attachment = AnyTextAttachment(range: range, attachment: attachment) let insertIndex = findInsertionIndex(for: range.location) orderedAttachments.insert(attachment, at: insertIndex) + + // This is ugly, but if our attachment meets the end of the next line, we need to merge that line with this + // one. + var getNextOne = false layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach { if $0.height != 0 { layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) } + + // Only do this if it's not the end of the document + if range.max == $0.range.max && range.max != layoutManager?.lineStorage.length { + getNextOne = true + } } + + if getNextOne, + let trailingLine = layoutManager?.lineStorage.getLine(atOffset: range.max), + trailingLine.height != 0 { + // Update the one trailing line. + layoutManager?.lineStorage.update(atOffset: range.max, delta: 0, deltaHeight: -trailingLine.height) + } + layoutManager?.setNeedsLayout() } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 042622830..472e29044 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -250,7 +250,7 @@ extension TextLayoutManager { let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) { renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() } - view.translatesAutoresizingMaskIntoConstraints = false + view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews view.setLineFragment(lineFragment.data) view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) layoutView?.addSubview(view) From b62ae940e985e539be9aec607f2b9e3e60b6d29f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:13:27 -0500 Subject: [PATCH 2/5] Add Test --- .../TextLayoutManagerAttachmentsTests.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift index a3510c608..de0be317a 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -108,4 +108,14 @@ struct TextLayoutManagerAttachmentsTests { // Line "5" is from the trailing newline. That shows up as an empty line in the view. #expect(lines.map { $0.index } == [0, 4]) } + + @Test + func addingAttachmentThatMeetsEndOfLineMergesNextLine() throws { + let height = try #require(layoutManager.textLineForOffset(0)).height + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 0, end: 3)) + + // With bug: this the line for offset 3 is > 0 because it wasn't updated for the new attachment. + #expect(layoutManager.textLineForOffset(0)?.height == height) + #expect(layoutManager.textLineForOffset(3)?.height == 0) + } } From 9ec949e2640f5445e04244d1cded4242a85e62c9 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:24:23 -0500 Subject: [PATCH 3/5] Fix Selection & Cursor Placement, Add Attachment Selection --- .../TextAttachments/TextAttachment.swift | 1 + .../TextAttachmentManager.swift | 44 +++++++++++++++++-- .../TextLayoutManager+Iterator.swift | 10 ++++- .../TextLayoutManager/TextLayoutManager.swift | 2 +- .../TextSelectionManager+FillRects.swift | 9 +++- .../CodeEditTextView/TextView/TextView.swift | 2 + .../TextLayoutManagerAttachmentsTests.swift | 6 +-- .../TypesetterTests.swift | 1 + 8 files changed, 65 insertions(+), 10 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index 3bdf331be..f3bc01209 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -10,6 +10,7 @@ import AppKit /// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view. public protocol TextAttachment: AnyObject { var width: CGFloat { get } + var isSelected: Bool { get set } func draw(in context: CGContext, rect: NSRect) } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index aed0f8584..d74993d0f 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -15,6 +15,7 @@ import Foundation public final class TextAttachmentManager { private var orderedAttachments: [AnyTextAttachment] = [] weak var layoutManager: TextLayoutManager? + private var selectionObserver: (any NSObjectProtocol)? /// Adds a new attachment, keeping `orderedAttachments` sorted by range.location. /// If two attachments overlap, the layout phase will later ignore the one with the higher start. @@ -94,7 +95,7 @@ public final class TextAttachmentManager { /// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`. public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] { // Find the first attachment whose end is beyond the start of the query. - guard let startIdx = firstIndex(where: { $0.range.upperBound > range.location }) else { + guard let startIdx = firstIndex(where: { $0.range.upperBound >= range.location }) else { return [] } @@ -107,8 +108,8 @@ public final class TextAttachmentManager { if attachment.range.location >= range.upperBound { break } - if attachment.range.intersection(range)?.length ?? 0 > 0, - results.last?.range != attachment.range { + if (attachment.range.intersection(range)?.length ?? 0 > 0 || attachment.range.max == range.location) + && results.last?.range != attachment.range { results.append(attachment) } idx += 1 @@ -131,6 +132,43 @@ public final class TextAttachmentManager { } } } + + /// Set up the attachment manager to listen to selection updates, giving text attachments a chance to respond to + /// selection state. + /// + /// This is specifically not in the initializer to prevent a bit of a chicken-and-the-egg situation where the + /// layout manager and selection manager need each other to init. + /// + /// - Parameter selectionManager: The selection manager to listen to. + func setUpSelectionListener(for selectionManager: TextSelectionManager) { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + + selectionObserver = NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: selectionManager, + queue: .main + ) { [weak self] notification in + guard let selectionManager = notification.object as? TextSelectionManager else { + return + } + let selectedSet = IndexSet(ranges: selectionManager.textSelections.map({ $0.range })) + for attachment in self?.orderedAttachments ?? [] { + let isSelected = selectedSet.contains(integersIn: attachment.range) + if attachment.attachment.isSelected != isSelected { + self?.layoutManager?.invalidateLayoutForRange(attachment.range) + } + attachment.attachment.isSelected = isSelected + } + } + } + + deinit { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + } } private extension TextAttachmentManager { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 4e5efede5..ba0a997ed 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -196,7 +196,7 @@ public extension TextLayoutManager { } if lastAttachment.range.max > originalPosition.position.range.max, - let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { + var extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { newPosition = TextLineStorage.TextLinePosition( data: newPosition.data, range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max), @@ -207,6 +207,14 @@ public extension TextLayoutManager { maxIndex = max(maxIndex, extendedLinePosition.index) } + if firstAttachment.range.location == newPosition.range.location { + minIndex = max(minIndex, 0) + } + + if lastAttachment.range.max == newPosition.range.max { + maxIndex = min(maxIndex, lineStorage.count - 1) + } + // Base case, we haven't updated anything if minIndex...maxIndex == originalPosition.indexRange { return (newPosition, minIndex...maxIndex) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 84195b00c..062fadebc 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -69,7 +69,7 @@ public class TextLayoutManager: NSObject { // MARK: - Internal weak var textStorage: NSTextStorage? - var lineStorage: TextLineStorage = TextLineStorage() + public var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() package var visibleLineIds: Set = [] diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index da5165f32..f3160bf3e 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -72,10 +72,15 @@ extension TextSelectionManager { } let maxRect: CGRect + let endOfLine = fragmentRange.max <= range.max || range.contains(fragmentRange.max) + let endOfDocument = intersectionRange.max == layoutManager.lineStorage.length + let emptyLine = linePosition.range.isEmpty + // If the selection is at the end of the line, or contains the end of the fragment, and is not the end // of the document, we select the entire line to the right of the selection point. - if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) - && intersectionRange.max != layoutManager.lineStorage.length { + // true, !true = false, false + // true, !true = false, true + if endOfLine && !(endOfDocument && !emptyLine) { maxRect = CGRect( x: rect.maxX, y: fragmentPosition.yPos + linePosition.yPos, diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 4c9cf7c31..873694591 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -346,6 +346,8 @@ public class TextView: NSView, NSTextContent { selectionManager = setUpSelectionManager() selectionManager.useSystemCursor = useSystemCursor + layoutManager.attachments.setUpSelectionListener(for: selectionManager) + _undoManager = CEUndoManager(textView: self) layoutManager.layoutLines() diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift index de0be317a..1841cc5ed 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -114,8 +114,8 @@ struct TextLayoutManagerAttachmentsTests { let height = try #require(layoutManager.textLineForOffset(0)).height layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 0, end: 3)) - // With bug: this the line for offset 3 is > 0 because it wasn't updated for the new attachment. - #expect(layoutManager.textLineForOffset(0)?.height == height) - #expect(layoutManager.textLineForOffset(3)?.height == 0) + // With bug: the line for offset 3 would be the 2nd line (index 1). They should be merged + #expect(layoutManager.textLineForOffset(0)?.index == 0) + #expect(layoutManager.textLineForOffset(3)?.index == 0) } } diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index e065cb69c..d671ea6ff 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -3,6 +3,7 @@ import XCTest final class DemoTextAttachment: TextAttachment { var width: CGFloat + var isSelected: Bool = false init(width: CGFloat = 100) { self.width = width From 95740ca297d0e96770301bfe758395d83b383357 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:27:22 -0500 Subject: [PATCH 4/5] lint:fix --- .../TextAttachments/TextAttachmentManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index d74993d0f..dfa561d84 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -132,7 +132,7 @@ public final class TextAttachmentManager { } } } - + /// Set up the attachment manager to listen to selection updates, giving text attachments a chance to respond to /// selection state. /// From 76eb034aa57948b0aec4453c850c10d196e40380 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:40:28 -0500 Subject: [PATCH 5/5] Fix `rectsFor` --- .../TextLayoutManager/TextLayoutManager+Public.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 6a1a4df61..bca881d05 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -203,7 +203,7 @@ extension TextLayoutManager { /// - line: The line to calculate rects for. /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. public func rectsFor(range: NSRange) -> [CGRect] { - return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } + return linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } } /// Calculates all text bounding rects that intersect with a given range, with a given line position.