table and HR blocks render in paragraph-spacing gaps instead of glyph-metric overlays
This commit is contained in:
parent
b4411cc33f
commit
405772ee6b
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue