table enhancements: focus-only chrome, clickable headers, drag handle, resize indicators, export

This commit is contained in:
jess 2026-04-06 13:51:07 -07:00
parent 63e926893b
commit 63fa4ef39a
1 changed files with 463 additions and 67 deletions

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import UniformTypeIdentifiers
// MARK: - MarkdownLayoutManager // MARK: - MarkdownLayoutManager
@ -340,11 +341,13 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
private let indicatorRowHeight: CGFloat = 20 private let indicatorRowHeight: CGFloat = 20
private let indicatorColWidth: CGFloat = 30 private let indicatorColWidth: CGFloat = 30
private var indicatorsVisible = true private var chromeVisible = false
private var indicatorContainer: NSView? private var chromeContainer: NSView?
private var dragHighlightView: NSView?
private var dragHandleView: NSView?
private var focusMonitor: Any? 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 dragMode: DragMode = .none
private var dragStartPoint: NSPoint = .zero private var dragStartPoint: NSPoint = .zero
private var dragStartSize: CGFloat = 0 private var dragStartSize: CGFloat = 0
@ -356,8 +359,6 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
wantsLayer = true wantsLayer = true
layer?.backgroundColor = Theme.current.base.cgColor layer?.backgroundColor = Theme.current.base.cgColor
layer?.cornerRadius = 4 layer?.cornerRadius = 4
layer?.borderWidth = 1
layer?.borderColor = Theme.current.surface2.cgColor
initSizes(width: width) initSizes(width: width)
buildGrid() buildGrid()
setupTrackingArea() setupTrackingArea()
@ -407,16 +408,21 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
return y return y
} }
private var gridOriginX: CGFloat { chromeVisible ? indicatorColWidth : 0 }
private var gridOriginY: CGFloat { chromeVisible ? indicatorRowHeight : 0 }
private func buildGrid() { private func buildGrid() {
subviews.forEach { $0.removeFromSuperview() } subviews.forEach { $0.removeFromSuperview() }
indicatorContainer = nil chromeContainer = nil
dragHighlightView = nil
dragHandleView = nil
cellFields = [] cellFields = []
let colCount = table.headers.count let colCount = table.headers.count
guard colCount > 0 else { return } guard colCount > 0 else { return }
let th = totalHeight let th = totalHeight
let ox = indicatorColWidth let ox = gridOriginX
let oy = indicatorRowHeight let oy = gridOriginY
let gridWidth = columnX(for: colCount) + 1 let gridWidth = columnX(for: colCount) + 1
let fullWidth = gridWidth + ox let fullWidth = gridWidth + ox
let fullHeight = th + oy let fullHeight = th + oy
@ -474,7 +480,14 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
addSubview(line) 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 { private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> NSTextField {
@ -513,16 +526,15 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
return result return result
} }
// MARK: - Indicators // MARK: - Chrome (indicators, handle, border focus-only)
private func buildIndicators() { private func buildChrome() {
indicatorContainer?.removeFromSuperview() chromeContainer?.removeFromSuperview()
let container = NSView(frame: bounds) let container = NSView(frame: bounds)
container.wantsLayer = true container.wantsLayer = true
container.alphaValue = indicatorsVisible ? 1 : 0
addSubview(container) addSubview(container)
indicatorContainer = container chromeContainer = container
let colCount = table.headers.count let colCount = table.headers.count
let totalRows = 1 + table.rows.count let totalRows = 1 + table.rows.count
@ -534,47 +546,58 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
corner.layer?.backgroundColor = indicatorBg.cgColor corner.layer?.backgroundColor = indicatorBg.cgColor
container.addSubview(corner) 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..<colCount { for col in 0..<colCount {
let x = indicatorColWidth + columnX(for: col) let x = indicatorColWidth + columnX(for: col)
let label = NSTextField(labelWithString: columnLetter(for: col)) let btn = TableIndicatorButton(frame: NSRect(x: x, y: topY - indicatorRowHeight, width: columnWidths[col], height: indicatorRowHeight))
label.font = NSFont.systemFont(ofSize: 10, weight: .medium) btn.label = columnLetter(for: col)
label.textColor = Theme.current.overlay2 btn.bgColor = indicatorBg
label.alignment = .center btn.textColor = Theme.current.overlay2
label.frame = NSRect(x: x, y: topY - indicatorRowHeight, width: columnWidths[col], height: indicatorRowHeight) btn.onPress = { [weak self] in self?.selectColumn(col) }
label.wantsLayer = true container.addSubview(btn)
label.layer?.backgroundColor = indicatorBg.cgColor
container.addSubview(label)
} }
for row in 0..<totalRows { for row in 0..<totalRows {
let y = rowY(for: row) + indicatorRowHeight let y = rowY(for: row) + indicatorRowHeight
let label = NSTextField(labelWithString: "\(row + 1)") let btn = TableIndicatorButton(frame: NSRect(x: 0, y: y, width: indicatorColWidth, height: rowHeights[row]))
label.font = NSFont.systemFont(ofSize: 10, weight: .medium) btn.label = "\(row + 1)"
label.textColor = Theme.current.overlay2 btn.bgColor = indicatorBg
label.alignment = .center btn.textColor = Theme.current.overlay2
label.frame = NSRect(x: 0, y: y, width: indicatorColWidth, height: rowHeights[row]) btn.onPress = { [weak self] in self?.selectRow(row) }
label.wantsLayer = true container.addSubview(btn)
label.layer?.backgroundColor = indicatorBg.cgColor
container.addSubview(label)
} }
} }
private func showIndicators() { private func showChrome() {
guard !indicatorsVisible else { return } guard !chromeVisible else { return }
indicatorsVisible = true chromeVisible = true
NSAnimationContext.runAnimationGroup { ctx in buildGrid()
ctx.duration = 0.2
self.indicatorContainer?.animator().alphaValue = 1
}
} }
private func hideIndicators() { private func hideChrome() {
guard indicatorsVisible else { return } guard chromeVisible else { return }
indicatorsVisible = false chromeVisible = false
NSAnimationContext.runAnimationGroup { ctx in buildGrid()
ctx.duration = 0.2
self.indicatorContainer?.animator().alphaValue = 0
}
} }
private func setupFocusMonitoring() { private func setupFocusMonitoring() {
@ -597,26 +620,58 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
} else { } else {
hasFocusedCell = cellFields.joined().contains { $0 === fr } hasFocusedCell = cellFields.joined().contains { $0 === fr }
} }
if hasFocusedCell && !indicatorsVisible { if hasFocusedCell && !chromeVisible {
showIndicators() showChrome()
} else if !hasFocusedCell && indicatorsVisible && !mouseInside { } else if !hasFocusedCell && chromeVisible && !mouseInside {
hideIndicators() hideChrome()
} }
} }
private var mouseInside = false private var mouseInside = false
// MARK: - Selection
private func selectColumn(_ col: Int) {
guard col < table.headers.count else { return }
for fieldRow in cellFields {
guard col < fieldRow.count else { continue }
fieldRow[col].selectText(nil)
}
if let first = cellFields.first, col < first.count {
window?.makeFirstResponder(first[col])
}
}
private func selectRow(_ row: Int) {
guard row < cellFields.count else { return }
let fields = cellFields[row]
for field in fields { field.selectText(nil) }
if let first = fields.first {
window?.makeFirstResponder(first)
}
}
private func selectAllCells() {
for fieldRow in cellFields {
for field in fieldRow { field.selectText(nil) }
}
if let first = cellFields.first?.first {
window?.makeFirstResponder(first)
}
}
// MARK: - Resize hit detection // MARK: - Resize hit detection
private func columnDivider(at point: NSPoint) -> Int? { private func columnDivider(at point: NSPoint) -> Int? {
let ox = gridOriginX
let colCount = table.headers.count let colCount = table.headers.count
for i in 1..<colCount { for i in 1..<colCount {
let divX = columnX(for: i) - 1 + indicatorColWidth let divX = columnX(for: i) - 1 + ox
if abs(point.x - divX) <= dividerHitZone { if abs(point.x - divX) <= dividerHitZone {
return i - 1 return i - 1
} }
} }
let lastX = columnX(for: colCount) + indicatorColWidth let lastX = columnX(for: colCount) + ox
if abs(point.x - lastX) <= dividerHitZone { if abs(point.x - lastX) <= dividerHitZone {
return colCount - 1 return colCount - 1
} }
@ -624,13 +679,14 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
} }
private func rowDivider(at point: NSPoint) -> Int? { private func rowDivider(at point: NSPoint) -> Int? {
let oy = gridOriginY
let totalRows = 1 + table.rows.count let totalRows = 1 + table.rows.count
for i in 0..<totalRows { for i in 0..<totalRows {
let divY: CGFloat let divY: CGFloat
if i == 0 { if i == 0 {
divY = totalHeight - rowHeights[0] + indicatorRowHeight divY = totalHeight - rowHeights[0] + oy
} else { } else {
divY = rowY(for: i) + indicatorRowHeight divY = rowY(for: i) + oy
} }
if abs(point.y - divY) <= dividerHitZone { if abs(point.y - divY) <= dividerHitZone {
return i return i
@ -639,6 +695,17 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
return nil return nil
} }
private func isInCorner(_ pt: NSPoint) -> 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 // MARK: - Tracking area
private func setupTrackingArea() { private func setupTrackingArea() {
@ -654,7 +721,9 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
override func mouseMoved(with event: NSEvent) { override func mouseMoved(with event: NSEvent) {
let pt = convert(event.locationInWindow, from: nil) 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() NSCursor.resizeLeftRight.set()
} else if rowDivider(at: pt) != nil { } else if rowDivider(at: pt) != nil {
NSCursor.resizeUpDown.set() NSCursor.resizeUpDown.set()
@ -665,21 +734,35 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
override func mouseEntered(with event: NSEvent) { override func mouseEntered(with event: NSEvent) {
mouseInside = true mouseInside = true
showIndicators() showChrome()
} }
override func mouseExited(with event: NSEvent) { override func mouseExited(with event: NSEvent) {
NSCursor.arrow.set() NSCursor.arrow.set()
mouseInside = false mouseInside = false
removeDragHighlight()
checkFocusState() checkFocusState()
} }
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
if !indicatorsVisible { if !chromeVisible {
mouseInside = true mouseInside = true
showIndicators() showChrome()
} }
let pt = convert(event.locationInWindow, from: nil) 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 let col = columnDivider(at: pt) {
if event.clickCount == 2 { if event.clickCount == 2 {
autoFitColumn(col) autoFitColumn(col)
@ -688,6 +771,7 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
dragMode = .column(col) dragMode = .column(col)
dragStartPoint = pt dragStartPoint = pt
dragStartSize = columnWidths[col] dragStartSize = columnWidths[col]
showColumnDragHighlight(col)
return return
} }
if let row = rowDivider(at: pt) { if let row = rowDivider(at: pt) {
@ -698,6 +782,7 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
dragMode = .row(row) dragMode = .row(row)
dragStartPoint = pt dragStartPoint = pt
dragStartSize = rowHeights[row] dragStartSize = rowHeights[row]
showRowDragHighlight(row)
return return
} }
dragMode = .none dragMode = .none
@ -711,22 +796,69 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
let delta = pt.x - dragStartPoint.x let delta = pt.x - dragStartPoint.x
columnWidths[col] = max(minColWidth, dragStartSize + delta) columnWidths[col] = max(minColWidth, dragStartSize + delta)
buildGrid() buildGrid()
showColumnDragHighlight(col)
case .row(let row): case .row(let row):
let delta = dragStartPoint.y - pt.y let delta = dragStartPoint.y - pt.y
rowHeights[row] = max(minRowHeight, dragStartSize + delta) rowHeights[row] = max(minRowHeight, dragStartSize + delta)
buildGrid() 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: case .none:
super.mouseDragged(with: event) super.mouseDragged(with: event)
} }
} }
override func mouseUp(with event: NSEvent) { 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) super.mouseUp(with: event)
} }
dragMode = .none 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 // MARK: - Auto-fit
private func measureColumnWidth(_ col: Int) -> CGFloat { private func measureColumnWidth(_ col: Int) -> CGFloat {
@ -777,11 +909,62 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
buildGrid() 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 // MARK: - Cell editing
func controlTextDidBeginEditing(_ obj: Notification) { func controlTextDidBeginEditing(_ obj: Notification) {
if !indicatorsVisible { if !chromeVisible {
showIndicators() 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 { private extension Array {
subscript(safe index: Int) -> Element? { subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil indices.contains(index) ? self[index] : nil
@ -923,8 +1146,8 @@ struct EditorView: View {
.padding(.top, 4) .padding(.top, 4)
} }
private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] { private func offsetEvalResults(_ results: [Int: EvalEntry]) -> [Int: EvalEntry] {
var shifted: [Int: String] = [:] var shifted: [Int: EvalEntry] = [:]
for (key, val) in results where key > 0 { for (key, val) in results where key > 0 {
shifted[key - 1] = val shifted[key - 1] = val
} }
@ -934,7 +1157,7 @@ struct EditorView: View {
struct EditorTextView: NSViewRepresentable { struct EditorTextView: NSViewRepresentable {
@Binding var text: String @Binding var text: String
var evalResults: [Int: String] var evalResults: [Int: EvalEntry]
var onEvaluate: () -> Void var onEvaluate: () -> Void
var onBackspaceAtStart: (() -> Void)? = nil var onBackspaceAtStart: (() -> Void)? = nil
@ -1586,8 +1809,13 @@ private func highlightCodeLine(_ line: String, lineRange: NSRange, textStorage:
return return
} }
// Eval prefix if trimmed.hasPrefix("/=|") || trimmed.hasPrefix("/=\\") {
if 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) let prefixRange = (textStorage.string as NSString).range(of: "/=", range: lineRange)
if prefixRange.location != NSNotFound { if prefixRange.location != NSNotFound {
textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange) textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange)
@ -2208,7 +2436,7 @@ private func resolveLocalImagePath(_ rawPath: String) -> String? {
class LineNumberTextView: NSTextView { class LineNumberTextView: NSTextView {
static let gutterWidth: CGFloat = 50 static let gutterWidth: CGFloat = 50
var evalResults: [Int: String] = [:] var evalResults: [Int: EvalEntry] = [:]
override var textContainerOrigin: NSPoint { override var textContainerOrigin: NSPoint {
return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height) 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)) numStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - numSize.width - 8, y: y))
} }
if let result = evalResults[lineNumber - 1] { if let entry = evalResults[lineNumber - 1] {
let resultStr = NSAttributedString(string: "\u{2192} \(result)", attributes: resultAttrs) 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 size = resultStr.size()
let rightEdge = visibleRect.maxX let rightEdge = visibleRect.maxX
resultStr.draw(at: NSPoint(x: rightEdge - size.width - 8, y: y)) 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) // MARK: - Paste (image from clipboard)
override func paste(_ sender: Any?) { override func paste(_ sender: Any?) {