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,198 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
// MARK: - PrintSourceProvider
|
||||
|
||||
/// **One print's content, read at most twice** — once without comments, once with them if the user ever
|
||||
/// asks.
|
||||
///
|
||||
/// ### Why it is a class and not a `PrintSource`
|
||||
///
|
||||
/// A thread is a **disk read** and comments are window-scoped, outside the board snapshot
|
||||
/// (01-storage-format.md § Enhanced schema: "the walk stays O(cards): the card window reads its own
|
||||
/// thread … and the board snapshot never loads comment content"). So a board print with comments on
|
||||
/// costs one read per card, and comments are **off by default** precisely so nobody pays that without
|
||||
/// asking (`PrintOptions.includesComments`). A plain value would force the choice at ⌘P, before the user
|
||||
/// has seen the dialog: either read every thread on every ⌘P, or make the comments toggle inert.
|
||||
///
|
||||
/// This is the third answer. The comment-less source is taken eagerly (it is a walk of an
|
||||
/// already-loaded snapshot and costs nothing), the comment-bearing one is built on first demand, and the
|
||||
/// result is kept — so flipping the toggle back and forth in the panel, which re-lays-out the document
|
||||
/// each time, reads each thread exactly once.
|
||||
///
|
||||
/// ### The snapshot is frozen either way
|
||||
///
|
||||
/// Both sources describe the board **as it was when ⌘P was pressed** (`PrintSource`'s own note). The
|
||||
/// comment read is the one thing that happens later, which is a deliberate seam rather than a leak: a
|
||||
/// thread that gained a comment between ⌘P and the toggle prints with it, and that is more useful than
|
||||
/// a print that hid a comment because a dialog was open when it arrived.
|
||||
@MainActor
|
||||
final class PrintSourceProvider {
|
||||
|
||||
private let withoutComments: PrintSource
|
||||
private let buildWithComments: () -> PrintSource
|
||||
private var cached: PrintSource?
|
||||
|
||||
init(withoutComments: PrintSource, withComments: @escaping () -> PrintSource) {
|
||||
self.withoutComments = withoutComments
|
||||
buildWithComments = withComments
|
||||
}
|
||||
|
||||
/// A source that already carries its comments — the card-window case, where "read the thread" is one
|
||||
/// read the window has already done and there is nothing to defer.
|
||||
init(complete source: PrintSource) {
|
||||
withoutComments = source
|
||||
buildWithComments = { source }
|
||||
cached = source
|
||||
}
|
||||
|
||||
func source(includingComments: Bool) -> PrintSource {
|
||||
guard includingComments else { return withoutComments }
|
||||
if let cached { return cached }
|
||||
let source = buildWithComments()
|
||||
cached = source
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PrintSession
|
||||
|
||||
/// **One ⌘P, from the dialog opening to the sheet coming out** — the live options, the content behind
|
||||
/// them, and the profile store they are saved into.
|
||||
///
|
||||
/// ### Why a session object at all
|
||||
///
|
||||
/// Three surfaces have to agree about one set of options while the print panel is up: the accessory's
|
||||
/// controls (which write them), the document view (which lays out from them, several times, as the user
|
||||
/// tries things), and the profile popup (which replaces them wholesale when a profile is chosen). A
|
||||
/// `PrintOptions` value passed by copy to each would give three views of one decision. So the session is
|
||||
/// the one holder, and it is the thing the accessory and the view are both built around — the
|
||||
/// `CardComments` / `CardAttachments` pattern applied to a modal rather than a window.
|
||||
///
|
||||
/// It is deliberately **per print**, created by `PrintCoordinator` and discarded when the operation
|
||||
/// finishes. Nothing here outlives the panel except what it writes into `PrintProfileStore`, which is
|
||||
/// where persistence belongs.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PrintSession {
|
||||
|
||||
/// The options the panel is currently configured with. Every control in the accessory writes here,
|
||||
/// and the document view reads here at layout time.
|
||||
var options: PrintOptions
|
||||
|
||||
/// Which row of the profile popup is showing — a name, either the reserved one or a saved profile's
|
||||
/// (`PrintProfileStore.menuNames`).
|
||||
///
|
||||
/// **It is a label, not a binding.** Choosing a profile copies its options in; editing a control
|
||||
/// afterwards does *not* write back to the profile — it marks the selection as modified
|
||||
/// (`isModified`), and only Save commits. A popup that silently rewrote the profile the user was
|
||||
/// looking at would make named profiles useless, which is `PrintOptions`' value-semantics note one
|
||||
/// level up.
|
||||
var selectedProfileName: String
|
||||
|
||||
/// Whether the live options have drifted from the selected profile's — what puts the popup's row in
|
||||
/// its "(modified)" state and what makes Save meaningful.
|
||||
///
|
||||
/// The reserved row is **never** modified: it *is* the last-used options, so drifting from it is
|
||||
/// what it is for.
|
||||
var isModified: Bool {
|
||||
guard !PrintProfile.isReserved(selectedProfileName) else { return false }
|
||||
guard let saved = profiles.catalog.options(named: selectedProfileName) else { return true }
|
||||
return saved != options
|
||||
}
|
||||
|
||||
let profiles: PrintProfileStore
|
||||
|
||||
/// What is being printed. Read through `source(for:)` so the comments toggle decides whether the
|
||||
/// threads are read at all.
|
||||
@ObservationIgnored
|
||||
private let provider: PrintSourceProvider
|
||||
|
||||
/// The printed card's own folder, for a card print — the anchor a relative image resolves against
|
||||
/// (`BodyTarget.resolve`). `nil` for a board print, where there is no single folder that would be
|
||||
/// right for every card (`PrintDocumentRenderer.appendBody`).
|
||||
@ObservationIgnored
|
||||
let cardFolder: URL?
|
||||
|
||||
/// The print job's name, as the print queue and the Save-as-PDF panel show it — the board's name for
|
||||
/// a board print, the card's for a card print.
|
||||
@ObservationIgnored
|
||||
let jobTitle: String
|
||||
|
||||
/// The board's name, for the running head. Held beside `jobTitle` rather than derived from it because
|
||||
/// the two differ for a card print, and beside the source rather than read out of it because the
|
||||
/// source is rebuilt when the comments toggle flips while the title never changes.
|
||||
@ObservationIgnored
|
||||
let boardTitle: String
|
||||
|
||||
init(
|
||||
provider: PrintSourceProvider,
|
||||
profiles: PrintProfileStore,
|
||||
cardFolder: URL?,
|
||||
jobTitle: String,
|
||||
boardTitle: String
|
||||
) {
|
||||
self.provider = provider
|
||||
self.profiles = profiles
|
||||
self.cardFolder = cardFolder
|
||||
self.jobTitle = jobTitle
|
||||
self.boardTitle = boardTitle
|
||||
// **⌘P opens on what the user did last**, which is the reserved pseudo-profile's whole purpose
|
||||
// (`PrintProfile.lastUsedName`) — and on the factory defaults the very first time, which is the
|
||||
// same thing said about a store with nothing in it.
|
||||
selectedProfileName = PrintProfile.lastUsedName
|
||||
options = profiles.lastUsed ?? PrintOptions()
|
||||
}
|
||||
|
||||
// MARK: The document
|
||||
|
||||
/// The blocks this print would lay out, given the options as they stand — one call, so the accessory's
|
||||
/// summary, the page count and the drawing can never describe three different documents.
|
||||
func blocks() -> [PrintBlock] {
|
||||
PrintDocumentBuilder.blocks(
|
||||
from: provider.source(includingComments: options.includesComments),
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Profiles
|
||||
|
||||
/// Applies a profile's options wholesale. A name that resolves to nothing leaves the options alone
|
||||
/// and the selection where it was — the honest answer for a menu that raced a deletion.
|
||||
func selectProfile(named name: String) {
|
||||
guard let applied = profiles.options(named: name) else { return }
|
||||
selectedProfileName = name
|
||||
options = applied
|
||||
}
|
||||
|
||||
/// Saves the live options under `name` and selects it. `false` is a refused name (blank, or the
|
||||
/// reserved one), which changes nothing.
|
||||
@discardableResult
|
||||
func saveProfile(named name: String) -> Bool {
|
||||
guard profiles.save(options, as: name) else { return false }
|
||||
selectedProfileName = PrintProfileCatalog.normalized(name)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func renameSelectedProfile(to newName: String) -> Bool {
|
||||
guard profiles.rename(selectedProfileName, to: newName) else { return false }
|
||||
selectedProfileName = PrintProfileCatalog.normalized(newName)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Deletes the selected profile and falls back to the reserved row — **keeping the options**. The
|
||||
/// user deleted a saved *name*, not the settings they are looking at, and resetting the panel under
|
||||
/// them would be the destructive reading of a management gesture.
|
||||
func deleteSelectedProfile() {
|
||||
guard !PrintProfile.isReserved(selectedProfileName) else { return }
|
||||
profiles.delete(selectedProfileName)
|
||||
selectedProfileName = PrintProfile.lastUsedName
|
||||
}
|
||||
|
||||
/// Whether the selected row can be renamed or deleted — the reserved pseudo-profile cannot
|
||||
/// (`PrintProfile.lastUsedName`), and neither can a selection that names nothing.
|
||||
var canManageSelection: Bool {
|
||||
!PrintProfile.isReserved(selectedProfileName) && profiles.catalog.contains(selectedProfileName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user