content-width tables, add-column button, column resize handles

This commit is contained in:
jess 2026-04-07 13:41:47 -07:00
parent 405772ee6b
commit 3e52866825
1 changed files with 243 additions and 35 deletions

View File

@ -358,6 +358,125 @@ 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 {
@ -366,13 +485,15 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
var sourceRange: NSRange
var onTableChanged: ((ParsedTable, NSRange) -> Void)?
private let containerView: NSView
private var cellFields: [[NSTextField]] = []
private var columnWidths: [CGFloat] = []
private let containerView: TableBlockView
fileprivate var cellFields: [[NSTextField]] = []
fileprivate var columnWidths: [CGFloat] = []
private var customWidths: [Int: CGFloat] = [:]
private var rowHeights: [CGFloat] = []
private var gridWidth: CGFloat = 0
private let minColWidth: CGFloat = 40
let minColWidth: CGFloat = 60
let maxColWidth: CGFloat = 300
private let cellPadding: CGFloat = 16
private let defaultHeaderHeight: CGFloat = 28
private let defaultCellHeight: CGFloat = 26
@ -382,49 +503,92 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
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
self.gridWidth = width
containerView = FlippedBlockView(frame: .zero)
containerView = TableBlockView(frame: .zero)
containerView.wantsLayer = true
containerView.layer?.backgroundColor = Theme.current.base.cgColor
containerView.layer?.cornerRadius = 4
super.init()
initSizes(width: width)
containerView.tableBlock = self
initSizes()
buildGrid()
}
private func initSizes(width: CGFloat) {
// 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 }
let available = width - CGFloat(colCount + 1)
let colW = available / CGFloat(colCount)
columnWidths = Array(repeating: max(colW, minColWidth), count: colCount)
columnWidths = (0..<colCount).map { col in
customWidths[col] ?? measureColumnWidth(col)
}
rowHeights = [defaultHeaderHeight]
for _ in 0..<table.rows.count {
rowHeights.append(defaultCellHeight)
}
}
private func columnX(for col: Int) -> CGFloat {
func columnX(for col: Int) -> CGFloat {
var x: CGFloat = 1
for i in 0..<col { x += columnWidths[i] + 1 }
return x
}
func layoutBlock(width: CGFloat) {
if abs(gridWidth - width) > 1 {
gridWidth = width
initSizes(width: width)
buildGrid()
}
func setColumnWidth(_ col: Int, to width: CGFloat) {
guard col >= 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..<table.rows.count {
table.rows[i].append("")
}
initSizes()
buildGrid()
onTableChanged?(table, sourceRange)
}
// MARK: - Grid
private func buildGrid() {
containerView.subviews.forEach { $0.removeFromSuperview() }
cellFields = []
@ -432,10 +596,11 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
let colCount = table.headers.count
guard colCount > 0 else { return }
let th = rowHeights.reduce(0, +)
let totalGridWidth = columnX(for: colCount) + 1
containerView.frame.size = NSSize(width: max(gridWidth, totalGridWidth), height: th + 2)
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: totalGridWidth, height: rowHeights[0]))
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)
@ -467,7 +632,6 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
yOffset += h
}
// Vertical dividers
for i in 1..<colCount {
let x = columnX(for: i) - 1
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: th))
@ -476,15 +640,17 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
containerView.addSubview(line)
}
// Horizontal dividers
var divY: CGFloat = rowHeights[0]
for i in 1..<rowHeights.count {
let line = NSView(frame: NSRect(x: 0, y: divY, width: totalGridWidth, height: 1))
let line = NSView(frame: NSRect(x: 0, y: divY, width: gridW, height: 1))
line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor
containerView.addSubview(line)
divY += rowHeights[i]
}
containerView.setupTracking()
containerView.updateAddButton()
}
private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> NSTextField {
@ -534,11 +700,28 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
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 fieldRow = nextRow + 1
if fieldRow < cellFields.count, actualCol < cellFields[fieldRow].count {
containerView.window?.makeFirstResponder(cellFields[fieldRow][actualCol])
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])
}
}
}
@ -1106,8 +1289,7 @@ struct CompositorRepresentable: NSViewRepresentable {
switch block.kind {
case .tableBlock:
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
let tableWidth = tc.containerSize.width - 8
let tb = TableBlock(table: parsed, width: tableWidth)
let tb = TableBlock(table: parsed, width: 0)
tb.onTableChanged = { [weak self] updatedTable, range in
self?.applyTableEdit(updatedTable, sourceRange: range)
}
@ -1164,7 +1346,6 @@ struct CompositorRepresentable: NSViewRepresentable {
for block in lm.blockRanges {
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
let w = tc.containerSize.width - 8
let x = origin.x + 4
switch block.kind {
@ -1172,14 +1353,15 @@ struct CompositorRepresentable: NSViewRepresentable {
guard tableIdx < tableBlocks.count else { continue }
let tb = tableBlocks[tableIdx]
tableIdx += 1
tb.layoutBlock(width: w)
tb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: w, height: tb.blockHeight)
let tableW = tb.view.frame.width
tb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: tableW, height: tb.blockHeight)
tv.addSubview(tb.view)
case .horizontalRule:
guard hrIdx < hrBlocks.count else { continue }
let hb = hrBlocks[hrIdx]
hrIdx += 1
let w = tc.containerSize.width - 8
hb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: w, height: hb.blockHeight)
tv.addSubview(hb.view)
@ -1199,6 +1381,32 @@ struct CompositorRepresentable: NSViewRepresentable {
ts.beginEditing()
ts.replaceCharacters(in: sourceRange, with: newMarkdown)
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
// Collapse new source text and set paragraph spacing for updated table height
let newRange = NSRange(location: sourceRange.location, length: (newMarkdown as NSString).length)
let blockHeight: CGFloat = 28 + CGFloat(table.rows.count) * 26 + 2
let tinyFont = NSFont.systemFont(ofSize: 0.1)
ts.addAttribute(.font, value: tinyFont, range: newRange)
ts.addAttribute(.foregroundColor, value: NSColor.clear, range: newRange)
let collapsePara = NSMutableParagraphStyle()
collapsePara.minimumLineHeight = 0.1
collapsePara.maximumLineHeight = 0.1
collapsePara.lineSpacing = 0
collapsePara.paragraphSpacing = 0
collapsePara.paragraphSpacingBefore = 0
ts.addAttribute(.paragraphStyle, value: collapsePara, range: newRange)
let text = ts.string as NSString
let lastLineRange = text.lineRange(for: NSRange(location: max(0, NSMaxRange(newRange) - 1), length: 0))
let lastPara = NSMutableParagraphStyle()
lastPara.minimumLineHeight = 0.1
lastPara.maximumLineHeight = 0.1
lastPara.lineSpacing = 0
lastPara.paragraphSpacing = blockHeight
lastPara.paragraphSpacingBefore = 0
ts.addAttribute(.paragraphStyle, value: lastPara, range: lastLineRange)
ts.endEditing()
(tv as? LineNumberTextView)?.applyEvalSpacing()
tv.selectedRanges = sel