add resizable columns and rows to markdown table view

This commit is contained in:
jess 2026-04-06 11:45:42 -07:00
parent 8f4f1fddc7
commit 96262ca9ef
1 changed files with 178 additions and 26 deletions

View File

@ -321,81 +321,129 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
var onTableChanged: ((ParsedTable) -> Void)? var onTableChanged: ((ParsedTable) -> Void)?
private var cellFields: [[NSTextField]] = [] private var cellFields: [[NSTextField]] = []
private let cellHeight: CGFloat = 26 private var columnWidths: [CGFloat] = []
private let cellPadding: CGFloat = 4 private var rowHeights: [CGFloat] = []
private let headerHeight: CGFloat = 28 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 enum DragMode { case none, column(Int), row(Int) }
private var dragMode: DragMode = .none
private var dragStartPoint: NSPoint = .zero
private var dragStartSize: CGFloat = 0
init(table: ParsedTable, width: CGFloat) { init(table: ParsedTable, width: CGFloat) {
self.table = table self.table = table
self.tableWidth = width
super.init(frame: .zero) super.init(frame: .zero)
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?.borderWidth = 1
layer?.borderColor = Theme.current.surface2.cgColor layer?.borderColor = Theme.current.surface2.cgColor
buildGrid(width: width) initSizes(width: width)
buildGrid()
setupTrackingArea()
} }
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
private func buildGrid(width: CGFloat) { 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..<table.rows.count {
rowHeights.append(defaultCellHeight)
}
}
private var totalHeight: CGFloat {
rowHeights.reduce(0, +)
}
private func columnX(for col: Int) -> CGFloat {
var x: CGFloat = 1
for i in 0..<col {
x += columnWidths[i] + 1
}
return x
}
private func rowY(for row: Int) -> CGFloat {
let th = totalHeight
var y = th
for i in 0...row {
y -= rowHeights[i]
}
return y
}
private func buildGrid() {
subviews.forEach { $0.removeFromSuperview() } subviews.forEach { $0.removeFromSuperview() }
cellFields = [] cellFields = []
let colCount = table.headers.count let colCount = table.headers.count
guard colCount > 0 else { return } guard colCount > 0 else { return }
let colWidth = (width - CGFloat(colCount + 1)) / CGFloat(colCount) let th = totalHeight
let totalRows = 1 + table.rows.count let totalWidth = columnX(for: colCount) + 1
let totalHeight = headerHeight + CGFloat(table.rows.count) * cellHeight
frame.size = NSSize(width: width, height: totalHeight) frame.size = NSSize(width: totalWidth, height: th)
// Header row let headerBg = NSView(frame: NSRect(x: 0, y: th - rowHeights[0], width: totalWidth, height: rowHeights[0]))
let headerBg = NSView(frame: NSRect(x: 0, y: totalHeight - headerHeight, width: width, height: headerHeight))
headerBg.wantsLayer = true headerBg.wantsLayer = true
headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor
addSubview(headerBg) addSubview(headerBg)
var headerFields: [NSTextField] = [] var headerFields: [NSTextField] = []
for (col, header) in table.headers.enumerated() { for (col, header) in table.headers.enumerated() {
let x = CGFloat(col) * colWidth + CGFloat(col + 1) let x = columnX(for: col)
let field = makeCell(text: header, frame: NSRect(x: x, y: totalHeight - headerHeight + 2, width: colWidth, height: headerHeight - 4), isHeader: true, row: -1, col: col) let h = rowHeights[0]
let field = makeCell(text: header, frame: NSRect(x: x, y: th - h + 2, width: columnWidths[col], height: h - 4), isHeader: true, row: -1, col: col)
addSubview(field) addSubview(field)
headerFields.append(field) headerFields.append(field)
} }
cellFields.append(headerFields) cellFields.append(headerFields)
// Data rows
for (rowIdx, row) in table.rows.enumerated() { for (rowIdx, row) in table.rows.enumerated() {
var rowFields: [NSTextField] = [] var rowFields: [NSTextField] = []
let y = totalHeight - headerHeight - CGFloat(rowIdx + 1) * cellHeight let y = rowY(for: rowIdx + 1)
let h = rowHeights[rowIdx + 1]
for (col, cell) in row.enumerated() where col < colCount { for (col, cell) in row.enumerated() where col < colCount {
let x = CGFloat(col) * colWidth + CGFloat(col + 1) let x = columnX(for: col)
let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col) 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) addSubview(field)
rowFields.append(field) rowFields.append(field)
} }
while rowFields.count < colCount { while rowFields.count < colCount {
let col = rowFields.count let col = rowFields.count
let x = CGFloat(col) * colWidth + CGFloat(col + 1) let x = columnX(for: col)
let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col) 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) addSubview(field)
rowFields.append(field) rowFields.append(field)
} }
cellFields.append(rowFields) cellFields.append(rowFields)
} }
// Grid lines let totalRows = 1 + table.rows.count
for i in 1..<colCount { for i in 1..<colCount {
let x = CGFloat(i) * (colWidth + 1) let x = columnX(for: i) - 1
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: totalHeight)) let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: th))
line.wantsLayer = true line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor line.layer?.backgroundColor = Theme.current.surface2.cgColor
addSubview(line) addSubview(line)
} }
for i in 0..<totalRows { for i in 0..<totalRows {
let y = totalHeight - headerHeight - CGFloat(i) * cellHeight let lineY = (i == 0) ? th - rowHeights[0] : rowY(for: i)
let line = NSView(frame: NSRect(x: 0, y: y, width: width, height: 1)) let line = NSView(frame: NSRect(x: 0, y: lineY, width: totalWidth, height: 1))
line.wantsLayer = true line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor line.layer?.backgroundColor = Theme.current.surface2.cgColor
addSubview(line) addSubview(line)
@ -426,6 +474,110 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
return field return field
} }
// MARK: - Resize hit detection
private func columnDivider(at point: NSPoint) -> Int? {
let colCount = table.headers.count
for i in 1..<colCount {
let divX = columnX(for: i) - 1
if abs(point.x - divX) <= dividerHitZone {
return i - 1
}
}
let lastX = columnX(for: colCount)
if abs(point.x - lastX) <= dividerHitZone {
return colCount - 1
}
return nil
}
private func rowDivider(at point: NSPoint) -> Int? {
let totalRows = 1 + table.rows.count
for i in 0..<totalRows {
let divY: CGFloat
if i == 0 {
divY = totalHeight - rowHeights[0]
} else {
divY = rowY(for: i)
}
if abs(point.y - divY) <= dividerHitZone {
return i
}
}
return nil
}
// 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 columnDivider(at: pt) != nil {
NSCursor.resizeLeftRight.set()
} else if rowDivider(at: pt) != nil {
NSCursor.resizeUpDown.set()
} else {
NSCursor.arrow.set()
}
}
override func mouseExited(with event: NSEvent) {
NSCursor.arrow.set()
}
override func mouseDown(with event: NSEvent) {
let pt = convert(event.locationInWindow, from: nil)
if let col = columnDivider(at: pt) {
dragMode = .column(col)
dragStartPoint = pt
dragStartSize = columnWidths[col]
return
}
if let row = rowDivider(at: pt) {
dragMode = .row(row)
dragStartPoint = pt
dragStartSize = rowHeights[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()
case .row(let row):
let delta = dragStartPoint.y - pt.y
rowHeights[row] = max(minRowHeight, dragStartSize + delta)
buildGrid()
case .none:
super.mouseDragged(with: event)
}
}
override func mouseUp(with event: NSEvent) {
if case .none = dragMode {
super.mouseUp(with: event)
}
dragMode = .none
}
// MARK: - Cell editing
func controlTextDidEndEditing(_ obj: Notification) { func controlTextDidEndEditing(_ obj: Notification) {
guard let field = obj.object as? NSTextField else { return } guard let field = obj.object as? NSTextField else { return }
let tag = field.tag let tag = field.tag
@ -464,8 +616,8 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
table.rows.append(Array(repeating: "", count: table.headers.count)) table.rows.append(Array(repeating: "", count: table.headers.count))
onTableChanged?(table) onTableChanged?(table)
let width = frame.width rowHeights.append(defaultCellHeight)
buildGrid(width: width) buildGrid()
needsLayout = true needsLayout = true
return true return true
} }