Skip to content

Commit 8f02a6b

Browse files
Add Text Attachment Actions (#111)
### Description Adds actions to text attachments. Actions are performed in either of the following: - A selection's range exactly matches that of the attachment, and the enter key is pressed. - For multiple cursors, if any selections match, the action is taken on all matching selections and other behaviors are ignored. - An attachment is double-clicked. Attachments can return an action enum to indicate what the textview should do when the action is invoked. This allows for attachments to let the textview handle some common cases like discarding the attachment or replacing it with text, and allows attachments to perform their own logic at the same time. ### 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 Example in CodeEdit's folding attachments. https://github.com/user-attachments/assets/1bd670b4-08ed-463e-8f06-8b2fff7e0cbb
1 parent c1fed34 commit 8f02a6b

File tree

6 files changed

+73
-3
lines changed

6 files changed

+73
-3
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,29 @@
77

88
import AppKit
99

10+
public enum TextAttachmentAction {
11+
/// Perform no action.
12+
case none
13+
/// Replace the attachment range with the given string.
14+
case replace(text: String)
15+
/// Discard the attachment and perform no other action, this is the default action.
16+
case discard
17+
}
18+
1019
/// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view.
1120
public protocol TextAttachment: AnyObject {
1221
var width: CGFloat { get }
1322
var isSelected: Bool { get set }
23+
1424
func draw(in context: CGContext, rect: NSRect)
25+
26+
/// The action that should be performed when this attachment is invoked (double-click, enter pressed).
27+
/// This method is optional, by default the attachment is discarded.
28+
func attachmentAction() -> TextAttachmentAction
29+
}
30+
31+
public extension TextAttachment {
32+
func attachmentAction() -> TextAttachmentAction { .discard }
1533
}
1634

1735
/// Type-erasing type for ``TextAttachment`` that also contains range information about the attachment.

Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public final class TextAttachmentManager {
102102
/// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`.
103103
public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] {
104104
// Find the first attachment whose end is beyond the start of the query.
105-
guard let startIdx = firstIndex(where: { $0.range.upperBound >= range.location }) else {
105+
guard let startIdx = orderedAttachments.firstIndex(where: { $0.range.upperBound >= range.location }) else {
106106
return []
107107
}
108108

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,4 +339,12 @@ extension TextLayoutManager {
339339
height: lineFragment.scaledHeight
340340
).pixelAligned
341341
}
342+
343+
func contentRun(at offset: Int) -> LineFragment.FragmentContent? {
344+
guard let textLine = textLineForOffset(offset),
345+
let fragment = textLine.data.lineFragments.getLine(atOffset: offset - textLine.range.location) else {
346+
return nil
347+
}
348+
return fragment.data.findContent(at: offset - textLine.range.location - fragment.range.location)?.content
349+
}
342350
}

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ public class TextSelectionManager: NSObject {
8080
textSelections = [selection]
8181
updateSelectionViews()
8282
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
83-
delegate?.setNeedsDisplay()
8483
}
8584

8685
/// Set the selected ranges to new ranges. Overrides any existing selections.

Sources/CodeEditTextView/TextView/TextView+Insert.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ import AppKit
99

1010
extension TextView {
1111
override public func insertNewline(_ sender: Any?) {
12+
var attachments: [AnyTextAttachment] = selectionManager.textSelections.compactMap({ selection in
13+
let content = layoutManager.contentRun(at: selection.range.location)
14+
if case let .attachment(attachment) = content?.data, attachment.range == selection.range {
15+
return attachment
16+
}
17+
return nil
18+
})
19+
20+
if !attachments.isEmpty {
21+
for attachment in attachments.sorted(by: { $0.range.location > $1.range.location }) {
22+
performAttachmentAction(attachment: attachment)
23+
}
24+
return
25+
}
26+
1227
insertText(layoutManager.detectedLineEnding.rawValue)
1328
}
1429

Sources/CodeEditTextView/TextView/TextView+Mouse.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ extension TextView {
1212
// Set cursor
1313
guard isSelectable,
1414
event.type == .leftMouseDown,
15-
let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else {
15+
let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)),
16+
let content = layoutManager.contentRun(at: offset) else {
1617
super.mouseDown(with: event)
1718
return
1819
}
1920

21+
if case let .attachment(attachment) = content.data, event.clickCount < 3 {
22+
handleAttachmentClick(event: event, offset: offset, attachment: attachment)
23+
return
24+
}
25+
2026
switch event.clickCount {
2127
case 1:
2228
handleSingleClick(event: event, offset: offset)
@@ -76,6 +82,30 @@ extension TextView {
7682
selectLine(nil)
7783
}
7884

85+
fileprivate func handleAttachmentClick(event: NSEvent, offset: Int, attachment: AnyTextAttachment) {
86+
switch event.clickCount {
87+
case 1:
88+
selectionManager.setSelectedRange(attachment.range)
89+
case 2:
90+
performAttachmentAction(attachment: attachment)
91+
default:
92+
break
93+
}
94+
}
95+
96+
func performAttachmentAction(attachment: AnyTextAttachment) {
97+
let action = attachment.attachment.attachmentAction()
98+
switch action {
99+
case .none:
100+
return
101+
case .discard:
102+
layoutManager.attachments.remove(atOffset: attachment.range.location)
103+
selectionManager.setSelectedRange(NSRange(location: attachment.range.location, length: 0))
104+
case let .replace(text):
105+
replaceCharacters(in: attachment.range, with: text)
106+
}
107+
}
108+
79109
override public func mouseUp(with event: NSEvent) {
80110
mouseDragAnchor = nil
81111
disableMouseAutoscrollTimer()

0 commit comments

Comments
 (0)