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() + } } } }