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
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user