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

201 lines
10 KiB
Swift

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 ?? [])
}
}