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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user