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

313 lines
15 KiB
Swift

import AppKit
import Observation
import SwiftUI
import os
// MARK: - The focused card window's printable card
/// **What a card window offers File ▸ Print…**: the card as the last snapshot found it, the lane it is in,
/// the board it belongs to, its folder, and the thread it is showing.
///
/// A handle of its own rather than a reuse of `cardComments` or `cardAttachments`, because neither carries
/// the card: the comments pane knows a title and a folder (it announces about them), the attachments
/// section knows a folder, and printing needs the whole `Card` — its `icon`, its reserved `labels`, its
/// body. `CardAttachments`' shape and for its reasons: one per window, `@State` in the host, published
/// through the focus system so a **menu row** reaches the frontmost card window without anyone keeping a
/// which-window-is-key register (`FocusedBoardStoreKey`).
///
/// **Re-derived from every snapshot**, like the window's title and subtitle: a card renamed, restyled or
/// moved between lanes mid-session prints as it is now, not as it was when the window opened.
@MainActor
@Observable
final class CardPrintSubject {
/// `nil` until the window has joined its board — which is also exactly when there is nothing to print.
var card: Card?
/// The lane the card is in, for the print's context line. `nil` renders as the same "Untitled"
/// placeholder a lane header shows (`PrintDocumentBuilder.untitled`).
var laneTitle: String?
/// The board's display name (`AppModel.displayName(of:)`), for the running head.
var boardTitle = ""
/// The card's folder — the anchor a relative image in its body resolves against (`BodyTarget.resolve`).
var cardFolder: URL?
/// The thread as the pane last read it, read again at print time.
///
/// A closure rather than a stored value for `CardComments.readThread`'s reason: comments are
/// window-scoped and outside the snapshot, so there is nothing to republish from — the pane re-reads
/// from disk, and a print asks the same question at the moment it is asked to print.
@ObservationIgnored
var readThread: (() -> CommentThread)?
init() {}
}
struct FocusedCardPrintKey: FocusedValueKey {
typealias Value = CardPrintSubject
}
extension FocusedValues {
var cardPrint: CardPrintSubject? {
get { self[FocusedCardPrintKey.self] }
set { self[FocusedCardPrintKey.self] = newValue }
}
}
// MARK: - File ▸ Print…
/// **File ▸ Print… (⌘P)** — 11-command-nexus.md's Print row, and the retirement of that document's "No
/// Print story in v1 (⌘P unused)" line.
///
/// ### Two scopes, one row
///
/// The board window prints **the board**: its lanes left to right, each lane's cards top to bottom, as a
/// linear document rather than a picture of the strip (`PrintSource.board`). The card window prints **that
/// card**. One menu row for both, which is what the platform means by Print — the frontmost window's
/// document — and which is why the row reaches its subject through the focus system rather than through the
/// app model.
///
/// The two are told apart by which focused value is present, and cannot both be: a card window publishes no
/// `boardStore` (`CardWindowHost`), so the board branch is scopeless there. The order below therefore
/// decides nothing, and it follows `RevealInFinderCommand`'s — the board in front wins, the card-window
/// branch stands when a card window is.
///
/// ### Validation is scope and nothing else
///
/// Neither the read-only lock nor the focused-editor rule closes it, unlike every mutating row in
/// `BoardCommands.swift`: a print is a **read**, and a locked board is exactly the board someone wants a
/// paper copy of (`RevealInFinderCommand`'s posture, and `BoardInfoCommand`'s). An inline title editor is no
/// obstacle either — the print takes the snapshot as it stands, which is what is on screen.
///
/// A board with **nothing to print** — no lanes, or every card empty of every included component — still
/// enables the row, deliberately: the honest place to discover that is the panel's own preview, and a ⌘P
/// that greys out on a board the user is looking at reads as a broken app rather than as an empty document.
/// The refusal, when it happens, is `PrintCoordinator`'s and it says so.
struct PrintCommand: View {
let appModel: AppModel
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.cardPrint) private var cardPrint
var body: some View {
Button("Print…") {
print()
}
.keyboardShortcut("p", modifiers: .command)
.disabled(!isEnabled)
}
/// One answer for both the `disabled` state and the action — the codebase's usual shape, for its usual
/// reason: two derivations of a rule are two chances to disagree.
private var isEnabled: Bool {
Self.isEnabled(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil)
}
/// The row's validation as a pure function of the two facts it turns on, extracted from `isEnabled` for
/// `SaveAsTemplateCommand.allowsSave`'s reason: a rule that can only be exercised through a menu is a
/// rule nobody tests.
///
/// **Two disjuncts and nothing else.** No lock, no focused-editor rule, no is-there-anything-to-print —
/// see the type's doc comment for why each of those is deliberately absent. A card window whose board
/// has not loaded yet publishes a subject with no card, which is the second disjunct's whole point:
/// scope alone would enable the row over a window with nothing behind it.
static func isEnabled(hasBoard: Bool, hasPrintableCard: Bool) -> Bool {
hasBoard || hasPrintableCard
}
private func print() {
if let store {
PrintCoordinator.printBoard(store: store, profiles: appModel.printProfiles)
return
}
if let cardPrint {
PrintCoordinator.printCard(cardPrint, profiles: appModel.printProfiles)
}
}
}
// MARK: - PrintCoordinator
/// **The AppKit half of ⌘P**: build the session, hand the panel our accessory, run the operation.
///
/// ### Why the operation is sheeted on the window rather than run modally
///
/// `runModal()` would block the main thread inside a nested run loop for as long as the panel is up, which
/// is the shape `NSSavePanel`'s uses in `DuplicateBoardCommand` — and is right there, because that panel
/// answers a question the copy is *waiting* on. A print is not: the board keeps reloading, the watcher keeps
/// running, an agent may be writing. So this uses the sheeted form, whose completion is where the Last Used
/// capture lands.
///
/// ### The one refusal
///
/// A document with no pages is refused with an alert instead of printed. It is reachable two ways — a board
/// with no cards, and every component toggled off — and both deserve a sentence rather than a sheet of
/// running heads over blank paper. The alert is the whole response: nothing failed, so this is not
/// 02-architecture.md's write-failure banner surface, which is about writes.
@MainActor
enum PrintCoordinator {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing")
// MARK: Entry points
/// **The board**, as of this moment (`PrintSource`'s frozen-snapshot note).
static func printBoard(store: BoardStore, profiles: PrintProfileStore) {
let snapshot = store.snapshot
let title = AppModel.displayName(of: store)
let session = PrintSession(
provider: PrintSourceProvider(
withoutComments: PrintSource.board(snapshot, titled: title),
// Deferred, and read at most once — the comments toggle decides whether a board print pays
// a thread read per card (`PrintSourceProvider`).
withComments: {
PrintSource.board(snapshot, titled: title) { card in
PrintComment.list(of: store.commentThread(inCard: card.id))
}
}
),
profiles: profiles,
// No single folder is right for every card's relative images, so a board print resolves none
// (`PrintDocumentRenderer.appendBody`).
cardFolder: nil,
jobTitle: title,
boardTitle: title
)
run(session)
}
/// **One card**, with the thread read now.
static func printCard(_ subject: CardPrintSubject, profiles: PrintProfileStore) {
guard let card = subject.card else { return }
let comments = subject.readThread.map { PrintComment.list(of: $0()) } ?? []
let source = PrintSource.card(
card,
laneTitle: subject.laneTitle,
boardTitle: subject.boardTitle,
comments: comments
)
let session = PrintSession(
provider: PrintSourceProvider(complete: source),
profiles: profiles,
cardFolder: subject.cardFolder,
jobTitle: card.title.value ?? PrintDocumentBuilder.untitled,
boardTitle: subject.boardTitle
)
run(session)
}
// MARK: The operation
private static func run(_ session: PrintSession) {
guard !session.blocks().isEmpty else {
refuse(session)
return
}
// `NSPrintInfo.shared`, deliberately: it is where the panel's paper, orientation and margins are
// remembered between prints, which is exactly the continuity a user expects from a print dialog —
// and an app with no `NSDocument` has no per-document print info for them to live in instead.
let printInfo = NSPrintInfo.shared
// Both modes are moot while the view answers `knowsPageRange` itself — AppKit takes the view's page
// rects and does not subdivide them further — and they are set anyway to say what the document is:
// one column exactly as wide as the page, which never spills sideways.
printInfo.horizontalPagination = .clip
printInfo.verticalPagination = .automatic
let view = PrintDocumentView(session: session, printInfo: printInfo)
let operation = NSPrintOperation(view: view, printInfo: printInfo)
operation.jobTitle = session.jobTitle
operation.showsPrintPanel = true
operation.showsProgressPanel = true
let panel = operation.printPanel
// The preview is what makes the accessory worth having (`PrintOptionsAccessoryController`), and the
// page-setup group is what lets the paper questions be answered in the same dialog rather than in a
// second one this app does not have (there is no File ▸ Page Setup… row — 11-command-nexus.md).
panel.options.formUnion([.showsPreview, .showsPaperSize, .showsOrientation, .showsScaling, .showsCopies, .showsPageRange])
panel.addAccessoryController(PrintOptionsAccessoryController(session: session))
// **The Last Used capture happens when the operation ends, and only if it ran** — see
// `PrintCompletion`, which is also the reason the sheeted form needs a delegate at all.
if let window = keyWindow() {
let completion = PrintCompletion(session: session)
operation.runModal(
for: window,
delegate: completion,
didRun: #selector(PrintCompletion.printOperationDidRun(_:success:contextInfo:)),
contextInfo: nil
)
} else {
// No window to sheet on — which should not happen for a command scoped to a focused window, but
// a print is still a legitimate thing to do and a modal run is the honest fallback. `run()` is
// synchronous, so the capture is an ordinary line rather than a callback.
if operation.run() {
session.profiles.captureLastUsed(session.options)
}
}
}
/// The window the sheet hangs on: the app's key window, which for a command validated against the focus
/// system *is* the window that published the subject. Asked of AppKit rather than threaded through the
/// focus system because a `NSWindow` is not a value a `FocusedValue` should carry, and because
/// `WindowAccessor`'s per-window controllers exist for window *lifecycle*, not for presenting over one.
private static func keyWindow() -> NSWindow? {
NSApp.keyWindow ?? NSApp.mainWindow
}
private static func refuse(_ session: PrintSession) {
logger.notice("print refused: the document has no content")
let alert = NSAlert()
alert.messageText = "Nothing to Print"
alert.informativeText = session.options.describesAnyContent
? "'\(session.jobTitle)' has no cards with any of the content you chose to include."
: "Turn on at least one of Title, Icon & Labels, Body or Comments."
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
/// The sheeted operation's delegate — **and the only place the Last Used capture can honestly happen.**
///
/// `runModal(for:delegate:didRun:contextInfo:)` presents the panel as a sheet and **returns immediately**,
/// which is the whole reason this class exists: capturing on the line after that call would record the
/// options the panel *opened* with, before the user touched a control. A synchronous capture is only
/// available on the windowless `run()` fallback, which is where `PrintCoordinator` puts one.
///
/// **The gate is `success`, so a cancelled print rewrites nothing.** Cancel and a printer failure are
/// indistinguishable here — both arrive as `success == false` — and of the two possible mistakes the
/// asymmetry is clear: capturing on Cancel would overwrite the user's remembered settings with ones they
/// abandoned, while declining to capture on a jam merely leaves Last Used where it was. So the settings a
/// jammed print was configured with are not remembered; that is the cheaper loss, and the print system's
/// own queue window is where the user retries the job anyway.
///
/// **It retains itself until the callback.** `NSPrintOperation` holds its delegate weakly, and the sheeted
/// form outlives every local in the method that presented it — so the object hands its own reference back
/// only once the callback has landed.
@MainActor
private final class PrintCompletion: NSObject {
private let session: PrintSession
private var untilTheCallback: PrintCompletion?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing")
init(session: PrintSession) {
self.session = session
super.init()
untilTheCallback = self
}
@objc func printOperationDidRun(_ operation: NSPrintOperation, success: Bool, contextInfo: UnsafeMutableRawPointer?) {
Self.logger.info("print operation finished — success: \(success, privacy: .public)")
if success {
session.profiles.captureLastUsed(session.options)
}
untilTheCallback = nil
}
}