⌘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
313 lines
15 KiB
Swift
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
|
|
}
|
|
}
|