Acord/src/CompositorView.swift

1413 lines
51 KiB
Swift

import SwiftUI
import AppKit
// MARK: - Block Protocol
protocol CompositorBlock: AnyObject {
var view: NSView { get }
var blockHeight: CGFloat { get }
func layoutBlock(width: CGFloat)
func becomeActiveBlock()
func resignActiveBlock()
}
// MARK: - CompositorView
class CompositorView: NSView {
var blocks: [CompositorBlock] = []
weak var scrollView: NSScrollView?
var onContentHeightChanged: (() -> Void)?
private var activeBlockIndex: Int? = nil
override var isFlipped: Bool { true }
func setBlocks(_ newBlocks: [CompositorBlock]) {
for block in blocks {
block.view.removeFromSuperview()
}
blocks = newBlocks
for block in blocks {
addSubview(block.view)
}
layoutAllBlocks()
}
func insertBlock(_ block: CompositorBlock, at index: Int) {
let clamped = min(index, blocks.count)
blocks.insert(block, at: clamped)
addSubview(block.view)
layoutBlocks(from: clamped)
}
func removeBlock(at index: Int) {
guard index < blocks.count else { return }
let block = blocks[index]
block.view.removeFromSuperview()
blocks.remove(at: index)
if activeBlockIndex == index {
activeBlockIndex = nil
} else if let active = activeBlockIndex, active > index {
activeBlockIndex = active - 1
}
layoutBlocks(from: index)
}
func layoutAllBlocks() {
layoutBlocks(from: 0)
}
func layoutBlocks(from startIndex: Int) {
let width = bounds.width
var y: CGFloat = startIndex > 0
? blocks[startIndex - 1].view.frame.maxY
: 0
for i in startIndex..<blocks.count {
let block = blocks[i]
block.layoutBlock(width: width)
block.view.frame = NSRect(x: 0, y: y, width: width, height: block.blockHeight)
y += block.blockHeight
}
let totalHeight = y
if frame.height != totalHeight {
frame.size.height = totalHeight
onContentHeightChanged?()
}
}
func blockDidResize(_ block: CompositorBlock) {
guard let idx = blocks.firstIndex(where: { $0 === block }) else { return }
layoutBlocks(from: idx)
}
override func resizeSubviews(withOldSize oldSize: NSSize) {
super.resizeSubviews(withOldSize: oldSize)
if oldSize.width != bounds.width {
layoutAllBlocks()
}
}
// MARK: - Focus Routing
func activateBlock(at index: Int) {
guard index >= 0, index < blocks.count else { return }
if let prev = activeBlockIndex, prev < blocks.count {
blocks[prev].resignActiveBlock()
}
activeBlockIndex = index
blocks[index].becomeActiveBlock()
}
func activateNextBlock() {
let next = (activeBlockIndex ?? -1) + 1
if next < blocks.count {
activateBlock(at: next)
}
}
func activatePreviousBlock() {
let prev = (activeBlockIndex ?? blocks.count) - 1
if prev >= 0 {
activateBlock(at: prev)
}
}
var contentHeight: CGFloat {
blocks.last.map { $0.view.frame.maxY } ?? 0
}
}
// MARK: - TextBlock
class TextBlock: NSObject, CompositorBlock, NSTextViewDelegate {
let textView: NSTextView
private let textContainer: NSTextContainer
private let layoutManager: NSLayoutManager
private let textStorage: NSTextStorage
weak var compositor: CompositorView?
var view: NSView { textView }
var blockHeight: CGFloat {
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
return max(usedRect.height + textView.textContainerInset.height * 2, 24)
}
var text: String {
get { textView.string }
set { textView.string = newValue }
}
override init() {
textStorage = NSTextStorage()
layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
layoutManager.addTextContainer(textContainer)
textView = NSTextView(frame: .zero, textContainer: textContainer)
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
textView.isRichText = false
textView.isVerticallyResizable = false
textView.isHorizontallyResizable = false
textView.font = Theme.editorFont
textView.textColor = Theme.current.text
textView.backgroundColor = .clear
textView.drawsBackground = false
textView.insertionPointColor = Theme.current.text
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.smartInsertDeleteEnabled = false
textView.isAutomaticLinkDetectionEnabled = false
textView.textContainerInset = NSSize(width: 4, height: 4)
super.init()
textView.delegate = self
}
convenience init(text: String) {
self.init()
textView.string = text
}
func layoutBlock(width: CGFloat) {
textContainer.size = NSSize(width: max(width - 8, 1), height: CGFloat.greatestFiniteMagnitude)
layoutManager.ensureLayout(for: textContainer)
}
func becomeActiveBlock() {
textView.window?.makeFirstResponder(textView)
}
func resignActiveBlock() {
if textView.window?.firstResponder === textView {
textView.window?.makeFirstResponder(nil)
}
}
// MARK: - NSTextViewDelegate
func textDidChange(_ notification: Notification) {
compositor?.blockDidResize(self)
}
}
// MARK: - Document Model
enum BlockType {
case text
case table
case horizontalRule
}
struct BlockDescriptor {
let type: BlockType
let content: String
}
func parseDocument(_ markdown: String) -> [BlockDescriptor] {
let lines = markdown.components(separatedBy: "\n")
var descriptors: [BlockDescriptor] = []
var currentTextLines: [String] = []
func flushText() {
if !currentTextLines.isEmpty {
let content = currentTextLines.joined(separator: "\n")
descriptors.append(BlockDescriptor(type: .text, content: content))
currentTextLines = []
}
}
var i = 0
while i < lines.count {
let line = lines[i]
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Horizontal rule: line is only dashes/spaces, at least 3 dashes
if isHorizontalRuleLine(trimmed) {
flushText()
descriptors.append(BlockDescriptor(type: .horizontalRule, content: line))
i += 1
continue
}
// Table: starts with | and next line is a separator row
if trimmed.hasPrefix("|") && i + 1 < lines.count {
let nextTrimmed = lines[i + 1].trimmingCharacters(in: .whitespaces)
if isDocTableSeparator(nextTrimmed) {
flushText()
var tableLines: [String] = [line, lines[i + 1]]
var j = i + 2
while j < lines.count {
let rowTrimmed = lines[j].trimmingCharacters(in: .whitespaces)
if rowTrimmed.hasPrefix("|") && rowTrimmed.hasSuffix("|") {
tableLines.append(lines[j])
j += 1
} else {
break
}
}
let content = tableLines.joined(separator: "\n")
descriptors.append(BlockDescriptor(type: .table, content: content))
i = j
continue
}
}
currentTextLines.append(line)
i += 1
}
flushText()
if descriptors.isEmpty {
descriptors.append(BlockDescriptor(type: .text, content: ""))
}
return descriptors
}
func serializeDocument(_ blocks: [CompositorBlock]) -> String {
var parts: [String] = []
for block in blocks {
if let textBlock = block as? TextBlock {
parts.append(textBlock.text)
} else if let hrBlock = block as? HRBlock {
parts.append(hrBlock.sourceText)
} else if let tableBlock = block as? TableBlock {
parts.append(rebuildTableMarkdown(tableBlock.table))
} else {
parts.append("")
}
}
return parts.joined(separator: "\n")
}
private func isHorizontalRuleLine(_ trimmed: String) -> Bool {
guard !trimmed.isEmpty else { return false }
let stripped = trimmed.replacingOccurrences(of: " ", with: "")
guard stripped.count >= 3 else { return false }
return stripped.allSatisfy { $0 == "-" }
}
private func isDocTableSeparator(_ trimmed: String) -> Bool {
guard trimmed.hasPrefix("|") else { return false }
let inner = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "| "))
guard !inner.isEmpty else { return false }
let cells = inner.components(separatedBy: "|")
return cells.allSatisfy { cell in
let c = cell.trimmingCharacters(in: .whitespaces)
return c.allSatisfy { $0 == "-" || $0 == ":" } && c.contains("-")
}
}
// MARK: - HRBlock
class HRDrawingView: NSView {
override var isFlipped: Bool { true }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
let y = bounds.midY
let path = NSBezierPath()
path.move(to: NSPoint(x: 16, y: y))
path.line(to: NSPoint(x: bounds.width - 16, y: y))
path.lineWidth = 1
Theme.current.overlay0.setStroke()
path.stroke()
}
}
class HRBlock: NSObject, CompositorBlock {
let sourceText: String
var sourceRange: NSRange
private let hrView: HRDrawingView
var view: NSView { hrView }
var blockHeight: CGFloat { 20 }
init(sourceText: String = "---", sourceRange: NSRange = NSRange(location: 0, length: 0)) {
self.sourceText = sourceText
self.sourceRange = sourceRange
hrView = HRDrawingView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
super.init()
}
func layoutBlock(width: CGFloat) {}
func becomeActiveBlock() {}
func resignActiveBlock() {}
}
class FlippedBlockView: NSView {
override var isFlipped: Bool { true }
}
// MARK: - TableBlockView
class TableBlockView: FlippedBlockView {
weak var tableBlock: TableBlock?
private var trackingArea: NSTrackingArea?
private var addColumnButton: NSButton?
override var isFlipped: Bool { true }
private enum DragMode { case none, column(Int) }
private var dragMode: DragMode = .none
private var dragStartX: CGFloat = 0
private var dragStartWidth: CGFloat = 0
private let dividerHitZone: CGFloat = 4
func setupTracking() {
if let old = trackingArea { removeTrackingArea(old) }
let area = NSTrackingArea(
rect: bounds,
options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
owner: self,
userInfo: nil
)
addTrackingArea(area)
trackingArea = area
}
func updateAddButton() {
addColumnButton?.removeFromSuperview()
guard let tb = tableBlock else { return }
let gridW = tb.totalGridWidth
let th = tb.totalGridHeight
let btn = NSButton(frame: NSRect(x: gridW + 2, y: 0, width: 20, height: min(th, 28)))
btn.title = "+"
btn.bezelStyle = .inline
btn.font = NSFont.systemFont(ofSize: 12, weight: .medium)
btn.isBordered = false
btn.wantsLayer = true
btn.layer?.backgroundColor = Theme.current.surface0.cgColor
btn.layer?.cornerRadius = 3
btn.contentTintColor = Theme.current.overlay2
btn.target = self
btn.action = #selector(addColumnClicked)
btn.alphaValue = 0
addSubview(btn)
addColumnButton = btn
}
@objc private func addColumnClicked() {
tableBlock?.addColumn()
}
override func mouseEntered(with event: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.15
addColumnButton?.animator().alphaValue = 1
}
}
override func mouseExited(with event: NSEvent) {
NSCursor.arrow.set()
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.15
addColumnButton?.animator().alphaValue = 0
}
dragMode = .none
}
override func mouseMoved(with event: NSEvent) {
let pt = convert(event.locationInWindow, from: nil)
if dividerColumn(at: pt) != nil {
NSCursor.resizeLeftRight.set()
} else {
NSCursor.arrow.set()
}
}
override func mouseDown(with event: NSEvent) {
let pt = convert(event.locationInWindow, from: nil)
if let col = dividerColumn(at: pt), let tb = tableBlock {
dragMode = .column(col)
dragStartX = pt.x
dragStartWidth = tb.columnWidths[col]
return
}
dragMode = .none
super.mouseDown(with: event)
}
override func mouseDragged(with event: NSEvent) {
let pt = convert(event.locationInWindow, from: nil)
switch dragMode {
case .column(let col):
guard let tb = tableBlock else { return }
let delta = pt.x - dragStartX
let newWidth = max(tb.minColWidth, min(dragStartWidth + delta, tb.maxColWidth))
tb.setColumnWidth(col, to: newWidth)
case .none:
super.mouseDragged(with: event)
}
}
override func mouseUp(with event: NSEvent) {
dragMode = .none
}
private func dividerColumn(at pt: NSPoint) -> Int? {
guard let tb = tableBlock else { return nil }
let colCount = tb.table.headers.count
for i in 1...colCount {
let divX = tb.columnX(for: i) - 1
if abs(pt.x - divX) <= dividerHitZone { return i - 1 }
}
return nil
}
}
// MARK: - TableBlock
class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
var table: ParsedTable
var sourceRange: NSRange
var onTableChanged: ((ParsedTable, NSRange) -> Void)?
private let containerView: TableBlockView
fileprivate var cellFields: [[NSTextField]] = []
fileprivate var columnWidths: [CGFloat] = []
private var customWidths: [Int: CGFloat] = [:]
private var rowHeights: [CGFloat] = []
let minColWidth: CGFloat = 60
let maxColWidth: CGFloat = 300
private let cellPadding: CGFloat = 16
private let defaultHeaderHeight: CGFloat = 28
private let defaultCellHeight: CGFloat = 26
var view: NSView { containerView }
var blockHeight: CGFloat {
rowHeights.reduce(0, +) + 2
}
var totalGridWidth: CGFloat {
columnX(for: table.headers.count) + 1
}
var totalGridHeight: CGFloat {
rowHeights.reduce(0, +)
}
init(table: ParsedTable, width: CGFloat) {
self.table = table
self.sourceRange = table.sourceRange
containerView = TableBlockView(frame: .zero)
containerView.wantsLayer = true
containerView.layer?.backgroundColor = Theme.current.base.cgColor
containerView.layer?.cornerRadius = 4
super.init()
containerView.tableBlock = self
initSizes()
buildGrid()
}
// MARK: - Sizing
private func measureColumnWidth(_ col: Int) -> CGFloat {
let headerFont = NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
let cellFont = Theme.editorFont
var maxW: CGFloat = 0
if col < table.headers.count {
let w = (table.headers[col] as NSString).size(withAttributes: [.font: headerFont]).width
maxW = max(maxW, w)
}
for row in table.rows {
guard col < row.count else { continue }
let w = (row[col] as NSString).size(withAttributes: [.font: cellFont]).width
maxW = max(maxW, w)
}
return min(max(maxW + cellPadding * 2, minColWidth), maxColWidth)
}
private func initSizes() {
let colCount = table.headers.count
guard colCount > 0 else { return }
columnWidths = (0..<colCount).map { col in
customWidths[col] ?? measureColumnWidth(col)
}
rowHeights = [defaultHeaderHeight]
for _ in 0..<table.rows.count {
rowHeights.append(defaultCellHeight)
}
}
func columnX(for col: Int) -> CGFloat {
var x: CGFloat = 1
for i in 0..<col { x += columnWidths[i] + 1 }
return x
}
func setColumnWidth(_ col: Int, to width: CGFloat) {
guard col >= 0, col < columnWidths.count else { return }
columnWidths[col] = width
customWidths[col] = width
buildGrid()
}
func layoutBlock(width: CGFloat) {}
func becomeActiveBlock() {}
func resignActiveBlock() {}
// MARK: - Add column
func addColumn() {
table.headers.append("Header")
table.alignments.append(.left)
for i in 0..<table.rows.count {
table.rows[i].append("")
}
initSizes()
buildGrid()
onTableChanged?(table, sourceRange)
}
// MARK: - Grid
private func buildGrid() {
containerView.subviews.forEach { $0.removeFromSuperview() }
cellFields = []
let colCount = table.headers.count
guard colCount > 0 else { return }
let th = rowHeights.reduce(0, +)
let gridW = totalGridWidth
let addBtnSpace: CGFloat = 24
containerView.frame.size = NSSize(width: gridW + addBtnSpace, height: th + 2)
let headerBg = NSView(frame: NSRect(x: 0, y: 0, width: gridW, height: rowHeights[0]))
headerBg.wantsLayer = true
headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor
containerView.addSubview(headerBg)
var headerFields: [NSTextField] = []
for (col, header) in table.headers.enumerated() {
let x = columnX(for: col)
let h = rowHeights[0]
let field = makeCell(text: header, frame: NSRect(x: x, y: 2, width: columnWidths[col], height: h - 4),
isHeader: true, row: -1, col: col)
containerView.addSubview(field)
headerFields.append(field)
}
cellFields.append(headerFields)
var yOffset = rowHeights[0]
for (rowIdx, row) in table.rows.enumerated() {
var rowFields: [NSTextField] = []
let h = rowHeights[rowIdx + 1]
for col in 0..<colCount {
let cellText = col < row.count ? row[col] : ""
let x = columnX(for: col)
let field = makeCell(text: cellText, frame: NSRect(x: x, y: yOffset + 2, width: columnWidths[col], height: h - 4),
isHeader: false, row: rowIdx, col: col)
containerView.addSubview(field)
rowFields.append(field)
}
cellFields.append(rowFields)
yOffset += h
}
for i in 1..<colCount {
let x = columnX(for: i) - 1
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: th))
line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor
containerView.addSubview(line)
}
var divY: CGFloat = rowHeights[0]
for i in 1..<rowHeights.count {
let line = NSView(frame: NSRect(x: 0, y: divY, width: gridW, height: 1))
line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor
containerView.addSubview(line)
divY += rowHeights[i]
}
containerView.setupTracking()
containerView.updateAddButton()
}
private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> NSTextField {
let field = NSTextField(frame: frame)
field.stringValue = text
field.isEditable = true
field.isSelectable = true
field.isBordered = false
field.isBezeled = false
field.drawsBackground = false
field.wantsLayer = true
field.font = isHeader
? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
: Theme.editorFont
field.textColor = Theme.current.text
field.focusRingType = .none
field.cell?.truncatesLastVisibleLine = true
field.cell?.usesSingleLineMode = true
field.tag = (row + 1) * 1000 + col
field.delegate = self
if let align = table.alignments[safe: col] {
switch align {
case .left: field.alignment = .left
case .center: field.alignment = .center
case .right: field.alignment = .right
}
}
return field
}
// MARK: - Cell editing
func controlTextDidEndEditing(_ obj: Notification) {
guard let field = obj.object as? NSTextField else { return }
let tag = field.tag
let row = tag / 1000 - 1
let col = tag % 1000
if row == -1 {
guard col < table.headers.count else { return }
table.headers[col] = field.stringValue
} else {
guard row < table.rows.count, col < table.rows[row].count else { return }
table.rows[row][col] = field.stringValue
}
onTableChanged?(table, sourceRange)
if let movement = obj.userInfo?["NSTextMovement"] as? Int, movement == NSTabTextMovement {
let nextCol = col + 1
if nextCol >= table.headers.count {
let isLastRow = (row == table.rows.count - 1) || (row == -1 && table.rows.isEmpty)
if isLastRow {
addColumn()
let fieldRow = row + 1
if fieldRow < cellFields.count {
let newCol = table.headers.count - 1
if newCol < cellFields[fieldRow].count {
containerView.window?.makeFirstResponder(cellFields[fieldRow][newCol])
}
}
} else {
let fieldRow = row + 2
if fieldRow < cellFields.count, !cellFields[fieldRow].isEmpty {
containerView.window?.makeFirstResponder(cellFields[fieldRow][0])
}
}
return
}
let fieldRow = row + 1
if fieldRow < cellFields.count, nextCol < cellFields[fieldRow].count {
containerView.window?.makeFirstResponder(cellFields[fieldRow][nextCol])
}
}
}
func control(_ control: NSControl, textView tv: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
table.rows.append(Array(repeating: "", count: table.headers.count))
rowHeights.append(defaultCellHeight)
buildGrid()
onTableChanged?(table, sourceRange)
return true
}
return false
}
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
// MARK: - CompositorRepresentable
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 = NSTextView.scrollableTextView()
let defaultTV = scrollView.documentView as! NSTextView
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)
context.coordinator.triggerImageUpdate()
context.coordinator.triggerTableUpdate()
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
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()
}
textView.selectedRanges = selectedRanges
updateBlockRanges(for: textView)
context.coordinator.triggerTableUpdate()
}
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: NSObject, NSTextViewDelegate {
var parent: CompositorRepresentable
weak var textView: NSTextView?
private var isUpdatingImages = false
private var isUpdatingTables = false
private var tableBlocks: [TableBlock] = []
private var hrBlocks: [HRBlock] = []
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()
})
}
deinit {
for obs in observers {
NotificationCenter.default.removeObserver(obs)
}
}
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
updateInlineImages()
updateEmbeddedTables()
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
insertNewlineWithAutoIndent(textView)
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: - Block positioning
private func updateEmbeddedTables() {
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
let tc = tv.textContainer, let ts = tv.textStorage else { return }
for tb in tableBlocks { tb.view.removeFromSuperview() }
for hb in hrBlocks { hb.view.removeFromSuperview() }
tableBlocks.removeAll()
hrBlocks.removeAll()
let origin = tv.textContainerOrigin
let text = tv.string as NSString
guard text.length > 0 else { return }
lm.ensureLayout(for: tc)
var blockSpacings: [(NSRange, CGFloat)] = []
for block in lm.blockRanges {
switch block.kind {
case .tableBlock:
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
let tb = TableBlock(table: parsed, width: 0)
tb.onTableChanged = { [weak self] updatedTable, range in
self?.applyTableEdit(updatedTable, sourceRange: range)
}
blockSpacings.append((block.range, tb.blockHeight))
tableBlocks.append(tb)
case .horizontalRule:
let src = text.substring(with: block.range)
let hb = HRBlock(sourceText: src, sourceRange: block.range)
blockSpacings.append((block.range, hb.blockHeight))
hrBlocks.append(hb)
default:
break
}
}
// Collapse source lines and create gaps via paragraph spacing
isUpdatingTables = true
let tinyFont = NSFont.systemFont(ofSize: 0.1)
ts.beginEditing()
for (range, height) in blockSpacings {
// Make source text invisible: tiny font, zero foreground alpha
ts.addAttribute(.font, value: tinyFont, range: range)
ts.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
// Collapse line heights and add spacing for the block view
let para = NSMutableParagraphStyle()
para.minimumLineHeight = 0.1
para.maximumLineHeight = 0.1
para.lineSpacing = 0
para.paragraphSpacing = 0
para.paragraphSpacingBefore = 0
ts.addAttribute(.paragraphStyle, value: para, range: range)
// On the last line, add paragraph spacing equal to block height
let lastLineRange = text.lineRange(for: NSRange(location: max(0, NSMaxRange(range) - 1), length: 0))
let lastPara = NSMutableParagraphStyle()
lastPara.minimumLineHeight = 0.1
lastPara.maximumLineHeight = 0.1
lastPara.lineSpacing = 0
lastPara.paragraphSpacing = height
lastPara.paragraphSpacingBefore = 0
ts.addAttribute(.paragraphStyle, value: lastPara, range: lastLineRange)
}
ts.endEditing()
isUpdatingTables = false
lm.ensureLayout(for: tc)
// Position blocks at the origin of their collapsed source text
var tableIdx = 0
var hrIdx = 0
for block in lm.blockRanges {
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
let x = origin.x + 4
switch block.kind {
case .tableBlock:
guard tableIdx < tableBlocks.count else { continue }
let tb = tableBlocks[tableIdx]
tableIdx += 1
let tableW = tb.view.frame.width
tb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: tableW, height: tb.blockHeight)
tv.addSubview(tb.view)
case .horizontalRule:
guard hrIdx < hrBlocks.count else { continue }
let hb = hrBlocks[hrIdx]
hrIdx += 1
let w = tc.containerSize.width - 8
hb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: w, height: hb.blockHeight)
tv.addSubview(hb.view)
default:
break
}
}
}
private func applyTableEdit(_ table: ParsedTable, sourceRange: NSRange) {
guard let tv = textView, let ts = tv.textStorage else { return }
let newMarkdown = rebuildTableMarkdown(table)
guard NSMaxRange(sourceRange) <= ts.length else { return }
isUpdatingTables = true
let sel = tv.selectedRanges
ts.beginEditing()
ts.replaceCharacters(in: sourceRange, with: newMarkdown)
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
// Collapse new source text and set paragraph spacing for updated table height
let newRange = NSRange(location: sourceRange.location, length: (newMarkdown as NSString).length)
let blockHeight: CGFloat = 28 + CGFloat(table.rows.count) * 26 + 2
let tinyFont = NSFont.systemFont(ofSize: 0.1)
ts.addAttribute(.font, value: tinyFont, range: newRange)
ts.addAttribute(.foregroundColor, value: NSColor.clear, range: newRange)
let collapsePara = NSMutableParagraphStyle()
collapsePara.minimumLineHeight = 0.1
collapsePara.maximumLineHeight = 0.1
collapsePara.lineSpacing = 0
collapsePara.paragraphSpacing = 0
collapsePara.paragraphSpacingBefore = 0
ts.addAttribute(.paragraphStyle, value: collapsePara, range: newRange)
let text = ts.string as NSString
let lastLineRange = text.lineRange(for: NSRange(location: max(0, NSMaxRange(newRange) - 1), length: 0))
let lastPara = NSMutableParagraphStyle()
lastPara.minimumLineHeight = 0.1
lastPara.maximumLineHeight = 0.1
lastPara.lineSpacing = 0
lastPara.paragraphSpacing = blockHeight
lastPara.paragraphSpacingBefore = 0
ts.addAttribute(.paragraphStyle, value: lastPara, range: lastLineRange)
ts.endEditing()
(tv as? LineNumberTextView)?.applyEvalSpacing()
tv.selectedRanges = sel
parent.text = tv.string
updateBlockRanges(for: tv)
isUpdatingTables = false
updateEmbeddedTables()
}
}
}