diff --git a/src/CompositorView.swift b/src/CompositorView.swift
index b5167d3..89f05ef 100644
--- a/src/CompositorView.swift
+++ b/src/CompositorView.swift
@@ -287,6 +287,8 @@ func serializeDocument(_ blocks: [CompositorBlock]) -> String {
parts.append(textBlock.text)
} else if let hrBlock = block as? HRBlock {
parts.append(hrBlock.sourceText)
+ } else if let tableBlock = block as? TableBlock {
+ parts.append(rebuildTableMarkdown(tableBlock.table))
} else {
parts.append("")
}
@@ -312,9 +314,10 @@ private func isDocTableSeparator(_ trimmed: String) -> Bool {
}
}
-// MARK: - HRBlock (placeholder for Stage 3, minimal for compilation)
+// MARK: - HRBlock
class HRDrawingView: NSView {
+ override var isFlipped: Bool { true }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
let y = bounds.midY
@@ -330,14 +333,16 @@ class HRDrawingView: NSView {
class HRBlock: NSObject, CompositorBlock {
let sourceText: String
+ var sourceRange: NSRange
private let hrView: HRDrawingView
var view: NSView { hrView }
var blockHeight: CGFloat { 20 }
- init(sourceText: String = "---") {
+ init(sourceText: String = "---", sourceRange: NSRange = NSRange(location: 0, length: 0)) {
self.sourceText = sourceText
+ self.sourceRange = sourceRange
hrView = HRDrawingView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
super.init()
}
@@ -349,6 +354,214 @@ class HRBlock: NSObject, CompositorBlock {
func resignActiveBlock() {}
}
+class FlippedBlockView: NSView {
+ override var isFlipped: Bool { true }
+}
+
+// MARK: - TableBlock
+
+class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
+
+ var table: ParsedTable
+ var sourceRange: NSRange
+ var onTableChanged: ((ParsedTable, NSRange) -> Void)?
+
+ private let containerView: NSView
+ private var cellFields: [[NSTextField]] = []
+ private var columnWidths: [CGFloat] = []
+ private var rowHeights: [CGFloat] = []
+ private var gridWidth: CGFloat = 0
+
+ private let minColWidth: CGFloat = 40
+ private let defaultHeaderHeight: CGFloat = 28
+ private let defaultCellHeight: CGFloat = 26
+
+ var view: NSView { containerView }
+
+ var blockHeight: CGFloat {
+ rowHeights.reduce(0, +) + 2
+ }
+
+ init(table: ParsedTable, width: CGFloat) {
+ self.table = table
+ self.sourceRange = table.sourceRange
+ self.gridWidth = width
+ containerView = FlippedBlockView(frame: .zero)
+ containerView.wantsLayer = true
+ containerView.layer?.backgroundColor = Theme.current.base.cgColor
+ containerView.layer?.cornerRadius = 4
+ super.init()
+ initSizes(width: width)
+ buildGrid()
+ }
+
+ 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 = [defaultHeaderHeight]
+ for _ in 0..
CGFloat {
+ var x: CGFloat = 1
+ for i in 0.. 1 {
+ gridWidth = width
+ initSizes(width: width)
+ buildGrid()
+ }
+ }
+
+ func becomeActiveBlock() {}
+
+ func resignActiveBlock() {}
+
+ private func buildGrid() {
+ containerView.subviews.forEach { $0.removeFromSuperview() }
+ cellFields = []
+
+ 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 headerBg = NSView(frame: NSRect(x: 0, y: 0, width: totalGridWidth, height: rowHeights[0]))
+ headerBg.wantsLayer = true
+ headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor
+ containerView.addSubview(headerBg)
+
+ var headerFields: [NSTextField] = []
+ for (col, header) in table.headers.enumerated() {
+ let x = columnX(for: col)
+ let h = rowHeights[0]
+ let field = makeCell(text: header, frame: NSRect(x: x, y: 2, width: columnWidths[col], height: h - 4),
+ isHeader: true, row: -1, col: col)
+ containerView.addSubview(field)
+ headerFields.append(field)
+ }
+ cellFields.append(headerFields)
+
+ var yOffset = rowHeights[0]
+ for (rowIdx, row) in table.rows.enumerated() {
+ var rowFields: [NSTextField] = []
+ let h = rowHeights[rowIdx + 1]
+ for col in 0.. NSTextField {
+ let field = NSTextField(frame: frame)
+ field.stringValue = text
+ field.isEditable = true
+ field.isSelectable = true
+ field.isBordered = false
+ field.isBezeled = false
+ field.drawsBackground = false
+ field.wantsLayer = true
+ field.font = isHeader
+ ? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
+ : Theme.editorFont
+ field.textColor = Theme.current.text
+ field.focusRingType = .none
+ field.cell?.truncatesLastVisibleLine = true
+ field.cell?.usesSingleLineMode = true
+ field.tag = (row + 1) * 1000 + col
+ field.delegate = self
+ if let align = table.alignments[safe: col] {
+ switch align {
+ case .left: field.alignment = .left
+ case .center: field.alignment = .center
+ case .right: field.alignment = .right
+ }
+ }
+ return field
+ }
+
+ // MARK: - Cell editing
+
+ func controlTextDidEndEditing(_ obj: Notification) {
+ guard let field = obj.object as? NSTextField else { return }
+ let tag = field.tag
+ let row = tag / 1000 - 1
+ let col = tag % 1000
+
+ if row == -1 {
+ guard col < table.headers.count else { return }
+ table.headers[col] = field.stringValue
+ } else {
+ guard row < table.rows.count, col < table.rows[row].count else { return }
+ table.rows[row][col] = field.stringValue
+ }
+ onTableChanged?(table, sourceRange)
+
+ 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])
+ }
+ }
+ }
+
+ func control(_ control: NSControl, textView tv: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+ if commandSelector == #selector(NSResponder.insertNewline(_:)) {
+ table.rows.append(Array(repeating: "", count: table.headers.count))
+ rowHeights.append(defaultCellHeight)
+ buildGrid()
+ onTableChanged?(table, sourceRange)
+ return true
+ }
+ return false
+ }
+
+}
+
+private extension Array {
+ subscript(safe index: Int) -> Element? {
+ indices.contains(index) ? self[index] : nil
+ }
+}
+
// MARK: - CompositorRepresentable
struct CompositorRepresentable: NSViewRepresentable {
@@ -439,6 +652,9 @@ struct CompositorRepresentable: NSViewRepresentable {
}
textView.selectedRanges = selectedRanges
updateBlockRanges(for: textView)
+ DispatchQueue.main.async {
+ context.coordinator.triggerTableUpdate()
+ }
}
textView.backgroundColor = Theme.current.base
textView.insertionPointColor = Theme.current.text
@@ -456,7 +672,8 @@ struct CompositorRepresentable: NSViewRepresentable {
weak var textView: NSTextView?
private var isUpdatingImages = false
private var isUpdatingTables = false
- private var embeddedTableViews: [MarkdownTableView] = []
+ private var tableBlocks: [TableBlock] = []
+ private var hrBlocks: [HRBlock] = []
private var observers: [NSObjectProtocol] = []
init(_ parent: CompositorRepresentable) {
@@ -866,53 +1083,121 @@ struct CompositorRepresentable: NSViewRepresentable {
return results
}
- // MARK: - Embedded tables
+ // MARK: - Block positioning
private func updateEmbeddedTables() {
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
- let tc = tv.textContainer else { return }
+ let tc = tv.textContainer, let ts = tv.textStorage else { return }
- for tableView in embeddedTableViews {
- tableView.removeFromSuperview()
- }
- embeddedTableViews.removeAll()
+ for tb in tableBlocks { tb.view.removeFromSuperview() }
+ for hb in hrBlocks { hb.view.removeFromSuperview() }
+ tableBlocks.removeAll()
+ hrBlocks.removeAll()
let origin = tv.textContainerOrigin
let text = tv.string as NSString
- for block in lm.blockRanges {
- guard case .tableBlock = block.kind else { continue }
- guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
+ guard text.length > 0 else { return }
+ lm.ensureLayout(for: tc)
+
+ var blockSpacings: [(NSRange, CGFloat)] = []
+
+ for block in lm.blockRanges {
+ 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)
+ tb.onTableChanged = { [weak self] updatedTable, range in
+ self?.applyTableEdit(updatedTable, sourceRange: range)
+ }
+ blockSpacings.append((block.range, tb.blockHeight))
+ tableBlocks.append(tb)
+
+ case .horizontalRule:
+ let src = text.substring(with: block.range)
+ let hb = HRBlock(sourceText: src, sourceRange: block.range)
+ blockSpacings.append((block.range, hb.blockHeight))
+ hrBlocks.append(hb)
+
+ default:
+ break
+ }
+ }
+
+ // Collapse source lines and create gaps via paragraph spacing
+ isUpdatingTables = true
+ let tinyFont = NSFont.systemFont(ofSize: 0.1)
+ ts.beginEditing()
+ for (range, height) in blockSpacings {
+ // Make source text invisible: tiny font, zero foreground alpha
+ ts.addAttribute(.font, value: tinyFont, range: range)
+ ts.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
+
+ // Collapse line heights and add spacing for the block view
+ let para = NSMutableParagraphStyle()
+ para.minimumLineHeight = 0.1
+ para.maximumLineHeight = 0.1
+ para.lineSpacing = 0
+ para.paragraphSpacing = 0
+ para.paragraphSpacingBefore = 0
+ ts.addAttribute(.paragraphStyle, value: para, range: range)
+
+ // On the last line, add paragraph spacing equal to block height
+ let lastLineRange = text.lineRange(for: NSRange(location: max(0, NSMaxRange(range) - 1), length: 0))
+ let lastPara = NSMutableParagraphStyle()
+ lastPara.minimumLineHeight = 0.1
+ lastPara.maximumLineHeight = 0.1
+ lastPara.lineSpacing = 0
+ lastPara.paragraphSpacing = height
+ lastPara.paragraphSpacingBefore = 0
+ ts.addAttribute(.paragraphStyle, value: lastPara, range: lastLineRange)
+ }
+ ts.endEditing()
+ isUpdatingTables = false
+
+ lm.ensureLayout(for: tc)
+
+ // Position blocks at the origin of their collapsed source text
+ var tableIdx = 0
+ var hrIdx = 0
+ 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
- let tableX = origin.x + 4
- let tableY = rect.origin.y + origin.y
- let tableWidth = tc.containerSize.width - 8
+ switch block.kind {
+ case .tableBlock:
+ 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)
+ tv.addSubview(tb.view)
- let tableView = MarkdownTableView(table: parsed, width: tableWidth)
- tableView.frame.origin = NSPoint(x: tableX, y: tableY)
- tableView.textView = tv
+ case .horizontalRule:
+ guard hrIdx < hrBlocks.count else { continue }
+ let hb = hrBlocks[hrIdx]
+ hrIdx += 1
+ hb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: w, height: hb.blockHeight)
+ tv.addSubview(hb.view)
- tableView.onTableChanged = { [weak self] updatedTable in
- self?.applyTableEdit(updatedTable)
+ default:
+ break
}
-
- tv.addSubview(tableView)
- embeddedTableViews.append(tableView)
}
}
- private func applyTableEdit(_ table: ParsedTable) {
+ private func applyTableEdit(_ table: ParsedTable, sourceRange: NSRange) {
guard let tv = textView, let ts = tv.textStorage else { return }
let newMarkdown = rebuildTableMarkdown(table)
- let range = table.sourceRange
- guard NSMaxRange(range) <= ts.length else { return }
+ guard NSMaxRange(sourceRange) <= ts.length else { return }
isUpdatingTables = true
let sel = tv.selectedRanges
ts.beginEditing()
- ts.replaceCharacters(in: range, with: newMarkdown)
+ ts.replaceCharacters(in: sourceRange, with: newMarkdown)
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
ts.endEditing()
(tv as? LineNumberTextView)?.applyEvalSpacing()
@@ -920,6 +1205,10 @@ struct CompositorRepresentable: NSViewRepresentable {
parent.text = tv.string
updateBlockRanges(for: tv)
isUpdatingTables = false
+
+ DispatchQueue.main.async { [weak self] in
+ self?.updateEmbeddedTables()
+ }
}
}
}