table enhancements: focus-only chrome, clickable headers, drag handle, resize indicators, export
This commit is contained in:
parent
63e926893b
commit
63fa4ef39a
|
|
@ -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?) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue