Skip to content

Commit d82b1c0

Browse files
Text Attachment Support (#93)
### Description Adds an API for creating "text attachments". Essentially, views that replace ranges of text and act as a single character in typesetting, layout, and selection. #### Detailed Changes Text layout consists of two steps: - Laying out whole lines - Typesetting line fragments The changes in this PR mostly consist of changes to the typesetting step. This step breaks down a line of text into fragments that fit into a constrained width. Text attachments are built by making 'runs' of content in that typesetting step. > These are intentionally kept separate from the text storage. If these modifications were in the storage object, they'd be shared between editors that share storage objects. Putting these in the layout system means that a user can fold lines in one editor, and view them normally in another. - Text attachments: - **New** `TextAttachment` protocol. A generic type that can draw it's contents in a line. - **New** `AnyTextAttachment` helps type-erase `any TextAttachment` and has a `range` for CETV to use. Very similar to `AnyHashable` or `AnyView`. - **New** `TextAttachmentManager` manages an ordered array of attachments, and manages hiding and showing text lines as needed, as well as invalidating layout when modifications happen. - `TextLayoutManager` changes: - Added a new `determineVisiblePosition` method. This method takes in a line position and returns a new (potentially larger) position by merging lines covered by attachments. This is the foundational method for merging lines that attachments cover. - Removing the existing `Iterator`. - Added two iterators, `YPositionIterator` and `RangeIterator` that iterate over a range of y positions and text offsets, respectively. These iterators are now used by the `layoutLines` method to merge lines that have attachments and not layout hidden lines. - Typesetting: - `Typesetter.swift` is marked as new, but that's because it's drastically changed. - `Typesetter` still performs typesetting on a text line, but it now takes into account attachments. It breaks the line into content runs, then calculates line fragments using those runs and a constrained width. - `TypesetContext` and `LineFragmentTypesetContext` represent partial parsing states while typesetting. They're both used once during typesetting and then discarded. Keeping them in their own structs makes `Typesetter` much more readable. - `CTLineTypesetData` was previously represented by a tuple, but a struct makes things clearer. It represents layout information received from a `CTTypesetter` for a `CTLine`. - Line break suggestion methods moved to a `CTTypesetter` extension. Each method was taking a `typesetter` argument, so moving to an extension makes them more ergonomic. - The only change to these methods was a change from passing a `startOffset` to instead pass a `subrange` that the typesetter finds line breaks in. - `LineFragment` - Line fragments now have to manage a series of content runs that can be either attachments or plain text. - Drawing methods have been updated to loop over runs and draw them. - Position fetching methods now take into account attachments as well as text positions. - Scroll view listeners - *this could have been a different PR but it's a small change, sorry!*. - Fixed up the way the text view found it's enclosing scroll view, and listens to scroll changes. #### Testing - Added typesetting tests for attachments. - Added layout manager tests. - Iteration - Invalidation - Attachments ### Related Issues * CodeEditApp/CodeEditSourceEditor#43 * CodeEditApp/CodeEditSourceEditor#287 * CodeEditApp/CodeEdit#1623 ### 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 > Demo menu item was a testing menu item, it either adds a demo attachment to the selected range, or removes selected attachments (if any are selected). It's not included in this PR. To test the changes like in the demo video replace `TextView+Menu.swift` with this: <details><summary>`TextView+Menu.swift`</summary> <p> ```swift // // TextView+Menu.swift // CodeEditTextView // // Created by Khan Winter on 8/21/23. // import AppKit extension TextView { override public func menu(for event: NSEvent) -> NSMenu? { guard event.type == .rightMouseDown else { return nil } let menu = NSMenu() menu.items = [ NSMenuItem(title: "Cut", action: #selector(cut(_:)), keyEquivalent: "x"), NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "c"), NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "v"), NSMenuItem(title: "Attach", action: #selector(toggleAttachmentAtSelection), keyEquivalent: "b") ] return menu } class DemoAttachment: TextAttachment { var width: CGFloat = 100 func draw(in context: CGContext, rect: NSRect) { context.setFillColor(NSColor.red.cgColor) context.fill(rect) } } @objc func toggleAttachmentAtSelection() { if layoutManager.attachments.get( startingIn: selectedRange() ).first?.range.location == selectedRange().location { layoutManager.attachments.remove(atOffset: selectedRange().location) } else { layoutManager.attachments.add(DemoAttachment(), for: selectedRange()) } } } ``` </p> </details> https://github.com/user-attachments/assets/b178fe13-d5d2-4e3d-aa55-f913df1d6c4b
1 parent e35d655 commit d82b1c0

29 files changed

+1661
-420
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1620"
4+
version = "1.7">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES"
8+
buildArchitectures = "Automatic">
9+
<BuildActionEntries>
10+
<BuildActionEntry
11+
buildForTesting = "YES"
12+
buildForRunning = "YES"
13+
buildForProfiling = "YES"
14+
buildForArchiving = "YES"
15+
buildForAnalyzing = "YES">
16+
<BuildableReference
17+
BuildableIdentifier = "primary"
18+
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
19+
BuildableName = "CodeEditTextViewExample.app"
20+
BlueprintName = "CodeEditTextViewExample"
21+
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
22+
</BuildableReference>
23+
</BuildActionEntry>
24+
</BuildActionEntries>
25+
</BuildAction>
26+
<TestAction
27+
buildConfiguration = "Debug"
28+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30+
shouldUseLaunchSchemeArgsEnv = "YES"
31+
shouldAutocreateTestPlan = "YES">
32+
</TestAction>
33+
<LaunchAction
34+
buildConfiguration = "Debug"
35+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
36+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
37+
launchStyle = "0"
38+
useCustomWorkingDirectory = "NO"
39+
ignoresPersistentStateOnLaunch = "NO"
40+
debugDocumentVersioning = "YES"
41+
debugServiceExtension = "internal"
42+
allowLocationSimulation = "YES">
43+
<BuildableProductRunnable
44+
runnableDebuggingMode = "0">
45+
<BuildableReference
46+
BuildableIdentifier = "primary"
47+
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
48+
BuildableName = "CodeEditTextViewExample.app"
49+
BlueprintName = "CodeEditTextViewExample"
50+
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
51+
</BuildableReference>
52+
</BuildableProductRunnable>
53+
</LaunchAction>
54+
<ProfileAction
55+
buildConfiguration = "Release"
56+
shouldUseLaunchSchemeArgsEnv = "YES"
57+
savedToolIdentifier = ""
58+
useCustomWorkingDirectory = "NO"
59+
debugDocumentVersioning = "YES">
60+
<BuildableProductRunnable
61+
runnableDebuggingMode = "0">
62+
<BuildableReference
63+
BuildableIdentifier = "primary"
64+
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
65+
BuildableName = "CodeEditTextViewExample.app"
66+
BlueprintName = "CodeEditTextViewExample"
67+
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
68+
</BuildableReference>
69+
</BuildableProductRunnable>
70+
</ProfileAction>
71+
<AnalyzeAction
72+
buildConfiguration = "Debug">
73+
</AnalyzeAction>
74+
<ArchiveAction
75+
buildConfiguration = "Release"
76+
revealArchiveInOrganizer = "YES">
77+
</ArchiveAction>
78+
</Scheme>

Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
import SwiftUI
99
import UniformTypeIdentifiers
1010

11-
struct CodeEditTextViewExampleDocument: FileDocument {
12-
var text: String
11+
struct CodeEditTextViewExampleDocument: FileDocument, @unchecked Sendable {
12+
var text: NSTextStorage
1313

1414
init(text: String = "") {
15-
self.text = text
15+
self.text = NSTextStorage(string: text)
1616
}
1717

1818
static var readableContentTypes: [UTType] {
@@ -25,11 +25,21 @@ struct CodeEditTextViewExampleDocument: FileDocument {
2525
guard let data = configuration.file.regularFileContents else {
2626
throw CocoaError(.fileReadCorruptFile)
2727
}
28-
text = String(bytes: data, encoding: .utf8) ?? ""
28+
text = try NSTextStorage(
29+
data: data,
30+
options: [.characterEncoding: NSUTF8StringEncoding, .fileType: NSAttributedString.DocumentType.plain],
31+
documentAttributes: nil
32+
)
2933
}
3034

3135
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
32-
let data = Data(text.utf8)
36+
let data = try text.data(
37+
from: NSRange(location: 0, length: text.length),
38+
documentAttributes: [
39+
.documentType: NSAttributedString.DocumentType.plain,
40+
.characterEncoding: NSUTF8StringEncoding
41+
]
42+
)
3343
return .init(regularFileWithContents: data)
3444
}
3545
}

Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ struct ContentView: View {
1919
Toggle("Inset Edges", isOn: $enableEdgeInsets)
2020
}
2121
Divider()
22-
SwiftUITextView(text: $document.text, wrapLines: $wrapLines, enableEdgeInsets: $enableEdgeInsets)
22+
SwiftUITextView(
23+
text: document.text,
24+
wrapLines: $wrapLines,
25+
enableEdgeInsets: $enableEdgeInsets
26+
)
2327
}
2428
}
2529
}

Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import AppKit
1010
import CodeEditTextView
1111

1212
struct SwiftUITextView: NSViewControllerRepresentable {
13-
@Binding var text: String
13+
var text: NSTextStorage
1414
@Binding var wrapLines: Bool
1515
@Binding var enableEdgeInsets: Bool
1616

1717
func makeNSViewController(context: Context) -> TextViewController {
18-
let controller = TextViewController(string: text)
19-
context.coordinator.controller = controller
18+
let controller = TextViewController(string: "")
19+
controller.textView.setTextStorage(text)
2020
controller.wrapLines = wrapLines
2121
controller.enableEdgeInsets = enableEdgeInsets
2222
return controller
@@ -26,39 +26,4 @@ struct SwiftUITextView: NSViewControllerRepresentable {
2626
nsViewController.wrapLines = wrapLines
2727
nsViewController.enableEdgeInsets = enableEdgeInsets
2828
}
29-
30-
func makeCoordinator() -> Coordinator {
31-
Coordinator(text: $text)
32-
}
33-
34-
@MainActor
35-
public class Coordinator: NSObject {
36-
weak var controller: TextViewController?
37-
var text: Binding<String>
38-
39-
init(text: Binding<String>) {
40-
self.text = text
41-
super.init()
42-
43-
NotificationCenter.default.addObserver(
44-
self,
45-
selector: #selector(textViewDidChangeText(_:)),
46-
name: TextView.textDidChangeNotification,
47-
object: nil
48-
)
49-
}
50-
51-
@objc func textViewDidChangeText(_ notification: Notification) {
52-
guard let textView = notification.object as? TextView,
53-
let controller,
54-
controller.textView === textView else {
55-
return
56-
}
57-
text.wrappedValue = textView.string
58-
}
59-
60-
deinit {
61-
NotificationCenter.default.removeObserver(self)
62-
}
63-
}
6429
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//
2+
// CTTypesetter+SuggestLineBreak.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 4/24/25.
6+
//
7+
8+
import AppKit
9+
10+
extension CTTypesetter {
11+
/// Suggest a line break for the given line break strategy.
12+
/// - Parameters:
13+
/// - typesetter: The typesetter to use.
14+
/// - strategy: The strategy that determines a valid line break.
15+
/// - startingOffset: Where to start breaking.
16+
/// - constrainingWidth: The available space for the line.
17+
/// - Returns: An offset relative to the entire string indicating where to break.
18+
func suggestLineBreak(
19+
using string: NSAttributedString,
20+
strategy: LineBreakStrategy,
21+
subrange: NSRange,
22+
constrainingWidth: CGFloat
23+
) -> Int {
24+
switch strategy {
25+
case .character:
26+
return suggestLineBreakForCharacter(
27+
string: string,
28+
startingOffset: subrange.location,
29+
constrainingWidth: constrainingWidth
30+
)
31+
case .word:
32+
return suggestLineBreakForWord(
33+
string: string,
34+
subrange: subrange,
35+
constrainingWidth: constrainingWidth
36+
)
37+
}
38+
}
39+
40+
/// Suggest a line break for the character break strategy.
41+
/// - Parameters:
42+
/// - typesetter: The typesetter to use.
43+
/// - startingOffset: Where to start breaking.
44+
/// - constrainingWidth: The available space for the line.
45+
/// - Returns: An offset relative to the entire string indicating where to break.
46+
private func suggestLineBreakForCharacter(
47+
string: NSAttributedString,
48+
startingOffset: Int,
49+
constrainingWidth: CGFloat
50+
) -> Int {
51+
var breakIndex: Int
52+
// Check if we need to skip to an attachment
53+
54+
breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth)
55+
guard breakIndex < string.length else {
56+
return breakIndex
57+
}
58+
let substring = string.attributedSubstring(from: NSRange(location: breakIndex - 1, length: 2)).string
59+
if substring == LineEnding.carriageReturnLineFeed.rawValue {
60+
// Breaking in the middle of the clrf line ending
61+
breakIndex += 1
62+
}
63+
64+
return breakIndex
65+
}
66+
67+
/// Suggest a line break for the word break strategy.
68+
/// - Parameters:
69+
/// - typesetter: The typesetter to use.
70+
/// - startingOffset: Where to start breaking.
71+
/// - constrainingWidth: The available space for the line.
72+
/// - Returns: An offset relative to the entire string indicating where to break.
73+
private func suggestLineBreakForWord(
74+
string: NSAttributedString,
75+
subrange: NSRange,
76+
constrainingWidth: CGFloat
77+
) -> Int {
78+
var breakIndex = subrange.location + CTTypesetterSuggestClusterBreak(self, subrange.location, constrainingWidth)
79+
let isBreakAtEndOfString = breakIndex >= subrange.max
80+
81+
let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string)
82+
if isNextCharacterCarriageReturn {
83+
breakIndex += 1
84+
}
85+
86+
let canLastCharacterBreak = (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1, for: string))
87+
88+
if isBreakAtEndOfString || canLastCharacterBreak {
89+
// Breaking either at the end of the string, or on a whitespace.
90+
return breakIndex
91+
} else if breakIndex - 1 > 0 {
92+
// Try to walk backwards until we hit a whitespace or punctuation
93+
var index = breakIndex - 1
94+
95+
while breakIndex - index < 100 && index > subrange.location {
96+
if ensureCharacterCanBreakLine(at: index, for: string) {
97+
return index + 1
98+
}
99+
index -= 1
100+
}
101+
}
102+
103+
return breakIndex
104+
}
105+
106+
/// Ensures the character at the given index can break a line.
107+
/// - Parameter index: The index to check at.
108+
/// - Returns: True, if the character is a whitespace or punctuation character.
109+
private func ensureCharacterCanBreakLine(at index: Int, for string: NSAttributedString) -> Bool {
110+
let subrange = (string.string as NSString).rangeOfComposedCharacterSequence(at: index)
111+
let set = CharacterSet(charactersIn: (string.string as NSString).substring(with: subrange))
112+
return set.isSubset(of: .whitespacesAndNewlines) || set.isSubset(of: .punctuationCharacters)
113+
}
114+
115+
/// Check if the break index is on a CRLF (`\r\n`) character, indicating a valid break position.
116+
/// - Parameter breakIndex: The index to check in the string.
117+
/// - Returns: True, if the break index lies after the `\n` character in a `\r\n` sequence.
118+
private func checkIfLineBreakOnCRLF(_ breakIndex: Int, for string: NSAttributedString) -> Bool {
119+
guard breakIndex - 1 > 0 && breakIndex + 1 <= string.length else {
120+
return false
121+
}
122+
let substringRange = NSRange(location: breakIndex - 1, length: 2)
123+
let substring = string.attributedSubstring(from: substringRange).string
124+
125+
return substring == LineEnding.carriageReturnLineFeed.rawValue
126+
}
127+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// TextAttachment.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 4/24/25.
6+
//
7+
8+
import AppKit
9+
10+
/// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view.
11+
public protocol TextAttachment: AnyObject {
12+
var width: CGFloat { get }
13+
func draw(in context: CGContext, rect: NSRect)
14+
}
15+
16+
/// Type-erasing type for ``TextAttachment`` that also contains range information about the attachment.
17+
///
18+
/// This type cannot be initialized outside of `CodeEditTextView`, but will be received when interrogating
19+
/// the ``TextAttachmentManager``.
20+
public struct AnyTextAttachment: Equatable {
21+
var range: NSRange
22+
let attachment: any TextAttachment
23+
24+
var width: CGFloat {
25+
attachment.width
26+
}
27+
28+
public static func == (_ lhs: AnyTextAttachment, _ rhs: AnyTextAttachment) -> Bool {
29+
lhs.range == rhs.range && lhs.attachment === rhs.attachment
30+
}
31+
}

0 commit comments

Comments
 (0)