Files
lanework/Kanban/Printing/PrintRunningHead.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

73 lines
3.4 KiB
Swift

import Foundation
/// **What the running head and foot say** — "page footer/header", the card's fifth bullet, as four
/// toggles composed into two lines.
///
/// ### Why the pieces are composed here and not drawn here
///
/// Which pieces appear, in what order, and how a line reads when only some of them are switched on is a
/// *decision*, and decisions live in the pure layer where a test can hold them (`PrintDocumentBuilder`'s
/// own argument). Where the ink lands is `PrintDocumentView`'s.
///
/// ### The date and the page number arrive pre-formatted
///
/// Both are strings the caller supplies rather than a `Date` and an `Int` this type formats. That keeps
/// every rule below **locale-free and therefore testable**: "Page 3 of 7" and "9 Aug 2026 at 14:30" are
/// the caller's renderings of values the system formats differently in every region, and a rule that
/// baked them in would be a rule whose test only passed in one place.
///
/// ### Leading and trailing, not left and right
///
/// The two slots are named by reading order because that is what they are: the view places them at the
/// two ends of the measure, which a right-to-left interface swaps. Nothing here knows which end is
/// which.
public enum PrintRunningHead {
/// A line of the running head or foot, as a pair of ends. Either may be empty; both empty means the
/// line does not print at all, which is what `isEmpty` is for and what lets the view reclaim the
/// space rather than leaving a band of blank paper.
public struct Line: Sendable, Equatable {
public var leading: String
public var trailing: String
public var isEmpty: Bool { leading.isEmpty && trailing.isEmpty }
public init(leading: String = "", trailing: String = "") {
self.leading = leading
self.trailing = trailing
}
}
/// The running head: the board's name at the leading end, the print's date at the trailing end.
///
/// The board's name leads because it is what the reader is looking for when they pick the sheet up;
/// the date trails because it answers a question they ask second. A board with no title contributes
/// nothing rather than the word "Untitled" — the running head is context, and inventing context is
/// worse than having none.
public static func header(options: PrintOptions, boardTitle: String, dateText: String) -> Line {
Line(
leading: options.headerShowsBoardTitle ? boardTitle : "",
trailing: options.headerShowsPrintDate ? dateText : ""
)
}
/// The running foot: the user's own line at the leading end, the folio at the trailing end — where
/// a book puts it.
///
/// The custom line is read through `normalized`, so a toggle left on over an emptied field prints
/// nothing rather than an indent of blank space (`PrintOptions.normalized`).
public static func footer(options rawOptions: PrintOptions, pageText: String) -> Line {
let options = rawOptions.normalized
return Line(
leading: options.footerShowsCustomLine ? options.footerCustomLine : "",
trailing: options.footerShowsPageNumbers ? pageText : ""
)
}
/// "Page 3 of 7" — the folio's wording, in one place because the view draws it and a summary line
/// in the print panel describes it.
public static func pageText(page: Int, of pageCount: Int) -> String {
"Page \(page) of \(pageCount)"
}
}