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:
2026-08-08 22:42:11 -04:00
parent cdc91d669d
commit 7651e40318
17 changed files with 3997 additions and 6 deletions
+259
View File
@@ -0,0 +1,259 @@
import Foundation
// MARK: - The three enumerated choices
/// Where a printed board starts a new page "whether to insert page breaking between cards or
/// between lanes", the card's own second bullet, with the third answer the one the card leaves
/// implicit: *nowhere*.
///
/// **Every case is a promise about paper, not about spacing.** `.betweenLanes` really starts a new
/// sheet at each lane, and `.betweenCards` really starts one at each card a "break" that only
/// added vertical air would be the option lying about the one thing it exists to control
/// (`PrintDocumentView` is where the promise is kept: a break splits the document into separately
/// paginated sections rather than inserting whitespace into one).
///
/// `.flow` is the default because it is the cheapest print: a ten-lane board under `.betweenLanes`
/// is ten sheets minimum, which is the right answer only when the user asked for it.
public enum PrintPageBreaks: String, Codable, Sendable, CaseIterable {
/// One continuous document; page boundaries fall wherever the text runs out of sheet.
case flow
/// A fresh sheet at each lane. Cards inside a lane still flow.
case betweenLanes
/// A fresh sheet at each card which implies one at each lane too, since a lane begins with a
/// card. The finest grain the option offers, and the most paper.
case betweenCards
}
/// Which end of a thread a printed card's comments start from "whether to include comments and
/// how to sort them" (the card's third bullet).
///
/// The two names are the *reading order* rather than a sort direction, deliberately: the thread's
/// own order is chronology (`CommentThread.sorted` `created` ascending, undated last), so this
/// chooses whether that order is walked forwards or backwards and never re-sorts by anything else.
/// It mirrors the comments pane's own header control, whose persisted bit is spelled the same way
/// (`AppPreferences.commentsNewestFirstKey`) but it is a *separate* value: how this user likes to
/// read a thread on screen and how they want it laid out on paper are two preferences, and binding
/// them would make a print profile silently rewrite a window.
public enum PrintCommentSort: String, Codable, Sendable, CaseIterable {
case oldestFirst
case newestFirst
}
// MARK: - PrintOptions
/// **Everything a print asks about a document, as one value type** the card's five bullets
/// (components, page breaks, comments, styling, header/footer) with nothing about *paper* in it:
/// sheet size, orientation, margins, copies and the printer itself are `NSPrintInfo`'s and stay
/// there, because they are the system's questions and the print panel already asks them better than
/// we could.
///
/// ### Why a plain `Codable` struct and not an `@Observable` bag
///
/// This is what a **print profile** is (`PrintProfile`), and a profile has to round-trip through
/// `UserDefaults` byte-for-byte save, quit, relaunch, restore. A reference type would also make
/// "the options this print is using" and "the options that profile holds" the same object, which is
/// exactly wrong: choosing a profile *copies* its values into the live sheet, and editing the sheet
/// afterwards must not rewrite the profile behind the user's back. Value semantics are that rule,
/// for free.
///
/// ### The decoder is total, on purpose
///
/// Every field decodes through `decodeIfPresent` onto its default, and out-of-range numbers are
/// clamped rather than rejected. A stored profile is a file in a preferences plist that a future
/// build may have written, an older build may be reading, and a human may have hand-edited the
/// storage layer's own leniency doctrine (01-storage-format.md § Frontmatter: lenient fields
/// degrade, they never refuse) applied to app-side state. The failure mode this rules out is the
/// one that matters: a single unknown key must not cost the user every profile they saved.
public struct PrintOptions: Codable, Sendable, Equatable {
// MARK: Components "which constituent components/datapoints to include"
/// The card's title line. On by default: a printed card with no title is a page of prose with no
/// idea what it is about.
public var includesTitle = true
/// The card's **icon and labels line** its `icon` symbol followed by whatever the reserved
/// `labels` key carries (`PrintCard.labels(of:)`).
///
/// One toggle for the pair rather than two, because they are one *line* on paper: an icon with
/// the labels switched off is a glyph alone on a line, which is furniture rather than
/// information. 01-storage-format.md § Frontmatter reserves `labels` and this version interprets
/// nothing by it (05-card-window.md Details: "ordinary unknown keys in this version"), so what
/// prints is what the file says, flattened never a chip, never a colour.
public var includesLabels = true
/// The card's body, **rendered** the Markdown subset Preview draws, through the same parse
/// (05-card-window.md Preview; `BodyMarkup`). Never the raw source: a print of the bytes is
/// what E is for, and a reader holding paper wants the document, not its markup.
public var includesBody = true
// MARK: Comments "whether to include comments and how to sort them"
/// **Off by default.** A thread is conversation *about* a card, and the overwhelmingly common
/// print is the card; a board print with comments on is also the one shape that costs a disk read
/// per card (`CommentThread` is window-scoped and outside the snapshot 01 § Enhanced schema),
/// which is a cost nobody should pay without asking.
public var includesComments = false
/// Which end the thread starts from when `includesComments` is on. Ignored entirely when it is
/// off kept rather than made optional so toggling comments back on restores the choice the user
/// last made instead of resetting it.
public var commentSort: PrintCommentSort = .oldestFirst
// MARK: Page breaks
public var pageBreaks: PrintPageBreaks = .flow
// MARK: Styling "font face, size & style"
/// The base font family, or `nil` for the system font.
///
/// **A family name, not a font.** Weight and slant are the document's to decide a heading is
/// bold because it is a heading, emphasis is italic because the author wrote `*it*` so what a
/// user picks here is the *face* the whole document is set in, and every derived style keeps its
/// own traits inside it (`PrintTypography.restyled`). A name the running system cannot resolve
/// degrades to the system font, `ItemSymbol.exists`' posture applied to type: a profile written
/// on a machine with Palatino installed must still print on one without it.
public var fontFamily: String?
/// The body point size. **The one size the document has**: headings, the labels line, comment
/// bylines and the header/footer are all multiples of it (`PrintTypography`), which is the
/// "body-vs-headings derive from one base choice" ruling a print dialog with six size fields is
/// a typesetting program, and this is a print dialog.
public var fontSize: Double = 11
/// The legal range, and the reason it is a range at all: a size of 0 draws nothing and a size of
/// 400 draws one letter per page, and both are reachable from a hand-edited plist.
public static let fontSizeRange: ClosedRange<Double> = 6 ... 36
// MARK: Header and footer "page footer/header"
/// The board's title, in the running head.
public var headerShowsBoardTitle = true
/// The date the print was run, in the running head. **The print's date, not the board's
/// `modified`**: a printout's own question is "how old is this piece of paper".
public var headerShowsPrintDate = true
/// "Page 3 of 7", in the running foot. On by default a stapled board print with no folios is
/// a pile.
public var footerShowsPageNumbers = true
/// A line of the user's own in the running foot a project code, a distribution note, a
/// confidentiality banner.
///
/// Two fields rather than one so switching the line off keeps the text: the toggle is a
/// *decision* and the string is *content*, and losing the content on every toggle would make the
/// pair useless for the case it exists for (a banner used on some prints and not others).
public var footerShowsCustomLine = false
public var footerCustomLine = ""
public init() {}
// MARK: - Normalizing
/// `size` inside `fontSizeRange` the one normalization that happens **on the way in**, because
/// a stored size is the likeliest defect in this whole structure and the only one that can make a
/// page undrawable (`AppPreferences.boardZoomLevelKey`'s own trap: an unset or hand-edited number
/// that renders a document of hairlines).
public static func clamped(fontSize size: Double) -> Double {
guard size.isFinite else { return PrintOptions().fontSize }
return min(max(size, fontSizeRange.lowerBound), fontSizeRange.upperBound)
}
/// **The render's reading of these options**, not a rewrite of them applied by the builder and
/// the renderer, never by the decoder, so `decode(encode(x)) == x` holds for every value a user
/// can reach.
///
/// It flattens the two "content without a reason to exist" cases into their honest form: a blank
/// custom line is the same as not having one, and a whitespace family name is the same as the
/// system font. Both stay *readings* the stored profile keeps whatever the user typed
/// (`footerCustomLine`'s own note), so a banner emptied for one print and typed back in for the
/// next never loses its toggle.
public var normalized: PrintOptions {
var copy = self
copy.fontSize = Self.clamped(fontSize: fontSize)
if let family = copy.fontFamily, family.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
copy.fontFamily = nil
}
if copy.footerCustomLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
copy.footerShowsCustomLine = false
}
return copy
}
/// Whether anything at all would print with these options the Print button's own floor.
///
/// All three component toggles off is a legal state a user can reach one click at a time, and it
/// prints a document of nothing but running heads. The command does not disable on it (a user
/// mid-configuration must not have the button taken away), but the builder answers it honestly:
/// `PrintDocumentBuilder.blocks` returns an empty document, and the operation refuses rather than
/// spending paper.
public var describesAnyContent: Bool {
includesTitle || includesLabels || includesBody || includesComments
}
// MARK: - Codable
private enum CodingKeys: String, CodingKey {
case includesTitle, includesLabels, includesBody
case includesComments, commentSort
case pageBreaks
case fontFamily, fontSize
case headerShowsBoardTitle, headerShowsPrintDate
case footerShowsPageNumbers, footerShowsCustomLine, footerCustomLine
}
/// See the type's doc comment: every field falls back to its default, and the two enumerations
/// fall back to theirs rather than failing the decode, so a value written by a build that knows a
/// fourth page-break mode reads as `.flow` here instead of taking the whole profile down with it.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
var options = PrintOptions()
/// A key read three ways at once: absent, present-but-the-wrong-type, and present-and-good
/// the first two answering the default. `try?` around `decodeIfPresent` is what collapses the
/// middle case, and the double optional it produces is why this is a function rather than an
/// expression repeated eleven times.
func read<T: Decodable>(_ type: T.Type, _ key: CodingKeys) -> T? {
guard let decoded = try? container.decodeIfPresent(type, forKey: key) else { return nil }
return decoded
}
options.includesTitle = read(Bool.self, .includesTitle) ?? options.includesTitle
options.includesLabels = read(Bool.self, .includesLabels) ?? options.includesLabels
options.includesBody = read(Bool.self, .includesBody) ?? options.includesBody
options.includesComments = read(Bool.self, .includesComments) ?? options.includesComments
options.commentSort = read(String.self, .commentSort)
.flatMap(PrintCommentSort.init(rawValue:)) ?? options.commentSort
options.pageBreaks = read(String.self, .pageBreaks)
.flatMap(PrintPageBreaks.init(rawValue:)) ?? options.pageBreaks
options.fontFamily = read(String.self, .fontFamily)
options.fontSize = read(Double.self, .fontSize) ?? options.fontSize
options.headerShowsBoardTitle = read(Bool.self, .headerShowsBoardTitle) ?? options.headerShowsBoardTitle
options.headerShowsPrintDate = read(Bool.self, .headerShowsPrintDate) ?? options.headerShowsPrintDate
options.footerShowsPageNumbers = read(Bool.self, .footerShowsPageNumbers) ?? options.footerShowsPageNumbers
options.footerShowsCustomLine = read(Bool.self, .footerShowsCustomLine) ?? options.footerShowsCustomLine
options.footerCustomLine = read(String.self, .footerCustomLine) ?? options.footerCustomLine
options.fontSize = Self.clamped(fontSize: options.fontSize)
self = options
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(includesTitle, forKey: .includesTitle)
try container.encode(includesLabels, forKey: .includesLabels)
try container.encode(includesBody, forKey: .includesBody)
try container.encode(includesComments, forKey: .includesComments)
try container.encode(commentSort.rawValue, forKey: .commentSort)
try container.encode(pageBreaks.rawValue, forKey: .pageBreaks)
try container.encodeIfPresent(fontFamily, forKey: .fontFamily)
try container.encode(fontSize, forKey: .fontSize)
try container.encode(headerShowsBoardTitle, forKey: .headerShowsBoardTitle)
try container.encode(headerShowsPrintDate, forKey: .headerShowsPrintDate)
try container.encode(footerShowsPageNumbers, forKey: .footerShowsPageNumbers)
try container.encode(footerShowsCustomLine, forKey: .footerShowsCustomLine)
try container.encode(footerCustomLine, forKey: .footerCustomLine)
}
}