Files
lanework/Kanban/UI/Print/PrintSession.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

199 lines
9.0 KiB
Swift

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