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

162 lines
8.0 KiB
Swift

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
}
}