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..= 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.. CGFloat { var x: CGFloat = 1 for i in 0..= 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.. 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.. 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 = ["}", ")", "]", "\"", "'"] 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).. 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 } } isUpdatingTables = true let tinyFont = NSFont.systemFont(ofSize: 0.1) let collapsedPara = NSMutableParagraphStyle() collapsedPara.minimumLineHeight = 0.1 collapsedPara.maximumLineHeight = 0.1 collapsedPara.lineSpacing = 0 collapsedPara.paragraphSpacing = 0 collapsedPara.paragraphSpacingBefore = 0 ts.beginEditing() for (range, height) in blockSpacings { ts.addAttribute(.font, value: tinyFont, range: range) ts.addAttribute(.foregroundColor, value: NSColor.clear, range: range) ts.addAttribute(.paragraphStyle, value: collapsedPara.copy() as! NSParagraphStyle, range: range) let lastLineRange = text.lineRange(for: NSRange(location: max(0, NSMaxRange(range) - 1), length: 0)) let spacerPara = collapsedPara.mutableCopy() as! NSMutableParagraphStyle spacerPara.paragraphSpacing = height ts.addAttribute(.paragraphStyle, value: spacerPara, range: lastLineRange) } ts.endEditing() isUpdatingTables = false lm.ensureLayout(for: tc) // Position blocks using first-glyph origin of collapsed source text var tableIdx = 0 var hrIdx = 0 let x = origin.x + 4 for block in lm.blockRanges { let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil) guard glyphRange.length > 0 else { continue } let lineRect = lm.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) let blockY = lineRect.origin.y + origin.y switch block.kind { case .tableBlock: guard tableIdx < tableBlocks.count else { continue } let tb = tableBlocks[tableIdx] tableIdx += 1 tb.view.frame = NSRect(x: x, y: blockY, width: tb.view.frame.width, height: tb.blockHeight) tv.addSubview(tb.view) case .horizontalRule: guard hrIdx < hrBlocks.count else { continue } let hb = hrBlocks[hrIdx] hrIdx += 1 hb.view.frame = NSRect(x: x, y: blockY, width: tc.containerSize.width - 8, 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) ts.endEditing() (tv as? LineNumberTextView)?.applyEvalSpacing() tv.selectedRanges = sel parent.text = tv.string updateBlockRanges(for: tv) isUpdatingTables = false updateEmbeddedTables() } } }