Files
lanework/Kanban/Printing/PrintDocumentBuilder.swift
T
rzen 7651e40318 Print boards and cards with configurable components and named print profiles
⌘P had no story: KanbanApp removed the platform's Print row outright on
11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line
retires. File ▸ Print… now prints the board in front — lanes left to right,
each lane's cards top to bottom, as a linear document rather than a picture
of the strip — or, from a card window, that card. The trash is unreachable
by construction: it is a sibling container of `lanes`, not a lane.

The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is
one Codable value carrying the printing card's five bullets — which
components (title, icon+labels line, rendered body, comments off by default
with either reading order), page breaks, one base face and size every other
size derives from, and a toggleable running head and foot. `PrintSource` is
what is being printed, frozen at ⌘P so the panel's repeated relayouts and a
board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the
pair into a block list, which is where every decision a rendered page hides
becomes something a test can hold: component order, comment ordering, and
page-break markers that are markers rather than whitespace. Empty is empty
all the way up — a card with nothing to print consumes no page break, and a
lane whose cards all dropped out takes its heading with it.

A page break is a pagination fact, not a spacing one. TextKit has no
page-break character, so `PrintDocumentView` splits the document into
sections at its breaks and flows each into as many page-sized text
containers as it needs: a container boundary *is* a sheet boundary, at any
paper size with any margins. Bodies come from the app's one Markdown pass —
`BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the
chosen family reaches the text and fixed-pitch code keeps its own, and drawn
under a forced light appearance so the card window's dynamic label colours
do not print white.

Options ride in the print panel's own accessory rather than a pre-flight
sheet of ours, which buys the system's live preview of the real paginated
document; the preview refreshes through one KVO revision counter rather than
thirteen mirrored properties. Profiles persist app-side in UserDefaults,
never in board files — a print profile is how this user likes to read, not
what a board is (`BoardZoomStore`'s argument). A name is a profile's
identity, folded case-insensitively; "Last Used" is reserved in every
spelling, kept out of the stored list, and captured when an operation
actually ran, so a cancelled print rewrites nothing. Both decoders are
total: one unrecognized key must not cost a user every profile they saved.

DESIGN/11-command-nexus.md gains the Print row and loses the sentence
saying it would never have one.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:42:11 -04:00

197 lines
10 KiB
Swift

import Foundation
// MARK: - PrintBlock
/// One element of a printed document, in reading order — **the whole vocabulary of what can appear on
/// paper**, and the only thing `PrintDocumentBuilder` produces.
///
/// ### Why a block list rather than an attributed string
///
/// The same argument `BodyMarkup` makes for existing: the decisions and the drawing are different
/// jobs, and a decision buried in a pile of font attributes is a decision nobody can test. Every rule
/// this feature has — which components appear, in what order, where a page break falls, which end of a
/// thread comes first — is settled here, in a value a test can compare against a literal; the renderer
/// beneath it (`PrintDocumentRenderer`) is then only ever wrong about *typography*.
///
/// `.pageBreak` is the clearest case. It is a **marker in the list**, not a paragraph of whitespace and
/// not a hint: `PrintDocumentView` splits the document at these markers and paginates each side
/// independently, so "between lanes" really starts a sheet. A test can hold the marker; nobody can hold
/// a promise about spacing.
///
/// Bodies stay **strings** here rather than parsed `BodyMarkup`. The parse belongs to the render pass
/// (which reuses `BodyMarkup.parse` and `BodyMarkupRenderer` wholesale rather than reading Markdown a
/// second way), and keeping the string means a test of the *document's structure* compares words rather
/// than block trees. What the builder does ask of the Markdown layer is the one question that is
/// structural: `BodyMarkup.isEmpty`, which decides whether there is a body to print at all.
public enum PrintBlock: Sendable, Equatable {
/// A real sheet boundary. Never the first or last block, and never doubled — see
/// `PrintDocumentBuilder`.
case pageBreak
/// The document's title, for a board print: the board's name, once, at the top. A card print has
/// none — its own title line is the card's, and the board is named in the running head.
case boardHeading(String)
/// A lane's name, opening its run of cards. Board prints only.
case laneHeading(String)
case cardTitle(String)
/// The icon-and-labels line. One block for the pair because it is one line on paper
/// (`PrintOptions.includesLabels`); emitted only when at least one half has something to say.
case cardMeta(icon: String?, labels: [String])
/// The card's Markdown, unparsed — see the type's note.
case cardBody(String)
/// The thread's own small heading, carrying its count so the reader knows what follows and how
/// much of it there is.
case commentsHeading(count: Int)
case comment(author: String?, created: Date?, body: String)
}
// MARK: - PrintDocumentBuilder
/// **`PrintSource` + `PrintOptions` → `[PrintBlock]`**: every rule about what a printed board or card
/// contains, in one pure function.
///
/// ### The order within a card is fixed
///
/// Title, then the icon-and-labels line, then the body, then the comments. It is not configurable and
/// the card that specifies this feature does not ask for it to be: the list is a document's natural
/// order (what is this, how is it tagged, what does it say, what was said about it), and an option that
/// let a user put the body above the title would be a page-layout program.
///
/// ### Empty is empty, all the way up
///
/// The build is bottom-up and drops what has nothing in it:
///
/// - a **card** that would emit no blocks (an untitled, unlabelled card with an empty body, or every
/// component toggled off) contributes nothing — and, crucially, does not consume a page break;
/// - a **lane** whose every card dropped out has no heading either;
/// - an **empty lane** is omitted entirely (see below).
///
/// This is what keeps `.betweenCards` from printing blank sheets for cards that had nothing on them,
/// which is the one way a page-break option can be actively harmful. It is also why the checks are here
/// rather than in the renderer: "would this print anything" is a question about content, and the
/// renderer has no business asking it.
///
/// **Empty lanes are omitted** (decision, 2026-08-09 — flagged for review on the card): an empty "Done"
/// column is real information on screen, but on paper it is a heading with nothing under it, and under
/// `.betweenLanes` it is a heading with nothing under it *on its own sheet*. A print is a document of
/// content. The alternative — heading with no break — was declined for making the page-break rule
/// conditional on a lane's contents, which is exactly the kind of clever nobody can predict.
///
/// ### Page breaks are emitted lazily, before content that follows content
///
/// Never leading (a document does not start with a sheet boundary), never trailing, never doubled. The
/// mechanism is one flag — has anything been emitted yet — consulted at each lane and each card, which
/// is what makes the three modes compose without a case analysis per pair.
public enum PrintDocumentBuilder {
/// What an untitled card or lane prints as. The word is a **rendering**, exactly as it is
/// everywhere else in the app (03-board-ui.md § Card face; `CardWindowHost.subtitle`), which is why
/// it lives here and not in `PrintCard.title`.
public static let untitled = "Untitled"
/// The document.
///
/// Options are read through `normalized` so the render's reading is the one that decides — a blank
/// custom line does not print an empty footer line, whatever the toggle says (`PrintOptions.normalized`).
public static func blocks(from source: PrintSource, options rawOptions: PrintOptions) -> [PrintBlock] {
let options = rawOptions.normalized
var blocks: [PrintBlock] = []
/// Whether a lane has already been laid down, and therefore whether a sheet boundary is owed
/// before the next one — the lazy-break mechanism the type's note describes. `false` until a
/// lane has actually been emitted, which is what makes a leading break impossible rather than
/// merely unlikely.
var hasEmittedLane = false
for lane in source.lanes {
// Built before anything about the lane is emitted, so a lane whose cards all dropped out
// takes its heading and its page break with it.
let cardRuns = lane.cards.map { cardBlocks($0, options: options) }.filter { !$0.isEmpty }
guard !cardRuns.isEmpty else { continue }
if options.pageBreaks != .flow, hasEmittedLane {
blocks.append(.pageBreak)
}
// A card print carries its lane for context (`PrintSource.card`), but the document is the
// card: a lane heading over a single card would be a document about the wrong thing.
if source.scope == .board {
blocks.append(.laneHeading(lane.title ?? untitled))
}
hasEmittedLane = true
for (offset, run) in cardRuns.enumerated() {
if options.pageBreaks == .betweenCards, offset > 0 {
blocks.append(.pageBreak)
}
blocks.append(contentsOf: run)
}
}
// **A document with no content has no heading either** — the "empty is empty, all the way up" rule
// taken to the top: with every component switched off, or on a board whose lanes are all empty, the
// answer is *nothing*, not a board title over blank paper (which is what `PrintCoordinator` refuses
// to spend a sheet on).
guard !blocks.isEmpty else { return [] }
// The board's name goes on last so it can be conditional on there being something to name — and it
// is deliberately outside the page-break bookkeeping above. It is the first lane's running-in title,
// not a title page: a break counted from the heading would put the board's name alone on sheet one
// of every print with breaks switched on, which nobody asked for and nobody would keep.
if source.scope == .board, !source.boardTitle.isEmpty {
blocks.insert(.boardHeading(source.boardTitle), at: 0)
}
return blocks
}
/// One card's blocks, or `[]` when the options and the card between them have nothing to print.
///
/// The four component toggles are read here and nowhere else, so "which components appear" is one
/// function rather than a condition spread across a walk.
private static func cardBlocks(_ card: PrintCard, options: PrintOptions) -> [PrintBlock] {
var blocks: [PrintBlock] = []
if options.includesTitle {
blocks.append(.cardTitle(card.title ?? untitled))
}
if options.includesLabels {
// Nothing to say is nothing printed: a card with no chosen icon and no labels would
// otherwise contribute a blank line, and a blank line is the shape a reader reads as a
// missing value.
let labels = card.labels.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
if card.icon != nil || !labels.isEmpty {
blocks.append(.cardMeta(icon: card.icon, labels: labels))
}
}
// `BodyMarkup.isEmpty` rather than `body.isEmpty`, which is the same question the card window
// asks to decide whether a body is worth previewing (05-card-window.md ▸ Mode grammar): a body
// of one newline previews as a blank page, and prints as one too unless something says
// otherwise.
if options.includesBody, !BodyMarkup.isEmpty(card.body) {
blocks.append(.cardBody(card.body))
}
if options.includesComments, !card.comments.isEmpty {
let ordered = options.commentSort == .newestFirst ? Array(card.comments.reversed()) : card.comments
blocks.append(.commentsHeading(count: ordered.count))
for comment in ordered {
blocks.append(.comment(author: comment.author, created: comment.created, body: comment.body))
}
}
// A card whose every block dropped out prints nothing at all — not a title, not a break. See
// the type's "Empty is empty" note; the caller relies on this being `[]` and not `[.cardTitle]`.
return blocks
}
}