Files
lanework/Kanban/UI/Card/BodyMarkupRenderer.swift
T
rzen e989c1f26e Build Edit mode with debounced, byte-honest saves
The editing surface: the same hosted TextKit-1 text view gains an
editable branch with a per-keystroke line-scanner highlighter — chosen
over a parser re-parse because a mid-typing buffer is usually invalid
Markdown and 05 wants the delimiters themselves dimmed; apply only sets
attributes, so presentation-never-transforms is structural. Saves ride
a ~700ms injectable debounce through BoardWriter.writeBody —
toggleTaskMarker's idiom widened to the body span, frontmatter bytes
untouched, refusing to write when disk already holds that body, which
enforces all three gates (untouched, reverted, echo) at the layer that
owns the bytes with one isDirty predicate above it. Mode grammar lands
whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape
returns, and every flip flushes first; window close flushes through
the existing retry/save-copy/discard modal, and the dismissal flush
deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always
follows the snapshot, the buffer only when clean, both surfaces render
the buffer. Undo is the editor's own session-scoped NSUndoManager;
endEditSession names the pro-m1 one-commit-per-session boundary.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 10:46:02 -04:00

563 lines
24 KiB
Swift

import AppKit
import Foundation
// MARK: - The clickable-run codec
/// The two kinds of thing a click in Preview can land on, encoded as URLs so that **AppKit's own
/// link hit-testing** is the whole of the click grammar (05-card-window.md ▸ Preview: "clicking the
/// rendered body selects text … and nothing else. The one interactive exception is task-list
/// checkboxes").
///
/// Encoding the checkbox as a link rather than tracking mouse events by hand is the point: a text
/// view already knows which character was clicked, already tells its delegate, already declines to
/// fire when the user was dragging out a selection, and already leaves every other character
/// selectable. Re-implementing that from `mouseDown` would be re-implementing text selection.
enum CardBodyLink {
/// A scheme nothing on the system claims, so a checkbox can never be handed to `NSWorkspace`
/// by a path that forgot to check.
static let taskScheme = "x-lanework-task"
/// `x-lanework-task:<byte offset>/<0 or 1>` — the offset the flip will aim at, and the state
/// the user is looking at, which is what `BoardWriter.toggleTaskMarker` re-verifies against
/// disk before it writes.
static func task(offset: Int, isChecked: Bool) -> URL? {
URL(string: "\(taskScheme):\(offset)/\(isChecked ? 1 : 0)")
}
/// The inverse. `nil` for every URL that is not one of ours — which is every ordinary link in
/// a card body, and is how the delegate tells the two apart.
static func parseTask(_ url: URL) -> (offset: Int, isChecked: Bool)? {
guard url.scheme == taskScheme else { return nil }
let parts = url.absoluteString.dropFirst(taskScheme.count + 1).split(separator: "/")
guard parts.count == 2, let offset = Int(parts[0]), let state = Int(parts[1]) else { return nil }
return (offset, state == 1)
}
}
// MARK: - BodyMarkupRenderer
/// `BodyMarkup` → `NSAttributedString`: the drawing half of Preview, and **only** the drawing half.
///
/// Every decision that is not typography was already made in `BodyMarkup` — what is HTML, what is a
/// remote image, where a checkbox's byte lives — which is what keeps this file free of policy and
/// makes it the one part of Preview that is legitimately unverifiable by a unit test. What it does
/// own is the app's type scale: body text in the system body font (the same `CardWindowMetrics`
/// point size the whole window derives from), code in a monospaced face, and every indent, padding
/// and image cap a multiple of that one size, so Preview grows with the system text size like the
/// rest of the window (10-accessibility.md ▸ Text).
@MainActor
enum BodyMarkupRenderer {
/// What a render needs beyond the markup itself: how big the body font is, and where the card's
/// own folder is — the anchor every relative image and link resolves against (05 ▸ Preview).
struct Context {
var pointSize: CGFloat
var cardFolder: URL?
init(pointSize: CGFloat, cardFolder: URL?) {
self.pointSize = pointSize
self.cardFolder = cardFolder
}
}
// MARK: Entry point
static func attributedString(for markup: BodyMarkup, context: Context) -> NSAttributedString {
let output = NSMutableAttributedString()
var frame = Frame(context: context)
append(markup.blocks, into: output, frame: &frame)
return output
}
// The Edit surface's raw, monospaced rendering used to live here as `rawText`, while Edit was a
// read-only placeholder sharing this file's substrate. It is now `MarkdownHighlighter`'s —
// base attributes plus a span pass — and the promise it carried travelled with it: the
// highlighter emits ranges, never a string, so "the text is the raw Markdown, character for
// character" (05 ▸ Edit) is structural rather than a convention.
// MARK: Block layout state
/// Where in the block structure the walk currently is: which text blocks enclose it (quotes and
/// table cells nest by *appending* to this), how far it is indented, and whether a list marker
/// is owed to the next paragraph.
private struct Frame {
let context: Context
var enclosing: [NSTextBlock] = []
var indent: CGFloat = 0
/// The bullet, number or checkbox that belongs on the first line of the next block — set
/// when a list item starts and consumed by whichever block opens it.
var pendingMarker: NSAttributedString?
}
private static func append(_ blocks: [BodyBlock], into output: NSMutableAttributedString, frame: inout Frame) {
for block in blocks {
append(block, into: output, frame: &frame)
}
}
private static func append(_ block: BodyBlock, into output: NSMutableAttributedString, frame: inout Frame) {
let size = frame.context.pointSize
switch block {
case let .heading(level, inlines, _):
let style = paragraphStyle(frame: frame)
style.paragraphSpacingBefore = size * (level <= 2 ? 1.0 : 0.75)
style.paragraphSpacing = size * 0.25
appendParagraph(
inlines: inlines,
base: [.font: headingFont(level: level, size: size), .foregroundColor: NSColor.labelColor],
style: style,
into: output,
frame: &frame
)
case let .paragraph(inlines, _):
let style = paragraphStyle(frame: frame)
style.paragraphSpacing = size * 0.5
appendParagraph(inlines: inlines, base: bodyAttributes(size), style: style, into: output, frame: &frame)
case let .code(code, _, _):
let style = paragraphStyle(frame: frame)
style.paragraphSpacing = size * 0.5
style.firstLineHeadIndent += CardWindowMetrics.previewPadding(bodyPointSize: size)
style.headIndent += CardWindowMetrics.previewPadding(bodyPointSize: size)
appendLiteral(
trimmingTrailingNewlines(code),
attributes: [
.font: monospacedFont(size),
.foregroundColor: NSColor.labelColor,
.backgroundColor: NSColor.quaternarySystemFill
],
style: style,
into: output,
frame: &frame
)
case let .html(raw, _):
// Verbatim, and styled as what it is: text the author typed, not a document the app
// interpreted (05 ▸ Preview; 00-vision.md's no-web-tech stance).
let style = paragraphStyle(frame: frame)
style.paragraphSpacing = size * 0.5
appendLiteral(
trimmingTrailingNewlines(raw),
attributes: [
.font: monospacedFont(size),
.foregroundColor: NSColor.secondaryLabelColor,
.backgroundColor: NSColor.quaternarySystemFill
],
style: style,
into: output,
frame: &frame
)
case .thematicBreak:
let rule = NSTextBlock()
rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .minY)
rule.setBorderColor(.separatorColor)
let style = paragraphStyle(frame: frame)
style.textBlocks = frame.enclosing + [rule]
style.paragraphSpacingBefore = size * 0.5
style.paragraphSpacing = size * 0.5
// A near-empty line carrying the rule: the border is the mark, the character is only
// something for the layout manager to hang it on.
output.append(NSAttributedString(string: "\u{00A0}\n", attributes: [
.font: NSFont.systemFont(ofSize: 1),
.paragraphStyle: style
]))
case let .quote(blocks, _):
let bar = NSTextBlock()
bar.setWidth(size * 0.2, type: .absoluteValueType, for: .border, edge: .minX)
bar.setBorderColor(.tertiaryLabelColor)
bar.setWidth(CardWindowMetrics.previewPadding(bodyPointSize: size), type: .absoluteValueType, for: .padding, edge: .minX)
var inner = frame
inner.enclosing.append(bar)
append(blocks, into: output, frame: &inner)
frame.pendingMarker = inner.pendingMarker
case let .list(list, _):
append(list, into: output, frame: &frame)
case let .table(table, _):
append(table, into: output, frame: &frame)
}
}
// MARK: Lists
private static func append(_ list: BodyList, into output: NSMutableAttributedString, frame: inout Frame) {
let size = frame.context.pointSize
for (offset, item) in list.items.enumerated() {
var inner = frame
inner.indent += CardWindowMetrics.previewIndent(bodyPointSize: size)
inner.pendingMarker = marker(for: item, list: list, number: list.start + offset, size: size)
if item.blocks.isEmpty {
// An empty item still has a marker to show — a bare `- [ ]` is a checkbox the user
// has not written a label for yet, and swallowing it would lose a live control.
let style = paragraphStyle(frame: inner)
appendParagraph(inlines: [], base: bodyAttributes(size), style: style, into: output, frame: &inner)
} else {
append(item.blocks, into: output, frame: &inner)
}
}
}
/// A list item's leading run: a checkbox when the item is a task, otherwise a bullet or its
/// number.
///
/// The checkbox carries a `.link` attribute — see `CardBodyLink` — which is what makes it the
/// one clickable thing in an otherwise read-only surface. When the parse could not locate its
/// source byte the box still renders, without the link: inert rather than absent, because the
/// box is content the user wrote.
private static func marker(
for item: BodyListItem,
list: BodyList,
number: Int,
size: CGFloat
) -> NSAttributedString {
guard let task = item.task else {
let text = list.isOrdered ? "\(number)." : "•"
return NSAttributedString(string: text + "\t", attributes: [
.font: NSFont.systemFont(ofSize: size),
.foregroundColor: NSColor.secondaryLabelColor
])
}
var attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: size * 1.1),
.foregroundColor: task.isChecked ? NSColor.controlAccentColor : NSColor.secondaryLabelColor
]
if let offset = task.markerOffset,
let url = CardBodyLink.task(offset: offset, isChecked: task.isChecked) {
attributes[.link] = url
attributes[.cursor] = NSCursor.pointingHand
}
let box = NSMutableAttributedString(string: task.isChecked ? "\u{2611}" : "\u{2610}", attributes: attributes)
box.append(NSAttributedString(string: "\t", attributes: [.font: NSFont.systemFont(ofSize: size)]))
return box
}
// MARK: Tables
/// A GFM table as an `NSTextTable`, whose automatic layout algorithm **is** the "columns sized
/// to contents with the browser sizing rule" the design asks for — the same rule, implemented
/// once by AppKit rather than approximated here with measured tab stops.
///
/// This is also the reason the whole surface runs on TextKit 1 (`CardBodySurfaceView`):
/// `NSTextTable` is a TextKit 1 construct, and a table drawn with tab stops would lose both
/// its rules and its wrapping.
private static func append(_ table: BodyTable, into output: NSMutableAttributedString, frame: inout Frame) {
let columns = max(1, table.columnCount)
let textTable = NSTextTable()
textTable.numberOfColumns = columns
textTable.layoutAlgorithm = .automaticLayoutAlgorithm
textTable.collapsesBorders = true
textTable.hidesEmptyCells = false
var row = 0
appendRow(table.header, isHeader: true, row: &row, of: textTable, table: table, into: output, frame: &frame)
for cells in table.rows {
appendRow(cells, isHeader: false, row: &row, of: textTable, table: table, into: output, frame: &frame)
}
}
private static func appendRow(
_ cells: [BodyTableCell],
isHeader: Bool,
row: inout Int,
of textTable: NSTextTable,
table: BodyTable,
into output: NSMutableAttributedString,
frame: inout Frame
) {
guard !cells.isEmpty else { return }
let size = frame.context.pointSize
let padding = CardWindowMetrics.previewPadding(bodyPointSize: size)
var column = 0
for cell in cells where column < max(1, table.columnCount) {
let span = max(1, min(cell.colspan, max(1, table.columnCount) - column))
let block = NSTextTableBlock(
table: textTable,
startingRow: row,
rowSpan: 1,
startingColumn: column,
columnSpan: span
)
block.setBorderColor(.separatorColor)
block.setWidth(1, type: .absoluteValueType, for: .border)
block.setWidth(padding, type: .absoluteValueType, for: .padding)
if isHeader { block.backgroundColor = .quaternarySystemFill }
let style = paragraphStyle(frame: frame)
style.textBlocks = frame.enclosing + [block]
style.alignment = alignment(table.alignments.indices.contains(column) ? table.alignments[column] : .unspecified)
// Cells never inherit the surrounding indent: the table block already places them.
style.firstLineHeadIndent = 0
style.headIndent = 0
var base = bodyAttributes(size)
if isHeader { base[.font] = NSFont.systemFont(ofSize: size, weight: .semibold) }
var cellFrame = frame
cellFrame.pendingMarker = nil
appendParagraph(inlines: cell.inlines, base: base, style: style, into: output, frame: &cellFrame)
column += span
}
row += 1
}
private static func alignment(_ alignment: BodyTable.Alignment) -> NSTextAlignment {
switch alignment {
case .unspecified, .leading: .natural
case .center: .center
case .trailing: .right
}
}
// MARK: Paragraph assembly
private static func appendParagraph(
inlines: [BodyInline],
base: [NSAttributedString.Key: Any],
style: NSMutableParagraphStyle,
into output: NSMutableAttributedString,
frame: inout Frame
) {
let paragraph = NSMutableAttributedString()
if let marker = frame.pendingMarker {
paragraph.append(marker)
frame.pendingMarker = nil
}
append(inlines, into: paragraph, base: base, frame: frame)
paragraph.append(NSAttributedString(string: "\n", attributes: base))
paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length))
output.append(paragraph)
}
/// A literal block — code, HTML — where the text's own newlines are content and no inline
/// parsing ever ran over it.
private static func appendLiteral(
_ text: String,
attributes: [NSAttributedString.Key: Any],
style: NSMutableParagraphStyle,
into output: NSMutableAttributedString,
frame: inout Frame
) {
let paragraph = NSMutableAttributedString()
if let marker = frame.pendingMarker {
paragraph.append(marker)
frame.pendingMarker = nil
}
paragraph.append(NSAttributedString(string: text + "\n", attributes: attributes))
paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length))
output.append(paragraph)
}
private static func paragraphStyle(frame: Frame) -> NSMutableParagraphStyle {
let style = NSMutableParagraphStyle()
style.textBlocks = frame.enclosing
style.firstLineHeadIndent = frame.indent
style.headIndent = frame.indent
style.lineSpacing = frame.context.pointSize * 0.15
// One tab stop, where a list item's text begins — which is what turns "marker, tab, text"
// into a hanging indent rather than a ragged one.
style.tabStops = [NSTextTab(textAlignment: .left, location: frame.indent, options: [:])]
style.defaultTabInterval = CardWindowMetrics.previewIndent(bodyPointSize: frame.context.pointSize)
return style
}
// MARK: Inlines
/// The inline traits carried down the recursion — bold and italic compose, so they are state
/// rather than a font chosen at each node.
private struct InlineTraits {
var isBold = false
var isItalic = false
var isStruck = false
}
private static func append(
_ inlines: [BodyInline],
into output: NSMutableAttributedString,
base: [NSAttributedString.Key: Any],
frame: Frame,
traits: InlineTraits = InlineTraits()
) {
for inline in inlines {
append(inline, into: output, base: base, frame: frame, traits: traits)
}
}
private static func append(
_ inline: BodyInline,
into output: NSMutableAttributedString,
base: [NSAttributedString.Key: Any],
frame: Frame,
traits: InlineTraits
) {
let size = frame.context.pointSize
switch inline {
case let .text(text):
output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits)))
case let .emphasis(children):
var inner = traits
inner.isItalic = true
append(children, into: output, base: base, frame: frame, traits: inner)
case let .strong(children):
var inner = traits
inner.isBold = true
append(children, into: output, base: base, frame: frame, traits: inner)
case let .strikethrough(children):
var inner = traits
inner.isStruck = true
append(children, into: output, base: base, frame: frame, traits: inner)
case let .code(code):
var attributes = attributes(base, traits: traits)
attributes[.font] = monospacedFont(size)
attributes[.backgroundColor] = NSColor.quaternarySystemFill
output.append(NSAttributedString(string: code, attributes: attributes))
case let .html(raw):
// Same rule as the block form, one line down: `<br>` is four characters and a `>`.
var attributes = attributes(base, traits: traits)
attributes[.font] = monospacedFont(size)
attributes[.foregroundColor] = NSColor.secondaryLabelColor
attributes[.backgroundColor] = NSColor.quaternarySystemFill
output.append(NSAttributedString(string: raw, attributes: attributes))
case .lineBreak, .softBreak:
// A soft break is a newline in the source and a space to the reader; a hard break is a
// line of its own. `NSAttributedString` has no soft-wrap character, so the difference
// is exactly this.
let text = if case .lineBreak = inline { "\u{2028}" } else { " " }
output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits)))
case let .link(target, children):
var attributes = attributes(base, traits: traits)
if let url = target.resolve(inCardFolder: frame.context.cardFolder) {
attributes[.link] = url
attributes[.foregroundColor] = NSColor.linkColor
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
}
let start = output.length
append(children, into: output, base: base, frame: frame, traits: traits)
if output.length == start {
output.append(NSAttributedString(string: target.text, attributes: attributes))
} else {
output.addAttributes(attributes, range: NSRange(location: start, length: output.length - start))
}
case let .image(image):
output.append(rendered(image, frame: frame, base: base, traits: traits))
}
}
/// An image: **loaded from the card's folder, or a chip that says what was not loaded.**
///
/// The fork is `BodyTarget`'s, made in the model; what happens here is only the consequence.
/// Nothing in this function opens a socket — a `.absolute` target is never handed to a loader
/// at all, which is how "Preview does no networking" is enforced rather than merely intended
/// (05 ▸ Preview).
private static func rendered(
_ image: BodyImage,
frame: Frame,
base: [NSAttributedString.Key: Any],
traits: InlineTraits
) -> NSAttributedString {
let size = frame.context.pointSize
if case .relative = image.target,
let url = image.target.resolve(inCardFolder: frame.context.cardFolder),
let loaded = NSImage(contentsOf: url) {
let attachment = NSTextAttachment()
attachment.image = loaded
let cap = CardWindowMetrics.previewImageMaximumWidth(bodyPointSize: size)
let scale = loaded.size.width > cap && loaded.size.width > 0 ? cap / loaded.size.width : 1
attachment.bounds = CGRect(
x: 0,
y: 0,
width: (loaded.size.width * scale).rounded(),
height: (loaded.size.height * scale).rounded()
)
let string = NSMutableAttributedString(attachment: attachment)
// The alt text as the tool tip: it is the one place an inline attachment can say what
// it is, and it is also what the text view reads out when nothing else describes it.
string.addAttribute(
.toolTip,
value: image.alt.isEmpty ? image.target.text : image.alt,
range: NSRange(location: 0, length: string.length)
)
return string
}
// The quiet placeholder chip: alt text where there is any, the URL where there is not, so
// the reader always learns *what* is not being shown.
let label = image.alt.isEmpty ? image.target.text : image.alt
var attributes = attributes(base, traits: traits)
attributes[.font] = NSFont.systemFont(ofSize: size * 0.9)
attributes[.foregroundColor] = NSColor.secondaryLabelColor
attributes[.backgroundColor] = NSColor.quaternarySystemFill
return NSAttributedString(string: " \u{1F5BC}\u{FE0E} \(label) ", attributes: attributes)
}
// MARK: Fonts
private static func attributes(
_ base: [NSAttributedString.Key: Any],
traits: InlineTraits
) -> [NSAttributedString.Key: Any] {
var attributes = base
if let font = base[.font] as? NSFont {
attributes[.font] = styled(font, traits: traits)
}
if traits.isStruck {
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
}
return attributes
}
private static func styled(_ font: NSFont, traits: InlineTraits) -> NSFont {
var mask: NSFontTraitMask = []
if traits.isBold { mask.insert(.boldFontMask) }
if traits.isItalic { mask.insert(.italicFontMask) }
guard !mask.isEmpty else { return font }
return NSFontManager.shared.convert(font, toHaveTrait: mask)
}
private static func bodyAttributes(_ size: CGFloat) -> [NSAttributedString.Key: Any] {
[.font: NSFont.systemFont(ofSize: size), .foregroundColor: NSColor.labelColor]
}
/// The heading ladder, in multiples of the body size so it scales with everything else. Levels
/// past four stop growing and stay merely emphasized, which is what they are.
private static func headingFont(level: Int, size: CGFloat) -> NSFont {
let scale: CGFloat = switch level {
case 1: 1.8
case 2: 1.5
case 3: 1.25
case 4: 1.1
default: 1.0
}
return NSFont.systemFont(ofSize: (size * scale).rounded(), weight: level <= 2 ? .bold : .semibold)
}
private static func monospacedFont(_ size: CGFloat) -> NSFont {
NSFont.monospacedSystemFont(ofSize: (size * 0.95).rounded(), weight: .regular)
}
private static func trimmingTrailingNewlines(_ text: String) -> String {
var text = text
while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() }
return text
}
}