Files
i7t5--edmund/Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift
T
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

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
}
}
}
}