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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5807b02
Initial Work
thecoolwinter Apr 25, 2025
cd1cfd4
Introduce Layout Manager API
thecoolwinter Apr 25, 2025
af713a3
Handle Attachments In Layout Manager Iterator
thecoolwinter May 2, 2025
8d967da
Finish Tests, Fix Bugs
thecoolwinter May 3, 2025
0c39c02
Fix Iterator Bug, Add Some Tests, Document Method
thecoolwinter May 5, 2025
4f2ca59
Rename `attachments(` to `get(`, Clarify Internal Methods
thecoolwinter May 5, 2025
2622e51
Whole Bunch of Fixes and Tests
thecoolwinter May 5, 2025
4427899
Trailing Spaces
thecoolwinter May 5, 2025
2ef1f12
Rearrange Break Strategy into `DisplayData`
thecoolwinter May 5, 2025
d692ca6
Tests Compile, Still Need To Fix Overridden Heights
thecoolwinter May 5, 2025
1607f04
Fix Overridding Delegate
thecoolwinter May 5, 2025
639104a
Linter
thecoolwinter May 5, 2025
1d09ede
Fix Some Typesetting Bugs, Add `RangeIterator`
thecoolwinter May 5, 2025
d86b59d
Add Range Iterator Tests
thecoolwinter May 5, 2025
7d2c81c
Update Selections, Remove Demo Menu Item
thecoolwinter May 5, 2025
61bb469
Delete CodeEditTextViewExample.xcscheme
thecoolwinter May 5, 2025
b43306c
Rename `Box` to `Any`
thecoolwinter May 5, 2025
d0f16b8
Reorder
thecoolwinter May 5, 2025
f8e3fa5
Docs
thecoolwinter May 5, 2025
ccf2d7b
Docs, Make `attachments` a constant
thecoolwinter May 5, 2025
579cd0a
Remove String Reference on `Typesetter`.
thecoolwinter May 5, 2025
fdf2df1
Remove Bad `Equatable` Conformance
thecoolwinter May 5, 2025
40e2a0f
Remove `Buh`
thecoolwinter May 5, 2025
a23d196
Update LineFragmentTypesetContext.swift
thecoolwinter May 7, 2025
1c811fd
Update TypesetContext.swift
thecoolwinter May 7, 2025
85cf92d
FIx Infinite Loop When Zero-Width
thecoolwinter May 7, 2025
4d35e1b
Merge branch 'feat/text-attachment-support' of https://github.com/the…
thecoolwinter May 7, 2025
2486baa
Document Iterator Structs, Recursion Depth Limit
thecoolwinter May 7, 2025
c5c7e46
Remove Date Doc Comments
thecoolwinter May 7, 2025
ac763fb
Comment Too Long
thecoolwinter May 7, 2025
1090c23
Finish Cutoff Doc Comment
thecoolwinter May 7, 2025
ef4e68f
Move Unnecessary NSAttributedString Extension
thecoolwinter May 7, 2025
2163fae
Rename Similar Method Names For Clarity
thecoolwinter May 7, 2025
b335af7
Update Tests Target After Rename
thecoolwinter May 7, 2025
3645aab
Fix Small Positioning Bug
thecoolwinter May 8, 2025
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
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
BuildableName = "CodeEditTextViewExample.app"
BlueprintName = "CodeEditTextViewExample"
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
BuildableName = "CodeEditTextViewExample.app"
BlueprintName = "CodeEditTextViewExample"
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6CCDA27C2D306A1B007CD84A"
BuildableName = "CodeEditTextViewExample.app"
BlueprintName = "CodeEditTextViewExample"
ReferencedContainer = "container:CodeEditTextViewExample.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
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,21 @@ 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(
from: NSRange(location: 0, length: text.length),
documentAttributes: [
.documentType: NSAttributedString.DocumentType.plain,
.characterEncoding: NSUTF8StringEncoding
]
)
return .init(regularFileWithContents: data)
}
}
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