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