From cd2960bb3217576aeced168e1e1a8a683d5ea46f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:05:39 -0500 Subject: [PATCH 1/2] Add Actions to Text Attachments --- .../TextAttachments/TextAttachment.swift | 18 ++++++++++++ .../TextAttachmentManager.swift | 2 +- .../TextLayoutManager+Public.swift | 8 ++++++ .../TextSelectionManager.swift | 1 - .../TextView/TextView+Mouse.swift | 28 ++++++++++++++++++- 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index f3bc01209..e1f4363d0 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -7,11 +7,29 @@ import AppKit +public enum TextAttachmentAction { + /// Perform no action. + case none + /// Replace the attachment range with the given string. + case replace(text: String) + /// Discard the attachment and perform no other action, this is the default action. + case discard +} + /// 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) + + /// The action that should be performed when this attachment is invoked (double-click, enter pressed). + /// This method is optional, by default the attachment is discarded. + func attachmentAction() -> TextAttachmentAction +} + +public extension TextAttachment { + func attachmentAction() -> TextAttachmentAction { .discard } } /// Type-erasing type for ``TextAttachment`` that also contains range information about the attachment. diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index 9eb1cde24..7b4d0c0e5 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -102,7 +102,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 = orderedAttachments.firstIndex(where: { $0.range.upperBound >= range.location }) else { return [] } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 19f67793a..ef61dd6ed 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -339,4 +339,12 @@ extension TextLayoutManager { height: lineFragment.scaledHeight ).pixelAligned } + + func contentRun(at offset: Int) -> LineFragment.FragmentContent? { + guard let textLine = textLineForOffset(offset), + let fragment = textLine.data.lineFragments.getLine(atOffset: offset - textLine.range.location) else { + return nil + } + return fragment.data.findContent(at: offset - textLine.range.location - fragment.range.location)?.content + } } diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 2bc3d1d4c..f05168629 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -80,7 +80,6 @@ public class TextSelectionManager: NSObject { textSelections = [selection] updateSelectionViews() NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - delegate?.setNeedsDisplay() } /// Set the selected ranges to new ranges. Overrides any existing selections. diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 636ea472a..c401e3bc8 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -12,11 +12,17 @@ extension TextView { // Set cursor guard isSelectable, event.type == .leftMouseDown, - let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else { + let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)), + let content = layoutManager.contentRun(at: offset) else { super.mouseDown(with: event) return } + if case let .attachment(attachment) = content.data, event.clickCount < 3 { + handleAttachmentClick(event: event, offset: offset, attachment: attachment) + return + } + switch event.clickCount { case 1: handleSingleClick(event: event, offset: offset) @@ -76,6 +82,26 @@ extension TextView { selectLine(nil) } + fileprivate func handleAttachmentClick(event: NSEvent, offset: Int, attachment: AnyTextAttachment) { + switch event.clickCount { + case 1: + selectionManager.setSelectedRange(attachment.range) + case 2: + let action = attachment.attachment.attachmentAction() + switch action { + case .none: + return + case .discard: + layoutManager.attachments.remove(atOffset: offset) + selectionManager.setSelectedRange(NSRange(location: attachment.range.location, length: 0)) + case let .replace(text): + replaceCharacters(in: attachment.range, with: text) + } + default: + break + } + } + override public func mouseUp(with event: NSEvent) { mouseDragAnchor = nil disableMouseAutoscrollTimer() From de222822c5b0ac6b43f219a06129873a34408d8d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:14:06 -0500 Subject: [PATCH 2/2] Press Enter to Activate Attachment Action --- .../TextView/TextView+Insert.swift | 15 ++++++++++++ .../TextView/TextView+Mouse.swift | 24 +++++++++++-------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Insert.swift b/Sources/CodeEditTextView/TextView/TextView+Insert.swift index 8c4fc408e..05bd092a7 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Insert.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Insert.swift @@ -9,6 +9,21 @@ import AppKit extension TextView { override public func insertNewline(_ sender: Any?) { + var attachments: [AnyTextAttachment] = selectionManager.textSelections.compactMap({ selection in + let content = layoutManager.contentRun(at: selection.range.location) + if case let .attachment(attachment) = content?.data, attachment.range == selection.range { + return attachment + } + return nil + }) + + if !attachments.isEmpty { + for attachment in attachments.sorted(by: { $0.range.location > $1.range.location }) { + performAttachmentAction(attachment: attachment) + } + return + } + insertText(layoutManager.detectedLineEnding.rawValue) } diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index c401e3bc8..7ab49d799 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -87,21 +87,25 @@ extension TextView { case 1: selectionManager.setSelectedRange(attachment.range) case 2: - let action = attachment.attachment.attachmentAction() - switch action { - case .none: - return - case .discard: - layoutManager.attachments.remove(atOffset: offset) - selectionManager.setSelectedRange(NSRange(location: attachment.range.location, length: 0)) - case let .replace(text): - replaceCharacters(in: attachment.range, with: text) - } + performAttachmentAction(attachment: attachment) default: break } } + func performAttachmentAction(attachment: AnyTextAttachment) { + let action = attachment.attachment.attachmentAction() + switch action { + case .none: + return + case .discard: + layoutManager.attachments.remove(atOffset: attachment.range.location) + selectionManager.setSelectedRange(NSRange(location: attachment.range.location, length: 0)) + case let .replace(text): + replaceCharacters(in: attachment.range, with: text) + } + } + override public func mouseUp(with event: NSEvent) { mouseDragAnchor = nil disableMouseAutoscrollTimer()