From 8f4f1fddc742ee92b9627b3b9d51f5131aeef7a6 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 11:40:05 -0700 Subject: [PATCH 1/4] hide raw markdown text behind table GUI elements --- src/EditorView.swift | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index c8817cc..f090450 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1496,27 +1496,8 @@ private func highlightFootnoteDefinition(textStorage: NSTextStorage, lineRange: // MARK: - Tables private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont, isHeader: Bool, isSeparator: Bool) { - let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) - let boldMono = NSFontManager.shared.convert(monoFont, toHaveTrait: .boldFontMask) - - textStorage.addAttribute(.font, value: monoFont, range: lineRange) - textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange) - - if isSeparator { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) - return - } - - if isHeader { - textStorage.addAttribute(.font, value: boldMono, range: lineRange) - } - - // Mute pipe delimiters - guard let pipeRegex = try? NSRegularExpression(pattern: "\\|") else { return } - let matches = pipeRegex.matches(in: textStorage.string, range: lineRange) - for match in matches { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: match.range) - } + textStorage.addAttribute(.foregroundColor, value: palette.base, range: lineRange) + textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: lineRange) } // MARK: - Lists and Horizontal Rules From 96262ca9ef0e913de2ee177b8d4239bca947cb8a Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 11:45:42 -0700 Subject: [PATCH 2/4] add resizable columns and rows to markdown table view --- src/EditorView.swift | 204 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 178 insertions(+), 26 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index f090450..27f8eae 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -321,81 +321,129 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { var onTableChanged: ((ParsedTable) -> Void)? private var cellFields: [[NSTextField]] = [] - private let cellHeight: CGFloat = 26 - private let cellPadding: CGFloat = 4 - private let headerHeight: CGFloat = 28 + private var columnWidths: [CGFloat] = [] + private var rowHeights: [CGFloat] = [] + private var tableWidth: CGFloat = 0 + + private let minColWidth: CGFloat = 40 + private let minRowHeight: CGFloat = 24 + private let defaultHeaderHeight: CGFloat = 28 + private let defaultCellHeight: CGFloat = 26 + private let dividerHitZone: CGFloat = 6 + + private enum DragMode { case none, column(Int), row(Int) } + private var dragMode: DragMode = .none + private var dragStartPoint: NSPoint = .zero + private var dragStartSize: CGFloat = 0 init(table: ParsedTable, width: CGFloat) { self.table = table + self.tableWidth = width super.init(frame: .zero) wantsLayer = true layer?.backgroundColor = Theme.current.base.cgColor layer?.cornerRadius = 4 layer?.borderWidth = 1 layer?.borderColor = Theme.current.surface2.cgColor - buildGrid(width: width) + initSizes(width: width) + buildGrid() + setupTrackingArea() } required init?(coder: NSCoder) { fatalError() } - private func buildGrid(width: CGFloat) { + 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 = [] + rowHeights.append(defaultHeaderHeight) + for _ in 0.. CGFloat { + var x: CGFloat = 1 + for i in 0.. CGFloat { + let th = totalHeight + var y = th + for i in 0...row { + y -= rowHeights[i] + } + return y + } + + private func buildGrid() { subviews.forEach { $0.removeFromSuperview() } cellFields = [] let colCount = table.headers.count guard colCount > 0 else { return } - let colWidth = (width - CGFloat(colCount + 1)) / CGFloat(colCount) - let totalRows = 1 + table.rows.count - let totalHeight = headerHeight + CGFloat(table.rows.count) * cellHeight + let th = totalHeight + let totalWidth = columnX(for: colCount) + 1 - frame.size = NSSize(width: width, height: totalHeight) + frame.size = NSSize(width: totalWidth, height: th) - // Header row - let headerBg = NSView(frame: NSRect(x: 0, y: totalHeight - headerHeight, width: width, height: headerHeight)) + let headerBg = NSView(frame: NSRect(x: 0, y: th - rowHeights[0], width: totalWidth, height: rowHeights[0])) headerBg.wantsLayer = true headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor addSubview(headerBg) var headerFields: [NSTextField] = [] for (col, header) in table.headers.enumerated() { - let x = CGFloat(col) * colWidth + CGFloat(col + 1) - let field = makeCell(text: header, frame: NSRect(x: x, y: totalHeight - headerHeight + 2, width: colWidth, height: headerHeight - 4), isHeader: true, row: -1, col: col) + let x = columnX(for: col) + let h = rowHeights[0] + let field = makeCell(text: header, frame: NSRect(x: x, y: th - h + 2, width: columnWidths[col], height: h - 4), isHeader: true, row: -1, col: col) addSubview(field) headerFields.append(field) } cellFields.append(headerFields) - // Data rows for (rowIdx, row) in table.rows.enumerated() { var rowFields: [NSTextField] = [] - let y = totalHeight - headerHeight - CGFloat(rowIdx + 1) * cellHeight + let y = rowY(for: rowIdx + 1) + let h = rowHeights[rowIdx + 1] for (col, cell) in row.enumerated() where col < colCount { - let x = CGFloat(col) * colWidth + CGFloat(col + 1) - let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col) + let x = columnX(for: col) + let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col) addSubview(field) rowFields.append(field) } while rowFields.count < colCount { let col = rowFields.count - let x = CGFloat(col) * colWidth + CGFloat(col + 1) - let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col) + let x = columnX(for: col) + let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col) addSubview(field) rowFields.append(field) } cellFields.append(rowFields) } - // Grid lines + let totalRows = 1 + table.rows.count for i in 1.. Int? { + let colCount = table.headers.count + for i in 1.. Int? { + let totalRows = 1 + table.rows.count + for i in 0.. Date: Mon, 6 Apr 2026 11:51:34 -0700 Subject: [PATCH 3/4] add auto-fit on double-click for table column/row dividers, row/col indicators on focus --- src/EditorView.swift | 234 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 218 insertions(+), 16 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index 27f8eae..0f54977 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -331,6 +331,12 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { private let defaultCellHeight: CGFloat = 26 private let dividerHitZone: CGFloat = 6 + private let indicatorRowHeight: CGFloat = 20 + private let indicatorColWidth: CGFloat = 30 + private var indicatorsVisible = false + private var indicatorContainer: NSView? + private var focusMonitor: Any? + private enum DragMode { case none, column(Int), row(Int) } private var dragMode: DragMode = .none private var dragStartPoint: NSPoint = .zero @@ -348,10 +354,17 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { initSizes(width: width) buildGrid() setupTrackingArea() + setupFocusMonitoring() } required init?(coder: NSCoder) { fatalError() } + deinit { + if let monitor = focusMonitor { + NotificationCenter.default.removeObserver(monitor) + } + } + private func initSizes(width: CGFloat) { let colCount = table.headers.count guard colCount > 0 else { return } @@ -389,25 +402,30 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { private func buildGrid() { subviews.forEach { $0.removeFromSuperview() } + indicatorContainer = nil cellFields = [] let colCount = table.headers.count guard colCount > 0 else { return } let th = totalHeight - let totalWidth = columnX(for: colCount) + 1 + let ox = indicatorOffset + let oy = indicatorTopOffset + let gridWidth = columnX(for: colCount) + 1 + let fullWidth = gridWidth + ox + let fullHeight = th + oy - frame.size = NSSize(width: totalWidth, height: th) + frame.size = NSSize(width: fullWidth, height: fullHeight) - let headerBg = NSView(frame: NSRect(x: 0, y: th - rowHeights[0], width: totalWidth, height: rowHeights[0])) + let headerBg = NSView(frame: NSRect(x: ox, y: th - rowHeights[0] + oy, width: gridWidth, height: rowHeights[0])) headerBg.wantsLayer = true headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor addSubview(headerBg) var headerFields: [NSTextField] = [] for (col, header) in table.headers.enumerated() { - let x = columnX(for: col) + let x = columnX(for: col) + ox let h = rowHeights[0] - let field = makeCell(text: header, frame: NSRect(x: x, y: th - h + 2, width: columnWidths[col], height: h - 4), isHeader: true, row: -1, col: col) + let field = makeCell(text: header, frame: NSRect(x: x, y: th - h + 2 + oy, width: columnWidths[col], height: h - 4), isHeader: true, row: -1, col: col) addSubview(field) headerFields.append(field) } @@ -415,17 +433,17 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { for (rowIdx, row) in table.rows.enumerated() { var rowFields: [NSTextField] = [] - let y = rowY(for: rowIdx + 1) + let y = rowY(for: rowIdx + 1) + oy let h = rowHeights[rowIdx + 1] for (col, cell) in row.enumerated() where col < colCount { - let x = columnX(for: col) + let x = columnX(for: col) + ox let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col) addSubview(field) rowFields.append(field) } while rowFields.count < colCount { let col = rowFields.count - let x = columnX(for: col) + let x = columnX(for: col) + ox let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col) addSubview(field) rowFields.append(field) @@ -435,19 +453,23 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { let totalRows = 1 + table.rows.count for i in 1.. NSTextField { @@ -474,17 +496,131 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { return field } + // MARK: - Column letter helper + + private func columnLetter(for index: Int) -> String { + var n = index + var result = "" + repeat { + result = String(UnicodeScalar(65 + (n % 26))!) + result + n = n / 26 - 1 + } while n >= 0 + return result + } + + // MARK: - Indicators + + private var indicatorOffset: CGFloat { + indicatorsVisible ? indicatorColWidth : 0 + } + + private var indicatorTopOffset: CGFloat { + indicatorsVisible ? indicatorRowHeight : 0 + } + + private func buildIndicators() { + indicatorContainer?.removeFromSuperview() + + let container = NSView(frame: bounds) + container.wantsLayer = true + container.alphaValue = indicatorsVisible ? 1 : 0 + addSubview(container) + indicatorContainer = container + + let colCount = table.headers.count + let totalRows = 1 + table.rows.count + let th = totalHeight + indicatorTopOffset + let indicatorBg = Theme.current.surface0 + + let corner = NSView(frame: NSRect(x: 0, y: th - indicatorRowHeight, width: indicatorColWidth, height: indicatorRowHeight)) + corner.wantsLayer = true + corner.layer?.backgroundColor = indicatorBg.cgColor + container.addSubview(corner) + + for col in 0.. Int? { + let ox = indicatorOffset let colCount = table.headers.count for i in 1.. Int? { + let oy = indicatorTopOffset let totalRows = 1 + table.rows.count for i in 0.. CGFloat { + let headerFont = NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask) + let cellFont = Theme.editorFont + + var maxW: CGFloat = 0 + if col < table.headers.count { + let w = (table.headers[col] as NSString).size(withAttributes: [.font: headerFont]).width + maxW = max(maxW, w) + } + for row in table.rows { + guard col < row.count else { continue } + let w = (row[col] as NSString).size(withAttributes: [.font: cellFont]).width + maxW = max(maxW, w) + } + return max(maxW + 16, minColWidth) + } + + private func measureRowHeight(_ row: Int) -> CGFloat { + let font: NSFont = row == 0 + ? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask) + : Theme.editorFont + let cells: [String] = row == 0 ? table.headers : (row - 1 < table.rows.count ? table.rows[row - 1] : []) + + var maxH: CGFloat = 0 + for (col, text) in cells.enumerated() where col < columnWidths.count { + let constrainedSize = NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude) + let rect = (text as NSString).boundingRect( + with: constrainedSize, + options: [.usesLineFragmentOrigin], + attributes: [.font: font] + ) + maxH = max(maxH, rect.height) + } + return max(maxH + 8, minRowHeight) + } + + private func autoFitColumn(_ col: Int) { + guard col >= 0, col < columnWidths.count else { return } + columnWidths[col] = measureColumnWidth(col) + buildGrid() + } + + private func autoFitRow(_ row: Int) { + guard row >= 0, row < rowHeights.count else { return } + rowHeights[row] = measureRowHeight(row) + buildGrid() + } + // MARK: - Cell editing func controlTextDidEndEditing(_ obj: Notification) { From ee621b7993a92861cea8d5a5483e3f5518dbec47 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 12:02:09 -0700 Subject: [PATCH 4/4] work in progress on table indicators --- src/EditorView.swift | 64 +++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index 0f54977..febb037 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -333,7 +333,7 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { private let indicatorRowHeight: CGFloat = 20 private let indicatorColWidth: CGFloat = 30 - private var indicatorsVisible = false + private var indicatorsVisible = true private var indicatorContainer: NSView? private var focusMonitor: Any? @@ -408,8 +408,8 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { let colCount = table.headers.count guard colCount > 0 else { return } let th = totalHeight - let ox = indicatorOffset - let oy = indicatorTopOffset + let ox = indicatorColWidth + let oy = indicatorRowHeight let gridWidth = columnX(for: colCount) + 1 let fullWidth = gridWidth + ox let fullHeight = th + oy @@ -467,9 +467,7 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { addSubview(line) } - if indicatorsVisible { - buildIndicators() - } + buildIndicators() } private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> NSTextField { @@ -510,14 +508,6 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { // MARK: - Indicators - private var indicatorOffset: CGFloat { - indicatorsVisible ? indicatorColWidth : 0 - } - - private var indicatorTopOffset: CGFloat { - indicatorsVisible ? indicatorRowHeight : 0 - } - private func buildIndicators() { indicatorContainer?.removeFromSuperview() @@ -529,10 +519,10 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { let colCount = table.headers.count let totalRows = 1 + table.rows.count - let th = totalHeight + indicatorTopOffset + let topY = totalHeight + indicatorRowHeight let indicatorBg = Theme.current.surface0 - let corner = NSView(frame: NSRect(x: 0, y: th - indicatorRowHeight, width: indicatorColWidth, height: indicatorRowHeight)) + let corner = NSView(frame: NSRect(x: 0, y: topY - indicatorRowHeight, width: indicatorColWidth, height: indicatorRowHeight)) corner.wantsLayer = true corner.layer?.backgroundColor = indicatorBg.cgColor container.addSubview(corner) @@ -543,14 +533,14 @@ class MarkdownTableView: NSView, NSTextFieldDelegate { label.font = NSFont.systemFont(ofSize: 10, weight: .medium) label.textColor = Theme.current.overlay2 label.alignment = .center - label.frame = NSRect(x: x, y: th - indicatorRowHeight, width: columnWidths[col], height: indicatorRowHeight) + label.frame = NSRect(x: x, y: topY - indicatorRowHeight, width: columnWidths[col], height: indicatorRowHeight) label.wantsLayer = true label.layer?.backgroundColor = indicatorBg.cgColor container.addSubview(label) } for row in 0.. Int? { - let ox = indicatorOffset let colCount = table.headers.count for i in 1.. Int? { - let oy = indicatorTopOffset let totalRows = 1 + table.rows.count for i in 0..