From 3e52866825d0de7028dbcf3ce63a7794fdfe513c Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 7 Apr 2026 13:41:47 -0700 Subject: [PATCH] content-width tables, add-column button, column resize handles --- src/CompositorView.swift | 278 ++++++++++++++++++++++++++++++++++----- 1 file changed, 243 insertions(+), 35 deletions(-) diff --git a/src/CompositorView.swift b/src/CompositorView.swift index 89f05ef..d1d5e32 100644 --- a/src/CompositorView.swift +++ b/src/CompositorView.swift @@ -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.. CGFloat { + func columnX(for col: Int) -> CGFloat { var x: CGFloat = 1 for i in 0.. 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.. 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.. 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