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)
|
parts.append(textBlock.text)
|
||||||
} else if let hrBlock = block as? HRBlock {
|
} else if let hrBlock = block as? HRBlock {
|
||||||
parts.append(hrBlock.sourceText)
|
parts.append(hrBlock.sourceText)
|
||||||
|
} else if let tableBlock = block as? TableBlock {
|
||||||
|
parts.append(rebuildTableMarkdown(tableBlock.table))
|
||||||
} else {
|
} else {
|
||||||
parts.append("")
|
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 {
|
class HRDrawingView: NSView {
|
||||||
|
override var isFlipped: Bool { true }
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
super.draw(dirtyRect)
|
super.draw(dirtyRect)
|
||||||
let y = bounds.midY
|
let y = bounds.midY
|
||||||
|
|
@ -330,14 +333,16 @@ class HRDrawingView: NSView {
|
||||||
class HRBlock: NSObject, CompositorBlock {
|
class HRBlock: NSObject, CompositorBlock {
|
||||||
|
|
||||||
let sourceText: String
|
let sourceText: String
|
||||||
|
var sourceRange: NSRange
|
||||||
private let hrView: HRDrawingView
|
private let hrView: HRDrawingView
|
||||||
|
|
||||||
var view: NSView { hrView }
|
var view: NSView { hrView }
|
||||||
|
|
||||||
var blockHeight: CGFloat { 20 }
|
var blockHeight: CGFloat { 20 }
|
||||||
|
|
||||||
init(sourceText: String = "---") {
|
init(sourceText: String = "---", sourceRange: NSRange = NSRange(location: 0, length: 0)) {
|
||||||
self.sourceText = sourceText
|
self.sourceText = sourceText
|
||||||
|
self.sourceRange = sourceRange
|
||||||
hrView = HRDrawingView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
|
hrView = HRDrawingView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
@ -349,6 +354,214 @@ class HRBlock: NSObject, CompositorBlock {
|
||||||
func resignActiveBlock() {}
|
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
|
// MARK: - CompositorRepresentable
|
||||||
|
|
||||||
struct CompositorRepresentable: NSViewRepresentable {
|
struct CompositorRepresentable: NSViewRepresentable {
|
||||||
|
|
@ -439,6 +652,9 @@ struct CompositorRepresentable: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
textView.selectedRanges = selectedRanges
|
textView.selectedRanges = selectedRanges
|
||||||
updateBlockRanges(for: textView)
|
updateBlockRanges(for: textView)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
context.coordinator.triggerTableUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
textView.backgroundColor = Theme.current.base
|
textView.backgroundColor = Theme.current.base
|
||||||
textView.insertionPointColor = Theme.current.text
|
textView.insertionPointColor = Theme.current.text
|
||||||
|
|
@ -456,7 +672,8 @@ struct CompositorRepresentable: NSViewRepresentable {
|
||||||
weak var textView: NSTextView?
|
weak var textView: NSTextView?
|
||||||
private var isUpdatingImages = false
|
private var isUpdatingImages = false
|
||||||
private var isUpdatingTables = false
|
private var isUpdatingTables = false
|
||||||
private var embeddedTableViews: [MarkdownTableView] = []
|
private var tableBlocks: [TableBlock] = []
|
||||||
|
private var hrBlocks: [HRBlock] = []
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
init(_ parent: CompositorRepresentable) {
|
init(_ parent: CompositorRepresentable) {
|
||||||
|
|
@ -866,53 +1083,121 @@ struct CompositorRepresentable: NSViewRepresentable {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Embedded tables
|
// MARK: - Block positioning
|
||||||
|
|
||||||
private func updateEmbeddedTables() {
|
private func updateEmbeddedTables() {
|
||||||
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
|
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 {
|
for tb in tableBlocks { tb.view.removeFromSuperview() }
|
||||||
tableView.removeFromSuperview()
|
for hb in hrBlocks { hb.view.removeFromSuperview() }
|
||||||
}
|
tableBlocks.removeAll()
|
||||||
embeddedTableViews.removeAll()
|
hrBlocks.removeAll()
|
||||||
|
|
||||||
let origin = tv.textContainerOrigin
|
let origin = tv.textContainerOrigin
|
||||||
let text = tv.string as NSString
|
let text = tv.string as NSString
|
||||||
for block in lm.blockRanges {
|
guard text.length > 0 else { return }
|
||||||
guard case .tableBlock = block.kind else { continue }
|
|
||||||
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
|
||||||
|
|
||||||
|
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 glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
||||||
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
||||||
|
let w = tc.containerSize.width - 8
|
||||||
|
let x = origin.x + 4
|
||||||
|
|
||||||
let tableX = origin.x + 4
|
switch block.kind {
|
||||||
let tableY = rect.origin.y + origin.y
|
case .tableBlock:
|
||||||
let tableWidth = tc.containerSize.width - 8
|
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)
|
case .horizontalRule:
|
||||||
tableView.frame.origin = NSPoint(x: tableX, y: tableY)
|
guard hrIdx < hrBlocks.count else { continue }
|
||||||
tableView.textView = tv
|
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
|
default:
|
||||||
self?.applyTableEdit(updatedTable)
|
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 }
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||||
let newMarkdown = rebuildTableMarkdown(table)
|
let newMarkdown = rebuildTableMarkdown(table)
|
||||||
let range = table.sourceRange
|
guard NSMaxRange(sourceRange) <= ts.length else { return }
|
||||||
guard NSMaxRange(range) <= ts.length else { return }
|
|
||||||
|
|
||||||
isUpdatingTables = true
|
isUpdatingTables = true
|
||||||
let sel = tv.selectedRanges
|
let sel = tv.selectedRanges
|
||||||
ts.beginEditing()
|
ts.beginEditing()
|
||||||
ts.replaceCharacters(in: range, with: newMarkdown)
|
ts.replaceCharacters(in: sourceRange, with: newMarkdown)
|
||||||
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
|
|
@ -920,6 +1205,10 @@ struct CompositorRepresentable: NSViewRepresentable {
|
||||||
parent.text = tv.string
|
parent.text = tv.string
|
||||||
updateBlockRanges(for: tv)
|
updateBlockRanges(for: tv)
|
||||||
isUpdatingTables = false
|
isUpdatingTables = false
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.updateEmbeddedTables()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue