diff --git a/src/EditorView.swift b/src/EditorView.swift index 56efa55..ffd943c 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers // MARK: - MarkdownLayoutManager @@ -340,11 +341,13 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { private let indicatorRowHeight: CGFloat = 20 private let indicatorColWidth: CGFloat = 30 - private var indicatorsVisible = true - private var indicatorContainer: NSView? + 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) } + 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 @@ -356,8 +359,6 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { wantsLayer = true layer?.backgroundColor = Theme.current.base.cgColor layer?.cornerRadius = 4 - layer?.borderWidth = 1 - layer?.borderColor = Theme.current.surface2.cgColor initSizes(width: width) buildGrid() setupTrackingArea() @@ -407,16 +408,21 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { return y } + private var gridOriginX: CGFloat { chromeVisible ? indicatorColWidth : 0 } + private var gridOriginY: CGFloat { chromeVisible ? indicatorRowHeight : 0 } + private func buildGrid() { subviews.forEach { $0.removeFromSuperview() } - indicatorContainer = nil + chromeContainer = nil + dragHighlightView = nil + dragHandleView = nil cellFields = [] let colCount = table.headers.count guard colCount > 0 else { return } let th = totalHeight - let ox = indicatorColWidth - let oy = indicatorRowHeight + let ox = gridOriginX + let oy = gridOriginY let gridWidth = columnX(for: colCount) + 1 let fullWidth = gridWidth + ox let fullHeight = th + oy @@ -474,7 +480,14 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { addSubview(line) } - buildIndicators() + if chromeVisible { + layer?.borderWidth = 1 + layer?.borderColor = Theme.current.surface2.cgColor + buildChrome() + } else { + layer?.borderWidth = 0 + layer?.borderColor = nil + } } private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> NSTextField { @@ -513,16 +526,15 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { return result } - // MARK: - Indicators + // MARK: - Chrome (indicators, handle, border — focus-only) - private func buildIndicators() { - indicatorContainer?.removeFromSuperview() + private func buildChrome() { + chromeContainer?.removeFromSuperview() let container = NSView(frame: bounds) container.wantsLayer = true - container.alphaValue = indicatorsVisible ? 1 : 0 addSubview(container) - indicatorContainer = container + chromeContainer = container let colCount = table.headers.count let totalRows = 1 + table.rows.count @@ -534,47 +546,58 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { 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() { @@ -654,7 +721,9 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { override func mouseMoved(with event: NSEvent) { let pt = convert(event.locationInWindow, from: nil) - if columnDivider(at: pt) != 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() @@ -665,21 +734,35 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { override func mouseEntered(with event: NSEvent) { mouseInside = true - showIndicators() + showChrome() } override func mouseExited(with event: NSEvent) { NSCursor.arrow.set() mouseInside = false + removeDragHighlight() checkFocusState() } override func mouseDown(with event: NSEvent) { - if !indicatorsVisible { + if !chromeVisible { mouseInside = true - showIndicators() + 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) @@ -688,6 +771,7 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { dragMode = .column(col) dragStartPoint = pt dragStartSize = columnWidths[col] + showColumnDragHighlight(col) return } if let row = rowDivider(at: pt) { @@ -698,6 +782,7 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { dragMode = .row(row) dragStartPoint = pt dragStartSize = rowHeights[row] + showRowDragHighlight(row) return } dragMode = .none @@ -711,22 +796,69 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { 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) { - if case .none = dragMode { + 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 { @@ -777,11 +909,62 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { 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 !indicatorsVisible { - showIndicators() + if !chromeVisible { + showChrome() } } @@ -832,6 +1015,46 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { } } +// 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 @@ -923,8 +1146,8 @@ struct EditorView: View { .padding(.top, 4) } - private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] { - var shifted: [Int: String] = [:] + private func offsetEvalResults(_ results: [Int: EvalEntry]) -> [Int: EvalEntry] { + var shifted: [Int: EvalEntry] = [:] for (key, val) in results where key > 0 { shifted[key - 1] = val } @@ -934,7 +1157,7 @@ struct EditorView: View { struct EditorTextView: NSViewRepresentable { @Binding var text: String - var evalResults: [Int: String] + var evalResults: [Int: EvalEntry] var onEvaluate: () -> Void var onBackspaceAtStart: (() -> Void)? = nil @@ -1586,8 +1809,13 @@ private func highlightCodeLine(_ line: String, lineRange: NSRange, textStorage: return } - // Eval prefix - if trimmed.hasPrefix("/=") { + 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) @@ -2208,7 +2436,7 @@ private func resolveLocalImagePath(_ rawPath: String) -> String? { class LineNumberTextView: NSTextView { static let gutterWidth: CGFloat = 50 - var evalResults: [Int: String] = [:] + var evalResults: [Int: EvalEntry] = [:] override var textContainerOrigin: NSPoint { return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height) @@ -2309,8 +2537,17 @@ class LineNumberTextView: NSTextView { numStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - numSize.width - 8, y: y)) } - if let result = evalResults[lineNumber - 1] { - let resultStr = NSAttributedString(string: "\u{2192} \(result)", attributes: resultAttrs) + if let entry = evalResults[lineNumber - 1] { + let displayText: String + switch entry.format { + case .table: + displayText = "\u{2192} [T] \(entry.result.prefix(50))" + case .tree: + displayText = "\u{2192} [R] \(entry.result.prefix(50))" + case .inline: + displayText = "\u{2192} \(entry.result)" + } + let resultStr = NSAttributedString(string: displayText, attributes: resultAttrs) let size = resultStr.size() let rightEdge = visibleRect.maxX resultStr.draw(at: NSPoint(x: rightEdge - size.width - 8, y: y)) @@ -2321,6 +2558,165 @@ class LineNumberTextView: NSTextView { } } + // MARK: - Table/Tree Rendering + + private func drawTableResult(_ json: String, at y: CGFloat, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) { + guard let data = json.data(using: .utf8), + let rows = try? JSONSerialization.jsonObject(with: data) as? [[Any]] else { + let fallback = NSAttributedString(string: "\u{2192} \(json)", attributes: resultAttrs) + let size = fallback.size() + fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: 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 rightEdge = visibleRect.maxX + let tableX = rightEdge - tableWidth - 8 + let tableY = y + + 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 + 1 + 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)) + linePath.line(to: NSPoint(x: tableX + tableWidth - 2, y: cy)) + linePath.lineWidth = 0.5 + linePath.stroke() + } + } + } + + private func drawTreeResult(_ json: String, at y: CGFloat, 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)", attributes: resultAttrs) + let size = fallback.size() + fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: 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() { + if item is [Any] { + let prefix = i == arr.count - 1 ? "\u{2514}" : "\u{251C}" + lines.append(("\(prefix) [\(((item as? [Any])?.count ?? 0))]", depth)) + walk(item, depth: depth + 1) + } else { + let prefix = i == arr.count - 1 ? "\u{2514}" : "\u{251C}" + 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 rightEdge = visibleRect.maxX + let treeX = rightEdge - treeWidth - 8 + let treeY = y + + 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: - Paste (image from clipboard) override func paste(_ sender: Any?) {