258 lines
14 KiB
Swift
258 lines
14 KiB
Swift
import AppKit
|
|
|
|
/// Table styling: the largest single case of the `styleBlock` switch. When the
|
|
/// caret is inside, the table shows as dimmed monospace; otherwise it's laid out
|
|
/// with a bold header, hidden pipes, kern-padded columns, and drawn borders (via
|
|
/// a `.tableRow` BlockDecoration). Row parsing helpers live in
|
|
/// EditorTextView+TableSupport; extracted from EditorTextView+Rendering.
|
|
extension EditorTextView {
|
|
|
|
/// Styles the `.table` content for one span. The caller has already
|
|
/// bounds-checked `span.fullRange` against `result`.
|
|
func styleTableSpan(_ result: NSMutableAttributedString,
|
|
span: SyntaxHighlighter.Span,
|
|
cursorInToken: Bool) {
|
|
if cursorInToken {
|
|
// Active: monospace, all pipes dimmed
|
|
result.addAttribute(.font, value: tableFont, range: span.fullRange)
|
|
let nsStr = (result.string as NSString)
|
|
var sr = span.fullRange
|
|
while sr.length > 0 {
|
|
let pr = nsStr.range(of: "|", options: [], range: sr)
|
|
guard pr.location != NSNotFound else { break }
|
|
result.addAttribute(.foregroundColor, value: syntaxDimColor, range: pr)
|
|
let ns = pr.upperBound
|
|
sr = NSRange(location: ns, length: max(0, span.fullRange.upperBound - ns))
|
|
}
|
|
} else {
|
|
// Non-active: bold header, hidden pipes, column-width alignment
|
|
// via kern, drawn vertical + horizontal borders via TableRowTextBlock,
|
|
// with cell padding for breathing room. A cell wider than its
|
|
// column instead hides its real characters and gets redrawn
|
|
// wrapped via `.tableCellWraps` (see EditorTextView+TextKit2.swift).
|
|
let tableNS = (result.string as NSString)
|
|
let tableStr = tableNS.substring(with: span.fullRange)
|
|
let lines = tableStr.components(separatedBy: "\n")
|
|
|
|
let cellHPad = bodyFont.pointSize * 0.3
|
|
let cellVPad = bodyFont.pointSize * 0.15
|
|
|
|
// --- Style each cell's inline markdown and measure the result ---
|
|
// Each cell runs through styleBlock so `**bold**`, `code`, links,
|
|
// ==marks== etc. render inside tables; hidden delimiters measure
|
|
// ~zero, so column widths reflect what's actually visible. Header
|
|
// cells are bolded before measuring.
|
|
// ponytail: block-level markdown in a cell (`# x`, `- x`) keeps its
|
|
// fonts but loses its block chrome (paragraph styles / decorations
|
|
// are row-owned, see the transplant below); tall math or image
|
|
// overlays get no extra line height in cells. A wrapped (overflowing)
|
|
// cell is drawn from a detached scratch text layout rather than the
|
|
// live glyph run, so click-to-caret placement inside it is
|
|
// approximate while the table is non-active — clicking anywhere in
|
|
// the cell still enters the table and lands the caret at the raw
|
|
// source's nearest position once active.
|
|
let headerCells = splitTableRow(lines[0])
|
|
let numCols = headerCells.count
|
|
guard numCols > 0 else { return }
|
|
var natural = [CGFloat](repeating: 0, count: numCols)
|
|
// Per line: the cell's character range within the line + its
|
|
// styled form (empty for the separator row).
|
|
var rowCells: [[(start: Int, end: Int, styled: NSAttributedString)]] = []
|
|
for (li, line) in lines.enumerated() {
|
|
guard li != 1 else { rowCells.append([]); continue }
|
|
let lineNS = line as NSString
|
|
var cells: [(start: Int, end: Int, styled: NSAttributedString)] = []
|
|
for cr in cellRanges(in: lineNS) {
|
|
let text = lineNS.substring(with: NSRange(location: cr.start,
|
|
length: cr.end - cr.start))
|
|
let styled = NSMutableAttributedString(
|
|
attributedString: styleBlock(text, cursorPosition: nil))
|
|
if li == 0 {
|
|
styled.enumerateAttribute(
|
|
.font, in: NSRange(location: 0, length: styled.length)
|
|
) { value, r, _ in
|
|
guard let f = value as? NSFont else { return }
|
|
styled.addAttribute(
|
|
.font,
|
|
value: NSFontManager.shared.convert(f, toHaveTrait: .boldFontMask),
|
|
range: r)
|
|
}
|
|
}
|
|
cells.append((cr.start, cr.end, styled))
|
|
}
|
|
rowCells.append(cells)
|
|
for ci in 0..<min(cells.count, numCols) {
|
|
natural[ci] = max(natural[ci], cells[ci].styled.size().width)
|
|
}
|
|
}
|
|
// Clamp column content widths to the available line width so a
|
|
// pathologically wide cell doesn't stretch the whole table off
|
|
// screen — the overflow gets wrapped (below) instead. Columns
|
|
// that already fit their fair share keep their natural width.
|
|
let minColWidth = bodyFont.pointSize * 3
|
|
let available = max(0, availableContentWidth - CGFloat(numCols) * 2 * cellHPad)
|
|
let clamped = distributeColumnWidths(natural: natural, available: available,
|
|
minWidth: minColWidth)
|
|
// Add horizontal padding to each column (space after cell text).
|
|
var colWidths = clamped
|
|
for ci in 0..<numCols {
|
|
colWidths[ci] += 2 * cellHPad
|
|
}
|
|
|
|
// Column-border X offsets (between columns) and total width.
|
|
// Each border is drawn cellHPad before the column boundary
|
|
// so the 2*cellHPad per column splits evenly: hPad of right
|
|
// padding for the current cell, hPad of left padding for the next.
|
|
var borderXOffsets: [CGFloat] = []
|
|
var colStartX: [CGFloat] = []
|
|
var cumX: CGFloat = 0
|
|
for ci in 0..<numCols {
|
|
colStartX.append(cumX + cellHPad)
|
|
cumX += colWidths[ci]
|
|
if ci < numCols - 1 { borderXOffsets.append(cumX - cellHPad) }
|
|
}
|
|
let totalWidth = cumX
|
|
|
|
// Per-column alignment from the separator row (`:--`/`:-:`/`--:`).
|
|
let aligns = tableColumnAlignments(separatorRow: lines.count > 1 ? lines[1] : "",
|
|
count: numCols)
|
|
|
|
// --- Style each row ---
|
|
var lineOffset = span.fullRange.location
|
|
for (i, line) in lines.enumerated() {
|
|
let lineLen = (line as NSString).length
|
|
let lineRange = NSRange(location: lineOffset, length: lineLen)
|
|
guard lineRange.upperBound <= result.length else { break }
|
|
|
|
// Row geometry via the paragraph style; the borders are
|
|
// drawn by a .tableRow BlockDecoration. Vertical padding
|
|
// becomes paragraph spacing (row gap = trailing + leading
|
|
// spacing = 2*cellVPad, same as the old block padding).
|
|
let ps = NSMutableParagraphStyle()
|
|
ps.lineSpacing = 0
|
|
ps.firstLineHeadIndent = cellHPad
|
|
ps.headIndent = cellHPad
|
|
if i == 1 {
|
|
// Separator row: its text is hidden; force a thin
|
|
// strip and draw the horizontal rule through it.
|
|
ps.minimumLineHeight = 4
|
|
ps.maximumLineHeight = 4
|
|
ps.paragraphSpacingBefore = 0
|
|
ps.paragraphSpacing = 0
|
|
} else {
|
|
ps.paragraphSpacingBefore = cellVPad + ((i == 0)
|
|
? bodyParagraphStyle.paragraphSpacingBefore : 0)
|
|
ps.paragraphSpacing = cellVPad
|
|
}
|
|
result.addAttribute(.paragraphStyle, value: ps, range: lineRange)
|
|
result.addAttribute(
|
|
.blockDecoration,
|
|
value: BlockDecoration(.tableRow(columnXOffsets: borderXOffsets,
|
|
width: totalWidth,
|
|
leftInset: cellHPad,
|
|
separator: i == 1,
|
|
bottomBorder: i > 1)),
|
|
range: lineRange)
|
|
|
|
// Cells whose styled width exceeds their column's (clamped)
|
|
// content width can't be kern-aligned in place — they get
|
|
// hidden and redrawn wrapped by `.tableCellWraps` instead
|
|
// (see DecoratedTextLayoutFragment). Computed once per row so
|
|
// both the hide/transplant step and the kern step below agree.
|
|
var overflowsCol = [Bool](repeating: false, count: numCols)
|
|
if i != 1, i < rowCells.count {
|
|
for ci in 0..<min(rowCells[i].count, numCols) {
|
|
overflowsCol[ci] = rowCells[i][ci].styled.size().width
|
|
> colWidths[ci] - 2 * cellHPad + 0.5
|
|
}
|
|
}
|
|
|
|
if i == 1 {
|
|
// Separator row: hide all text
|
|
result.addAttribute(.font, value: hiddenFont, range: lineRange)
|
|
result.addAttribute(.foregroundColor, value: NSColor.clear, range: lineRange)
|
|
} else if i < rowCells.count {
|
|
// Transplant each styled cell's attributes onto the table,
|
|
// skipping .paragraphStyle and .blockDecoration — row
|
|
// geometry and borders stay owned by the table code.
|
|
// Overflowing cells instead hide their real characters and
|
|
// get redrawn wrapped, since kern alone can't wrap text.
|
|
var wraps: [TableCellWrap] = []
|
|
for (ci, cell) in rowCells[i].enumerated() where ci < numCols {
|
|
let offset = lineOffset + cell.start
|
|
if overflowsCol[ci] {
|
|
let hideRange = NSRange(location: offset, length: cell.end - cell.start)
|
|
guard hideRange.upperBound <= result.length else { continue }
|
|
result.addAttribute(.font, value: hiddenFont, range: hideRange)
|
|
result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange)
|
|
wraps.append(TableCellWrap(styled: cell.styled, x: colStartX[ci],
|
|
contentWidth: colWidths[ci] - 2 * cellHPad))
|
|
} else {
|
|
cell.styled.enumerateAttributes(
|
|
in: NSRange(location: 0, length: cell.styled.length)
|
|
) { attrs, r, _ in
|
|
let target = NSRange(location: offset + r.location, length: r.length)
|
|
guard target.upperBound <= result.length else { return }
|
|
for (key, value) in attrs {
|
|
guard key != .paragraphStyle && key != .blockDecoration else { continue }
|
|
result.addAttribute(key, value: value, range: target)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !wraps.isEmpty {
|
|
result.addAttribute(.tableCellWraps, value: TableCellWrapList(wraps),
|
|
range: lineRange)
|
|
}
|
|
}
|
|
|
|
// Hide all structural pipes (zero-width + clear). A `\|` is
|
|
// escaped cell content, not a separator — leave it visible
|
|
// (its `\` is already hidden by the cell's escape span).
|
|
let lineNS = line as NSString
|
|
for ci in 0..<lineNS.length {
|
|
if lineNS.character(at: ci) == 0x7C,
|
|
!(ci > 0 && lineNS.character(at: ci - 1) == 0x5C) {
|
|
let pipeRange = NSRange(location: lineOffset + ci, length: 1)
|
|
result.addAttribute(.font, value: hiddenFont, range: pipeRange)
|
|
result.addAttribute(.foregroundColor, value: NSColor.clear, range: pipeRange)
|
|
}
|
|
}
|
|
|
|
// Kern-pad each cell to its column width, distributing the slack
|
|
// by column alignment (skip separator). Left pads after content;
|
|
// right pads before it (kern on the cell's leading hidden pipe,
|
|
// which still adds advance though it's near-zero-width); center
|
|
// splits the slack. Kern adds advance *after* a glyph, so the
|
|
// "before" kern goes on the char preceding the cell content.
|
|
if i != 1, i < rowCells.count {
|
|
for ci in 0..<min(rowCells[i].count, numCols) {
|
|
guard !overflowsCol[ci] else { continue }
|
|
let cr = rowCells[i][ci]
|
|
let cellWidth = cr.styled.size().width
|
|
let padding = colWidths[ci] - cellWidth
|
|
guard padding > 0.5 else { continue }
|
|
let leadingIdx = (cr.start - 1 >= 0 && lineNS.character(at: cr.start - 1) == 0x7C)
|
|
? cr.start - 1 : cr.start
|
|
let trailingIdx = cr.end - 1
|
|
func kern(_ amount: CGFloat, at idx: Int) {
|
|
result.addAttribute(.kern, value: amount,
|
|
range: NSRange(location: lineOffset + idx, length: 1))
|
|
}
|
|
switch aligns[ci] {
|
|
case .left: kern(padding, at: trailingIdx)
|
|
case .right: kern(padding, at: leadingIdx)
|
|
case .center:
|
|
let half = (padding / 2).rounded()
|
|
kern(half, at: leadingIdx)
|
|
kern(padding - half, at: trailingIdx)
|
|
}
|
|
}
|
|
}
|
|
|
|
lineOffset += lineLen + 1
|
|
}
|
|
}
|
|
}
|
|
}
|