Skip to content

Text Attachment Support #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
import SwiftUI
import UniformTypeIdentifiers

struct CodeEditTextViewExampleDocument: FileDocument {
var text: String
struct CodeEditTextViewExampleDocument: FileDocument, @unchecked Sendable {
var text: NSTextStorage

init(text: String = "") {
self.text = text
self.text = NSTextStorage(string: text)
}

static var readableContentTypes: [UTType] {
Expand All @@ -25,11 +25,27 @@ struct CodeEditTextViewExampleDocument: FileDocument {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
text = String(bytes: data, encoding: .utf8) ?? ""
text = try NSTextStorage(
data: data,
options: [.characterEncoding: NSUTF8StringEncoding, .fileType: NSAttributedString.DocumentType.plain],
documentAttributes: nil
)
}

func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = Data(text.utf8)
let data = try text.data(for: NSRange(location: 0, length: text.length))
return .init(regularFileWithContents: data)
}
}

extension NSAttributedString {
func data(for range: NSRange) throws -> Data {
try data(
from: range,
documentAttributes: [
.documentType: NSAttributedString.DocumentType.plain,
.characterEncoding: NSUTF8StringEncoding
]
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ struct ContentView: View {
Toggle("Inset Edges", isOn: $enableEdgeInsets)
}
Divider()
SwiftUITextView(text: $document.text, wrapLines: $wrapLines, enableEdgeInsets: $enableEdgeInsets)
SwiftUITextView(
text: document.text,
wrapLines: $wrapLines,
enableEdgeInsets: $enableEdgeInsets
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import AppKit
import CodeEditTextView

struct SwiftUITextView: NSViewControllerRepresentable {
@Binding var text: String
var text: NSTextStorage
@Binding var wrapLines: Bool
@Binding var enableEdgeInsets: Bool

func makeNSViewController(context: Context) -> TextViewController {
let controller = TextViewController(string: text)
context.coordinator.controller = controller
let controller = TextViewController(string: "")
controller.textView.setTextStorage(text)
controller.wrapLines = wrapLines
controller.enableEdgeInsets = enableEdgeInsets
return controller
Expand All @@ -26,39 +26,4 @@ struct SwiftUITextView: NSViewControllerRepresentable {
nsViewController.wrapLines = wrapLines
nsViewController.enableEdgeInsets = enableEdgeInsets
}

func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}

@MainActor
public class Coordinator: NSObject {
weak var controller: TextViewController?
var text: Binding<String>

init(text: Binding<String>) {
self.text = text
super.init()

NotificationCenter.default.addObserver(
self,
selector: #selector(textViewDidChangeText(_:)),
name: TextView.textDidChangeNotification,
object: nil
)
}

@objc func textViewDidChangeText(_ notification: Notification) {
guard let textView = notification.object as? TextView,
let controller,
controller.textView === textView else {
return
}
text.wrappedValue = textView.string
}

deinit {
NotificationCenter.default.removeObserver(self)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//
// CTTypesetter+SuggestLineBreak.swift
// CodeEditTextView
//
// Created by Khan Winter on 4/24/25.
//

import AppKit

extension CTTypesetter {
/// Suggest a line break for the given line break strategy.
/// - Parameters:
/// - typesetter: The typesetter to use.
/// - strategy: The strategy that determines a valid line break.
/// - startingOffset: Where to start breaking.
/// - constrainingWidth: The available space for the line.
/// - Returns: An offset relative to the entire string indicating where to break.
func suggestLineBreak(
using string: NSAttributedString,
strategy: LineBreakStrategy,
subrange: NSRange,
constrainingWidth: CGFloat
) -> Int {
switch strategy {
case .character:
return suggestLineBreakForCharacter(
string: string,
startingOffset: subrange.location,
constrainingWidth: constrainingWidth
)
case .word:
return suggestLineBreakForWord(
string: string,
subrange: subrange,
constrainingWidth: constrainingWidth
)
}
}

/// Suggest a line break for the character break strategy.
/// - Parameters:
/// - typesetter: The typesetter to use.
/// - startingOffset: Where to start breaking.
/// - constrainingWidth: The available space for the line.
/// - Returns: An offset relative to the entire string indicating where to break.
private func suggestLineBreakForCharacter(
string: NSAttributedString,
startingOffset: Int,
constrainingWidth: CGFloat
) -> Int {
var breakIndex: Int
// Check if we need to skip to an attachment

breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(self, startingOffset, constrainingWidth)
guard breakIndex < string.length else {
return breakIndex
}
let substring = string.attributedSubstring(from: NSRange(location: breakIndex - 1, length: 2)).string
if substring == LineEnding.carriageReturnLineFeed.rawValue {
// Breaking in the middle of the clrf line ending
breakIndex += 1
}

return breakIndex
}

/// Suggest a line break for the word break strategy.
/// - Parameters:
/// - typesetter: The typesetter to use.
/// - startingOffset: Where to start breaking.
/// - constrainingWidth: The available space for the line.
/// - Returns: An offset relative to the entire string indicating where to break.
private func suggestLineBreakForWord(
string: NSAttributedString,
subrange: NSRange,
constrainingWidth: CGFloat
) -> Int {
var breakIndex = subrange.location + CTTypesetterSuggestClusterBreak(self, subrange.location, constrainingWidth)
let isBreakAtEndOfString = breakIndex >= subrange.max

let isNextCharacterCarriageReturn = checkIfLineBreakOnCRLF(breakIndex, for: string)
if isNextCharacterCarriageReturn {
breakIndex += 1
}

let canLastCharacterBreak = (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1, for: string))

if isBreakAtEndOfString || canLastCharacterBreak {
// Breaking either at the end of the string, or on a whitespace.
return breakIndex
} else if breakIndex - 1 > 0 {
// Try to walk backwards until we hit a whitespace or punctuation
var index = breakIndex - 1

while breakIndex - index < 100 && index > subrange.location {
if ensureCharacterCanBreakLine(at: index, for: string) {
return index + 1
}
index -= 1
}
}

return breakIndex
}

/// Ensures the character at the given index can break a line.
/// - Parameter index: The index to check at.
/// - Returns: True, if the character is a whitespace or punctuation character.
private func ensureCharacterCanBreakLine(at index: Int, for string: NSAttributedString) -> Bool {
let subrange = (string.string as NSString).rangeOfComposedCharacterSequence(at: index)
let set = CharacterSet(charactersIn: (string.string as NSString).substring(with: subrange))
return set.isSubset(of: .whitespacesAndNewlines) || set.isSubset(of: .punctuationCharacters)
}

/// Check if the break index is on a CRLF (`\r\n`) character, indicating a valid break position.
/// - Parameter breakIndex: The index to check in the string.
/// - Returns: True, if the break index lies after the `\n` character in a `\r\n` sequence.
private func checkIfLineBreakOnCRLF(_ breakIndex: Int, for string: NSAttributedString) -> Bool {
guard breakIndex - 1 > 0 && breakIndex + 1 <= string.length else {
return false
}
let substringRange = NSRange(location: breakIndex - 1, length: 2)
let substring = string.attributedSubstring(from: substringRange).string

return substring == LineEnding.carriageReturnLineFeed.rawValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// TextAttachment.swift
// CodeEditTextView
//
// Created by Khan Winter on 4/24/25.
//

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 }
func draw(in context: CGContext, rect: NSRect)
}

/// Type-erasing type for ``TextAttachment`` that also contains range information about the attachment.
///
/// 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

var width: CGFloat {
attachment.width
}

public static func == (_ lhs: AnyTextAttachment, _ rhs: AnyTextAttachment) -> Bool {
lhs.range == rhs.range && lhs.attachment === rhs.attachment
}
}
Loading