⌘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
199 lines
9.0 KiB
Swift
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)
|
|
}
|
|
}
|