add auto-fit on double-click for table column/row dividers, row/col indicators on focus
This commit is contained in:
parent
96262ca9ef
commit
de1b494189
|
|
@ -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..<colCount {
|
||||
let x = columnX(for: i) - 1
|
||||
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: th))
|
||||
let x = columnX(for: i) - 1 + ox
|
||||
let line = NSView(frame: NSRect(x: x, y: oy, width: 1, height: th))
|
||||
line.wantsLayer = true
|
||||
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
||||
addSubview(line)
|
||||
}
|
||||
for i in 0..<totalRows {
|
||||
let lineY = (i == 0) ? th - rowHeights[0] : rowY(for: i)
|
||||
let line = NSView(frame: NSRect(x: 0, y: lineY, width: totalWidth, height: 1))
|
||||
let lineY = ((i == 0) ? th - rowHeights[0] : rowY(for: i)) + oy
|
||||
let line = NSView(frame: NSRect(x: ox, y: lineY, width: gridWidth, height: 1))
|
||||
line.wantsLayer = true
|
||||
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
||||
addSubview(line)
|
||||
}
|
||||
|
||||
if indicatorsVisible {
|
||||
buildIndicators()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> 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..<colCount {
|
||||
let x = indicatorColWidth + columnX(for: col)
|
||||
let label = NSTextField(labelWithString: columnLetter(for: col))
|
||||
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.wantsLayer = true
|
||||
label.layer?.backgroundColor = indicatorBg.cgColor
|
||||
container.addSubview(label)
|
||||
}
|
||||
|
||||
for row in 0..<totalRows {
|
||||
let y = rowY(for: row)
|
||||
let label = NSTextField(labelWithString: "\(row + 1)")
|
||||
label.font = NSFont.systemFont(ofSize: 10, weight: .medium)
|
||||
label.textColor = Theme.current.overlay2
|
||||
label.alignment = .center
|
||||
label.frame = NSRect(x: 0, y: y, width: indicatorColWidth, height: rowHeights[row])
|
||||
label.wantsLayer = true
|
||||
label.layer?.backgroundColor = indicatorBg.cgColor
|
||||
container.addSubview(label)
|
||||
}
|
||||
}
|
||||
|
||||
private func showIndicators() {
|
||||
guard !indicatorsVisible else { return }
|
||||
indicatorsVisible = true
|
||||
buildGrid()
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.2
|
||||
self.indicatorContainer?.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
private func hideIndicators() {
|
||||
guard indicatorsVisible else { return }
|
||||
NSAnimationContext.runAnimationGroup({ ctx in
|
||||
ctx.duration = 0.2
|
||||
self.indicatorContainer?.animator().alphaValue = 0
|
||||
}, completionHandler: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.indicatorsVisible = false
|
||||
self.buildGrid()
|
||||
})
|
||||
}
|
||||
|
||||
private func setupFocusMonitoring() {
|
||||
focusMonitor = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didUpdateNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.checkFocusState()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkFocusState() {
|
||||
guard let w = window else { return }
|
||||
let fr = w.firstResponder
|
||||
let hasFocusedCell = cellFields.joined().contains { field in
|
||||
fr === field || (fr as? NSTextView)?.delegate === field
|
||||
}
|
||||
if hasFocusedCell && !indicatorsVisible {
|
||||
showIndicators()
|
||||
} else if !hasFocusedCell && indicatorsVisible && !mouseInside {
|
||||
hideIndicators()
|
||||
}
|
||||
}
|
||||
|
||||
private var mouseInside = false
|
||||
|
||||
// MARK: - Resize hit detection
|
||||
|
||||
private func columnDivider(at point: NSPoint) -> Int? {
|
||||
let ox = indicatorOffset
|
||||
let colCount = table.headers.count
|
||||
for i in 1..<colCount {
|
||||
let divX = columnX(for: i) - 1
|
||||
let divX = columnX(for: i) - 1 + ox
|
||||
if abs(point.x - divX) <= dividerHitZone {
|
||||
return i - 1
|
||||
}
|
||||
}
|
||||
let lastX = columnX(for: colCount)
|
||||
let lastX = columnX(for: colCount) + ox
|
||||
if abs(point.x - lastX) <= dividerHitZone {
|
||||
return colCount - 1
|
||||
}
|
||||
|
|
@ -492,13 +628,14 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
|
|||
}
|
||||
|
||||
private func rowDivider(at point: NSPoint) -> Int? {
|
||||
let oy = indicatorTopOffset
|
||||
let totalRows = 1 + table.rows.count
|
||||
for i in 0..<totalRows {
|
||||
let divY: CGFloat
|
||||
if i == 0 {
|
||||
divY = totalHeight - rowHeights[0]
|
||||
divY = totalHeight - rowHeights[0] + oy
|
||||
} else {
|
||||
divY = rowY(for: i)
|
||||
divY = rowY(for: i) + oy
|
||||
}
|
||||
if abs(point.y - divY) <= dividerHitZone {
|
||||
return i
|
||||
|
|
@ -531,19 +668,34 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
mouseInside = true
|
||||
showIndicators()
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
NSCursor.arrow.set()
|
||||
mouseInside = false
|
||||
checkFocusState()
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
let pt = convert(event.locationInWindow, from: nil)
|
||||
if let col = columnDivider(at: pt) {
|
||||
if event.clickCount == 2 {
|
||||
autoFitColumn(col)
|
||||
return
|
||||
}
|
||||
dragMode = .column(col)
|
||||
dragStartPoint = pt
|
||||
dragStartSize = columnWidths[col]
|
||||
return
|
||||
}
|
||||
if let row = rowDivider(at: pt) {
|
||||
if event.clickCount == 2 {
|
||||
autoFitRow(row)
|
||||
return
|
||||
}
|
||||
dragMode = .row(row)
|
||||
dragStartPoint = pt
|
||||
dragStartSize = rowHeights[row]
|
||||
|
|
@ -576,6 +728,56 @@ class MarkdownTableView: NSView, NSTextFieldDelegate {
|
|||
dragMode = .none
|
||||
}
|
||||
|
||||
// MARK: - Auto-fit
|
||||
|
||||
private func measureColumnWidth(_ col: Int) -> 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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue