Skip to content

Commit 5807b02

Browse files
committed
Initial Work
1 parent 2a6e7c7 commit 5807b02

File tree

14 files changed

+824
-345
lines changed

14 files changed

+824
-345
lines changed

Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift

Lines changed: 22 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,28 @@ 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+
)
33+
print(String(decoding: data, as: UTF8.self), text.string)
2934
}
3035

3136
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
32-
let data = Data(text.utf8)
37+
let data = try text.data(for: NSRange(location: 0, length: text.length))
3338
return .init(regularFileWithContents: data)
3439
}
3540
}
41+
42+
extension NSAttributedString {
43+
func data(for range: NSRange) throws -> Data {
44+
try data(
45+
from: range,
46+
documentAttributes: [
47+
.documentType: NSAttributedString.DocumentType.plain,
48+
.characterEncoding: NSUTF8StringEncoding
49+
]
50+
)
51+
}
52+
}

Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,19 @@ struct ContentView: View {
1717
HStack {
1818
Toggle("Wrap Lines", isOn: $wrapLines)
1919
Toggle("Inset Edges", isOn: $enableEdgeInsets)
20+
Button {
21+
22+
} label: {
23+
Text("Insert Attachment")
24+
}
25+
2026
}
2127
Divider()
22-
SwiftUITextView(text: $document.text, wrapLines: $wrapLines, enableEdgeInsets: $enableEdgeInsets)
28+
SwiftUITextView(
29+
text: document.text,
30+
wrapLines: $wrapLines,
31+
enableEdgeInsets: $enableEdgeInsets
32+
)
2333
}
2434
}
2535
}

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: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//
2+
// File.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+
startingOffset: Int,
22+
constrainingWidth: CGFloat
23+
) -> Int {
24+
switch strategy {
25+
case .character:
26+
return suggestLineBreakForCharacter(
27+
string: string,
28+
startingOffset: startingOffset,
29+
constrainingWidth: constrainingWidth
30+
)
31+
case .word:
32+
return suggestLineBreakForWord(
33+
string: string,
34+
startingOffset: startingOffset,
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+
startingOffset: Int,
76+
constrainingWidth: CGFloat
77+
) -> Int {
78+
var breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth)
79+
80+
let isBreakAtEndOfString = breakIndex >= string.length
81+
82+
let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string)
83+
if isNextCharacterCarriageReturn {
84+
breakIndex += 1
85+
}
86+
87+
let canLastCharacterBreak = (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1, for: string))
88+
89+
if isBreakAtEndOfString || canLastCharacterBreak {
90+
// Breaking either at the end of the string, or on a whitespace.
91+
return breakIndex
92+
} else if breakIndex - 1 > 0 {
93+
// Try to walk backwards until we hit a whitespace or punctuation
94+
var index = breakIndex - 1
95+
96+
while breakIndex - index < 100 && index > startingOffset {
97+
if ensureCharacterCanBreakLine(at: index, for: string) {
98+
return index + 1
99+
}
100+
index -= 1
101+
}
102+
}
103+
104+
return breakIndex
105+
}
106+
107+
/// Ensures the character at the given index can break a line.
108+
/// - Parameter index: The index to check at.
109+
/// - Returns: True, if the character is a whitespace or punctuation character.
110+
private func ensureCharacterCanBreakLine(at index: Int, for string: NSAttributedString) -> Bool {
111+
let set = CharacterSet(
112+
charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string
113+
)
114+
return set.isSubset(of: .whitespacesAndNewlines) || set.isSubset(of: .punctuationCharacters)
115+
}
116+
117+
/// Check if the break index is on a CRLF (`\r\n`) character, indicating a valid break position.
118+
/// - Parameter breakIndex: The index to check in the string.
119+
/// - Returns: True, if the break index lies after the `\n` character in a `\r\n` sequence.
120+
private func checkIfLineBreakOnCRLF(_ breakIndex: Int, for string: NSAttributedString) -> Bool {
121+
guard breakIndex - 1 > 0 && breakIndex + 1 <= string.length else {
122+
return false
123+
}
124+
let substringRange = NSRange(location: breakIndex - 1, length: 2)
125+
let substring = string.attributedSubstring(from: substringRange).string
126+
127+
return substring == LineEnding.carriageReturnLineFeed.rawValue
128+
}
129+
130+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// TextAttachmentManager.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 4/24/25.
6+
//
7+
8+
import Foundation
9+
10+
/// Manages a set of attachments for the layout manager, provides methods for efficiently finding attachments for a
11+
/// line range.
12+
///
13+
/// If two attachments are overlapping, the one placed further along in the document will be
14+
/// ignored when laying out attachments.
15+
public final class TextAttachmentManager {
16+
private var orderedAttachments: [TextAttachmentBox] = []
17+
18+
public func addAttachment(_ attachment: any TextAttachment, for range: NSRange) {
19+
let box = TextAttachmentBox(range: range, attachment: attachment)
20+
21+
// Insert new box into the ordered list.
22+
23+
}
24+
25+
/// Finds attachments for the given line range, and returns them as an array.
26+
/// Returned attachment's ranges will be relative to the _document_, not the line.
27+
public func attachments(forLineRange range: NSRange) -> [TextAttachmentBox] {
28+
// Use binary search to find start/end index
29+
30+
}
31+
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,40 +69,42 @@ extension TextLayoutManager {
6969
guard point.y <= estimatedHeight() else { // End position is a special case.
7070
return textStorage?.length
7171
}
72-
guard let position = lineStorage.getLine(atPosition: point.y),
73-
let fragmentPosition = position.data.typesetter.lineFragments.getLine(
74-
atPosition: point.y - position.yPos
72+
guard let linePosition = lineStorage.getLine(atPosition: point.y),
73+
let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
74+
atPosition: point.y - linePosition.yPos
7575
) else {
7676
return nil
7777
}
7878
let fragment = fragmentPosition.data
7979

8080
if fragment.width == 0 {
81-
return position.range.location + fragmentPosition.range.location
81+
return linePosition.range.location + fragmentPosition.range.location
8282
} else if fragment.width < point.x - edgeInsets.left {
83-
let fragmentRange = CTLineGetStringRange(fragment.ctLine)
84-
let globalFragmentRange = NSRange(
85-
location: position.range.location + fragmentRange.location,
86-
length: fragmentRange.length
87-
)
88-
let endPosition = position.range.location + fragmentRange.location + fragmentRange.length
83+
let fragmentRange = fragment.documentRange
84+
let endPosition = linePosition.range.location + fragmentRange.location + fragmentRange.length
8985

9086
// If the endPosition is at the end of the line, and the line ends with a line ending character
9187
// return the index before the eol.
92-
if endPosition == position.range.max,
93-
let lineEnding = LineEnding(line: textStorage?.substring(from: globalFragmentRange) ?? "") {
88+
if endPosition == linePosition.range.max,
89+
let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") {
9490
return endPosition - lineEnding.length
9591
} else {
9692
return endPosition
9793
}
98-
} else {
99-
// Somewhere in the fragment
100-
let fragmentIndex = CTLineGetStringIndexForPosition(
101-
fragment.ctLine,
102-
CGPoint(x: point.x - edgeInsets.left, y: fragment.height/2)
103-
)
104-
return position.range.location + fragmentIndex
94+
} else if let (content, contentPosition) = fragment.findContent(atX: point.x) {
95+
switch content.data {
96+
case .text(let ctLine):
97+
let fragmentIndex = CTLineGetStringIndexForPosition(
98+
ctLine,
99+
CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2)
100+
)
101+
return fragmentIndex + contentPosition.offset + linePosition.range.location
102+
case .attachment:
103+
return contentPosition.offset + linePosition.range.location
104+
}
105105
}
106+
107+
return nil
106108
}
107109

108110
// MARK: - Rect For Offset

0 commit comments

Comments
 (0)