wire compositor into app, replacing EditorTextView with CompositorRepresentable

This commit is contained in:
jess 2026-04-07 07:26:57 -07:00
parent b6a007490e
commit b4411cc33f
2 changed files with 540 additions and 49 deletions

View File

@ -353,82 +353,573 @@ class HRBlock: NSObject, CompositorBlock {
struct CompositorRepresentable: NSViewRepresentable {
@Binding var text: String
var evalResults: [Int: EvalEntry]
var fileFormat: FileFormat
var onEvaluate: () -> Void
var onBackspaceAtStart: (() -> Void)?
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.drawsBackground = true
scrollView.backgroundColor = Theme.current.base
let scrollView = NSTextView.scrollableTextView()
let defaultTV = scrollView.documentView as! NSTextView
let compositor = CompositorView(frame: scrollView.contentView.bounds)
compositor.scrollView = scrollView
compositor.autoresizingMask = [.width]
compositor.onContentHeightChanged = {
compositor.frame.size.height = compositor.contentHeight
let tc = defaultTV.textContainer!
tc.replaceLayoutManager(MarkdownLayoutManager())
let textView = LineNumberTextView(frame: defaultTV.frame, textContainer: tc)
textView.minSize = defaultTV.minSize
textView.maxSize = defaultTV.maxSize
textView.isVerticallyResizable = defaultTV.isVerticallyResizable
textView.isHorizontallyResizable = defaultTV.isHorizontallyResizable
textView.autoresizingMask = defaultTV.autoresizingMask
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
textView.isRichText = true
textView.usesFindBar = true
textView.isIncrementalSearchingEnabled = true
textView.font = Theme.editorFont
textView.textColor = Theme.current.text
textView.backgroundColor = Theme.current.base
textView.insertionPointColor = Theme.current.text
textView.selectedTextAttributes = [
.backgroundColor: Theme.current.surface1
]
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.smartInsertDeleteEnabled = false
textView.isAutomaticLinkDetectionEnabled = false
textView.textContainerInset = NSSize(width: 4, height: 8)
textView.textContainer?.widthTracksTextView = false
textView.registerForDraggedTypes([.fileURL])
scrollView.documentView = textView
textView.string = text
textView.evalResults = evalResults
textView.delegate = context.coordinator
context.coordinator.textView = textView
if let ts = textView.textStorage {
ts.beginEditing()
applySyntaxHighlighting(to: ts, format: fileFormat)
ts.endEditing()
}
textView.applyEvalSpacing()
textView.typingAttributes = [
.font: Theme.editorFont,
.foregroundColor: Theme.current.text
]
updateBlockRanges(for: textView)
scrollView.documentView = compositor
context.coordinator.compositor = compositor
context.coordinator.loadDocument(text)
DispatchQueue.main.async {
context.coordinator.triggerImageUpdate()
context.coordinator.triggerTableUpdate()
}
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
let coord = context.coordinator
if coord.lastSyncedText != text {
coord.loadDocument(text)
guard let textView = scrollView.documentView as? LineNumberTextView else { return }
if textView.string != text {
let selectedRanges = textView.selectedRanges
textView.string = text
if let ts = textView.textStorage {
ts.beginEditing()
applySyntaxHighlighting(to: ts, format: fileFormat)
ts.endEditing()
}
scrollView.backgroundColor = Theme.current.base
textView.selectedRanges = selectedRanges
updateBlockRanges(for: textView)
}
textView.backgroundColor = Theme.current.base
textView.insertionPointColor = Theme.current.text
textView.typingAttributes = [
.font: Theme.editorFont,
.foregroundColor: Theme.current.text
]
textView.evalResults = evalResults
textView.needsDisplay = true
}
class Coordinator {
class Coordinator: NSObject, NSTextViewDelegate {
var parent: CompositorRepresentable
var compositor: CompositorView?
var lastSyncedText: String = ""
weak var textView: NSTextView?
private var isUpdatingImages = false
private var isUpdatingTables = false
private var embeddedTableViews: [MarkdownTableView] = []
private var observers: [NSObjectProtocol] = []
init(_ parent: CompositorRepresentable) {
self.parent = parent
super.init()
observers.append(NotificationCenter.default.addObserver(
forName: .focusEditor, object: nil, queue: .main
) { [weak self] _ in
guard let tv = self?.textView else { return }
tv.window?.makeFirstResponder(tv)
tv.setSelectedRange(NSRange(location: 0, length: 0))
})
observers.append(NotificationCenter.default.addObserver(
forName: .formatDocument, object: nil, queue: .main
) { [weak self] _ in
self?.formatCurrentDocument()
})
observers.append(NotificationCenter.default.addObserver(
forName: .settingsChanged, object: nil, queue: .main
) { [weak self] _ in
guard let tv = self?.textView, let ts = tv.textStorage else { return }
let palette = Theme.current
tv.backgroundColor = palette.base
tv.insertionPointColor = palette.text
tv.selectedTextAttributes = [.backgroundColor: palette.surface1]
tv.typingAttributes = [
.font: Theme.editorFont,
.foregroundColor: palette.text
]
ts.beginEditing()
applySyntaxHighlighting(to: ts, format: self?.parent.fileFormat ?? .markdown)
ts.endEditing()
(tv as? LineNumberTextView)?.applyEvalSpacing()
tv.needsDisplay = true
})
observers.append(NotificationCenter.default.addObserver(
forName: .boldSelection, object: nil, queue: .main
) { [weak self] _ in
self?.wrapSelection(with: "**")
})
observers.append(NotificationCenter.default.addObserver(
forName: .italicizeSelection, object: nil, queue: .main
) { [weak self] _ in
self?.wrapSelection(with: "*")
})
observers.append(NotificationCenter.default.addObserver(
forName: .insertTable, object: nil, queue: .main
) { [weak self] _ in
self?.insertBlankTable()
})
observers.append(NotificationCenter.default.addObserver(
forName: .smartEval, object: nil, queue: .main
) { [weak self] _ in
self?.performSmartEval()
})
}
func loadDocument(_ markdown: String) {
guard let compositor = compositor else { return }
lastSyncedText = markdown
let descriptors = parseDocument(markdown)
var newBlocks: [CompositorBlock] = []
for desc in descriptors {
switch desc.type {
case .text:
let block = TextBlock(text: desc.content)
block.compositor = compositor
newBlocks.append(block)
case .horizontalRule:
newBlocks.append(HRBlock(sourceText: desc.content))
case .table:
// Stage 3 will add TableBlock; for now treat as text
let block = TextBlock(text: desc.content)
block.compositor = compositor
newBlocks.append(block)
deinit {
for obs in observers {
NotificationCenter.default.removeObserver(obs)
}
}
compositor.setBlocks(newBlocks)
func textDidChange(_ notification: Notification) {
guard let tv = textView, let ts = tv.textStorage else { return }
if isUpdatingImages || isUpdatingTables { return }
parent.text = tv.string
let sel = tv.selectedRanges
ts.beginEditing()
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing()
(tv as? LineNumberTextView)?.applyEvalSpacing()
tv.typingAttributes = [
.font: Theme.editorFont,
.foregroundColor: Theme.current.text
]
tv.selectedRanges = sel
updateBlockRanges(for: tv)
tv.needsDisplay = true
DispatchQueue.main.async { [weak self] in
self?.updateInlineImages()
self?.updateEmbeddedTables()
}
}
func syncToBinding() {
guard let compositor = compositor else { return }
let serialized = serializeDocument(compositor.blocks)
lastSyncedText = serialized
parent.text = serialized
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
insertNewlineWithAutoIndent(textView)
DispatchQueue.main.async { [weak self] in
self?.parent.onEvaluate()
}
return true
}
if commandSelector == #selector(NSResponder.deleteBackward(_:)) {
if textView.selectedRange().location == 0 && textView.selectedRange().length == 0 {
parent.onBackspaceAtStart?()
return true
}
}
return false
}
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool {
guard let text = text, text.count == 1 else { return true }
let ch = text.first!
let hasSelection = range.length > 0
if !hasSelection {
let closerChars: Set<Character> = ["}", ")", "]", "\"", "'"]
if closerChars.contains(ch) {
let str = textView.string as NSString
if range.location < str.length {
let next = Character(UnicodeScalar(str.character(at: range.location))!)
if next == ch {
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
return false
}
}
}
}
let pairClosers: [Character: Character] = ["{": "}", "(": ")", "[": "]"]
if let close = pairClosers[ch] {
if hasSelection {
let selected = (textView.string as NSString).substring(with: range)
textView.insertText(String(ch) + selected + String(close), replacementRange: range)
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
} else {
textView.insertText(String(ch) + String(close), replacementRange: range)
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
}
return false
}
if ch == "\"" || ch == "'" {
if hasSelection {
let selected = (textView.string as NSString).substring(with: range)
textView.insertText(String(ch) + selected + String(ch), replacementRange: range)
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
} else {
textView.insertText(String(ch) + String(ch), replacementRange: range)
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
}
return false
}
return true
}
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
var urlString: String?
if let url = link as? URL {
urlString = url.absoluteString
} else if let str = link as? String {
urlString = str
}
guard let str = urlString, let url = URL(string: str) else { return false }
NSWorkspace.shared.open(url)
return true
}
// MARK: - Editing helpers
private func formatCurrentDocument() {
guard let tv = textView else { return }
let format = parent.fileFormat
let text = tv.string
var formatted: String?
switch format {
case .json:
formatted = formatJSON(text)
default:
if format.isCode {
formatted = normalizeIndentation(text)
}
}
if let result = formatted, result != text {
let sel = tv.selectedRanges
tv.string = result
parent.text = result
if let ts = tv.textStorage {
ts.beginEditing()
applySyntaxHighlighting(to: ts, format: format)
ts.endEditing()
}
(tv as? LineNumberTextView)?.applyEvalSpacing()
tv.selectedRanges = sel
tv.needsDisplay = true
}
}
private func formatJSON(_ text: String) -> String? {
guard let data = text.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
let result = String(data: pretty, encoding: .utf8) else { return nil }
return result
}
private func normalizeIndentation(_ text: String) -> String {
let lines = text.components(separatedBy: "\n")
var result: [String] = []
var depth = 0
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
result.append("")
continue
}
let opens = trimmed.filter { "{([".contains($0) }.count
let closes = trimmed.filter { "})]".contains($0) }.count
let delta = opens - closes
if delta < 0 { depth = max(0, depth + delta) }
let indent = String(repeating: " ", count: depth)
result.append(indent + trimmed)
if delta > 0 { depth += delta }
}
return result.joined(separator: "\n")
}
private func insertNewlineWithAutoIndent(_ textView: NSTextView) {
let str = textView.string as NSString
let cursor = textView.selectedRange().location
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
let currentLine = str.substring(with: lineRange)
var indent = ""
for c in currentLine {
if c == " " || c == "\t" { indent.append(c) }
else { break }
}
let trimmed = currentLine.trimmingCharacters(in: .whitespacesAndNewlines)
let shouldIndent = trimmed.hasSuffix("{") || trimmed.hasSuffix(":") ||
trimmed.hasSuffix("do") || trimmed.hasSuffix("then") ||
trimmed.hasSuffix("(") || trimmed.hasSuffix("[")
if shouldIndent {
indent += " "
}
let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil
let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil
if let before = charBeforeCursor, let after = charAtCursor,
(before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") {
let baseIndent = indent.count >= 4 ? String(indent.dropLast(4)) : ""
let insertion = "\n" + indent + "\n" + baseIndent
textView.insertText(insertion, replacementRange: textView.selectedRange())
textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0))
return
}
textView.insertText("\n" + indent, replacementRange: textView.selectedRange())
}
private func wrapSelection(with wrapper: String) {
guard let tv = textView else { return }
let sel = tv.selectedRange()
guard sel.length > 0 else { return }
let str = tv.string as NSString
let selected = str.substring(with: sel)
let wrapped = wrapper + selected + wrapper
tv.insertText(wrapped, replacementRange: sel)
tv.setSelectedRange(NSRange(location: sel.location + wrapper.count, length: selected.count))
}
private func insertBlankTable() {
guard let tv = textView else { return }
let table = "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n"
tv.insertText(table, replacementRange: tv.selectedRange())
}
private func performSmartEval() {
guard let tv = textView else { return }
let str = tv.string as NSString
let cursor = tv.selectedRange().location
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
let line = str.substring(with: lineRange).trimmingCharacters(in: .newlines)
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("let ") {
if let eqIdx = trimmed.firstIndex(of: "="), eqIdx > trimmed.startIndex {
let afterLet = trimmed[trimmed.index(trimmed.startIndex, offsetBy: 4)..<eqIdx]
.trimmingCharacters(in: .whitespaces)
let insertion = "\n/= \(afterLet)\n"
let endOfLine = NSMaxRange(lineRange)
tv.insertText(insertion, replacementRange: NSRange(location: endOfLine, length: 0))
}
} else if !trimmed.isEmpty {
let lineStart = lineRange.location
let whitespacePrefix = line.prefix(while: { $0 == " " || $0 == "\t" })
let insertLoc = lineStart + whitespacePrefix.count
tv.insertText("/= ", replacementRange: NSRange(location: insertLoc, length: 0))
}
}
// MARK: - Images
func triggerImageUpdate() {
updateInlineImages()
}
func triggerTableUpdate() {
updateEmbeddedTables()
}
private static let imageRegex: NSRegularExpression? = {
try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)")
}()
private func updateInlineImages() {
guard let tv = textView, let ts = tv.textStorage else { return }
guard let regex = Coordinator.imageRegex else { return }
let text = ts.string
let nsText = text as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
let existingAttachmentRanges = findExistingImageAttachments(in: ts)
let matches = regex.matches(in: text, range: fullRange)
var resolvedPaths: [String] = []
for match in matches {
let urlRange = match.range(at: 1)
let rawPath = nsText.substring(with: urlRange)
if let resolved = resolveLocalImagePath(rawPath), FileManager.default.fileExists(atPath: resolved) {
resolvedPaths.append(resolved)
}
}
let neededSet = Set(resolvedPaths)
let existingSet = Set(existingAttachmentRanges.map { $0.1 })
if neededSet == existingSet { return }
isUpdatingImages = true
ts.beginEditing()
for (range, _) in existingAttachmentRanges.reversed() {
ts.deleteCharacters(in: range)
}
let recalcText = ts.string
let recalcNS = recalcText as NSString
let recalcFull = NSRange(location: 0, length: recalcNS.length)
let recalcMatches = regex.matches(in: recalcText, range: recalcFull)
var offset = 0
for match in recalcMatches {
let urlRange = NSRange(location: match.range(at: 1).location + offset, length: match.range(at: 1).length)
let rawPath = recalcNS.substring(with: NSRange(location: urlRange.location, length: urlRange.length))
guard let resolved = resolveLocalImagePath(rawPath),
FileManager.default.fileExists(atPath: resolved),
let image = NSImage(contentsOfFile: resolved) else { continue }
let maxWidth: CGFloat = min(600, tv.bounds.width - 40)
let ratio = image.size.width > maxWidth ? maxWidth / image.size.width : 1.0
let displaySize = NSSize(
width: image.size.width * ratio,
height: image.size.height * ratio
)
image.size = displaySize
let attachment = NSTextAttachment()
let cell = NSTextAttachmentCell(imageCell: image)
attachment.attachmentCell = cell
let attachStr = NSMutableAttributedString(string: "\n")
attachStr.append(NSAttributedString(attachment: attachment))
attachStr.addAttribute(.toolTip, value: resolved, range: NSRange(location: 1, length: 1))
let lineEnd = NSMaxRange(match.range) + offset
let insertAt = min(lineEnd, ts.length)
ts.insert(attachStr, at: insertAt)
offset += attachStr.length
}
ts.endEditing()
isUpdatingImages = false
}
private func findExistingImageAttachments(in textStorage: NSTextStorage) -> [(NSRange, String)] {
var results: [(NSRange, String)] = []
let fullRange = NSRange(location: 0, length: textStorage.length)
textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
if value is NSTextAttachment {
var extRange = range
if extRange.location > 0 {
let prev = NSRange(location: extRange.location - 1, length: 1)
let ch = (textStorage.string as NSString).substring(with: prev)
if ch == "\n" {
extRange = NSRange(location: prev.location, length: extRange.length + 1)
}
}
let tip = textStorage.attribute(.toolTip, at: range.location, effectiveRange: nil) as? String ?? ""
results.append((extRange, tip))
}
}
return results
}
// MARK: - Embedded tables
private func updateEmbeddedTables() {
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
let tc = tv.textContainer else { return }
for tableView in embeddedTableViews {
tableView.removeFromSuperview()
}
embeddedTableViews.removeAll()
let origin = tv.textContainerOrigin
let text = tv.string as NSString
for block in lm.blockRanges {
guard case .tableBlock = block.kind else { continue }
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
let tableX = origin.x + 4
let tableY = rect.origin.y + origin.y
let tableWidth = tc.containerSize.width - 8
let tableView = MarkdownTableView(table: parsed, width: tableWidth)
tableView.frame.origin = NSPoint(x: tableX, y: tableY)
tableView.textView = tv
tableView.onTableChanged = { [weak self] updatedTable in
self?.applyTableEdit(updatedTable)
}
tv.addSubview(tableView)
embeddedTableViews.append(tableView)
}
}
private func applyTableEdit(_ table: ParsedTable) {
guard let tv = textView, let ts = tv.textStorage else { return }
let newMarkdown = rebuildTableMarkdown(table)
let range = table.sourceRange
guard NSMaxRange(range) <= ts.length else { return }
isUpdatingTables = true
let sel = tv.selectedRanges
ts.beginEditing()
ts.replaceCharacters(in: range, with: newMarkdown)
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing()
(tv as? LineNumberTextView)?.applyEvalSpacing()
tv.selectedRanges = sel
parent.text = tv.string
updateBlockRanges(for: tv)
isUpdatingTables = false
}
}
}

View File

@ -1084,7 +1084,7 @@ struct EditorView: View {
}
var body: some View {
EditorTextView(
CompositorRepresentable(
text: bodyBinding,
evalResults: offsetEvalResults(state.evalResults),
fileFormat: state.currentFileFormat,
@ -2683,14 +2683,14 @@ private func rangeOverlapsAny(_ range: NSRange, ranges: [NSRange]) -> Bool {
// MARK: - Image Cache & Path Resolution
private let imageCacheDir: URL = {
let imageCacheDir: URL = {
let dir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".swiftly/images", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
private func resolveLocalImagePath(_ rawPath: String) -> String? {
func resolveLocalImagePath(_ rawPath: String) -> String? {
if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil }
let expanded: String
if rawPath.hasPrefix("~/") {