⌘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
201 lines
10 KiB
Swift
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 ?? [])
|
|
}
|
|
}
|