table and HR blocks render in paragraph-spacing gaps instead of glyph-metric overlays

This commit is contained in:
jess 2026-04-07 07:44:08 -07:00
parent b4411cc33f
commit 405772ee6b
1 changed files with 316 additions and 27 deletions

View File

@ -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..<table.rows.count {
rowHeights.append(defaultCellHeight)
}
}
private 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 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..<colCount {
let cellText = col < row.count ? row[col] : ""
let x = columnX(for: col)
let field = makeCell(text: cellText, frame: NSRect(x: x, y: yOffset + 2, width: columnWidths[col], height: h - 4),
isHeader: false, row: rowIdx, col: col)
containerView.addSubview(field)
rowFields.append(field)
}
cellFields.append(rowFields)
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))
line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor
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))
line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor
containerView.addSubview(line)
divY += rowHeights[i]
}
}
private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> 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()
}
}
}
}