diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index b81ae1c..37d7dc6 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -24,8 +24,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { _ = ConfigManager.shared appState = AppState() - let contentView = ContentView(state: appState) - let hostingView = NSHostingView(rootView: contentView) + let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) + viewport.autoresizingMask = [.width, .height] window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), @@ -37,7 +37,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.titleVisibility = .hidden window.backgroundColor = Theme.current.base window.title = "Swiftly" - window.contentView = hostingView + window.contentView = viewport window.center() window.setFrameAutosaveName("SwiftlyMainWindow") window.makeKeyAndOrderFront(nil) @@ -235,8 +235,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func newWindow() { let state = AppState() - let contentView = ContentView(state: state) - let hostingView = NSHostingView(rootView: contentView) + let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) + viewport.autoresizingMask = [.width, .height] let win = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), @@ -248,7 +248,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { win.titleVisibility = .hidden win.backgroundColor = Theme.current.base win.title = "Swiftly" - win.contentView = hostingView + win.contentView = viewport win.center() win.makeKeyAndOrderFront(nil) diff --git a/src/CompositorView.swift b/src/CompositorView.swift deleted file mode 100644 index f00d623..0000000 --- a/src/CompositorView.swift +++ /dev/null @@ -1,1378 +0,0 @@ -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() - } - } -} diff --git a/src/ContentView.swift b/src/ContentView.swift index 3eaaa82..8d37e0e 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -6,17 +6,12 @@ struct ContentView: View { var body: some View { let _ = themeVersion - HSplitView { - EditorView(state: state) - .frame(minWidth: 400) - IcedViewportRepresentable() - .frame(minWidth: 200) - } - .frame(minWidth: 700, minHeight: 400) - .background(Color(ns: Theme.current.base)) - .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in - themeVersion += 1 - } + IcedViewportRepresentable() + .frame(minWidth: 700, minHeight: 400) + .background(Color(ns: Theme.current.base)) + .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in + themeVersion += 1 + } } } diff --git a/src/EditorView.swift b/src/EditorView.swift deleted file mode 100644 index 810e7b7..0000000 --- a/src/EditorView.swift +++ /dev/null @@ -1,3199 +0,0 @@ -import SwiftUI -import AppKit -import UniformTypeIdentifiers - -// MARK: - MarkdownLayoutManager - -class MarkdownLayoutManager: NSLayoutManager { - struct BlockRange { - let range: NSRange - let kind: BlockKind - } - - enum BlockKind { - case codeBlock - case blockquote - case horizontalRule - case checkbox(checked: Bool) - case tableBlock(columns: Int) - } - - var blockRanges: [BlockRange] = [] - - override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { - super.drawBackground(forGlyphRange: glyphsToShow, at: origin) - - guard let textContainer = textContainers.first else { return } - - for block in blockRanges { - let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil) - guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue } - - switch block.kind { - case .codeBlock: - drawCodeBlockBackground(glyphRange: glyphRange, origin: origin, container: textContainer) - case .blockquote: - drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer) - case .horizontalRule: - drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer) - case .checkbox: - break - case .tableBlock: - drawTableBackground(glyphRange: glyphRange, origin: origin, container: textContainer) - } - } - } - - override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { - guard let textContainer = textContainers.first else { - super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) - return - } - - var skipRanges: [NSRange] = [] - for block in blockRanges { - let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil) - guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue } - - switch block.kind { - case .checkbox(let checked): - skipRanges.append(glyphRange) - drawCheckbox(checked: checked, glyphRange: glyphRange, origin: origin, container: textContainer) - case .horizontalRule: - skipRanges.append(glyphRange) - case .tableBlock: - skipRanges.append(glyphRange) - default: - break - } - } - - if skipRanges.isEmpty { - super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) - return - } - - skipRanges.sort { $0.location < $1.location } - var cursor = glyphsToShow.location - for skip in skipRanges { - if cursor < skip.location { - super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: skip.location - cursor), at: origin) - } - cursor = NSMaxRange(skip) - } - if cursor < NSMaxRange(glyphsToShow) { - super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: NSMaxRange(glyphsToShow) - cursor), at: origin) - } - } - - private func drawCodeBlockBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { - var rect = boundingRect(forGlyphRange: glyphRange, in: container) - rect.origin.x = origin.x + 4 - rect.origin.y += origin.y - 4 - rect.size.width = container.containerSize.width - 8 - rect.size.height += 8 - - let path = NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6) - Theme.current.surface0.setFill() - path.fill() - Theme.current.surface1.setStroke() - path.lineWidth = 1 - path.stroke() - } - - private func drawBlockquoteBorder(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { - var rect = boundingRect(forGlyphRange: glyphRange, in: container) - rect.origin.x = origin.x - rect.origin.y += origin.y - rect.size.width = container.containerSize.width - - let bgRect = NSRect(x: rect.origin.x + 8, y: rect.origin.y, width: rect.size.width - 16, height: rect.size.height) - Theme.current.surface0.withAlphaComponent(0.3).setFill() - bgRect.fill() - - let barRect = NSRect(x: origin.x + 8, y: rect.origin.y, width: 3, height: rect.size.height) - Theme.current.lavender.setFill() - barRect.fill() - } - - private func drawHorizontalRule(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { - let rect = boundingRect(forGlyphRange: glyphRange, in: container) - let y = rect.origin.y + origin.y + rect.size.height / 2 - - let path = NSBezierPath() - path.move(to: NSPoint(x: origin.x + 8, y: y)) - path.line(to: NSPoint(x: origin.x + container.containerSize.width - 8, y: y)) - path.lineWidth = 1 - Theme.current.overlay0.setStroke() - path.stroke() - } - - private func drawCheckbox(checked: Bool, glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { - let rect = boundingRect(forGlyphRange: glyphRange, in: container) - let size: CGFloat = 14 - let x = rect.origin.x + origin.x - let y = rect.origin.y + origin.y + (rect.size.height - size) / 2 - let boxRect = NSRect(x: x, y: y, width: size, height: size) - let path = NSBezierPath(roundedRect: boxRect, xRadius: 3, yRadius: 3) - - if checked { - Theme.current.green.setFill() - path.fill() - - let check = NSBezierPath() - check.move(to: NSPoint(x: x + 3, y: y + size / 2)) - check.line(to: NSPoint(x: x + size * 0.4, y: y + 3)) - check.line(to: NSPoint(x: x + size - 3, y: y + size - 3)) - check.lineWidth = 2 - check.lineCapStyle = .round - check.lineJoinStyle = .round - NSColor.white.setStroke() - check.stroke() - } else { - Theme.current.overlay0.setStroke() - path.lineWidth = 1.5 - path.stroke() - } - } - - private func drawTableBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { - var rect = boundingRect(forGlyphRange: glyphRange, in: container) - rect.origin.x = origin.x + 4 - rect.origin.y += origin.y - rect.size.width = container.containerSize.width - 8 - - let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4) - Theme.current.base.setFill() - path.fill() - } - -} - -// MARK: - Interactive Table Component - -enum TableAlignment { - case left, center, right -} - -struct ParsedTable { - var headers: [String] - var alignments: [TableAlignment] - var rows: [[String]] - var sourceRange: NSRange -} - -func parseMarkdownTable(from text: NSString, range: NSRange) -> ParsedTable? { - let tableText = text.substring(with: range) - let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty } - guard lines.count >= 2 else { return nil } - - func parseCells(_ line: String) -> [String] { - var trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("|") { trimmed = String(trimmed.dropFirst()) } - if trimmed.hasSuffix("|") { trimmed = String(trimmed.dropLast()) } - return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) } - } - - let headers = parseCells(lines[0]) - guard headers.count > 0 else { return nil } - - var sepIdx = 1 - var alignments: [TableAlignment] = Array(repeating: .left, count: headers.count) - - if sepIdx < lines.count && isTableSeparator(lines[sepIdx].trimmingCharacters(in: .whitespacesAndNewlines)) { - let sepCells = parseCells(lines[sepIdx]) - for (i, cell) in sepCells.enumerated() where i < alignments.count { - let c = cell.trimmingCharacters(in: .whitespaces) - if c.hasPrefix(":") && c.hasSuffix(":") { - alignments[i] = .center - } else if c.hasSuffix(":") { - alignments[i] = .right - } - } - sepIdx += 1 - } - - var rows: [[String]] = [] - for i in sepIdx.. headers.count { row = Array(row.prefix(headers.count)) } - rows.append(row) - } - - return ParsedTable(headers: headers, alignments: alignments, rows: rows, sourceRange: range) -} - -func rebuildTableMarkdown(_ table: ParsedTable) -> String { - let colCount = table.headers.count - var colWidths = Array(repeating: 3, count: colCount) - for (i, h) in table.headers.enumerated() { - colWidths[i] = max(colWidths[i], h.count) - } - for row in table.rows { - for (i, cell) in row.enumerated() where i < colCount { - colWidths[i] = max(colWidths[i], cell.count) - } - } - - func formatRow(_ cells: [String]) -> String { - var parts: [String] = [] - for (i, cell) in cells.enumerated() where i < colCount { - parts.append(" " + cell.padding(toLength: colWidths[i], withPad: " ", startingAt: 0) + " ") - } - return "|" + parts.joined(separator: "|") + "|" - } - - var lines: [String] = [] - lines.append(formatRow(table.headers)) - - var sepParts: [String] = [] - for i in 0.. Void)? - - private var cellFields: [[NSTextField]] = [] - private var columnWidths: [CGFloat] = [] - private var rowHeights: [CGFloat] = [] - private var tableWidth: CGFloat = 0 - - private let minColWidth: CGFloat = 40 - private let minRowHeight: CGFloat = 24 - private let defaultHeaderHeight: CGFloat = 28 - private let defaultCellHeight: CGFloat = 26 - private let dividerHitZone: CGFloat = 6 - - private let indicatorRowHeight: CGFloat = 20 - private let indicatorColWidth: CGFloat = 30 - private var chromeVisible = false - private var chromeContainer: NSView? - private var dragHighlightView: NSView? - private var dragHandleView: NSView? - private var focusMonitor: Any? - - private enum DragMode { case none, column(Int), row(Int), move } - private var dragMode: DragMode = .none - private var dragStartPoint: NSPoint = .zero - private var dragStartSize: CGFloat = 0 - - init(table: ParsedTable, width: CGFloat) { - self.table = table - self.tableWidth = width - super.init(frame: .zero) - wantsLayer = true - layer?.backgroundColor = Theme.current.base.cgColor - layer?.cornerRadius = 4 - initSizes(width: width) - buildGrid() - setupTrackingArea() - setupFocusMonitoring() - } - - required init?(coder: NSCoder) { fatalError() } - - deinit { - if let monitor = focusMonitor { - NotificationCenter.default.removeObserver(monitor) - } - } - - private func initSizes(width: CGFloat) { - let colCount = table.headers.count - guard colCount > 0 else { return } - let available = width - CGFloat(colCount + 1) - let colW = available / CGFloat(colCount) - columnWidths = Array(repeating: max(colW, minColWidth), count: colCount) - - rowHeights = [] - rowHeights.append(defaultHeaderHeight) - for _ in 0.. CGFloat { - var x: CGFloat = 1 - for i in 0.. CGFloat { - let th = totalHeight - var y = th - for i in 0...row { - y -= rowHeights[i] - } - return y - } - - private var gridOriginX: CGFloat { chromeVisible ? indicatorColWidth : 0 } - private var gridOriginY: CGFloat { chromeVisible ? indicatorRowHeight : 0 } - - private func buildGrid() { - subviews.forEach { $0.removeFromSuperview() } - chromeContainer = nil - dragHighlightView = nil - dragHandleView = nil - cellFields = [] - - let colCount = table.headers.count - guard colCount > 0 else { return } - let th = totalHeight - let ox = gridOriginX - let oy = gridOriginY - let gridWidth = columnX(for: colCount) + 1 - let fullWidth = gridWidth + ox - let fullHeight = th + oy - - frame.size = NSSize(width: fullWidth, height: fullHeight) - - let headerBg = NSView(frame: NSRect(x: ox, y: th - rowHeights[0] + oy, width: gridWidth, height: rowHeights[0])) - headerBg.wantsLayer = true - headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor - addSubview(headerBg) - - var headerFields: [NSTextField] = [] - for (col, header) in table.headers.enumerated() { - let x = columnX(for: col) + ox - let h = rowHeights[0] - let field = makeCell(text: header, frame: NSRect(x: x, y: th - h + 2 + oy, width: columnWidths[col], height: h - 4), isHeader: true, row: -1, col: col) - addSubview(field) - headerFields.append(field) - } - cellFields.append(headerFields) - - for (rowIdx, row) in table.rows.enumerated() { - var rowFields: [NSTextField] = [] - let y = rowY(for: rowIdx + 1) + oy - let h = rowHeights[rowIdx + 1] - for (col, cell) in row.enumerated() where col < colCount { - let x = columnX(for: col) + ox - let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col) - addSubview(field) - rowFields.append(field) - } - while rowFields.count < colCount { - let col = rowFields.count - let x = columnX(for: col) + ox - let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col) - addSubview(field) - rowFields.append(field) - } - cellFields.append(rowFields) - } - - let totalRows = 1 + table.rows.count - for i in 1.. 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: - Column letter helper - - private func columnLetter(for index: Int) -> String { - var n = index - var result = "" - repeat { - result = String(UnicodeScalar(65 + (n % 26))!) + result - n = n / 26 - 1 - } while n >= 0 - return result - } - - // MARK: - Chrome (indicators, handle, border — focus-only) - - private func buildChrome() { - chromeContainer?.removeFromSuperview() - - let container = NSView(frame: bounds) - container.wantsLayer = true - addSubview(container) - chromeContainer = container - - let colCount = table.headers.count - let totalRows = 1 + table.rows.count - let topY = totalHeight + indicatorRowHeight - let indicatorBg = Theme.current.surface0 - - let corner = NSView(frame: NSRect(x: 0, y: topY - indicatorRowHeight, width: indicatorColWidth, height: indicatorRowHeight)) - corner.wantsLayer = true - corner.layer?.backgroundColor = indicatorBg.cgColor - container.addSubview(corner) - - // Drag handle dots in corner - let handle = NSView(frame: NSRect(x: 4, y: topY - indicatorRowHeight + 3, width: indicatorColWidth - 8, height: indicatorRowHeight - 6)) - handle.wantsLayer = true - let handleLayer = CAShapeLayer() - let handlePath = CGMutablePath() - let dotSize: CGFloat = 2 - let hW = handle.bounds.width - let hH = handle.bounds.height - for dotRow in 0..<3 { - for dotCol in 0..<2 { - let cx = (hW / 3) * CGFloat(dotCol + 1) - let cy = (hH / 4) * CGFloat(dotRow + 1) - handlePath.addEllipse(in: CGRect(x: cx - dotSize/2, y: cy - dotSize/2, width: dotSize, height: dotSize)) - } - } - handleLayer.path = handlePath - handleLayer.fillColor = Theme.current.overlay1.cgColor - handle.layer?.addSublayer(handleLayer) - container.addSubview(handle) - dragHandleView = handle - - for col in 0.. Int? { - let ox = gridOriginX - let colCount = table.headers.count - for i in 1.. Int? { - let oy = gridOriginY - let totalRows = 1 + table.rows.count - for i in 0.. Bool { - guard chromeVisible else { return false } - let topY = totalHeight + indicatorRowHeight - return pt.x < indicatorColWidth && pt.y > topY - indicatorRowHeight - } - - private func isInDragHandle(_ pt: NSPoint) -> Bool { - guard chromeVisible, let hv = dragHandleView else { return false } - return hv.frame.contains(pt) - } - - // MARK: - Tracking area - - private func setupTrackingArea() { - for area in trackingAreas { removeTrackingArea(area) } - let area = NSTrackingArea( - rect: bounds, - options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], - owner: self, - userInfo: nil - ) - addTrackingArea(area) - } - - override func mouseMoved(with event: NSEvent) { - let pt = convert(event.locationInWindow, from: nil) - if isInDragHandle(pt) { - NSCursor.openHand.set() - } else if columnDivider(at: pt) != nil { - NSCursor.resizeLeftRight.set() - } else if rowDivider(at: pt) != nil { - NSCursor.resizeUpDown.set() - } else { - NSCursor.arrow.set() - } - } - - override func mouseEntered(with event: NSEvent) { - mouseInside = true - showChrome() - } - - override func mouseExited(with event: NSEvent) { - NSCursor.arrow.set() - mouseInside = false - removeDragHighlight() - checkFocusState() - } - - override func mouseDown(with event: NSEvent) { - if !chromeVisible { - mouseInside = true - showChrome() - } - let pt = convert(event.locationInWindow, from: nil) - - if isInCorner(pt) { - selectAllCells() - return - } - - if isInDragHandle(pt) { - dragMode = .move - dragStartPoint = convert(event.locationInWindow, from: nil) - NSCursor.closedHand.set() - return - } - - if let col = columnDivider(at: pt) { - if event.clickCount == 2 { - autoFitColumn(col) - return - } - dragMode = .column(col) - dragStartPoint = pt - dragStartSize = columnWidths[col] - showColumnDragHighlight(col) - return - } - if let row = rowDivider(at: pt) { - if event.clickCount == 2 { - autoFitRow(row) - return - } - dragMode = .row(row) - dragStartPoint = pt - dragStartSize = rowHeights[row] - showRowDragHighlight(row) - 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): - let delta = pt.x - dragStartPoint.x - columnWidths[col] = max(minColWidth, dragStartSize + delta) - buildGrid() - showColumnDragHighlight(col) - case .row(let row): - let delta = dragStartPoint.y - pt.y - rowHeights[row] = max(minRowHeight, dragStartSize + delta) - buildGrid() - showRowDragHighlight(row) - case .move: - let delta = NSPoint(x: pt.x - dragStartPoint.x, y: pt.y - dragStartPoint.y) - frame.origin.x += delta.x - frame.origin.y += delta.y - case .none: - super.mouseDragged(with: event) - } - } - - override func mouseUp(with event: NSEvent) { - removeDragHighlight() - if case .move = dragMode { - NSCursor.openHand.set() - } else if case .none = dragMode { - super.mouseUp(with: event) - } - dragMode = .none - } - - // MARK: - Resize drag indicators - - private func showColumnDragHighlight(_ col: Int) { - removeDragHighlight() - let ox = gridOriginX - let oy = gridOriginY - let x = columnX(for: col + 1) - 1 + ox - let highlight = NSView(frame: NSRect(x: x - 1, y: oy, width: 3, height: totalHeight)) - highlight.wantsLayer = true - highlight.layer?.backgroundColor = Theme.current.blue.withAlphaComponent(0.6).cgColor - addSubview(highlight) - dragHighlightView = highlight - } - - private func showRowDragHighlight(_ row: Int) { - removeDragHighlight() - let ox = gridOriginX - let oy = gridOriginY - let colCount = table.headers.count - let gridWidth = columnX(for: colCount) + 1 - let divY: CGFloat - if row == 0 { - divY = totalHeight - rowHeights[0] + oy - } else { - divY = rowY(for: row) + oy - } - let highlight = NSView(frame: NSRect(x: ox, y: divY - 1, width: gridWidth, height: 3)) - highlight.wantsLayer = true - highlight.layer?.backgroundColor = Theme.current.blue.withAlphaComponent(0.6).cgColor - addSubview(highlight) - dragHighlightView = highlight - } - - private func removeDragHighlight() { - dragHighlightView?.removeFromSuperview() - dragHighlightView = nil - } - - // MARK: - Auto-fit - - 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 max(maxW + 16, minColWidth) - } - - private func measureRowHeight(_ row: Int) -> CGFloat { - let font: NSFont = row == 0 - ? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask) - : Theme.editorFont - let cells: [String] = row == 0 ? table.headers : (row - 1 < table.rows.count ? table.rows[row - 1] : []) - - var maxH: CGFloat = 0 - for (col, text) in cells.enumerated() where col < columnWidths.count { - let constrainedSize = NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude) - let rect = (text as NSString).boundingRect( - with: constrainedSize, - options: [.usesLineFragmentOrigin], - attributes: [.font: font] - ) - maxH = max(maxH, rect.height) - } - return max(maxH + 8, minRowHeight) - } - - private func autoFitColumn(_ col: Int) { - guard col >= 0, col < columnWidths.count else { return } - columnWidths[col] = measureColumnWidth(col) - buildGrid() - } - - private func autoFitRow(_ row: Int) { - guard row >= 0, row < rowHeights.count else { return } - rowHeights[row] = measureRowHeight(row) - buildGrid() - } - - // MARK: - Export - - override var acceptsFirstResponder: Bool { true } - - override func keyDown(with event: NSEvent) { - if event.modifierFlags.contains(.control) && event.charactersIgnoringModifiers == "e" { - showExportDialog() - return - } - super.keyDown(with: event) - } - - private func showExportDialog() { - let panel = NSSavePanel() - panel.allowedContentTypes = [ - .init(filenameExtension: "md")!, - .init(filenameExtension: "csv")! - ] - panel.nameFieldStringValue = "table" - panel.title = "Export Table" - panel.begin { [weak self] result in - guard result == .OK, let url = panel.url, let self = self else { return } - let ext = url.pathExtension.lowercased() - let content: String - if ext == "csv" { - content = self.exportCSV() - } else { - content = rebuildTableMarkdown(self.table) - } - try? content.write(to: url, atomically: true, encoding: .utf8) - } - } - - private func exportCSV() -> String { - var lines: [String] = [] - lines.append(table.headers.map { escapeCSV($0) }.joined(separator: ",")) - for row in table.rows { - var cells = row - while cells.count < table.headers.count { cells.append("") } - lines.append(cells.map { escapeCSV($0) }.joined(separator: ",")) - } - return lines.joined(separator: "\n") - } - - private func escapeCSV(_ value: String) -> String { - if value.contains(",") || value.contains("\"") || value.contains("\n") { - return "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" - } - return value - } - - // MARK: - Cell editing - - func controlTextDidBeginEditing(_ obj: Notification) { - if !chromeVisible { - showChrome() - } - } - - 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) - - if let movement = obj.userInfo?["NSTextMovement"] as? Int, movement == NSTabTextMovement { - let nextCol = col + 1 - let nextRow = row + (nextCol >= table.headers.count ? 1 : 0) - let actualCol = nextCol % table.headers.count - let actualRow = nextRow - let fieldRow = actualRow + 1 - if fieldRow < cellFields.count, actualCol < cellFields[fieldRow].count { - window?.makeFirstResponder(cellFields[fieldRow][actualCol]) - } - } - } - - func control(_ control: NSControl, textView tv: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(NSResponder.insertNewline(_:)) { - guard let field = control as? NSTextField else { return false } - let tag = field.tag - let row = tag / 1000 - 1 - let col = tag % 1000 - _ = row; _ = col - - table.rows.append(Array(repeating: "", count: table.headers.count)) - onTableChanged?(table) - - rowHeights.append(defaultCellHeight) - buildGrid() - needsLayout = true - return true - } - return false - } -} - -// MARK: - Clickable indicator for column/row headers - -private class TableIndicatorButton: NSView { - var label: String = "" - var bgColor: NSColor = Theme.current.surface0 - var textColor: NSColor = Theme.current.overlay2 - var onPress: (() -> Void)? - - override init(frame: NSRect) { - super.init(frame: frame) - wantsLayer = true - } - required init?(coder: NSCoder) { fatalError() } - - override func draw(_ dirtyRect: NSRect) { - bgColor.setFill() - dirtyRect.fill() - let attrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 10, weight: .medium), - .foregroundColor: textColor - ] - let str = NSAttributedString(string: label, attributes: attrs) - let size = str.size() - let pt = NSPoint(x: (bounds.width - size.width) / 2, y: (bounds.height - size.height) / 2) - str.draw(at: pt) - } - - override func mouseDown(with event: NSEvent) { - layer?.backgroundColor = Theme.current.surface1.cgColor - } - - override func mouseUp(with event: NSEvent) { - layer?.backgroundColor = bgColor.cgColor - let pt = convert(event.locationInWindow, from: nil) - if bounds.contains(pt) { - onPress?() - } - } -} - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} - -// MARK: - File Embed Attachment Cell - -class FileEmbedCell: NSTextAttachmentCell { - let filePath: String - let fileName: String - private let fileIcon: NSImage - - init(filePath: String) { - self.filePath = filePath - self.fileName = (filePath as NSString).lastPathComponent - self.fileIcon = NSWorkspace.shared.icon(forFile: filePath) - self.fileIcon.size = NSSize(width: 16, height: 16) - super.init() - } - - required init(coder: NSCoder) { fatalError() } - - override func cellSize() -> NSSize { - let textSize = (fileName as NSString).size(withAttributes: [.font: Theme.editorFont]) - return NSSize(width: 16 + 8 + textSize.width + 16, height: max(24, textSize.height + 8)) - } - - override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) { - let path = NSBezierPath(roundedRect: cellFrame.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4) - Theme.current.surface1.setFill() - path.fill() - Theme.current.surface2.setStroke() - path.lineWidth = 1 - path.stroke() - - let iconRect = NSRect(x: cellFrame.origin.x + 6, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: 16, height: 16) - fileIcon.draw(in: iconRect) - - let textRect = NSRect(x: iconRect.maxX + 4, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: cellFrame.width - 30, height: 16) - let attrs: [NSAttributedString.Key: Any] = [ - .font: Theme.editorFont, - .foregroundColor: Theme.current.text - ] - fileName.draw(in: textRect, withAttributes: attrs) - } - - override func wantsToTrackMouse() -> Bool { true } - - override func trackMouse(with theEvent: NSEvent, in cellFrame: NSRect, of controlView: NSView?, untilMouseUp flag: Bool) -> Bool { - if theEvent.clickCount >= 1 { - NSWorkspace.shared.open(URL(fileURLWithPath: filePath)) - } - return true - } -} - -// MARK: - Editor View - -struct EditorView: View { - @ObservedObject var state: AppState - - private var bodyBinding: Binding { - Binding( - get: { - let text = state.documentText - guard let firstNewline = text.firstIndex(of: "\n") else { - return "" - } - return String(text[text.index(after: firstNewline)...]) - }, - set: { newBody in - let lines = state.documentText.components(separatedBy: "\n") - let title = lines.first ?? "" - state.documentText = title + "\n" + newBody - } - ) - } - - var body: some View { - CompositorRepresentable( - text: bodyBinding, - evalResults: offsetEvalResults(state.evalResults), - fileFormat: state.currentFileFormat, - onEvaluate: { state.evaluate() }, - onBackspaceAtStart: { - NotificationCenter.default.post(name: .focusTitle, object: nil) - } - ) - .padding(.top, 4) - } - - private func offsetEvalResults(_ results: [Int: EvalEntry]) -> [Int: EvalEntry] { - var shifted: [Int: EvalEntry] = [:] - for (key, val) in results where key > 0 { - shifted[key - 1] = val - } - return shifted - } -} - -struct EditorTextView: NSViewRepresentable { - @Binding var text: String - var evalResults: [Int: EvalEntry] - var fileFormat: FileFormat = .markdown - var onEvaluate: () -> Void - var onBackspaceAtStart: (() -> Void)? = nil - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - let defaultTV = scrollView.documentView as! NSTextView - - // Build LineNumberTextView reusing the default text container - 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) - } - 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: EditorTextView - weak var textView: NSTextView? - private var isUpdatingImages = false - private var isUpdatingTables = false - private var embeddedTableViews: [MarkdownTableView] = [] - private var observers: [NSObjectProtocol] = [] - - init(_ parent: EditorTextView) { - 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: parent.fileFormat) - 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 - - // Skip over matching closer when cursor is right before it - 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 - } - - 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 - - // Between matching pairs: insert extra newline - 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).. 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 - } - - func triggerImageUpdate() { - updateInlineImages() - } - - func triggerTableUpdate() { - updateEmbeddedTables() - } - - // 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 - } - - 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 Range Detection - -private let checkboxPattern: NSRegularExpression? = { - try? NSRegularExpression(pattern: "\\[[ xX]\\]") -}() - -func updateBlockRanges(for textView: NSTextView) { - guard let lm = textView.layoutManager as? MarkdownLayoutManager else { return } - let text = textView.string as NSString - guard text.length > 0 else { - lm.blockRanges = [] - return - } - - var blocks: [MarkdownLayoutManager.BlockRange] = [] - var lineStart = 0 - var openFence: Int? = nil - var blockquoteStart: Int? = nil - var blockquoteEnd: Int = 0 - var tableStart: Int? = nil - var tableEnd: Int = 0 - var tableColumns: Int = 0 - - while lineStart < text.length { - let lineRange = text.lineRange(for: NSRange(location: lineStart, length: 0)) - let line = text.substring(with: lineRange) - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - - if openFence == nil && trimmed.hasPrefix("```") { - openFence = lineRange.location - } else if openFence != nil && trimmed == "```" { - let range = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!) - blocks.append(.init(range: range, kind: .codeBlock)) - openFence = nil - lineStart = NSMaxRange(lineRange) - continue - } - - if openFence != nil { - lineStart = NSMaxRange(lineRange) - continue - } - - if trimmed.hasPrefix("> ") || trimmed == ">" { - if blockquoteStart == nil { blockquoteStart = lineRange.location } - blockquoteEnd = NSMaxRange(lineRange) - } else { - if let start = blockquoteStart { - blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote)) - blockquoteStart = nil - } - } - - if isHorizontalRule(trimmed) && openFence == nil { - blocks.append(.init(range: lineRange, kind: .horizontalRule)) - } - - // Task list checkboxes - let taskPrefixes = ["- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] "] - let strippedLine = trimmed - for prefix in taskPrefixes { - if strippedLine.hasPrefix(prefix) { - let checked = prefix.contains("x") || prefix.contains("X") - if let regex = checkboxPattern, - let match = regex.firstMatch(in: text as String, range: lineRange) { - blocks.append(.init(range: match.range, kind: .checkbox(checked: checked))) - } - break - } - } - - if trimmed.hasPrefix("|") { - if tableStart == nil { - tableStart = lineRange.location - tableColumns = trimmed.filter({ $0 == "|" }).count - 1 - if tableColumns < 1 { tableColumns = 1 } - } - tableEnd = NSMaxRange(lineRange) - } else { - if let start = tableStart { - blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns))) - tableStart = nil - tableColumns = 0 - } - } - - lineStart = NSMaxRange(lineRange) - } - - if let start = blockquoteStart { - blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote)) - } - if let start = tableStart { - blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns))) - } - - lm.blockRanges = blocks - - let fullRange = NSRange(location: 0, length: text.length) - lm.invalidateDisplay(forCharacterRange: fullRange) -} - -// MARK: - Syntax Highlighting - -private let syntaxKeywords: Set = [ - "let", "fn", "if", "else", "for", "while", "return", "mut", "in", - "map", "cast", "plot", "sch" -] - -private let syntaxTypes: Set = [ - "bool", "int", "float", "str", "i32", "f64", "Vec", "String" -] - -private let syntaxBooleans: Set = ["true", "false"] - -private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,.&|%") - -func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat = .markdown) { - let text = textStorage.string - let fullRange = NSRange(location: 0, length: (text as NSString).length) - let palette = Theme.current - let syn = Theme.syntax - - let baseFont = Theme.editorFont - let baseAttrs: [NSAttributedString.Key: Any] = [ - .font: baseFont, - .foregroundColor: palette.text - ] - textStorage.setAttributes(baseAttrs, range: fullRange) - - if format.isCode { - applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont, format: format) - return - } - - let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont) - let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges) - - let nsText = text as NSString - var lineStart = 0 - var braceDepth = 0 - - while lineStart < nsText.length { - let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) - - if isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) { - lineStart = NSMaxRange(lineRange) - continue - } - - let line = nsText.substring(with: lineRange) - let trimmed = line.trimmingCharacters(in: .whitespaces) - let isTableHeader = tableHeaderLines.contains(lineRange.location) - - let inBlock = braceDepth > 0 - - if isCordialLine(trimmed) || inBlock { - let opens = trimmed.filter { $0 == "{" }.count - let closes = trimmed.filter { $0 == "}" }.count - braceDepth += opens - closes - if braceDepth < 0 { braceDepth = 0 } - highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn) - } else if highlightMarkdownLine(trimmed, line: line, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette, isTableHeader: isTableHeader) { - highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont) - highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette) - highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette) - lineStart = NSMaxRange(lineRange) - continue - } - - highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont) - highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette) - highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette) - - lineStart = NSMaxRange(lineRange) - } - - highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) - highlightLinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) - highlightImages(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) - highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) -} - -private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, format: FileFormat = .unknown) { - let text = textStorage.string - - if let lang = format.treeSitterLang { - let spans = RustBridge.shared.highlight(source: text, lang: lang) - if !spans.isEmpty { - applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: baseFont) - return - } - } - - let nsText = text as NSString - var lineStart = 0 - while lineStart < nsText.length { - let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) - let line = nsText.substring(with: lineRange) - highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn) - lineStart = NSMaxRange(lineRange) - } - highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) -} - -private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, offset: Int = 0) { - let palette = Theme.current - let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) - let textLen = (textStorage.string as NSString).length - - // When offset > 0, spans are byte-relative to a substring starting at `offset` chars. - // Extract that substring and convert bytes to chars within it. - let sourceStr: String - if offset > 0 { - sourceStr = (textStorage.string as NSString).substring(from: offset) - } else { - sourceStr = textStorage.string - } - let sourceBytes = Array(sourceStr.utf8) - - for span in spans { - guard span.start < sourceBytes.count && span.end <= sourceBytes.count && span.start < span.end else { continue } - - // Convert byte offsets to character offsets within sourceStr - let prefix = sourceStr.utf8.prefix(span.start) - let charStart = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: prefix.count)) - let endPrefix = sourceStr.utf8.prefix(span.end) - let charEnd = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: endPrefix.count)) - - let absStart = offset + charStart - let absEnd = offset + charEnd - guard absStart < textLen && absEnd <= textLen && absStart < absEnd else { continue } - let range = NSRange(location: absStart, length: absEnd - absStart) - - let color: NSColor - var font: NSFont? = nil - - switch span.kind { - case 0: color = syn.keyword - case 1: color = syn.function - case 2: color = syn.function - case 3: color = syn.type - case 4: color = syn.type - case 5: color = syn.type - case 6: color = palette.peach - case 7: color = palette.peach - case 8: color = syn.string - case 9: color = syn.number - case 10: color = syn.comment; font = italicFont - case 11: color = palette.text - case 12: color = palette.red - case 13: color = palette.maroon - case 14: color = syn.operator - case 15: color = palette.overlay2 - case 16: color = palette.overlay2 - case 17: color = palette.overlay2 - case 18: color = palette.teal - case 19: color = palette.red - case 20: color = palette.yellow - case 21: color = palette.sapphire - case 22: color = palette.pink - case 23: color = palette.teal - default: continue - } - - textStorage.addAttribute(.foregroundColor, value: color, range: range) - if let f = font { - textStorage.addAttribute(.font, value: f, range: range) - } - } -} - -private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool { - if trimmed.hasPrefix("### ") { - let hashRange = (textStorage.string as NSString).range(of: "###", range: lineRange) - if hashRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: hashRange) - let contentStart = hashRange.location + hashRange.length - let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart) - if contentRange.length > 0 { - let h3Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.15), weight: .bold) - textStorage.addAttribute(.font, value: h3Font, range: contentRange) - textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange) - } - } - return true - } - - if trimmed.hasPrefix("## ") { - let hashRange = (textStorage.string as NSString).range(of: "##", range: lineRange) - if hashRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: hashRange) - let contentStart = hashRange.location + hashRange.length - let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart) - if contentRange.length > 0 { - let h2Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.38), weight: .bold) - textStorage.addAttribute(.font, value: h2Font, range: contentRange) - textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange) - } - } - return true - } - - if trimmed.hasPrefix("# ") { - let hashRange = (textStorage.string as NSString).range(of: "#", range: lineRange) - if hashRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: hashRange) - let contentStart = hashRange.location + hashRange.length - let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart) - if contentRange.length > 0 { - let h1Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.69), weight: .bold) - textStorage.addAttribute(.font, value: h1Font, range: contentRange) - textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange) - } - } - return true - } - - if trimmed.hasPrefix("> ") || trimmed == ">" { - textStorage.addAttribute(.foregroundColor, value: palette.overlay2, range: lineRange) - let indent = NSMutableParagraphStyle() - indent.headIndent = 24 - indent.firstLineHeadIndent = 24 - textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange) - let gtRange = (textStorage.string as NSString).range(of: ">", options: [], range: lineRange) - if gtRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: gtRange) - } - return true - } - - if isHorizontalRule(trimmed) { - textStorage.addAttribute(.foregroundColor, value: NSColor.clear, range: lineRange) - textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: lineRange) - return true - } - - // Footnote definition - if trimmed.hasPrefix("[^") && trimmed.contains("]:") { - highlightFootnoteDefinition(textStorage: textStorage, lineRange: lineRange, palette: palette) - return true - } - - // Task list (check before generic unordered list) - if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || - trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("* [X] ") || - trimmed.hasPrefix("+ [ ] ") || trimmed.hasPrefix("+ [x] ") || trimmed.hasPrefix("+ [X] ") { - highlightTaskList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette) - return true - } - - // Table rows - if trimmed.hasPrefix("|") { - let isSep = isTableSeparator(trimmed) - highlightTableLine(trimmed, lineRange: lineRange, textStorage: textStorage, palette: palette, baseFont: baseFont, isHeader: isTableHeader, isSeparator: isSep) - return true - } - - // Unordered list - if let regex = try? NSRegularExpression(pattern: "^\\s*[-*+] "), - regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil { - highlightUnorderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette) - return true - } - - // Ordered list - if let regex = try? NSRegularExpression(pattern: "^\\s*\\d+\\. "), - regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil { - highlightOrderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette) - return true - } - - return false -} - -private func highlightCodeLine(_ line: String, lineRange: NSRange, textStorage: NSTextStorage, syn: Theme.SyntaxColors) { - let nsLine = line as NSString - let baseFont = Theme.editorFont - let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) - let boldFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) - - let trimmed = line.trimmingCharacters(in: .whitespaces) - - // Line comment - if let commentRange = findLineComment(nsLine) { - let absRange = NSRange(location: lineRange.location + commentRange.location, length: commentRange.length) - textStorage.addAttributes([ - .foregroundColor: syn.comment, - .font: italicFont - ], range: absRange) - highlightCodeTokens(nsLine, inRange: NSRange(location: 0, length: commentRange.location), lineOffset: lineRange.location, textStorage: textStorage, syn: syn) - return - } - - if trimmed.hasPrefix("/=|") || trimmed.hasPrefix("/=\\") { - let prefix = trimmed.hasPrefix("/=|") ? "/=|" : "/=\\" - let prefixRange = (textStorage.string as NSString).range(of: prefix, range: lineRange) - if prefixRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange) - } - } else if trimmed.hasPrefix("/=") { - let prefixRange = (textStorage.string as NSString).range(of: "/=", range: lineRange) - if prefixRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange) - } - } - - highlightCodeTokens(nsLine, inRange: NSRange(location: 0, length: nsLine.length), lineOffset: lineRange.location, textStorage: textStorage, syn: syn) - - // Bold markers **text** - highlightInlineMarkdown(textStorage: textStorage, lineRange: lineRange, pattern: "\\*\\*(.+?)\\*\\*", markerLen: 2, trait: .boldFontMask, font: boldFont) - - // Italic markers *text* - highlightInlineMarkdown(textStorage: textStorage, lineRange: lineRange, pattern: "(? 0 { - textStorage.addAttribute(.font, value: font, range: contentRange) - textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange) - } - } -} - -private func findLineComment(_ line: NSString) -> NSRange? { - guard line.length >= 2 else { return nil } - var i = 0 - var inString = false - while i < line.length - 1 { - let ch = line.character(at: i) - if ch == UInt16(UnicodeScalar("\"").value) { - inString = !inString - } else if !inString && ch == UInt16(UnicodeScalar("/").value) && line.character(at: i + 1) == UInt16(UnicodeScalar("/").value) { - return NSRange(location: i, length: line.length - i) - } - i += 1 - } - return nil -} - -private func highlightCodeTokens(_ line: NSString, inRange range: NSRange, lineOffset: Int, textStorage: NSTextStorage, syn: Theme.SyntaxColors) { - let sub = line.substring(with: range) - let scanner = Scanner(string: sub) - scanner.charactersToBeSkipped = nil - let digitChars = CharacterSet.decimalDigits.union(CharacterSet(charactersIn: ".")) - let identStart = CharacterSet.letters.union(CharacterSet(charactersIn: "_")) - let identChars = identStart.union(.decimalDigits) - - var prevIdent: String? = nil - - while !scanner.isAtEnd { - let pos = scanner.currentIndex - - // String literal - if scanner.scanString("\"") != nil { - let startIdx = sub.distance(from: sub.startIndex, to: pos) - var strContent = "\"" - var escaped = false - while !scanner.isAtEnd { - let ch = sub[scanner.currentIndex] - strContent.append(ch) - scanner.currentIndex = sub.index(after: scanner.currentIndex) - if escaped { - escaped = false - continue - } - if ch == "\\" { escaped = true; continue } - if ch == "\"" { break } - } - let absRange = NSRange(location: lineOffset + range.location + startIdx, length: strContent.count) - textStorage.addAttribute(.foregroundColor, value: syn.string, range: absRange) - prevIdent = nil - continue - } - - // Number - if let numStr = scanner.scanCharacters(from: digitChars) { - let startIdx = sub.distance(from: sub.startIndex, to: pos) - let absRange = NSRange(location: lineOffset + range.location + startIdx, length: numStr.count) - textStorage.addAttribute(.foregroundColor, value: syn.number, range: absRange) - prevIdent = nil - continue - } - - // Identifier / keyword - if let ident = scanner.scanCharacters(from: identChars) { - if let first = ident.unicodeScalars.first, identStart.contains(first) { - let startIdx = sub.distance(from: sub.startIndex, to: pos) - let absRange = NSRange(location: lineOffset + range.location + startIdx, length: ident.count) - if syntaxKeywords.contains(ident) { - textStorage.addAttribute(.foregroundColor, value: syn.keyword, range: absRange) - } else if syntaxBooleans.contains(ident) { - textStorage.addAttribute(.foregroundColor, value: syn.boolean, range: absRange) - } else if syntaxTypes.contains(ident) { - textStorage.addAttribute(.foregroundColor, value: syn.type, range: absRange) - } else if prevIdent == "fn" { - textStorage.addAttribute(.foregroundColor, value: syn.function, range: absRange) - } else { - // Check if followed by '(' -> function call - let remaining = sub[scanner.currentIndex...] - let trimmedRest = remaining.drop(while: { $0 == " " }) - if trimmedRest.first == "(" { - textStorage.addAttribute(.foregroundColor, value: syn.function, range: absRange) - } - } - } - prevIdent = ident - continue - } - - // Operator - if let op = scanner.scanCharacters(from: syntaxOperatorChars) { - let startIdx = sub.distance(from: sub.startIndex, to: pos) - let absRange = NSRange(location: lineOffset + range.location + startIdx, length: op.count) - textStorage.addAttribute(.foregroundColor, value: syn.operator, range: absRange) - prevIdent = nil - continue - } - - // Skip unrecognized character (preserve prevIdent across whitespace) - let ch = sub[scanner.currentIndex] - if !ch.isWhitespace { prevIdent = nil } - scanner.currentIndex = sub.index(after: scanner.currentIndex) - } -} - -private func highlightBlockComments(textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) { - let text = textStorage.string - let nsText = text as NSString - let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) - let len = nsText.length - guard len >= 4 else { return } - - var i = 0 - while i < len - 1 { - if nsText.character(at: i) == 0x2F && nsText.character(at: i + 1) == 0x2A { // /* - let start = i - var depth = 1 - i += 2 - while i < len - 1 && depth > 0 { - let c = nsText.character(at: i) - let n = nsText.character(at: i + 1) - if c == 0x2F && n == 0x2A { // /* - depth += 1 - i += 2 - } else if c == 0x2A && n == 0x2F { // */ - depth -= 1 - i += 2 - } else { - i += 1 - } - } - if i > len { i = len } - let range = NSRange(location: start, length: i - start) - textStorage.addAttributes([ - .foregroundColor: syn.comment, - .font: italicFont - ], range: range) - } else { - i += 1 - } - } -} - -// MARK: - Fenced Code Blocks - -private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont) -> [NSRange] { - let text = textStorage.string - let nsText = text as NSString - let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) - let syn = Theme.syntax - var fencedRanges: [NSRange] = [] - var lineStart = 0 - var openFence: Int? = nil - var fenceLang: String? = nil - var codeStart: Int = 0 - - while lineStart < nsText.length { - let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) - let line = nsText.substring(with: lineRange) - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - - if openFence == nil { - if trimmed.hasPrefix("```") { - openFence = lineRange.location - codeStart = NSMaxRange(lineRange) - let langId = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) - fenceLang = langId.isEmpty ? nil : langId.lowercased() - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) - textStorage.addAttribute(.font, value: monoFont, range: lineRange) - } - } else { - if trimmed == "```" { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) - textStorage.addAttribute(.font, value: monoFont, range: lineRange) - let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!) - fencedRanges.append(blockRange) - - // Apply tree-sitter highlighting to the code content - let codeEnd = lineRange.location - if let lang = fenceLang, codeEnd > codeStart { - let codeRange = NSRange(location: codeStart, length: codeEnd - codeStart) - let code = nsText.substring(with: codeRange) - textStorage.addAttribute(.font, value: monoFont, range: codeRange) - let spans = RustBridge.shared.highlight(source: code, lang: lang) - if !spans.isEmpty { - applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: monoFont, offset: codeStart) - } else { - textStorage.addAttribute(.foregroundColor, value: palette.text, range: codeRange) - } - } - - openFence = nil - fenceLang = nil - } else { - textStorage.addAttribute(.font, value: monoFont, range: lineRange) - textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange) - } - } - - lineStart = NSMaxRange(lineRange) - } - - return fencedRanges -} - -private func isInsideFencedBlock(_ lineRange: NSRange, fencedRanges: [NSRange]) -> Bool { - for fenced in fencedRanges { - if lineRange.location >= fenced.location && NSMaxRange(lineRange) <= NSMaxRange(fenced) { - return true - } - } - return false -} - -// MARK: - Inline Code - -private func highlightInlineCode(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette, baseFont: NSFont) { - guard let regex = try? NSRegularExpression(pattern: "`([^`]+)`") else { return } - let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) - let matches = regex.matches(in: textStorage.string, range: lineRange) - for match in matches { - let fullRange = match.range - let openTick = NSRange(location: fullRange.location, length: 1) - let closeTick = NSRange(location: fullRange.location + fullRange.length - 1, length: 1) - let contentRange = NSRange(location: fullRange.location + 1, length: fullRange.length - 2) - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openTick) - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeTick) - if contentRange.length > 0 { - textStorage.addAttribute(.font, value: monoFont, range: contentRange) - textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange) - textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: contentRange) - } - } -} - -// MARK: - Strikethrough - -private func highlightStrikethrough(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) { - guard let regex = try? NSRegularExpression(pattern: "~~(.+?)~~") else { return } - let matches = regex.matches(in: textStorage.string, range: lineRange) - for match in matches { - let fullRange = match.range - let openMarker = NSRange(location: fullRange.location, length: 2) - let closeMarker = NSRange(location: fullRange.location + fullRange.length - 2, length: 2) - let contentRange = NSRange(location: fullRange.location + 2, length: fullRange.length - 4) - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openMarker) - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeMarker) - if contentRange.length > 0 { - textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: contentRange) - } - } -} - -// MARK: - Footnotes - -private func highlightFootnoteRefs(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) { - // Inline refs: [^label] (but not definitions which start line with [^label]:) - guard let regex = try? NSRegularExpression(pattern: "\\[\\^[^\\]]+\\](?!:)") else { return } - let matches = regex.matches(in: textStorage.string, range: lineRange) - for match in matches { - textStorage.addAttribute(.foregroundColor, value: palette.blue, range: match.range) - textStorage.addAttribute(.superscript, value: 1, range: match.range) - } -} - -private func highlightFootnoteDefinition(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) { - guard let regex = try? NSRegularExpression(pattern: "^\\[\\^[^\\]]+\\]:") else { return } - let nsText = textStorage.string as NSString - let lineContent = nsText.substring(with: lineRange) - let localRange = NSRange(location: 0, length: (lineContent as NSString).length) - let matches = regex.matches(in: lineContent, range: localRange) - for match in matches { - let absRange = NSRange(location: lineRange.location + match.range.location, length: match.range.length) - textStorage.addAttribute(.foregroundColor, value: palette.lavender, range: absRange) - } -} - -// MARK: - Tables - -private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont, isHeader: Bool, isSeparator: Bool) { - textStorage.addAttribute(.foregroundColor, value: NSColor.clear, range: lineRange) - textStorage.addAttribute(.font, value: baseFont, range: lineRange) -} - -// MARK: - Lists and Horizontal Rules - -private func highlightOrderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { - guard let regex = try? NSRegularExpression(pattern: "^(\\s*)(\\d+\\.)( )") else { return } - let nsLine = line as NSString - let localRange = NSRange(location: 0, length: nsLine.length) - guard let match = regex.firstMatch(in: line, range: localRange) else { return } - let markerRange = match.range(at: 2) - let absMarker = NSRange(location: lineRange.location + markerRange.location, length: markerRange.length) - textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absMarker) - - applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) -} - -private func highlightUnorderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { - guard let regex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])( )") else { return } - let nsLine = line as NSString - let localRange = NSRange(location: 0, length: nsLine.length) - guard let match = regex.firstMatch(in: line, range: localRange) else { return } - let bulletRange = match.range(at: 2) - let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length) - textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet) - - applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) -} - -private func highlightTaskList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { - let checked: Bool - if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("+ [ ] ") { - checked = false - } else if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("+ [x] ") || - trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("* [X] ") || trimmed.hasPrefix("+ [X] ") { - checked = true - } else { - return - } - - // Find the checkbox in the actual line - guard let cbRegex = try? NSRegularExpression(pattern: "\\[[ xX]\\]") else { return } - guard let cbMatch = cbRegex.firstMatch(in: textStorage.string, range: lineRange) else { return } - let cbRange = cbMatch.range - - if checked { - textStorage.addAttribute(.foregroundColor, value: palette.green, range: cbRange) - let afterCb = NSRange(location: cbRange.location + cbRange.length, length: NSMaxRange(lineRange) - (cbRange.location + cbRange.length)) - if afterCb.length > 0 { - textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: afterCb) - } - } else { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: cbRange) - } - - // Bullet coloring - guard let bulletRegex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])") else { return } - let nsLine = line as NSString - guard let bMatch = bulletRegex.firstMatch(in: line, range: NSRange(location: 0, length: nsLine.length)) else { return } - let bulletRange = bMatch.range(at: 2) - let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length) - textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet) - - applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) -} - -private func applyListIndent(line: String, lineRange: NSRange, textStorage: NSTextStorage) { - let leading = line.prefix(while: { $0 == " " || $0 == "\t" }) - let spaces = leading.filter { $0 == " " }.count - let tabs = leading.filter { $0 == "\t" }.count - let level = tabs + spaces / 2 - if level > 0 { - let indent = NSMutableParagraphStyle() - let px = CGFloat(level) * 20.0 - indent.headIndent = px - indent.firstLineHeadIndent = px - textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange) - } -} - -private func isCordialLine(_ trimmed: String) -> Bool { - if trimmed.isEmpty { return false } - if trimmed.hasPrefix("/=") { return true } - if trimmed.hasPrefix("//") { return true } - if trimmed.hasPrefix("/*") { return true } - if trimmed.hasPrefix("let ") { return trimmed.contains("=") } - if trimmed.hasPrefix("fn ") { return true } - if trimmed.hasPrefix("while ") || trimmed.hasPrefix("while(") { return true } - if trimmed.hasPrefix("if ") || trimmed.hasPrefix("if(") { return true } - if trimmed.hasPrefix("else ") || trimmed == "else" || trimmed.hasPrefix("else{") { return true } - if trimmed.hasPrefix("for ") { return true } - if trimmed.hasPrefix("return ") || trimmed == "return" { return true } - if trimmed == "}" || trimmed.hasPrefix("} ") { return true } - guard let eqIdx = trimmed.firstIndex(of: "=") else { return false } - if eqIdx == trimmed.startIndex { return false } - let after = trimmed.index(after: eqIdx) - if after < trimmed.endIndex && trimmed[after] == "=" { return false } - let before = trimmed[trimmed.index(before: eqIdx)] - if before == "!" || before == "<" || before == ">" { return false } - return true -} - -private func isHorizontalRule(_ trimmed: String) -> Bool { - if trimmed.isEmpty { return false } - let stripped = trimmed.replacingOccurrences(of: " ", with: "") - if stripped.count < 3 { return false } - let allDash = stripped.allSatisfy { $0 == "-" } - let allStar = stripped.allSatisfy { $0 == "*" } - let allUnderscore = stripped.allSatisfy { $0 == "_" } - return allDash || allStar || allUnderscore -} - -private func findTableHeaderLines(textStorage: NSTextStorage, fencedRanges: [NSRange]) -> Set { - var headerStarts = Set() - let nsText = textStorage.string as NSString - var lineStart = 0 - var prevLineStart: Int? = nil - var prevTrimmed: String? = nil - - while lineStart < nsText.length { - let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) - - if !isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) { - let line = nsText.substring(with: lineRange) - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.hasPrefix("|"), isTableSeparator(trimmed), - let pStart = prevLineStart, let pTrimmed = prevTrimmed, - pTrimmed.hasPrefix("|") { - headerStarts.insert(pStart) - } - - prevLineStart = lineRange.location - prevTrimmed = trimmed - } else { - prevLineStart = nil - prevTrimmed = nil - } - - lineStart = NSMaxRange(lineRange) - } - - return headerStarts -} - -private func isTableSeparator(_ 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) - guard let regex = try? NSRegularExpression(pattern: "^:?-{1,}:?$") else { return false } - return regex.firstMatch(in: c, range: NSRange(location: 0, length: (c as NSString).length)) != nil - } -} - -// MARK: - Markdown Links - -private func highlightLinks(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) { - guard let regex = try? NSRegularExpression(pattern: "(? 0 { - let urlStr = (text as NSString).substring(with: match.range(at: 2)) - textStorage.addAttributes([ - .foregroundColor: palette.blue, - .underlineStyle: NSUnderlineStyle.single.rawValue, - .link: urlStr - ], range: textRange) - } - } -} - -// MARK: - Markdown Images - -private func highlightImages(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) { - guard let regex = try? NSRegularExpression(pattern: "!\\[([^\\]]*)\\]\\(([^)]+)\\)") else { return } - let text = textStorage.string - let fullRange = NSRange(location: 0, length: (text as NSString).length) - let matches = regex.matches(in: text, range: fullRange) - for match in matches { - if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue } - if isInsideInlineCode(match.range, in: textStorage) { continue } - - let bang = NSRange(location: match.range.location, length: 1) - let openBracket = NSRange(location: match.range.location + 1, length: 1) - let altRange = match.range(at: 1) - let closeBracketParen = NSRange( - location: altRange.location + altRange.length, - length: 2 - ) - let urlRange = match.range(at: 2) - let closeParen = NSRange( - location: match.range.location + match.range.length - 1, - length: 1 - ) - - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: bang) - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openBracket) - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeBracketParen) - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeParen) - - if altRange.length > 0 { - textStorage.addAttribute(.foregroundColor, value: palette.green, range: altRange) - } - if urlRange.length > 0 { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: urlRange) - } - } -} - -// MARK: - Autolinks - -private let autolinkDetector: NSDataDetector? = { - try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) -}() - -private func highlightAutolinks(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) { - guard let detector = autolinkDetector else { return } - let text = textStorage.string - let fullRange = NSRange(location: 0, length: (text as NSString).length) - - let inlineCodeRanges = collectInlineCodeRanges(in: textStorage) - let linkAttrRanges = collectLinkAttributeRanges(in: textStorage) - - let matches = detector.matches(in: text, range: fullRange) - for match in matches { - guard let url = match.url else { continue } - if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue } - if rangeOverlapsAny(match.range, ranges: inlineCodeRanges) { continue } - if rangeOverlapsAny(match.range, ranges: linkAttrRanges) { continue } - - textStorage.addAttributes([ - .foregroundColor: palette.blue, - .underlineStyle: NSUnderlineStyle.single.rawValue, - .link: url.absoluteString - ], range: match.range) - } -} - -private func isInsideInlineCode(_ range: NSRange, in textStorage: NSTextStorage) -> Bool { - guard let regex = try? NSRegularExpression(pattern: "`[^`]+`") else { return false } - let fullRange = NSRange(location: 0, length: (textStorage.string as NSString).length) - let matches = regex.matches(in: textStorage.string, range: fullRange) - for m in matches { - if m.range.location <= range.location && NSMaxRange(m.range) >= NSMaxRange(range) { - return true - } - } - return false -} - -private func collectInlineCodeRanges(in textStorage: NSTextStorage) -> [NSRange] { - guard let regex = try? NSRegularExpression(pattern: "`[^`]+`") else { return [] } - let fullRange = NSRange(location: 0, length: (textStorage.string as NSString).length) - return regex.matches(in: textStorage.string, range: fullRange).map { $0.range } -} - -private func collectLinkAttributeRanges(in textStorage: NSTextStorage) -> [NSRange] { - var ranges: [NSRange] = [] - let fullRange = NSRange(location: 0, length: textStorage.length) - textStorage.enumerateAttribute(.link, in: fullRange) { value, range, _ in - if value != nil { ranges.append(range) } - } - return ranges -} - -private func rangeOverlapsAny(_ range: NSRange, ranges: [NSRange]) -> Bool { - for r in ranges { - if NSIntersectionRange(range, r).length > 0 { return true } - } - return false -} - -// MARK: - Image Cache & Path Resolution - -let imageCacheDir: URL = { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".swiftly/images", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir -}() - -func resolveLocalImagePath(_ rawPath: String) -> String? { - if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil } - let expanded: String - if rawPath.hasPrefix("~/") { - expanded = (rawPath as NSString).expandingTildeInPath - } else if rawPath.hasPrefix("/") { - expanded = rawPath - } else if rawPath.hasPrefix("file://") { - expanded = URL(string: rawPath)?.path ?? rawPath - } else { - expanded = rawPath - } - return expanded -} - -// MARK: - LineNumberTextView - -class LineNumberTextView: NSTextView { - static let gutterWidth: CGFloat = 50 - static let evalLeftMargin: CGFloat = 80 - - var evalResults: [Int: EvalEntry] = [:] { - didSet { applyEvalSpacing() } - } - - override var textContainerOrigin: NSPoint { - return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height) - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - let insetWidth = textContainerInset.width - textContainer?.size.width = newSize.width - LineNumberTextView.gutterWidth - insetWidth - } - - override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) { - var widened = rect - widened.size.width = 2 - super.drawInsertionPoint(in: widened, color: color, turnedOn: flag) - } - - override func drawBackground(in rect: NSRect) { - super.drawBackground(in: rect) - - let origin = textContainerOrigin - let gutterRect = NSRect(x: 0, y: rect.origin.y, width: LineNumberTextView.gutterWidth, height: rect.height) - let lineMode = UserDefaults.standard.string(forKey: "lineIndicatorMode") ?? "on" - (lineMode == "off" ? Theme.current.base : Theme.current.mantle).setFill() - gutterRect.fill() - - drawLineNumbers(origin: origin) - } - - private func drawLineNumbers(origin: NSPoint) { - guard let lm = layoutManager, let tc = textContainer else { return } - let palette = Theme.current - let text = string as NSString - guard text.length > 0 else { return } - - let lineMode = ConfigManager.shared.lineIndicatorMode - - // Collect collapsed block source ranges (tables, HRs) to skip in gutter - var collapsedRanges: [NSRange] = [] - if let mlm = lm as? MarkdownLayoutManager { - for block in mlm.blockRanges { - switch block.kind { - case .tableBlock, .horizontalRule: - collapsedRanges.append(block.range) - default: - break - } - } - } - - var containerVisible = visibleRect - containerVisible.origin.x -= origin.x - containerVisible.origin.y -= origin.y - let visibleGlyphs = lm.glyphRange(forBoundingRect: containerVisible, in: tc) - let visibleChars = lm.characterRange(forGlyphRange: visibleGlyphs, actualGlyphRange: nil) - - var lineNumber = 1 - var idx = 0 - while idx < visibleChars.location { - if text.character(at: idx) == 0x0A { lineNumber += 1 } - idx += 1 - } - - var cursorLine = 1 - if lineMode == "vim" { - let cursorPos = selectedRange().location - var ci = 0 - while ci < min(cursorPos, text.length) { - if text.character(at: ci) == 0x0A { cursorLine += 1 } - ci += 1 - } - } - - let lineAttrs: [NSAttributedString.Key: Any] = [ - .font: Theme.gutterFont, - .foregroundColor: palette.overlay0 - ] - let currentLineAttrs: [NSAttributedString.Key: Any] = [ - .font: Theme.gutterFont, - .foregroundColor: palette.text - ] - let resultAttrs: [NSAttributedString.Key: Any] = [ - .font: Theme.gutterFont, - .foregroundColor: palette.teal - ] - - var charIndex = visibleChars.location - while charIndex < NSMaxRange(visibleChars) { - let lineRange = text.lineRange(for: NSRange(location: charIndex, length: 0)) - - let inCollapsedBlock = collapsedRanges.contains { NSIntersectionRange($0, lineRange).length > 0 } - if inCollapsedBlock { - lineNumber += 1 - charIndex = NSMaxRange(lineRange) - continue - } - - let lineGlyphRange = lm.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) - let lineRect = lm.boundingRect(forGlyphRange: lineGlyphRange, in: tc) - let y = lineRect.origin.y + origin.y - - if lineMode != "off" { - let displayNum: Int - let attrs: [NSAttributedString.Key: Any] - if lineMode == "vim" { - if lineNumber == cursorLine { - displayNum = lineNumber - attrs = currentLineAttrs - } else { - displayNum = abs(lineNumber - cursorLine) - attrs = lineAttrs - } - } else { - displayNum = lineNumber - attrs = lineAttrs - } - let numStr = NSAttributedString(string: "\(displayNum)", attributes: attrs) - let numSize = numStr.size() - numStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - numSize.width - 8, y: y)) - } - - if let entry = evalResults[lineNumber - 1] { - switch entry.format { - case .table: - drawTableResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs) - case .tree: - drawTreeResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs) - case .inline: - let resultStr = NSAttributedString(string: "\u{2192} \(entry.result)", attributes: resultAttrs) - let size = resultStr.size() - let rightEdge = visibleRect.maxX - resultStr.draw(at: NSPoint(x: rightEdge - size.width - 8, y: y)) - } - } - - lineNumber += 1 - charIndex = NSMaxRange(lineRange) - } - } - - // MARK: - Table/Tree Rendering - - private func drawTableResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) { - guard let data = json.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data), - let rows = parsed as? [[Any]] else { - let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs) - let size = fallback.size() - fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: lineRect.origin.y + origin.y)) - return - } - - let palette = Theme.current - let font = Theme.gutterFont - let headerAttrs: [NSAttributedString.Key: Any] = [ - .font: NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask), - .foregroundColor: palette.teal - ] - let cellAttrs: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: palette.subtext0 - ] - let borderColor = palette.surface1 - - let stringRows: [[String]] = rows.map { row in - row.map { cell in - if let s = cell as? String { return s } - if let n = cell as? NSNumber { return "\(n)" } - return "\(cell)" - } - } - guard !stringRows.isEmpty else { return } - - let colCount = stringRows.map(\.count).max() ?? 0 - guard colCount > 0 else { return } - - var colWidths = [CGFloat](repeating: 0, count: colCount) - for row in stringRows { - for (ci, cell) in row.enumerated() where ci < colCount { - let w = (cell as NSString).size(withAttributes: cellAttrs).width - colWidths[ci] = max(colWidths[ci], w) - } - } - - let cellPad: CGFloat = 8 - let rowHeight: CGFloat = font.pointSize + 6 - let tableWidth = colWidths.reduce(0, +) + cellPad * CGFloat(colCount + 1) + CGFloat(colCount - 1) - let tableHeight = rowHeight * CGFloat(stringRows.count) + CGFloat(stringRows.count + 1) - - let tableX = LineNumberTextView.evalLeftMargin - let tableY = lineRect.origin.y + origin.y + lineRect.height + 4 - - let tableRect = NSRect(x: tableX, y: tableY, width: tableWidth, height: tableHeight) - palette.mantle.setFill() - let path = NSBezierPath(roundedRect: tableRect, xRadius: 4, yRadius: 4) - path.fill() - borderColor.setStroke() - path.lineWidth = 0.5 - path.stroke() - - var cy = tableY + 2 - for (ri, row) in stringRows.enumerated() { - let attrs = ri == 0 ? headerAttrs : cellAttrs - var cx = tableX + cellPad - for (ci, cell) in row.enumerated() where ci < colCount { - let str = NSAttributedString(string: cell, attributes: attrs) - str.draw(at: NSPoint(x: cx, y: cy)) - cx += colWidths[ci] + cellPad - } - cy += rowHeight - - if ri == 0 { - borderColor.setStroke() - let linePath = NSBezierPath() - linePath.move(to: NSPoint(x: tableX + 2, y: cy - 1)) - linePath.line(to: NSPoint(x: tableX + tableWidth - 2, y: cy - 1)) - linePath.lineWidth = 0.5 - linePath.stroke() - } - } - } - - private func drawTreeResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) { - guard let data = json.data(using: .utf8), - let root = try? JSONSerialization.jsonObject(with: data) else { - let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs) - let size = fallback.size() - fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: lineRect.origin.y + origin.y)) - return - } - - let palette = Theme.current - let font = Theme.gutterFont - let nodeAttrs: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: palette.teal - ] - let branchAttrs: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: palette.overlay0 - ] - - var lines: [(String, Int)] = [] - func walk(_ node: Any, depth: Int) { - if let arr = node as? [Any] { - for (i, item) in arr.enumerated() { - let prefix = i == arr.count - 1 ? "\u{2514}" : "\u{251C}" - if item is [Any] { - lines.append(("\(prefix) [\(((item as? [Any])?.count ?? 0))]", depth)) - walk(item, depth: depth + 1) - } else { - lines.append(("\(prefix) \(item)", depth)) - } - } - } else { - lines.append(("\(node)", depth)) - } - } - - if let arr = root as? [Any] { - lines.append(("[\(arr.count)]", 0)) - walk(root, depth: 1) - } else { - lines.append(("\(root)", 0)) - } - - let lineHeight = font.pointSize + 4 - let indent: CGFloat = 14 - var maxWidth: CGFloat = 0 - for (text, depth) in lines { - let w = (text as NSString).size(withAttributes: nodeAttrs).width + CGFloat(depth) * indent - maxWidth = max(maxWidth, w) - } - - let treeHeight = lineHeight * CGFloat(lines.count) + 4 - let treeWidth = maxWidth + 16 - let treeX = LineNumberTextView.evalLeftMargin - let treeY = lineRect.origin.y + origin.y + lineRect.height + 4 - - let treeRect = NSRect(x: treeX, y: treeY, width: treeWidth, height: treeHeight) - palette.mantle.setFill() - let path = NSBezierPath(roundedRect: treeRect, xRadius: 4, yRadius: 4) - path.fill() - palette.surface1.setStroke() - path.lineWidth = 0.5 - path.stroke() - - var cy = treeY + 2 - for (text, depth) in lines { - let x = treeX + 8 + CGFloat(depth) * indent - let attrs = depth == 0 ? nodeAttrs : branchAttrs - let str = NSAttributedString(string: text, attributes: attrs) - str.draw(at: NSPoint(x: x, y: cy)) - cy += lineHeight - } - } - - // MARK: - Eval Spacing - - func applyEvalSpacing() { - guard let ts = textStorage else { return } - let text = ts.string as NSString - guard text.length > 0 else { return } - - ts.beginEditing() - - var lineStart = 0 - var lineNum = 0 - while lineStart < text.length { - let lineRange = text.lineRange(for: NSRange(location: lineStart, length: 0)) - if let entry = evalResults[lineNum] { - let spacing: CGFloat - switch entry.format { - case .tree: - spacing = evalTreeHeight(entry.result) + 8 - case .table: - spacing = evalTableHeight(entry.result) + 8 - case .inline: - spacing = 0 - } - if spacing > 0 { - let para = NSMutableParagraphStyle() - if let existing = ts.attribute(.paragraphStyle, at: lineRange.location, effectiveRange: nil) as? NSParagraphStyle { - para.setParagraphStyle(existing) - } - para.paragraphSpacing = spacing - ts.addAttribute(.paragraphStyle, value: para, range: lineRange) - } - } - lineNum += 1 - lineStart = NSMaxRange(lineRange) - } - - ts.endEditing() - } - - private func evalTreeHeight(_ json: String) -> CGFloat { - guard let data = json.data(using: .utf8), - let root = try? JSONSerialization.jsonObject(with: data) else { return 0 } - let font = Theme.gutterFont - let lineHeight = font.pointSize + 4 - var count = 0 - func walk(_ node: Any) { - if let arr = node as? [Any] { - for item in arr { - count += 1 - if item is [Any] { walk(item) } - } - } else { - count += 1 - } - } - if root is [Any] { - count = 1 - walk(root) - } else { - count = 1 - } - return lineHeight * CGFloat(count) + 4 - } - - private func evalTableHeight(_ json: String) -> CGFloat { - guard let data = json.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data), - let rows = parsed as? [[Any]] else { return 0 } - let font = Theme.gutterFont - let rowHeight = font.pointSize + 6 - return rowHeight * CGFloat(rows.count) + CGFloat(rows.count + 1) - } - - // MARK: - Paste - - override func paste(_ sender: Any?) { - let pb = NSPasteboard.general - - // Image paste - let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png] - if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }), - let data = pb.data(forType: imageType) { - if let image = NSImage(data: data), let pngData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: pngData), - let png = bitmap.representation(using: .png, properties: [:]) { - let uuid = UUID().uuidString - let path = imageCacheDir.appendingPathComponent("\(uuid).png") - do { - try png.write(to: path) - let markdown = "![image](~/.swiftly/images/\(uuid).png)" - insertText(markdown, replacementRange: selectedRange()) - return - } catch {} - } - } - - // Smart text paste with indent adjustment - if let text = pb.string(forType: .string) { - let lines = text.components(separatedBy: "\n") - if lines.count > 1 { - let str = string as NSString - let cursor = selectedRange().location - let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0)) - let currentLine = str.substring(with: lineRange) - var currentIndent = "" - for c in currentLine { - if c == " " || c == "\t" { currentIndent.append(c) } - else { break } - } - - var minIndent = Int.max - for line in lines where !line.trimmingCharacters(in: .whitespaces).isEmpty { - let spaces = line.prefix(while: { $0 == " " || $0 == "\t" }).count - minIndent = min(minIndent, spaces) - } - if minIndent == Int.max { minIndent = 0 } - - var adjusted: [String] = [] - for (i, line) in lines.enumerated() { - if line.trimmingCharacters(in: .whitespaces).isEmpty { - adjusted.append("") - } else { - let stripped = String(line.dropFirst(minIndent)) - if i == 0 { - adjusted.append(stripped) - } else { - adjusted.append(currentIndent + stripped) - } - } - } - - let result = adjusted.joined(separator: "\n") - insertText(result, replacementRange: selectedRange()) - return - } - } - - super.paste(sender) - } - - // MARK: - Drag and Drop - - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - if sender.draggingPasteboard.canReadObject(forClasses: [NSURL.self], options: [ - .urlReadingFileURLsOnly: true - ]) { - return .copy - } - return super.draggingEntered(sender) - } - - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: [ - .urlReadingFileURLsOnly: true - ]) as? [URL] else { - return super.performDragOperation(sender) - } - - let imageExts: Set = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"] - guard let ts = textStorage else { return false } - - var didInsert = false - for url in urls { - let ext = url.pathExtension.lowercased() - if imageExts.contains(ext) { - let uuid = UUID().uuidString - let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)") - let markdown: String - do { - try FileManager.default.copyItem(at: url, to: dest) - markdown = "![image](~/.swiftly/images/\(uuid).\(ext))" - } catch { - markdown = "![\(url.lastPathComponent)](\(url.path))" - } - insertText(markdown, replacementRange: selectedRange()) - didInsert = true - } else { - let attachment = NSTextAttachment() - let cell = FileEmbedCell(filePath: url.path) - attachment.attachmentCell = cell - let attachStr = NSMutableAttributedString(string: "\n") - attachStr.append(NSAttributedString(attachment: attachment)) - let insertAt = min(selectedRange().location, ts.length) - ts.insert(attachStr, at: insertAt) - didInsert = true - } - } - - return didInsert || super.performDragOperation(sender) - } -} - diff --git a/src/IcedViewportView.swift b/src/IcedViewportView.swift index f59bd01..3cbd480 100644 --- a/src/IcedViewportView.swift +++ b/src/IcedViewportView.swift @@ -26,6 +26,7 @@ class IcedViewportView: NSView { if window != nil && viewportHandle == nil { createViewport() startDisplayLink() + window?.makeFirstResponder(self) } else if window == nil { stopDisplayLink() destroyViewport() @@ -105,6 +106,7 @@ class IcedViewportView: NSView { // MARK: - Mouse Events override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) guard let h = viewportHandle else { return } let pt = convert(event.locationInWindow, from: nil) viewport_mouse_event(h, Float(pt.x), Float(pt.y), 0, true) diff --git a/src/SidebarView.swift b/src/SidebarView.swift deleted file mode 100644 index e6c6d46..0000000 --- a/src/SidebarView.swift +++ /dev/null @@ -1 +0,0 @@ -import SwiftUI diff --git a/viewport/src/bridge.rs b/viewport/src/bridge.rs index dfb9afb..bcf227d 100644 --- a/viewport/src/bridge.rs +++ b/viewport/src/bridge.rs @@ -6,7 +6,7 @@ use smol_str::SmolStr; use crate::ViewportHandle; pub fn push_mouse_event(handle: &mut ViewportHandle, x: f32, y: f32, button: u8, pressed: bool) { - let position = Point::new(x / handle.scale, y / handle.scale); + let position = Point::new(x, y); handle.cursor = mouse::Cursor::Available(position); handle.events.push(Event::Mouse(mouse::Event::CursorMoved { position })); @@ -34,10 +34,21 @@ pub fn push_key_event( ) { let modifiers = decode_modifiers(modifier_flags); let physical = key::Physical::Unidentified(key::NativeCode::MacOS(keycode as u16)); - let logical = text - .filter(|s| !s.is_empty()) - .map(|s| keyboard::Key::Character(SmolStr::new(s))) - .unwrap_or(keyboard::Key::Unidentified); + + let named = keycode_to_named(keycode); + let logical = if let Some(n) = named { + keyboard::Key::Named(n) + } else { + text.filter(|s| !s.is_empty()) + .map(|s| keyboard::Key::Character(SmolStr::new(s))) + .unwrap_or(keyboard::Key::Unidentified) + }; + + let insert_text = if named.is_some() { + None + } else { + text.filter(|s| !s.is_empty()).map(SmolStr::new) + }; if pressed { handle.events.push(Event::Keyboard(keyboard::Event::KeyPressed { @@ -46,7 +57,7 @@ pub fn push_key_event( physical_key: physical, location: keyboard::Location::Standard, modifiers, - text: text.filter(|s| !s.is_empty()).map(SmolStr::new), + text: insert_text, repeat: false, })); } else { @@ -60,6 +71,38 @@ pub fn push_key_event( } } +fn keycode_to_named(keycode: u32) -> Option { + use keyboard::key::Named; + match keycode { + 36 => Some(Named::Enter), + 48 => Some(Named::Tab), + 51 => Some(Named::Backspace), + 53 => Some(Named::Escape), + 117 => Some(Named::Delete), + 123 => Some(Named::ArrowLeft), + 124 => Some(Named::ArrowRight), + 125 => Some(Named::ArrowDown), + 126 => Some(Named::ArrowUp), + 115 => Some(Named::Home), + 119 => Some(Named::End), + 116 => Some(Named::PageUp), + 121 => Some(Named::PageDown), + 122 => Some(Named::F1), + 120 => Some(Named::F2), + 99 => Some(Named::F3), + 118 => Some(Named::F4), + 96 => Some(Named::F5), + 97 => Some(Named::F6), + 98 => Some(Named::F7), + 100 => Some(Named::F8), + 101 => Some(Named::F9), + 109 => Some(Named::F10), + 103 => Some(Named::F11), + 111 => Some(Named::F12), + _ => None, + } +} + pub fn push_scroll_event( handle: &mut ViewportHandle, x: f32, @@ -67,7 +110,7 @@ pub fn push_scroll_event( delta_x: f32, delta_y: f32, ) { - let position = Point::new(x / handle.scale, y / handle.scale); + let position = Point::new(x, y); handle.cursor = mouse::Cursor::Available(position); handle.events.push(Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Pixels { diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index f2a21e8..37f7410 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -1,27 +1,50 @@ -use iced_wgpu::core::{Color, Element, Length, Theme}; -use iced_widget::{container, Text}; +use iced_wgpu::core::text::Wrapping; +use iced_wgpu::core::{ + Background, Border, Color, Element, Font, Length, Padding, Theme, +}; +use iced_widget::text_editor::{self, Style}; + +#[derive(Debug, Clone)] +pub enum Message { + EditorAction(text_editor::Action), +} pub struct EditorState { - pub text: String, + pub content: text_editor::Content, + pub font_size: f32, } impl EditorState { pub fn new() -> Self { Self { - text: String::from("Swiftly"), + content: text_editor::Content::new(), + font_size: 14.0, } } - pub fn view(&self) -> Element<'_, (), Theme, iced_wgpu::Renderer> { - container( - Text::new(&self.text) - .size(32) - .color(Color::WHITE), - ) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .into() + pub fn update(&mut self, message: Message) { + match message { + Message::EditorAction(action) => { + self.content.perform(action); + } + } + } + + pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + iced_widget::text_editor(&self.content) + .on_action(Message::EditorAction) + .font(Font::MONOSPACE) + .size(self.font_size) + .height(Length::Fill) + .padding(Padding { top: 38.0, right: 8.0, bottom: 8.0, left: 8.0 }) + .wrapping(Wrapping::Word) + .style(|_theme, _status| Style { + background: Background::Color(Color::from_rgb(0.08, 0.08, 0.10)), + border: Border::default(), + placeholder: Color::from_rgb(0.4, 0.4, 0.4), + value: Color::WHITE, + selection: Color::from_rgba(0.3, 0.5, 0.8, 0.4), + }) + .into() } } diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 4118200..cbb6b68 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -1,17 +1,49 @@ use std::ffi::c_void; use std::ptr::NonNull; -use iced_graphics::{Viewport, Shell}; +use iced_graphics::{Shell, Viewport}; use iced_runtime::user_interface::{self, UserInterface}; use iced_wgpu::core::renderer::Style; -use iced_wgpu::core::{clipboard, mouse, Color, Font, Pixels, Size, Theme}; +use iced_wgpu::core::time::Instant; +use iced_wgpu::core::{clipboard, mouse, window, Color, Event, Font, Pixels, Point, Size, Theme}; use iced_wgpu::Engine; -use raw_window_handle::{AppKitDisplayHandle, AppKitWindowHandle, RawDisplayHandle, RawWindowHandle}; +use raw_window_handle::{ + AppKitDisplayHandle, AppKitWindowHandle, RawDisplayHandle, RawWindowHandle, +}; -use crate::editor::EditorState; +use crate::editor::{EditorState, Message}; use crate::ViewportHandle; -pub fn create(nsview: *mut c_void, width: f32, height: f32, scale: f32) -> Option { +struct MacClipboard; + +impl clipboard::Clipboard for MacClipboard { + fn read(&self, _kind: clipboard::Kind) -> Option { + std::process::Command::new("pbpaste") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + } + + fn write(&mut self, _kind: clipboard::Kind, contents: String) { + use std::io::Write; + if let Ok(mut child) = std::process::Command::new("pbcopy") + .stdin(std::process::Stdio::piped()) + .spawn() + { + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(contents.as_bytes()); + } + let _ = child.wait(); + } + } +} + +pub fn create( + nsview: *mut c_void, + width: f32, + height: f32, + scale: f32, +) -> Option { let ptr = NonNull::new(nsview)?; let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { @@ -72,16 +104,17 @@ pub fn create(nsview: *mut c_void, width: f32, height: f32, scale: f32) -> Optio Shell::headless(), ); - let renderer = iced_wgpu::Renderer::new( - engine, - Font::DEFAULT, - Pixels(16.0), - ); + let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(16.0)); - let viewport = Viewport::with_physical_size( - Size::new(phys_w.max(1), phys_h.max(1)), - scale, - ); + let viewport = + Viewport::with_physical_size(Size::new(phys_w.max(1), phys_h.max(1)), scale); + + let focus_point = Point::new(width / 2.0, height / 2.0); + let initial_events = vec![ + Event::Mouse(mouse::Event::CursorMoved { position: focus_point }), + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ]; Some(ViewportHandle { surface, @@ -95,8 +128,8 @@ pub fn create(nsview: *mut c_void, width: f32, height: f32, scale: f32) -> Optio viewport, cache: user_interface::Cache::new(), state: EditorState::new(), - events: Vec::new(), - cursor: mouse::Cursor::Unavailable, + events: initial_events, + cursor: mouse::Cursor::Available(focus_point), }) } @@ -109,6 +142,10 @@ pub fn render(handle: &mut ViewportHandle) { let logical_size = handle.viewport.logical_size(); + handle + .events + .push(Event::Window(window::Event::RedrawRequested(Instant::now()))); + let cache = std::mem::take(&mut handle.cache); let mut ui = UserInterface::build( handle.state.view(), @@ -117,8 +154,8 @@ pub fn render(handle: &mut ViewportHandle) { &mut handle.renderer, ); - let mut clipboard = clipboard::Null; - let mut messages: Vec<()> = Vec::new(); + let mut clipboard = MacClipboard; + let mut messages: Vec = Vec::new(); let _ = ui.update( &handle.events, @@ -129,21 +166,31 @@ pub fn render(handle: &mut ViewportHandle) { ); handle.events.clear(); + let cache = ui.into_cache(); + + for msg in messages.drain(..) { + handle.state.update(msg); + } + let theme = Theme::Dark; let style = Style { text_color: Color::WHITE, }; + let mut ui = UserInterface::build( + handle.state.view(), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); handle.cache = ui.into_cache(); let bg = Color::from_rgb(0.08, 0.08, 0.10); - handle.renderer.present( - Some(bg), - handle.format, - &view, - &handle.viewport, - ); + handle + .renderer + .present(Some(bg), handle.format, &view, &handle.viewport); frame.present(); } @@ -159,10 +206,7 @@ pub fn resize(handle: &mut ViewportHandle, width: f32, height: f32, scale: f32) handle.height = phys_h; handle.scale = scale; - handle.viewport = Viewport::with_physical_size( - Size::new(phys_w, phys_h), - scale, - ); + handle.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale); handle.surface.configure( &handle.device,