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,196 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - PrintProfile
|
||||
|
||||
/// A named set of print options — "these configurations probably good to persist (as named print
|
||||
/// profiles) and reused", the card's closing line, as a type.
|
||||
///
|
||||
/// ### Its name is its identity
|
||||
///
|
||||
/// No UUID, deliberately. A print profile is a **user's own label** for a way of printing ("Standup
|
||||
/// handout", "Archive, comments on"), and a label is what the popup menu shows, what a rename
|
||||
/// changes, and what a save collides on. Minting an id beside it would create a second identity the
|
||||
/// UI never shows and the user could never reconcile — two profiles both called "Handout", one of
|
||||
/// them unreachable. So names are unique (case-insensitively — see `PrintProfileCatalog`), and
|
||||
/// renaming *is* re-identifying.
|
||||
///
|
||||
/// This is the same reasoning `ItemID` states for a card folder and reaches the opposite conclusion
|
||||
/// for the opposite reason: a card's title is content that may repeat and must be free to, while a
|
||||
/// profile's name is a key the user types.
|
||||
public struct PrintProfile: Codable, Sendable, Equatable, Identifiable {
|
||||
|
||||
public var name: String
|
||||
public var options: PrintOptions
|
||||
|
||||
public var id: String { PrintProfileCatalog.key(name) }
|
||||
|
||||
public init(name: String, options: PrintOptions) {
|
||||
self.name = name
|
||||
self.options = options
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case name, options }
|
||||
|
||||
/// Lenient for `PrintOptions`' reason, one level up: a stored profile missing its name or its
|
||||
/// options decodes to a nameless one rather than throwing, and a nameless profile is dropped by
|
||||
/// `PrintProfileCatalog.init(profiles:)`. Without this, one malformed entry in the plist would
|
||||
/// fail the whole array's decode and cost the user every profile they saved — the exact failure
|
||||
/// mode the option decoder exists to rule out.
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = ((try? container.decodeIfPresent(String.self, forKey: .name)) ?? nil) ?? ""
|
||||
options = ((try? container.decodeIfPresent(PrintOptions.self, forKey: .options)) ?? nil) ?? PrintOptions()
|
||||
}
|
||||
|
||||
// MARK: The reserved pseudo-profile
|
||||
|
||||
/// **"Last Used" is reserved** and is not a member of the catalog at all — it is the options the
|
||||
/// last print ran with, captured automatically, and it exists so that ⌘P opens on *what the user
|
||||
/// did last* rather than on a factory default they overrode a hundred prints ago.
|
||||
///
|
||||
/// A pseudo-profile rather than a real one because the two behave nothing alike: this one cannot
|
||||
/// be renamed, cannot be deleted, and rewrites itself on every print — a named profile does none
|
||||
/// of those and would be worthless if it did (the point of "Handout" is that it stays what it was
|
||||
/// when you saved it). Keeping the reserved name out of the stored list is also what makes the
|
||||
/// rule enforceable rather than remembered: `PrintProfileCatalog` refuses the name, so no code
|
||||
/// path can create a shadow of it.
|
||||
public static let lastUsedName = "Last Used"
|
||||
|
||||
/// Whether `name` is one a user may claim. The comparison is the catalog's own key rule, so
|
||||
/// "last used" and "LAST USED" are refused exactly as the canonical spelling is.
|
||||
public static func isReserved(_ name: String) -> Bool {
|
||||
PrintProfileCatalog.key(name) == PrintProfileCatalog.key(lastUsedName)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PrintProfileCatalog
|
||||
|
||||
/// **The named profiles, and every rule about them** — save, rename, delete, look up — as a pure
|
||||
/// value type with no `UserDefaults` and no `@Observable` anywhere near it.
|
||||
///
|
||||
/// The split is the codebase's usual one (`BoardZoom` beside `BoardZoomStore`, `StyleRecents.updated`
|
||||
/// beside the store that persists it): the rules are the interesting part and a rule that can only be
|
||||
/// exercised through a preferences domain is a rule nobody tests. `PrintProfileStore` is this type's
|
||||
/// persistence and its observability, and it owns no rules of its own.
|
||||
///
|
||||
/// ### Order is the user's, not the alphabet's
|
||||
///
|
||||
/// Profiles keep the order they were saved in, newest last, and a rename does not move one. The popup
|
||||
/// menu is short by nature (a handful of ways one person prints), and a list that re-sorted itself
|
||||
/// when a profile was renamed would move the row the user was looking at. `StyleRecents`' most-recent-
|
||||
/// first list makes the opposite choice for the opposite reason — that list *is* a recency ranking.
|
||||
public struct PrintProfileCatalog: Codable, Sendable, Equatable {
|
||||
|
||||
public private(set) var profiles: [PrintProfile]
|
||||
|
||||
public init(profiles: [PrintProfile] = []) {
|
||||
// Sanitized on the way in for the same reason the option decoder is lenient: this value comes
|
||||
// out of a preferences plist a human may have edited. Reserved and blank names are dropped,
|
||||
// and a duplicate keeps its first occurrence — the reading a menu can actually render.
|
||||
var kept: [PrintProfile] = []
|
||||
for profile in profiles {
|
||||
let name = Self.normalized(profile.name)
|
||||
guard !name.isEmpty, !PrintProfile.isReserved(name) else { continue }
|
||||
guard !kept.contains(where: { Self.key($0.name) == Self.key(name) }) else { continue }
|
||||
kept.append(PrintProfile(name: name, options: profile.options))
|
||||
}
|
||||
self.profiles = kept
|
||||
}
|
||||
|
||||
// MARK: Names
|
||||
|
||||
/// A name as stored: outer whitespace trimmed, inner text untouched. Trimming is what makes
|
||||
/// `" Handout "` and `"Handout"` the same profile rather than two rows that look identical.
|
||||
public static func normalized(_ name: String) -> String {
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// The comparison key — the normalized name case-folded. Case-insensitive because a user typing
|
||||
/// "handout" a week later means the profile they called "Handout", and two rows differing only in
|
||||
/// case is a bug report, not a feature.
|
||||
///
|
||||
/// `localizedLowercase` rather than `lowercased()`: these are human words in the user's own
|
||||
/// language, unlike `ItemID`'s ASCII-hex fold.
|
||||
public static func key(_ name: String) -> String {
|
||||
normalized(name).localizedLowercase
|
||||
}
|
||||
|
||||
/// Whether `name` may be saved or renamed to — blank and reserved refused, an existing name
|
||||
/// allowed (a save over one's own profile is an overwrite, which is what the Save button means
|
||||
/// when the popup is already on that profile).
|
||||
public static func isAcceptable(_ name: String) -> Bool {
|
||||
!normalized(name).isEmpty && !PrintProfile.isReserved(name)
|
||||
}
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
public func contains(_ name: String) -> Bool {
|
||||
profiles.contains { Self.key($0.name) == Self.key(name) }
|
||||
}
|
||||
|
||||
/// The named profile's options, or `nil` — the popup's selection resolved. Never a fallback to
|
||||
/// anything: a selection that names no profile is a UI out of step with its model, and quietly
|
||||
/// substituting the defaults would hide that.
|
||||
public func options(named name: String) -> PrintOptions? {
|
||||
profiles.first { Self.key($0.name) == Self.key(name) }?.options
|
||||
}
|
||||
|
||||
/// The names in list order — the popup's rows below the reserved one.
|
||||
public var names: [String] { profiles.map(\.name) }
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
/// Saves `options` under `name`, overwriting a profile of that name in place.
|
||||
///
|
||||
/// **Overwrite rather than a second row**, and *in place* rather than moved to the end: saving
|
||||
/// again over "Handout" is the gesture "this is what Handout means now", and re-ordering the menu
|
||||
/// as a side effect of it would be the list moving under the user's cursor. The spelling is
|
||||
/// updated to whatever was typed — `"handout"` saved over `"Handout"` renames the case — because
|
||||
/// the last spelling the user typed is the one they meant.
|
||||
///
|
||||
/// Refuses a blank or reserved name (`isAcceptable`), answering `false`. A refusal writes nothing.
|
||||
@discardableResult
|
||||
public mutating func save(_ options: PrintOptions, as name: String) -> Bool {
|
||||
guard Self.isAcceptable(name) else { return false }
|
||||
let stored = PrintProfile(name: Self.normalized(name), options: options)
|
||||
if let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) }) {
|
||||
profiles[index] = stored
|
||||
} else {
|
||||
profiles.append(stored)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Renames a profile, keeping its position and its options.
|
||||
///
|
||||
/// Refuses when the old name names nothing, when the new one is blank or reserved, or when it is
|
||||
/// already another profile's — a rename that swallowed a sibling would destroy a profile the user
|
||||
/// never mentioned. Renaming to a different **case of its own name** is allowed, and is the one
|
||||
/// case where the target name already exists.
|
||||
@discardableResult
|
||||
public mutating func rename(_ name: String, to newName: String) -> Bool {
|
||||
guard Self.isAcceptable(newName),
|
||||
let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) })
|
||||
else { return false }
|
||||
let collision = profiles.firstIndex { Self.key($0.name) == Self.key(newName) }
|
||||
guard collision == nil || collision == index else { return false }
|
||||
profiles[index].name = Self.normalized(newName)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Deletes a profile. A name that matches nothing is a no-op — the honest answer for a menu row
|
||||
/// that raced a deletion, and one no caller has to guard against.
|
||||
public mutating func delete(_ name: String) {
|
||||
profiles.removeAll { Self.key($0.name) == Self.key(name) }
|
||||
}
|
||||
|
||||
// MARK: Codable
|
||||
|
||||
/// A keyed container rather than a bare array, so the stored shape has somewhere to grow (an
|
||||
/// ordering key, a per-profile note) without every existing plist becoming undecodable. The
|
||||
/// decode routes through `init(profiles:)`, which is what applies the sanitizing rule to bytes
|
||||
/// that may have been hand-edited.
|
||||
private enum CodingKeys: String, CodingKey { case profiles }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let decoded = (try? container.decodeIfPresent([PrintProfile].self, forKey: .profiles)) ?? nil
|
||||
self.init(profiles: decoded ?? [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// The named print profiles and the Last Used capture, persisted — **app-side, never in a board**
|
||||
/// (02-architecture.md § Per-board app state: "App-wide state has the same home").
|
||||
///
|
||||
/// ### Why `UserDefaults` and not the board
|
||||
///
|
||||
/// A print profile describes *how this user likes to read on paper*. It is not a property of any
|
||||
/// board, it is not shared with a collaborator, and it has no business in frontmatter — the same
|
||||
/// argument `BoardZoomStore` makes for the zoom level, one rung stronger: a lane's `width` genuinely
|
||||
/// is board data because everyone opening the board sees it, while "Archive, comments on, Palatino
|
||||
/// 10pt" is one person's habit. Writing it into a `.kanban` package would also make it a thing agents
|
||||
/// and syncs have to round-trip, for a value no agent will ever read.
|
||||
///
|
||||
/// It is not in `BoardRegistry` either, for `BoardZoomStore`'s reason: profiles are not per board, and
|
||||
/// a board opened on two machines wants the same *preference* applied rather than a per-board memory
|
||||
/// of one.
|
||||
///
|
||||
/// ### Why `@Observable` rather than `@AppStorage`
|
||||
///
|
||||
/// The accessory's profile popup and the print operation's live preview both read this, and the
|
||||
/// preview's refresh is driven by KVO on the accessory controller
|
||||
/// (`PrintOptionsAccessoryController`), which needs a change it can observe — a property wrapper
|
||||
/// living inside a SwiftUI view body cannot give a menu row or a print panel that. `BoardZoomStore`'s
|
||||
/// shape exactly, and for the same two consumers' reasons.
|
||||
///
|
||||
/// ### Rules live in `PrintProfileCatalog`
|
||||
///
|
||||
/// Everything about names, collisions, ordering and the reserved pseudo-profile is that value type's.
|
||||
/// This object holds one, publishes it, and persists it — nothing else. `defaults` is injectable so a
|
||||
/// test drives a suite of its own rather than the developer's own preferences (`BoardZoomStore`'s
|
||||
/// note, verbatim in intent).
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class PrintProfileStore {
|
||||
|
||||
/// The named profiles. Mutated only through the three methods below, each of which persists.
|
||||
public private(set) var catalog: PrintProfileCatalog
|
||||
|
||||
/// **The options the last print ran with** — the reserved "Last Used" pseudo-profile's content
|
||||
/// (`PrintProfile.lastUsedName`).
|
||||
///
|
||||
/// A first launch has none, and that absence is meaningful rather than a missing value to paper
|
||||
/// over: `options(named:)` answers the factory defaults for it, and the popup still shows the row,
|
||||
/// because "Last Used" naming today's defaults on the very first print is the truth — nothing else
|
||||
/// has been used yet.
|
||||
public private(set) var lastUsed: PrintOptions?
|
||||
|
||||
@ObservationIgnored
|
||||
private let defaults: UserDefaults
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing")
|
||||
|
||||
/// - Parameter defaults: the domain to persist in. Injected for `BoardZoomStore`'s reason — a test
|
||||
/// must be able to hold its own without touching the user's.
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
catalog = Self.decode(PrintProfileCatalog.self, from: defaults, key: AppPreferences.printProfilesKey)
|
||||
?? PrintProfileCatalog()
|
||||
lastUsed = Self.decode(PrintOptions.self, from: defaults, key: AppPreferences.printLastUsedKey)
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// The options a menu selection resolves to: the reserved row answers `lastUsed` (or the factory
|
||||
/// defaults on a first launch — see `lastUsed`), a named row answers the catalog, and a name that
|
||||
/// matches neither answers `nil`.
|
||||
public func options(named name: String) -> PrintOptions? {
|
||||
if PrintProfile.isReserved(name) { return lastUsed ?? PrintOptions() }
|
||||
return catalog.options(named: name)
|
||||
}
|
||||
|
||||
/// What the profile popup lists, top to bottom: the reserved row first, then the named ones in the
|
||||
/// order they were saved (`PrintProfileCatalog`'s own ordering note).
|
||||
///
|
||||
/// The reserved row leads because it is what ⌘P opens on, and a menu whose first row is not the
|
||||
/// selected one reads as a menu that lost the user's place.
|
||||
public var menuNames: [String] { [PrintProfile.lastUsedName] + catalog.names }
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// **Captures the options a print just ran with.** Called when the operation ends and only if it ran
|
||||
/// (`PrintCompletion`, which carries the reasoning): the sheeted print panel returns control
|
||||
/// immediately, so there is no "on the way in" moment at which the user's edits exist yet, and Cancel is
|
||||
/// indistinguishable from a printer failure at the end — so the gate is success, and a cancelled print
|
||||
/// leaves the remembered settings exactly where they were.
|
||||
///
|
||||
/// An unchanged capture writes nothing and publishes nothing, `BoardZoomStore.setLevel`'s guard and
|
||||
/// for its load-bearing reason: `@Observable` notifies on every set, and a no-op notification here
|
||||
/// would invalidate the accessory's own view mid-print.
|
||||
public func captureLastUsed(_ options: PrintOptions) {
|
||||
guard options != lastUsed else { return }
|
||||
lastUsed = options
|
||||
persist(options, key: AppPreferences.printLastUsedKey)
|
||||
}
|
||||
|
||||
/// Saves `options` as a named profile, overwriting one of that name — the catalog's rule, persisted.
|
||||
/// `false` is a refused name (blank, or the reserved one), which writes nothing.
|
||||
@discardableResult
|
||||
public func save(_ options: PrintOptions, as name: String) -> Bool {
|
||||
var updated = catalog
|
||||
guard updated.save(options, as: name) else { return false }
|
||||
catalog = updated
|
||||
persistCatalog()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func rename(_ name: String, to newName: String) -> Bool {
|
||||
var updated = catalog
|
||||
guard updated.rename(name, to: newName) else { return false }
|
||||
catalog = updated
|
||||
persistCatalog()
|
||||
return true
|
||||
}
|
||||
|
||||
public func delete(_ name: String) {
|
||||
var updated = catalog
|
||||
updated.delete(name)
|
||||
guard updated != catalog else { return }
|
||||
catalog = updated
|
||||
persistCatalog()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// JSON in a single `Data` value rather than a plist tree of dictionaries.
|
||||
///
|
||||
/// The stored shape is then `Codable`'s, which is the shape the tests round-trip and the shape a
|
||||
/// future field extends — as opposed to a hand-written `[[String: Any]]` mapping that would have to
|
||||
/// be kept in step with the struct by hand. `UserDefaults` stores `Data` natively, so this costs
|
||||
/// nothing but a serialization the app performs at most once per print.
|
||||
private func persistCatalog() {
|
||||
persist(catalog, key: AppPreferences.printProfilesKey)
|
||||
}
|
||||
|
||||
private func persist<Value: Encodable>(_ value: Value, key: String) {
|
||||
do {
|
||||
defaults.set(try JSONEncoder().encode(value), forKey: key)
|
||||
} catch {
|
||||
// Nothing user-facing: a preferences write that fails costs a remembered profile, not any
|
||||
// of the user's content, and there is no board banner that would be honest about it (02 §
|
||||
// Write-failure surfacing is about *board* writes). The log is the whole response.
|
||||
Self.logger.error("print profiles could not be persisted: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Total, by the whole file's doctrine: a key holding the wrong type, truncated JSON, or a shape
|
||||
/// from a build that has moved on all answer `nil`, which reads as "nothing stored yet" — the same
|
||||
/// degrade `StyleRecents` gives a garbage preference rather than taking the surface down with it.
|
||||
private static func decode<Value: Decodable>(_ type: Value.Type, from defaults: UserDefaults, key: String) -> Value? {
|
||||
guard let data = defaults.data(forKey: key) else { return nil }
|
||||
guard let value = try? JSONDecoder().decode(type, from: data) else {
|
||||
logger.warning("stored value for '\(key, privacy: .public)' could not be read — ignored")
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - The leaves
|
||||
|
||||
/// One comment as paper needs it: who, when, and what they said — `Comment` with everything a
|
||||
/// *window* needs stripped out (its identity, its attachments, its edited flag, its whole parsed
|
||||
/// document).
|
||||
///
|
||||
/// The narrowing is the point. A print is a snapshot taken once and then re-laid-out several times as
|
||||
/// the user tries options in the panel, so what crosses into the printing layer should be the smallest
|
||||
/// thing that can answer every option — anything richer invites the renderer to start making decisions
|
||||
/// the builder should have made.
|
||||
public struct PrintComment: Sendable, Equatable {
|
||||
|
||||
/// `nil` renders unattributed, exactly as the pane does — "**Missing renders unattributed**; there
|
||||
/// is no identity system behind it and none is implied" (`Comment.author`).
|
||||
public var author: String?
|
||||
|
||||
/// `nil` for a comment whose `created` was missing or unreadable — a lenient field, and the byline
|
||||
/// simply says less rather than the print refusing.
|
||||
public var created: Date?
|
||||
|
||||
public var body: String
|
||||
|
||||
public init(author: String? = nil, created: Date? = nil, body: String) {
|
||||
self.author = author
|
||||
self.created = created
|
||||
self.body = body
|
||||
}
|
||||
|
||||
/// A thread, flattened — **in the thread's own order** (`CommentThread.sorted`: `created`
|
||||
/// ascending, undated after dated, folder-name tie-break).
|
||||
///
|
||||
/// The order arrives already correct and is never re-derived here: `PrintCommentSort.newestFirst`
|
||||
/// *reverses* this sequence rather than sorting by a key of its own, which is the same discipline
|
||||
/// the comments pane keeps ("The header's sort control reverses it for display and never re-sorts"
|
||||
/// — `CardComments.thread`). A second sort would be a second chance to disagree with the format's
|
||||
/// own chronology rule about what an undated comment means.
|
||||
public static func list(of thread: CommentThread) -> [PrintComment] {
|
||||
thread.comments.map {
|
||||
PrintComment(author: $0.author.value, created: $0.created.value, body: $0.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One card as paper needs it — the four things the component toggles can ask for, and nothing else.
|
||||
public struct PrintCard: Sendable, Equatable {
|
||||
|
||||
/// The title as written, or `nil` for an untitled card. The **placeholder is the builder's**
|
||||
/// (`PrintDocumentBuilder.untitled`), never stored here: "Untitled" is a rendering, never a value
|
||||
/// (03-board-ui.md § Card face), and putting the word in this struct would make it indistinguishable
|
||||
/// from a card someone actually named that.
|
||||
public var title: String?
|
||||
|
||||
/// The card's `icon` — a name already resolved against the running system, or `nil` when the field
|
||||
/// named no symbol this OS can draw. Resolution happens at extraction (`ItemSymbol`), so the
|
||||
/// renderer never has to ask whether a glyph exists and the print of a hand-typed typo silently
|
||||
/// omits the glyph rather than drawing an empty box.
|
||||
public var icon: String?
|
||||
|
||||
/// The reserved `labels` key, flattened to strings (`PrintCard.labels(of:)`).
|
||||
public var labels: [String]
|
||||
|
||||
public var body: String
|
||||
|
||||
/// The card's thread, in chronological order. Empty both for a card with no comments and for a
|
||||
/// print that never asked for them — the extraction reads a thread only when the options want one
|
||||
/// (`PrintSource.board(_:titled:comments:)`), which is what keeps a comment-less board print from
|
||||
/// paying a disk read per card.
|
||||
public var comments: [PrintComment]
|
||||
|
||||
public init(title: String? = nil, icon: String? = nil, labels: [String] = [], body: String = "", comments: [PrintComment] = []) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.labels = labels
|
||||
self.body = body
|
||||
self.comments = comments
|
||||
}
|
||||
|
||||
// MARK: Extraction
|
||||
|
||||
/// A snapshot card, narrowed — with its thread supplied by the caller, because a thread is a disk
|
||||
/// read and this type is a value.
|
||||
public static func from(_ card: Card, comments: [PrintComment] = []) -> PrintCard {
|
||||
PrintCard(
|
||||
title: card.title.value,
|
||||
// `nil` rather than the level default: a print is a document, and a `doc.text` glyph in
|
||||
// front of every single card is furniture rather than information. A card whose author
|
||||
// *chose* an icon gets it; the board's own defaults stay on screen where they help
|
||||
// scanning (03-board-ui.md § Card face).
|
||||
icon: card.icon.value.flatMap { ItemSymbol.exists($0) ? $0 : nil },
|
||||
labels: labels(of: card.document),
|
||||
body: card.body,
|
||||
comments: comments
|
||||
)
|
||||
}
|
||||
|
||||
/// **The reserved `labels` key, read as a list of words** — and the only place in the app that
|
||||
/// interprets it at all.
|
||||
///
|
||||
/// 01-storage-format.md § Frontmatter reserves `labels` for the tracker-integration story and this
|
||||
/// version gives it no life: it is an ordinary unknown key, shown verbatim in the card window's
|
||||
/// Details rows and searched by nothing (04 ▸ Search: "labels/tags and their kin are reserved,
|
||||
/// inert keys this version"). Printing is the one surface that asks for it by name, because the
|
||||
/// card that specifies this feature asks for it by name.
|
||||
///
|
||||
/// So the reading is deliberately shallow and deliberately lenient — it is a *display* of bytes,
|
||||
/// not the activation of a field:
|
||||
///
|
||||
/// - A **sequence** is its scalar members, in order; nested collections are skipped rather than
|
||||
/// flattened, since a list of lists is not a label row.
|
||||
/// - A **single scalar** is one label, *except* that a comma-separated one splits — `labels: bug,
|
||||
/// ui` is YAML's one string `"bug, ui"` and is overwhelmingly likely to be a hand-written pair.
|
||||
/// This is the one inference here, and it is the friendly reading of the shape a human types.
|
||||
/// - Anything else (a mapping, a null, an empty string) contributes nothing.
|
||||
///
|
||||
/// Blank members are dropped and the rest keep their bytes exactly. Nothing here can throw and
|
||||
/// nothing can fail — `CardDetails.display`'s posture, one key narrower.
|
||||
public static func labels(of document: FrontmatterDocument) -> [String] {
|
||||
guard let value = document.value(for: labelsKey) else { return [] }
|
||||
switch value {
|
||||
case let .sequence(members):
|
||||
return members.compactMap(scalarText(of:)).filter { !$0.isEmpty }
|
||||
case .mapping, .null:
|
||||
return []
|
||||
default:
|
||||
guard let text = scalarText(of: value), !text.isEmpty else { return [] }
|
||||
return text
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
/// The reserved key's spelling, in one place — 01's own name for it.
|
||||
static let labelsKey = "labels"
|
||||
|
||||
/// A scalar's text, or `nil` for a shape that is not a scalar. `YAMLValue.description` is the
|
||||
/// engine's own rendering and is the right answer for every scalar case (a bare `2026-01-01` label
|
||||
/// reads as its ISO form, which is what the file means); the collection cases are excluded here
|
||||
/// rather than described, since their `description` is diagnostic syntax nobody wants on paper.
|
||||
private static func scalarText(of value: YAMLValue) -> String? {
|
||||
switch value {
|
||||
case .null, .sequence, .mapping: nil
|
||||
default: value.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One lane as paper needs it: its heading and its cards, top to bottom.
|
||||
public struct PrintLane: Sendable, Equatable {
|
||||
|
||||
/// The lane's title, or `nil` for an untitled lane — the placeholder is the builder's, exactly as
|
||||
/// a card's is.
|
||||
public var title: String?
|
||||
|
||||
public var cards: [PrintCard]
|
||||
|
||||
public init(title: String? = nil, cards: [PrintCard]) {
|
||||
self.title = title
|
||||
self.cards = cards
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PrintSource
|
||||
|
||||
/// **What is being printed, as one immutable value** — the whole input to `PrintDocumentBuilder`, and
|
||||
/// the seam between the app and the printing layer.
|
||||
///
|
||||
/// ### Why a snapshot of a snapshot
|
||||
///
|
||||
/// `BoardModel` is already immutable, so copying out of it looks redundant until you count what a
|
||||
/// print does with it: the panel's preview re-lays-out the document every time the user flips a
|
||||
/// toggle, and the board underneath may reload (an agent writing, a sync landing) at any point during
|
||||
/// that dialog. A print that re-read the live store between preview refreshes would show one document
|
||||
/// and put another on paper. So the source is taken **once**, when ⌘P is pressed, and the print is of
|
||||
/// the board as it was at that moment — which is also what a user means by "print this".
|
||||
///
|
||||
/// It is also what makes the printing layer testable end to end without a board on disk, a store, or
|
||||
/// a window: every rule below `PrintSource` is a function of this value and `PrintOptions`.
|
||||
public struct PrintSource: Sendable, Equatable {
|
||||
|
||||
/// Which of the two ⌘P targets produced this — the board window's, or one card window's.
|
||||
///
|
||||
/// The builder needs to know, and cannot infer it: a one-lane board with one card is shape-identical
|
||||
/// to a printed card, and the two documents differ (a board print names the board and its lanes; a
|
||||
/// card print is the card).
|
||||
public enum Scope: Sendable, Equatable {
|
||||
case board
|
||||
case card
|
||||
}
|
||||
|
||||
public var scope: Scope
|
||||
|
||||
/// The board's display name — `AppModel.displayName(of:)`'s answer, which falls back to the folder
|
||||
/// name for an untitled board. Carried for both scopes: a printed card's running head names the
|
||||
/// board it came from, which is the one piece of context a loose sheet needs.
|
||||
public var boardTitle: String
|
||||
|
||||
/// For `.board`, the live lanes in display order. For `.card`, exactly one lane — the card's own,
|
||||
/// carried so the print can say which lane it came from and so the two scopes share one shape.
|
||||
public var lanes: [PrintLane]
|
||||
|
||||
public init(scope: Scope, boardTitle: String, lanes: [PrintLane]) {
|
||||
self.scope = scope
|
||||
self.boardTitle = boardTitle
|
||||
self.lanes = lanes
|
||||
}
|
||||
|
||||
// MARK: Extraction
|
||||
|
||||
/// **A whole board, lane by lane, card by card** — `snapshot.lanes` in display order, each lane's
|
||||
/// `cards` in display order, exactly as the loader ranked them (`Ranks.sortedForDisplay`). The
|
||||
/// left-to-right strip becomes a top-to-bottom document by reading it in the order it is already
|
||||
/// stored in; nothing here sorts anything.
|
||||
///
|
||||
/// **The trash is excluded**, and by construction rather than by a filter: `BoardModel.trash` and
|
||||
/// `trashedLanes` are sibling containers of `lanes`, not members of it (`BoardModel.trash`'s own
|
||||
/// note — "A sibling container of `lanes`, not a lane"), so a walk of `lanes` cannot reach them. A
|
||||
/// board print is a print of the board; deleted cards are deleted.
|
||||
///
|
||||
/// - Parameter comments: the thread read, per card — supplied by the caller so it can be skipped
|
||||
/// entirely when the options do not want comments, and memoized when they do. Comments are
|
||||
/// window-scoped and outside the snapshot (01 § Enhanced schema), so there is no board-level
|
||||
/// reading of them to inherit; this closure is that read.
|
||||
public static func board(
|
||||
_ snapshot: BoardModel,
|
||||
titled boardTitle: String,
|
||||
comments: (Card) -> [PrintComment] = { _ in [] }
|
||||
) -> PrintSource {
|
||||
PrintSource(
|
||||
scope: .board,
|
||||
boardTitle: boardTitle,
|
||||
lanes: snapshot.lanes.map { lane in
|
||||
PrintLane(
|
||||
title: lane.title.value,
|
||||
cards: lane.cards.map { PrintCard.from($0, comments: comments($0)) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// **One card**, wrapped in its lane so both scopes share one shape.
|
||||
public static func card(
|
||||
_ card: Card,
|
||||
laneTitle: String?,
|
||||
boardTitle: String,
|
||||
comments: [PrintComment] = []
|
||||
) -> PrintSource {
|
||||
PrintSource(
|
||||
scope: .card,
|
||||
boardTitle: boardTitle,
|
||||
lanes: [PrintLane(title: laneTitle, cards: [PrintCard.from(card, comments: comments)])]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user