From 7651e40318030d06520bcd2572b9fe877902d03e Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 22:40:37 -0400 Subject: [PATCH] Print boards and cards with configurable components and named print profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘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 --- DESIGN/11-command-nexus.md | 3 +- Kanban/App/AppModel.swift | 24 + Kanban/App/CardWindowHost.swift | 22 + Kanban/KanbanApp.swift | 20 +- Kanban/Printing/PrintDocumentBuilder.swift | 196 ++++ Kanban/Printing/PrintOptions.swift | 259 +++++ Kanban/Printing/PrintProfile.swift | 200 ++++ Kanban/Printing/PrintProfileStore.swift | 161 +++ Kanban/Printing/PrintRunningHead.swift | 72 ++ Kanban/Printing/PrintSource.swift | 256 +++++ Kanban/UI/Print/PrintCommand.swift | 312 +++++ Kanban/UI/Print/PrintDocumentRenderer.swift | 277 +++++ Kanban/UI/Print/PrintDocumentView.swift | 329 ++++++ Kanban/UI/Print/PrintOptionsAccessory.swift | 395 +++++++ Kanban/UI/Print/PrintSession.swift | 198 ++++ Kanban/UI/Print/PrintTypography.swift | 153 +++ KanbanTests/PrintTests.swift | 1126 +++++++++++++++++++ 17 files changed, 3997 insertions(+), 6 deletions(-) create mode 100644 Kanban/Printing/PrintDocumentBuilder.swift create mode 100644 Kanban/Printing/PrintOptions.swift create mode 100644 Kanban/Printing/PrintProfile.swift create mode 100644 Kanban/Printing/PrintProfileStore.swift create mode 100644 Kanban/Printing/PrintRunningHead.swift create mode 100644 Kanban/Printing/PrintSource.swift create mode 100644 Kanban/UI/Print/PrintCommand.swift create mode 100644 Kanban/UI/Print/PrintDocumentRenderer.swift create mode 100644 Kanban/UI/Print/PrintDocumentView.swift create mode 100644 Kanban/UI/Print/PrintOptionsAccessory.swift create mode 100644 Kanban/UI/Print/PrintSession.swift create mode 100644 Kanban/UI/Print/PrintTypography.swift create mode 100644 KanbanTests/PrintTests.swift diff --git a/DESIGN/11-command-nexus.md b/DESIGN/11-command-nexus.md index fd18967..28ae25f 100644 --- a/DESIGN/11-command-nexus.md +++ b/DESIGN/11-command-nexus.md @@ -32,6 +32,7 @@ The single source of truth for **every command and action the app can perform** | File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column | | File | Delete | ⌘⌫ | Board window, any card or lane selection — staged by place (resettled 2026-07-28; lanes rejoined 2026-07-29): board cards and lanes move to `.trash/`, trash selections delete permanently (03's recoverability confirm — freight-counting for lanes). Deliberately **not** extended to the card window: an enabled ⌘⌫ key equivalent would steal delete-to-line-start from the window's text surfaces, so there the card's delete is the sidebar Actions button (05). **Delete Immediately (⌥⌘⌫) is deliberately absent** (removed 2026-07-30): permanence is only reachable inside the trash — 03 ▸ Trash | | File | Empty Trash… (confirmed) | ⇧⌘⌫ | Board window, trash shown and non-empty (whole-trash scope, search-independent — 03 ▸ Trash) | +| File | Print… | ⌘P | Board window: prints **the board** as a linear document — lanes in left-to-right order, each lane's cards top-to-bottom, never a graphical snapshot of the strip; the trash is unreachable (it is a sibling container of `lanes`, not a lane — 01 ▸ Deletion). Card window: prints **that card**. Validation is scope and nothing else — a print is a read, so neither the read-only lock nor the focused-editor rule closes it (Reveal in Finder's posture). Options ride in a **print-panel accessory** with the system's live preview: which components (title, icon+labels line, rendered body, comments — off by default, oldest- or newest-first), page breaks (continuous / between lanes / between cards, real sheet boundaries), one base font face and size every other size derives from, and a toggleable header/footer (board title, print date, page numbers, custom line). The option sets persist app-side as **named print profiles** with a reserved "Last Used" pseudo-profile that auto-captures the most recent settings — `UserDefaults`, never board data (02 ▸ Per-board app state), the zoom level's own argument. **Page Setup… is deliberately absent**: the paper questions are answered in the print panel's own page-setup group, so a second dialog would be a second place to set one margin | | File | Close | ⌘W | Any window; flushes per 02 ▸ Windows | | Edit | Undo / Redo (M−) | ⌘Z / ⇧⌘Z | Focus-routed (06 ▸ Undo routing): text undo in a focused editor, git undo otherwise; git undo disabled on no-git and repo-nested boards, during 06's abnormal-state pause (detached HEAD, in-progress merge/rebase), and under the read-only lock (02) | | Edit | Cut / Copy / Paste | ⌘X / ⌘C / ⌘V | Board window: cards and lanes (cards-XOR-lanes selections; lane paste lands after the anchor lane — 04 ▸ Clipboard; on a zero-lane board only a lane payload pastes — 04 ▸ ⌘N target rule); in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard restore path (resettled 2026-07-28 — 04 ▸ The trash); paste never targets the trash; text editors: standard text clipboard | @@ -115,7 +116,7 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ The boar ## Standard macOS furniture -System-provided: App menu (About, Hide, Quit), Window menu, Help (carries the one line teaching the System Settings remap path — 04). The app's own additions to this furniture are inventoried in Menu commands above — App ▸ Settings… and Window ▸ Welcome to Lanework; the remaining app-wide preferences, quick-style recents and `NSUserKeyEquivalents`, need no UI. **No Print story in v1** (⌘P unused). Customize Toolbar… per system convention (03). **Window tabbing stays enabled** (settled): the system's Show Tab Bar / tab items appear with their standard chords — ⇧⌘T is the system's, which is why Show Trash ships without a default (Menu commands above); tabbed board windows are ordinary system behavior, each tab still a full board window (a tab's saved per-board frame applies when it stands alone — 02-architecture.md). +System-provided: App menu (About, Hide, Quit), Window menu, Help (carries the one line teaching the System Settings remap path — 04). The app's own additions to this furniture are inventoried in Menu commands above — App ▸ Settings… and Window ▸ Welcome to Lanework; the remaining app-wide preferences, quick-style recents and `NSUserKeyEquivalents`, need no UI. **Print is the app's own row now** (added 2026-08-09, retiring "No Print story in v1 (⌘P unused)"): File ▸ Print… ⌘P above replaces the platform's nil-target item outright, for Undo/Redo's reason — the standard row resolves through the responder chain and this app's print target is the document behind the focused *window*, which no responder vends. Customize Toolbar… per system convention (03). **Window tabbing stays enabled** (settled): the system's Show Tab Bar / tab items appear with their standard chords — ⇧⌘T is the system's, which is why Show Trash ships without a default (Menu commands above); tabbed board windows are ordinary system behavior, each tab still a full board window (a tab's saved per-board frame applies when it stands alone — 02-architecture.md). ## Changes from Kanban diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 6686035..b708b9b 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -127,6 +127,23 @@ public enum AppPreferences { /// stale-from-a-future-build value indistinguishable from a legal one downstream. public static let boardZoomLevelKey = "boardZoomLevel" + // MARK: The print profiles + + /// **The named print profiles** (11-command-nexus.md ▸ File ▸ Print…; the printing card's closing + /// line, "these configurations probably good to persist (as named print profiles) and reused"). + /// Read and written by `PrintProfileStore`, which owns their rules; the key is declared here with + /// its neighbours for `WindowID`'s reason. + /// + /// The value is JSON `Data`, not a plist tree — see that type's persistence note. It is app-side + /// and never board data: a print profile is how this user likes to read on paper, which no + /// collaborator and no agent has any business round-tripping. + public static let printProfilesKey = "printProfiles" + + /// **The reserved "Last Used" pseudo-profile's content** — the options the last print ran with, so + /// ⌘P opens on what the user did last. Absent until the first print, which reads as the factory + /// defaults (`PrintProfileStore.lastUsed`). + public static let printLastUsedKey = "printLastUsed" + // MARK: The appearance override /// **View ▸ Appearance** (11-command-nexus.md) — Auto / Light / Dark, app-wide and persisted @@ -289,6 +306,12 @@ public final class AppModel { /// one question. public let appearance: AppearanceStore + /// The app-wide named print profiles (11-command-nexus.md ▸ File ▸ Print…). Owned here for + /// `appearance`'s reason exactly: app-scoped, persisted beside its neighbours, and reached by File ▸ + /// Print… — a menu row, which lives outside every scene's environment and therefore receives this + /// object rather than looking a store up. + public let printProfiles: PrintProfileStore + /// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop). /// /// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the @@ -612,6 +635,7 @@ public final class AppModel { styleRecents = StyleRecents(defaults: preferences) zoom = BoardZoomStore(defaults: preferences) appearance = AppearanceStore(defaults: preferences) + printProfiles = PrintProfileStore(defaults: preferences) clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot) // Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first // menu pass — a launch that restores boards never shows welcome, and a submenu that filled diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index aea71d5..2c3ff1c 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -285,6 +285,10 @@ struct CardWindowHost: View { /// reason: two card windows on one board have two different selections, and the menu bar reaches /// the frontmost one through the focus system. @State private var attachments = CardAttachments() + /// This window's printable subject — the card, its lane, its board and its thread, as File ▸ Print… + /// needs them (`CardPrintSubject`). Window-scoped for `CardBodyPresentation`'s reason: two card + /// windows are two documents, and the menu bar reaches the frontmost one through the focus system. + @State private var cardPrint = CardPrintSubject() /// This window's thumbnail memory. Held here rather than in the section so it survives every /// snapshot the store applies — a cache that died with the view would regenerate every thumbnail /// on every reload (`AttachmentThumbnailCache`). @@ -381,6 +385,9 @@ struct CardWindowHost: View { // know a card window is in front at all (11-command-nexus.md scopes all three to the card // window). .focusedSceneValue(\.cardComments, session.comments) + // File ▸ Print… (⌘P) reaches the frontmost card window the same way — the card-window scope + // of a row the board window answers with its whole board (11-command-nexus.md). + .focusedSceneValue(\.cardPrint, cardPrint) // The raw-source outlet's detailed alert, presented over this window — a validation // refusal on Apply, or a file that could not be opened as source. It hangs *here* rather // than inside the editor because the second of those fires while source mode is still @@ -473,6 +480,21 @@ struct CardWindowHost: View { // would open nothing. attachments.cardFolder = folder session.comments.cardFolder = folder + cardPrint.cardFolder = folder + } + // **File ▸ Print…'s subject, re-derived from every snapshot** for the folder's and the + // announcer's reason: a card renamed, restyled, relabelled or moved to another lane prints as + // it is now (`CardPrintSubject`). The thread is a closure rather than a value, so ⌘P reads the + // comments at the moment it is pressed rather than whatever the pane last saw. + .onChange(of: placement.card, initial: true) { _, card in + cardPrint.card = card + cardPrint.readThread = { store.commentThread(inCard: card.id) } + } + .onChange(of: placement.lane.title.value, initial: true) { _, title in + cardPrint.laneTitle = title + } + .onChange(of: AppModel.displayName(of: store), initial: true) { _, title in + cardPrint.boardTitle = title } // The announcer's subject, re-derived from every snapshot for the folder's reason: a card // renamed mid-session is announced under its new name ("New comment on '⟨card⟩'"). diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index cb7f488..3968191 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -306,11 +306,21 @@ struct KanbanApp: App { } } - // "No Print story in v1 (⌘P unused)" (11-command-nexus.md ▸ Standard macOS furniture) — the - // system's default Print item is removed outright rather than left dead, since a menu item - // with nothing behind it is exactly what the Nexus's "a command absent here doesn't exist" - // rules out in the other direction too. - CommandGroup(replacing: .printItem) {} + // File ▸ Print… (⌘P) — **the app's own row**, replacing the system's nil-target one + // (11-command-nexus.md's Print row; the "No Print story in v1 (⌘P unused)" line it retired). + // + // `replacing: .printItem` rather than an addition, for the same reason Undo/Redo replace the + // platform's pair: the standard rows are nil-target actions resolved through the responder + // chain, and this app's print target is the *frontmatter-shaped document behind the focused + // window*, which no responder vends. Two items sharing the title "Print…" is also exactly what + // titles-are-API forbids. + // + // Page Setup… stays absent with it: the paper questions are answered in the print panel's own + // page-setup group (`PrintCoordinator`), so a second dialog would be a second place to set one + // margin. + CommandGroup(replacing: .printItem) { + PrintCommand(appModel: appModel) + } // Help carries "the one line teaching the System Settings remap path" (11-command-nexus.md ▸ // Standard macOS furniture) — this app's whole Help menu, since there is no other content to diff --git a/Kanban/Printing/PrintDocumentBuilder.swift b/Kanban/Printing/PrintDocumentBuilder.swift new file mode 100644 index 0000000..73bbc8e --- /dev/null +++ b/Kanban/Printing/PrintDocumentBuilder.swift @@ -0,0 +1,196 @@ +import Foundation + +// MARK: - PrintBlock + +/// One element of a printed document, in reading order — **the whole vocabulary of what can appear on +/// paper**, and the only thing `PrintDocumentBuilder` produces. +/// +/// ### Why a block list rather than an attributed string +/// +/// The same argument `BodyMarkup` makes for existing: the decisions and the drawing are different +/// jobs, and a decision buried in a pile of font attributes is a decision nobody can test. Every rule +/// this feature has — which components appear, in what order, where a page break falls, which end of a +/// thread comes first — is settled here, in a value a test can compare against a literal; the renderer +/// beneath it (`PrintDocumentRenderer`) is then only ever wrong about *typography*. +/// +/// `.pageBreak` is the clearest case. It is a **marker in the list**, not a paragraph of whitespace and +/// not a hint: `PrintDocumentView` splits the document at these markers and paginates each side +/// independently, so "between lanes" really starts a sheet. A test can hold the marker; nobody can hold +/// a promise about spacing. +/// +/// Bodies stay **strings** here rather than parsed `BodyMarkup`. The parse belongs to the render pass +/// (which reuses `BodyMarkup.parse` and `BodyMarkupRenderer` wholesale rather than reading Markdown a +/// second way), and keeping the string means a test of the *document's structure* compares words rather +/// than block trees. What the builder does ask of the Markdown layer is the one question that is +/// structural: `BodyMarkup.isEmpty`, which decides whether there is a body to print at all. +public enum PrintBlock: Sendable, Equatable { + + /// A real sheet boundary. Never the first or last block, and never doubled — see + /// `PrintDocumentBuilder`. + case pageBreak + + /// The document's title, for a board print: the board's name, once, at the top. A card print has + /// none — its own title line is the card's, and the board is named in the running head. + case boardHeading(String) + + /// A lane's name, opening its run of cards. Board prints only. + case laneHeading(String) + + case cardTitle(String) + + /// The icon-and-labels line. One block for the pair because it is one line on paper + /// (`PrintOptions.includesLabels`); emitted only when at least one half has something to say. + case cardMeta(icon: String?, labels: [String]) + + /// The card's Markdown, unparsed — see the type's note. + case cardBody(String) + + /// The thread's own small heading, carrying its count so the reader knows what follows and how + /// much of it there is. + case commentsHeading(count: Int) + + case comment(author: String?, created: Date?, body: String) +} + +// MARK: - PrintDocumentBuilder + +/// **`PrintSource` + `PrintOptions` → `[PrintBlock]`**: every rule about what a printed board or card +/// contains, in one pure function. +/// +/// ### The order within a card is fixed +/// +/// Title, then the icon-and-labels line, then the body, then the comments. It is not configurable and +/// the card that specifies this feature does not ask for it to be: the list is a document's natural +/// order (what is this, how is it tagged, what does it say, what was said about it), and an option that +/// let a user put the body above the title would be a page-layout program. +/// +/// ### Empty is empty, all the way up +/// +/// The build is bottom-up and drops what has nothing in it: +/// +/// - a **card** that would emit no blocks (an untitled, unlabelled card with an empty body, or every +/// component toggled off) contributes nothing — and, crucially, does not consume a page break; +/// - a **lane** whose every card dropped out has no heading either; +/// - an **empty lane** is omitted entirely (see below). +/// +/// This is what keeps `.betweenCards` from printing blank sheets for cards that had nothing on them, +/// which is the one way a page-break option can be actively harmful. It is also why the checks are here +/// rather than in the renderer: "would this print anything" is a question about content, and the +/// renderer has no business asking it. +/// +/// **Empty lanes are omitted** (decision, 2026-08-09 — flagged for review on the card): an empty "Done" +/// column is real information on screen, but on paper it is a heading with nothing under it, and under +/// `.betweenLanes` it is a heading with nothing under it *on its own sheet*. A print is a document of +/// content. The alternative — heading with no break — was declined for making the page-break rule +/// conditional on a lane's contents, which is exactly the kind of clever nobody can predict. +/// +/// ### Page breaks are emitted lazily, before content that follows content +/// +/// Never leading (a document does not start with a sheet boundary), never trailing, never doubled. The +/// mechanism is one flag — has anything been emitted yet — consulted at each lane and each card, which +/// is what makes the three modes compose without a case analysis per pair. +public enum PrintDocumentBuilder { + + /// What an untitled card or lane prints as. The word is a **rendering**, exactly as it is + /// everywhere else in the app (03-board-ui.md § Card face; `CardWindowHost.subtitle`), which is why + /// it lives here and not in `PrintCard.title`. + public static let untitled = "Untitled" + + /// The document. + /// + /// Options are read through `normalized` so the render's reading is the one that decides — a blank + /// custom line does not print an empty footer line, whatever the toggle says (`PrintOptions.normalized`). + public static func blocks(from source: PrintSource, options rawOptions: PrintOptions) -> [PrintBlock] { + let options = rawOptions.normalized + var blocks: [PrintBlock] = [] + + /// Whether a lane has already been laid down, and therefore whether a sheet boundary is owed + /// before the next one — the lazy-break mechanism the type's note describes. `false` until a + /// lane has actually been emitted, which is what makes a leading break impossible rather than + /// merely unlikely. + var hasEmittedLane = false + + for lane in source.lanes { + // Built before anything about the lane is emitted, so a lane whose cards all dropped out + // takes its heading and its page break with it. + let cardRuns = lane.cards.map { cardBlocks($0, options: options) }.filter { !$0.isEmpty } + guard !cardRuns.isEmpty else { continue } + + if options.pageBreaks != .flow, hasEmittedLane { + blocks.append(.pageBreak) + } + + // A card print carries its lane for context (`PrintSource.card`), but the document is the + // card: a lane heading over a single card would be a document about the wrong thing. + if source.scope == .board { + blocks.append(.laneHeading(lane.title ?? untitled)) + } + hasEmittedLane = true + + for (offset, run) in cardRuns.enumerated() { + if options.pageBreaks == .betweenCards, offset > 0 { + blocks.append(.pageBreak) + } + blocks.append(contentsOf: run) + } + } + + // **A document with no content has no heading either** — the "empty is empty, all the way up" rule + // taken to the top: with every component switched off, or on a board whose lanes are all empty, the + // answer is *nothing*, not a board title over blank paper (which is what `PrintCoordinator` refuses + // to spend a sheet on). + guard !blocks.isEmpty else { return [] } + + // The board's name goes on last so it can be conditional on there being something to name — and it + // is deliberately outside the page-break bookkeeping above. It is the first lane's running-in title, + // not a title page: a break counted from the heading would put the board's name alone on sheet one + // of every print with breaks switched on, which nobody asked for and nobody would keep. + if source.scope == .board, !source.boardTitle.isEmpty { + blocks.insert(.boardHeading(source.boardTitle), at: 0) + } + + return blocks + } + + /// One card's blocks, or `[]` when the options and the card between them have nothing to print. + /// + /// The four component toggles are read here and nowhere else, so "which components appear" is one + /// function rather than a condition spread across a walk. + private static func cardBlocks(_ card: PrintCard, options: PrintOptions) -> [PrintBlock] { + var blocks: [PrintBlock] = [] + + if options.includesTitle { + blocks.append(.cardTitle(card.title ?? untitled)) + } + + if options.includesLabels { + // Nothing to say is nothing printed: a card with no chosen icon and no labels would + // otherwise contribute a blank line, and a blank line is the shape a reader reads as a + // missing value. + let labels = card.labels.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + if card.icon != nil || !labels.isEmpty { + blocks.append(.cardMeta(icon: card.icon, labels: labels)) + } + } + + // `BodyMarkup.isEmpty` rather than `body.isEmpty`, which is the same question the card window + // asks to decide whether a body is worth previewing (05-card-window.md ▸ Mode grammar): a body + // of one newline previews as a blank page, and prints as one too unless something says + // otherwise. + if options.includesBody, !BodyMarkup.isEmpty(card.body) { + blocks.append(.cardBody(card.body)) + } + + if options.includesComments, !card.comments.isEmpty { + let ordered = options.commentSort == .newestFirst ? Array(card.comments.reversed()) : card.comments + blocks.append(.commentsHeading(count: ordered.count)) + for comment in ordered { + blocks.append(.comment(author: comment.author, created: comment.created, body: comment.body)) + } + } + + // A card whose every block dropped out prints nothing at all — not a title, not a break. See + // the type's "Empty is empty" note; the caller relies on this being `[]` and not `[.cardTitle]`. + return blocks + } +} diff --git a/Kanban/Printing/PrintOptions.swift b/Kanban/Printing/PrintOptions.swift new file mode 100644 index 0000000..a88a3d3 --- /dev/null +++ b/Kanban/Printing/PrintOptions.swift @@ -0,0 +1,259 @@ +import Foundation + +// MARK: - The three enumerated choices + +/// Where a printed board starts a new page — "whether to insert page breaking between cards or +/// between lanes", the card's own second bullet, with the third answer the one the card leaves +/// implicit: *nowhere*. +/// +/// **Every case is a promise about paper, not about spacing.** `.betweenLanes` really starts a new +/// sheet at each lane, and `.betweenCards` really starts one at each card — a "break" that only +/// added vertical air would be the option lying about the one thing it exists to control +/// (`PrintDocumentView` is where the promise is kept: a break splits the document into separately +/// paginated sections rather than inserting whitespace into one). +/// +/// `.flow` is the default because it is the cheapest print: a ten-lane board under `.betweenLanes` +/// is ten sheets minimum, which is the right answer only when the user asked for it. +public enum PrintPageBreaks: String, Codable, Sendable, CaseIterable { + /// One continuous document; page boundaries fall wherever the text runs out of sheet. + case flow + /// A fresh sheet at each lane. Cards inside a lane still flow. + case betweenLanes + /// A fresh sheet at each card — which implies one at each lane too, since a lane begins with a + /// card. The finest grain the option offers, and the most paper. + case betweenCards +} + +/// Which end of a thread a printed card's comments start from — "whether to include comments and +/// how to sort them" (the card's third bullet). +/// +/// The two names are the *reading order* rather than a sort direction, deliberately: the thread's +/// own order is chronology (`CommentThread.sorted` — `created` ascending, undated last), so this +/// chooses whether that order is walked forwards or backwards and never re-sorts by anything else. +/// It mirrors the comments pane's own header control, whose persisted bit is spelled the same way +/// (`AppPreferences.commentsNewestFirstKey`) — but it is a *separate* value: how this user likes to +/// read a thread on screen and how they want it laid out on paper are two preferences, and binding +/// them would make a print profile silently rewrite a window. +public enum PrintCommentSort: String, Codable, Sendable, CaseIterable { + case oldestFirst + case newestFirst +} + +// MARK: - PrintOptions + +/// **Everything a print asks about a document, as one value type** — the card's five bullets +/// (components, page breaks, comments, styling, header/footer) with nothing about *paper* in it: +/// sheet size, orientation, margins, copies and the printer itself are `NSPrintInfo`'s and stay +/// there, because they are the system's questions and the print panel already asks them better than +/// we could. +/// +/// ### Why a plain `Codable` struct and not an `@Observable` bag +/// +/// This is what a **print profile** is (`PrintProfile`), and a profile has to round-trip through +/// `UserDefaults` byte-for-byte — save, quit, relaunch, restore. A reference type would also make +/// "the options this print is using" and "the options that profile holds" the same object, which is +/// exactly wrong: choosing a profile *copies* its values into the live sheet, and editing the sheet +/// afterwards must not rewrite the profile behind the user's back. Value semantics are that rule, +/// for free. +/// +/// ### The decoder is total, on purpose +/// +/// Every field decodes through `decodeIfPresent` onto its default, and out-of-range numbers are +/// clamped rather than rejected. A stored profile is a file in a preferences plist that a future +/// build may have written, an older build may be reading, and a human may have hand-edited — the +/// storage layer's own leniency doctrine (01-storage-format.md § Frontmatter: lenient fields +/// degrade, they never refuse) applied to app-side state. The failure mode this rules out is the +/// one that matters: a single unknown key must not cost the user every profile they saved. +public struct PrintOptions: Codable, Sendable, Equatable { + + // MARK: Components — "which constituent components/datapoints to include" + + /// The card's title line. On by default: a printed card with no title is a page of prose with no + /// idea what it is about. + public var includesTitle = true + + /// The card's **icon and labels line** — its `icon` symbol followed by whatever the reserved + /// `labels` key carries (`PrintCard.labels(of:)`). + /// + /// One toggle for the pair rather than two, because they are one *line* on paper: an icon with + /// the labels switched off is a glyph alone on a line, which is furniture rather than + /// information. 01-storage-format.md § Frontmatter reserves `labels` and this version interprets + /// nothing by it (05-card-window.md ▸ Details: "ordinary unknown keys in this version"), so what + /// prints is what the file says, flattened — never a chip, never a colour. + public var includesLabels = true + + /// The card's body, **rendered** — the Markdown subset Preview draws, through the same parse + /// (05-card-window.md ▸ Preview; `BodyMarkup`). Never the raw source: a print of the bytes is + /// what ⌥⌘E is for, and a reader holding paper wants the document, not its markup. + public var includesBody = true + + // MARK: Comments — "whether to include comments and how to sort them" + + /// **Off by default.** A thread is conversation *about* a card, and the overwhelmingly common + /// print is the card; a board print with comments on is also the one shape that costs a disk read + /// per card (`CommentThread` is window-scoped and outside the snapshot — 01 § Enhanced schema), + /// which is a cost nobody should pay without asking. + public var includesComments = false + + /// Which end the thread starts from when `includesComments` is on. Ignored entirely when it is + /// off — kept rather than made optional so toggling comments back on restores the choice the user + /// last made instead of resetting it. + public var commentSort: PrintCommentSort = .oldestFirst + + // MARK: Page breaks + + public var pageBreaks: PrintPageBreaks = .flow + + // MARK: Styling — "font face, size & style" + + /// The base font family, or `nil` for the system font. + /// + /// **A family name, not a font.** Weight and slant are the document's to decide — a heading is + /// bold because it is a heading, emphasis is italic because the author wrote `*it*` — so what a + /// user picks here is the *face* the whole document is set in, and every derived style keeps its + /// own traits inside it (`PrintTypography.restyled`). A name the running system cannot resolve + /// degrades to the system font, `ItemSymbol.exists`' posture applied to type: a profile written + /// on a machine with Palatino installed must still print on one without it. + public var fontFamily: String? + + /// The body point size. **The one size the document has**: headings, the labels line, comment + /// bylines and the header/footer are all multiples of it (`PrintTypography`), which is the + /// "body-vs-headings derive from one base choice" ruling — a print dialog with six size fields is + /// a typesetting program, and this is a print dialog. + public var fontSize: Double = 11 + + /// The legal range, and the reason it is a range at all: a size of 0 draws nothing and a size of + /// 400 draws one letter per page, and both are reachable from a hand-edited plist. + public static let fontSizeRange: ClosedRange = 6 ... 36 + + // MARK: Header and footer — "page footer/header" + + /// The board's title, in the running head. + public var headerShowsBoardTitle = true + + /// The date the print was run, in the running head. **The print's date, not the board's + /// `modified`**: a printout's own question is "how old is this piece of paper". + public var headerShowsPrintDate = true + + /// "Page 3 of 7", in the running foot. On by default — a stapled board print with no folios is + /// a pile. + public var footerShowsPageNumbers = true + + /// A line of the user's own in the running foot — a project code, a distribution note, a + /// confidentiality banner. + /// + /// Two fields rather than one so switching the line off keeps the text: the toggle is a + /// *decision* and the string is *content*, and losing the content on every toggle would make the + /// pair useless for the case it exists for (a banner used on some prints and not others). + public var footerShowsCustomLine = false + public var footerCustomLine = "" + + public init() {} + + // MARK: - Normalizing + + /// `size` inside `fontSizeRange` — the one normalization that happens **on the way in**, because + /// a stored size is the likeliest defect in this whole structure and the only one that can make a + /// page undrawable (`AppPreferences.boardZoomLevelKey`'s own trap: an unset or hand-edited number + /// that renders a document of hairlines). + public static func clamped(fontSize size: Double) -> Double { + guard size.isFinite else { return PrintOptions().fontSize } + return min(max(size, fontSizeRange.lowerBound), fontSizeRange.upperBound) + } + + /// **The render's reading of these options**, not a rewrite of them — applied by the builder and + /// the renderer, never by the decoder, so `decode(encode(x)) == x` holds for every value a user + /// can reach. + /// + /// It flattens the two "content without a reason to exist" cases into their honest form: a blank + /// custom line is the same as not having one, and a whitespace family name is the same as the + /// system font. Both stay *readings* — the stored profile keeps whatever the user typed + /// (`footerCustomLine`'s own note), so a banner emptied for one print and typed back in for the + /// next never loses its toggle. + public var normalized: PrintOptions { + var copy = self + copy.fontSize = Self.clamped(fontSize: fontSize) + if let family = copy.fontFamily, family.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + copy.fontFamily = nil + } + if copy.footerCustomLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + copy.footerShowsCustomLine = false + } + return copy + } + + /// Whether anything at all would print with these options — the Print button's own floor. + /// + /// All three component toggles off is a legal state a user can reach one click at a time, and it + /// prints a document of nothing but running heads. The command does not disable on it (a user + /// mid-configuration must not have the button taken away), but the builder answers it honestly: + /// `PrintDocumentBuilder.blocks` returns an empty document, and the operation refuses rather than + /// spending paper. + public var describesAnyContent: Bool { + includesTitle || includesLabels || includesBody || includesComments + } + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case includesTitle, includesLabels, includesBody + case includesComments, commentSort + case pageBreaks + case fontFamily, fontSize + case headerShowsBoardTitle, headerShowsPrintDate + case footerShowsPageNumbers, footerShowsCustomLine, footerCustomLine + } + + /// See the type's doc comment: every field falls back to its default, and the two enumerations + /// fall back to theirs rather than failing the decode, so a value written by a build that knows a + /// fourth page-break mode reads as `.flow` here instead of taking the whole profile down with it. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var options = PrintOptions() + + /// A key read three ways at once: absent, present-but-the-wrong-type, and present-and-good — + /// the first two answering the default. `try?` around `decodeIfPresent` is what collapses the + /// middle case, and the double optional it produces is why this is a function rather than an + /// expression repeated eleven times. + func read(_ type: T.Type, _ key: CodingKeys) -> T? { + guard let decoded = try? container.decodeIfPresent(type, forKey: key) else { return nil } + return decoded + } + + options.includesTitle = read(Bool.self, .includesTitle) ?? options.includesTitle + options.includesLabels = read(Bool.self, .includesLabels) ?? options.includesLabels + options.includesBody = read(Bool.self, .includesBody) ?? options.includesBody + options.includesComments = read(Bool.self, .includesComments) ?? options.includesComments + options.commentSort = read(String.self, .commentSort) + .flatMap(PrintCommentSort.init(rawValue:)) ?? options.commentSort + options.pageBreaks = read(String.self, .pageBreaks) + .flatMap(PrintPageBreaks.init(rawValue:)) ?? options.pageBreaks + options.fontFamily = read(String.self, .fontFamily) + options.fontSize = read(Double.self, .fontSize) ?? options.fontSize + options.headerShowsBoardTitle = read(Bool.self, .headerShowsBoardTitle) ?? options.headerShowsBoardTitle + options.headerShowsPrintDate = read(Bool.self, .headerShowsPrintDate) ?? options.headerShowsPrintDate + options.footerShowsPageNumbers = read(Bool.self, .footerShowsPageNumbers) ?? options.footerShowsPageNumbers + options.footerShowsCustomLine = read(Bool.self, .footerShowsCustomLine) ?? options.footerShowsCustomLine + options.footerCustomLine = read(String.self, .footerCustomLine) ?? options.footerCustomLine + options.fontSize = Self.clamped(fontSize: options.fontSize) + + self = options + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(includesTitle, forKey: .includesTitle) + try container.encode(includesLabels, forKey: .includesLabels) + try container.encode(includesBody, forKey: .includesBody) + try container.encode(includesComments, forKey: .includesComments) + try container.encode(commentSort.rawValue, forKey: .commentSort) + try container.encode(pageBreaks.rawValue, forKey: .pageBreaks) + try container.encodeIfPresent(fontFamily, forKey: .fontFamily) + try container.encode(fontSize, forKey: .fontSize) + try container.encode(headerShowsBoardTitle, forKey: .headerShowsBoardTitle) + try container.encode(headerShowsPrintDate, forKey: .headerShowsPrintDate) + try container.encode(footerShowsPageNumbers, forKey: .footerShowsPageNumbers) + try container.encode(footerShowsCustomLine, forKey: .footerShowsCustomLine) + try container.encode(footerCustomLine, forKey: .footerCustomLine) + } +} diff --git a/Kanban/Printing/PrintProfile.swift b/Kanban/Printing/PrintProfile.swift new file mode 100644 index 0000000..b1835a7 --- /dev/null +++ b/Kanban/Printing/PrintProfile.swift @@ -0,0 +1,200 @@ +import Foundation + +// MARK: - PrintProfile + +/// A named set of print options — "these configurations probably good to persist (as named print +/// profiles) and reused", the card's closing line, as a type. +/// +/// ### Its name is its identity +/// +/// No UUID, deliberately. A print profile is a **user's own label** for a way of printing ("Standup +/// handout", "Archive, comments on"), and a label is what the popup menu shows, what a rename +/// changes, and what a save collides on. Minting an id beside it would create a second identity the +/// UI never shows and the user could never reconcile — two profiles both called "Handout", one of +/// them unreachable. So names are unique (case-insensitively — see `PrintProfileCatalog`), and +/// renaming *is* re-identifying. +/// +/// This is the same reasoning `ItemID` states for a card folder and reaches the opposite conclusion +/// for the opposite reason: a card's title is content that may repeat and must be free to, while a +/// profile's name is a key the user types. +public struct PrintProfile: Codable, Sendable, Equatable, Identifiable { + + public var name: String + public var options: PrintOptions + + public var id: String { PrintProfileCatalog.key(name) } + + public init(name: String, options: PrintOptions) { + self.name = name + self.options = options + } + + private enum CodingKeys: String, CodingKey { case name, options } + + /// Lenient for `PrintOptions`' reason, one level up: a stored profile missing its name or its + /// options decodes to a nameless one rather than throwing, and a nameless profile is dropped by + /// `PrintProfileCatalog.init(profiles:)`. Without this, one malformed entry in the plist would + /// fail the whole array's decode and cost the user every profile they saved — the exact failure + /// mode the option decoder exists to rule out. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = ((try? container.decodeIfPresent(String.self, forKey: .name)) ?? nil) ?? "" + options = ((try? container.decodeIfPresent(PrintOptions.self, forKey: .options)) ?? nil) ?? PrintOptions() + } + + // MARK: The reserved pseudo-profile + + /// **"Last Used" is reserved** and is not a member of the catalog at all — it is the options the + /// last print ran with, captured automatically, and it exists so that ⌘P opens on *what the user + /// did last* rather than on a factory default they overrode a hundred prints ago. + /// + /// A pseudo-profile rather than a real one because the two behave nothing alike: this one cannot + /// be renamed, cannot be deleted, and rewrites itself on every print — a named profile does none + /// of those and would be worthless if it did (the point of "Handout" is that it stays what it was + /// when you saved it). Keeping the reserved name out of the stored list is also what makes the + /// rule enforceable rather than remembered: `PrintProfileCatalog` refuses the name, so no code + /// path can create a shadow of it. + public static let lastUsedName = "Last Used" + + /// Whether `name` is one a user may claim. The comparison is the catalog's own key rule, so + /// "last used" and "LAST USED" are refused exactly as the canonical spelling is. + public static func isReserved(_ name: String) -> Bool { + PrintProfileCatalog.key(name) == PrintProfileCatalog.key(lastUsedName) + } +} + +// MARK: - PrintProfileCatalog + +/// **The named profiles, and every rule about them** — save, rename, delete, look up — as a pure +/// value type with no `UserDefaults` and no `@Observable` anywhere near it. +/// +/// The split is the codebase's usual one (`BoardZoom` beside `BoardZoomStore`, `StyleRecents.updated` +/// beside the store that persists it): the rules are the interesting part and a rule that can only be +/// exercised through a preferences domain is a rule nobody tests. `PrintProfileStore` is this type's +/// persistence and its observability, and it owns no rules of its own. +/// +/// ### Order is the user's, not the alphabet's +/// +/// Profiles keep the order they were saved in, newest last, and a rename does not move one. The popup +/// menu is short by nature (a handful of ways one person prints), and a list that re-sorted itself +/// when a profile was renamed would move the row the user was looking at. `StyleRecents`' most-recent- +/// first list makes the opposite choice for the opposite reason — that list *is* a recency ranking. +public struct PrintProfileCatalog: Codable, Sendable, Equatable { + + public private(set) var profiles: [PrintProfile] + + public init(profiles: [PrintProfile] = []) { + // Sanitized on the way in for the same reason the option decoder is lenient: this value comes + // out of a preferences plist a human may have edited. Reserved and blank names are dropped, + // and a duplicate keeps its first occurrence — the reading a menu can actually render. + var kept: [PrintProfile] = [] + for profile in profiles { + let name = Self.normalized(profile.name) + guard !name.isEmpty, !PrintProfile.isReserved(name) else { continue } + guard !kept.contains(where: { Self.key($0.name) == Self.key(name) }) else { continue } + kept.append(PrintProfile(name: name, options: profile.options)) + } + self.profiles = kept + } + + // MARK: Names + + /// A name as stored: outer whitespace trimmed, inner text untouched. Trimming is what makes + /// `" Handout "` and `"Handout"` the same profile rather than two rows that look identical. + public static func normalized(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// The comparison key — the normalized name case-folded. Case-insensitive because a user typing + /// "handout" a week later means the profile they called "Handout", and two rows differing only in + /// case is a bug report, not a feature. + /// + /// `localizedLowercase` rather than `lowercased()`: these are human words in the user's own + /// language, unlike `ItemID`'s ASCII-hex fold. + public static func key(_ name: String) -> String { + normalized(name).localizedLowercase + } + + /// Whether `name` may be saved or renamed to — blank and reserved refused, an existing name + /// allowed (a save over one's own profile is an overwrite, which is what the Save button means + /// when the popup is already on that profile). + public static func isAcceptable(_ name: String) -> Bool { + !normalized(name).isEmpty && !PrintProfile.isReserved(name) + } + + // MARK: Reading + + public func contains(_ name: String) -> Bool { + profiles.contains { Self.key($0.name) == Self.key(name) } + } + + /// The named profile's options, or `nil` — the popup's selection resolved. Never a fallback to + /// anything: a selection that names no profile is a UI out of step with its model, and quietly + /// substituting the defaults would hide that. + public func options(named name: String) -> PrintOptions? { + profiles.first { Self.key($0.name) == Self.key(name) }?.options + } + + /// The names in list order — the popup's rows below the reserved one. + public var names: [String] { profiles.map(\.name) } + + // MARK: Writing + + /// Saves `options` under `name`, overwriting a profile of that name in place. + /// + /// **Overwrite rather than a second row**, and *in place* rather than moved to the end: saving + /// again over "Handout" is the gesture "this is what Handout means now", and re-ordering the menu + /// as a side effect of it would be the list moving under the user's cursor. The spelling is + /// updated to whatever was typed — `"handout"` saved over `"Handout"` renames the case — because + /// the last spelling the user typed is the one they meant. + /// + /// Refuses a blank or reserved name (`isAcceptable`), answering `false`. A refusal writes nothing. + @discardableResult + public mutating func save(_ options: PrintOptions, as name: String) -> Bool { + guard Self.isAcceptable(name) else { return false } + let stored = PrintProfile(name: Self.normalized(name), options: options) + if let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) }) { + profiles[index] = stored + } else { + profiles.append(stored) + } + return true + } + + /// Renames a profile, keeping its position and its options. + /// + /// Refuses when the old name names nothing, when the new one is blank or reserved, or when it is + /// already another profile's — a rename that swallowed a sibling would destroy a profile the user + /// never mentioned. Renaming to a different **case of its own name** is allowed, and is the one + /// case where the target name already exists. + @discardableResult + public mutating func rename(_ name: String, to newName: String) -> Bool { + guard Self.isAcceptable(newName), + let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) }) + else { return false } + let collision = profiles.firstIndex { Self.key($0.name) == Self.key(newName) } + guard collision == nil || collision == index else { return false } + profiles[index].name = Self.normalized(newName) + return true + } + + /// Deletes a profile. A name that matches nothing is a no-op — the honest answer for a menu row + /// that raced a deletion, and one no caller has to guard against. + public mutating func delete(_ name: String) { + profiles.removeAll { Self.key($0.name) == Self.key(name) } + } + + // MARK: Codable + + /// A keyed container rather than a bare array, so the stored shape has somewhere to grow (an + /// ordering key, a per-profile note) without every existing plist becoming undecodable. The + /// decode routes through `init(profiles:)`, which is what applies the sanitizing rule to bytes + /// that may have been hand-edited. + private enum CodingKeys: String, CodingKey { case profiles } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decoded = (try? container.decodeIfPresent([PrintProfile].self, forKey: .profiles)) ?? nil + self.init(profiles: decoded ?? []) + } +} diff --git a/Kanban/Printing/PrintProfileStore.swift b/Kanban/Printing/PrintProfileStore.swift new file mode 100644 index 0000000..1f79a18 --- /dev/null +++ b/Kanban/Printing/PrintProfileStore.swift @@ -0,0 +1,161 @@ +import Foundation +import Observation +import os + +/// The named print profiles and the Last Used capture, persisted — **app-side, never in a board** +/// (02-architecture.md § Per-board app state: "App-wide state has the same home"). +/// +/// ### Why `UserDefaults` and not the board +/// +/// A print profile describes *how this user likes to read on paper*. It is not a property of any +/// board, it is not shared with a collaborator, and it has no business in frontmatter — the same +/// argument `BoardZoomStore` makes for the zoom level, one rung stronger: a lane's `width` genuinely +/// is board data because everyone opening the board sees it, while "Archive, comments on, Palatino +/// 10pt" is one person's habit. Writing it into a `.kanban` package would also make it a thing agents +/// and syncs have to round-trip, for a value no agent will ever read. +/// +/// It is not in `BoardRegistry` either, for `BoardZoomStore`'s reason: profiles are not per board, and +/// a board opened on two machines wants the same *preference* applied rather than a per-board memory +/// of one. +/// +/// ### Why `@Observable` rather than `@AppStorage` +/// +/// The accessory's profile popup and the print operation's live preview both read this, and the +/// preview's refresh is driven by KVO on the accessory controller +/// (`PrintOptionsAccessoryController`), which needs a change it can observe — a property wrapper +/// living inside a SwiftUI view body cannot give a menu row or a print panel that. `BoardZoomStore`'s +/// shape exactly, and for the same two consumers' reasons. +/// +/// ### Rules live in `PrintProfileCatalog` +/// +/// Everything about names, collisions, ordering and the reserved pseudo-profile is that value type's. +/// This object holds one, publishes it, and persists it — nothing else. `defaults` is injectable so a +/// test drives a suite of its own rather than the developer's own preferences (`BoardZoomStore`'s +/// note, verbatim in intent). +@MainActor +@Observable +public final class PrintProfileStore { + + /// The named profiles. Mutated only through the three methods below, each of which persists. + public private(set) var catalog: PrintProfileCatalog + + /// **The options the last print ran with** — the reserved "Last Used" pseudo-profile's content + /// (`PrintProfile.lastUsedName`). + /// + /// A first launch has none, and that absence is meaningful rather than a missing value to paper + /// over: `options(named:)` answers the factory defaults for it, and the popup still shows the row, + /// because "Last Used" naming today's defaults on the very first print is the truth — nothing else + /// has been used yet. + public private(set) var lastUsed: PrintOptions? + + @ObservationIgnored + private let defaults: UserDefaults + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing") + + /// - Parameter defaults: the domain to persist in. Injected for `BoardZoomStore`'s reason — a test + /// must be able to hold its own without touching the user's. + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + catalog = Self.decode(PrintProfileCatalog.self, from: defaults, key: AppPreferences.printProfilesKey) + ?? PrintProfileCatalog() + lastUsed = Self.decode(PrintOptions.self, from: defaults, key: AppPreferences.printLastUsedKey) + } + + // MARK: - Reading + + /// The options a menu selection resolves to: the reserved row answers `lastUsed` (or the factory + /// defaults on a first launch — see `lastUsed`), a named row answers the catalog, and a name that + /// matches neither answers `nil`. + public func options(named name: String) -> PrintOptions? { + if PrintProfile.isReserved(name) { return lastUsed ?? PrintOptions() } + return catalog.options(named: name) + } + + /// What the profile popup lists, top to bottom: the reserved row first, then the named ones in the + /// order they were saved (`PrintProfileCatalog`'s own ordering note). + /// + /// The reserved row leads because it is what ⌘P opens on, and a menu whose first row is not the + /// selected one reads as a menu that lost the user's place. + public var menuNames: [String] { [PrintProfile.lastUsedName] + catalog.names } + + // MARK: - Writing + + /// **Captures the options a print just ran with.** Called when the operation ends and only if it ran + /// (`PrintCompletion`, which carries the reasoning): the sheeted print panel returns control + /// immediately, so there is no "on the way in" moment at which the user's edits exist yet, and Cancel is + /// indistinguishable from a printer failure at the end — so the gate is success, and a cancelled print + /// leaves the remembered settings exactly where they were. + /// + /// An unchanged capture writes nothing and publishes nothing, `BoardZoomStore.setLevel`'s guard and + /// for its load-bearing reason: `@Observable` notifies on every set, and a no-op notification here + /// would invalidate the accessory's own view mid-print. + public func captureLastUsed(_ options: PrintOptions) { + guard options != lastUsed else { return } + lastUsed = options + persist(options, key: AppPreferences.printLastUsedKey) + } + + /// Saves `options` as a named profile, overwriting one of that name — the catalog's rule, persisted. + /// `false` is a refused name (blank, or the reserved one), which writes nothing. + @discardableResult + public func save(_ options: PrintOptions, as name: String) -> Bool { + var updated = catalog + guard updated.save(options, as: name) else { return false } + catalog = updated + persistCatalog() + return true + } + + @discardableResult + public func rename(_ name: String, to newName: String) -> Bool { + var updated = catalog + guard updated.rename(name, to: newName) else { return false } + catalog = updated + persistCatalog() + return true + } + + public func delete(_ name: String) { + var updated = catalog + updated.delete(name) + guard updated != catalog else { return } + catalog = updated + persistCatalog() + } + + // MARK: - Persistence + + /// JSON in a single `Data` value rather than a plist tree of dictionaries. + /// + /// The stored shape is then `Codable`'s, which is the shape the tests round-trip and the shape a + /// future field extends — as opposed to a hand-written `[[String: Any]]` mapping that would have to + /// be kept in step with the struct by hand. `UserDefaults` stores `Data` natively, so this costs + /// nothing but a serialization the app performs at most once per print. + private func persistCatalog() { + persist(catalog, key: AppPreferences.printProfilesKey) + } + + private func persist(_ value: Value, key: String) { + do { + defaults.set(try JSONEncoder().encode(value), forKey: key) + } catch { + // Nothing user-facing: a preferences write that fails costs a remembered profile, not any + // of the user's content, and there is no board banner that would be honest about it (02 § + // Write-failure surfacing is about *board* writes). The log is the whole response. + Self.logger.error("print profiles could not be persisted: \(error.localizedDescription, privacy: .public)") + } + } + + /// Total, by the whole file's doctrine: a key holding the wrong type, truncated JSON, or a shape + /// from a build that has moved on all answer `nil`, which reads as "nothing stored yet" — the same + /// degrade `StyleRecents` gives a garbage preference rather than taking the surface down with it. + private static func decode(_ type: Value.Type, from defaults: UserDefaults, key: String) -> Value? { + guard let data = defaults.data(forKey: key) else { return nil } + guard let value = try? JSONDecoder().decode(type, from: data) else { + logger.warning("stored value for '\(key, privacy: .public)' could not be read — ignored") + return nil + } + return value + } +} diff --git a/Kanban/Printing/PrintRunningHead.swift b/Kanban/Printing/PrintRunningHead.swift new file mode 100644 index 0000000..b121dfb --- /dev/null +++ b/Kanban/Printing/PrintRunningHead.swift @@ -0,0 +1,72 @@ +import Foundation + +/// **What the running head and foot say** — "page footer/header", the card's fifth bullet, as four +/// toggles composed into two lines. +/// +/// ### Why the pieces are composed here and not drawn here +/// +/// Which pieces appear, in what order, and how a line reads when only some of them are switched on is a +/// *decision*, and decisions live in the pure layer where a test can hold them (`PrintDocumentBuilder`'s +/// own argument). Where the ink lands is `PrintDocumentView`'s. +/// +/// ### The date and the page number arrive pre-formatted +/// +/// Both are strings the caller supplies rather than a `Date` and an `Int` this type formats. That keeps +/// every rule below **locale-free and therefore testable**: "Page 3 of 7" and "9 Aug 2026 at 14:30" are +/// the caller's renderings of values the system formats differently in every region, and a rule that +/// baked them in would be a rule whose test only passed in one place. +/// +/// ### Leading and trailing, not left and right +/// +/// The two slots are named by reading order because that is what they are: the view places them at the +/// two ends of the measure, which a right-to-left interface swaps. Nothing here knows which end is +/// which. +public enum PrintRunningHead { + + /// A line of the running head or foot, as a pair of ends. Either may be empty; both empty means the + /// line does not print at all, which is what `isEmpty` is for and what lets the view reclaim the + /// space rather than leaving a band of blank paper. + public struct Line: Sendable, Equatable { + public var leading: String + public var trailing: String + + public var isEmpty: Bool { leading.isEmpty && trailing.isEmpty } + + public init(leading: String = "", trailing: String = "") { + self.leading = leading + self.trailing = trailing + } + } + + /// The running head: the board's name at the leading end, the print's date at the trailing end. + /// + /// The board's name leads because it is what the reader is looking for when they pick the sheet up; + /// the date trails because it answers a question they ask second. A board with no title contributes + /// nothing rather than the word "Untitled" — the running head is context, and inventing context is + /// worse than having none. + public static func header(options: PrintOptions, boardTitle: String, dateText: String) -> Line { + Line( + leading: options.headerShowsBoardTitle ? boardTitle : "", + trailing: options.headerShowsPrintDate ? dateText : "" + ) + } + + /// The running foot: the user's own line at the leading end, the folio at the trailing end — where + /// a book puts it. + /// + /// The custom line is read through `normalized`, so a toggle left on over an emptied field prints + /// nothing rather than an indent of blank space (`PrintOptions.normalized`). + public static func footer(options rawOptions: PrintOptions, pageText: String) -> Line { + let options = rawOptions.normalized + return Line( + leading: options.footerShowsCustomLine ? options.footerCustomLine : "", + trailing: options.footerShowsPageNumbers ? pageText : "" + ) + } + + /// "Page 3 of 7" — the folio's wording, in one place because the view draws it and a summary line + /// in the print panel describes it. + public static func pageText(page: Int, of pageCount: Int) -> String { + "Page \(page) of \(pageCount)" + } +} diff --git a/Kanban/Printing/PrintSource.swift b/Kanban/Printing/PrintSource.swift new file mode 100644 index 0000000..2961917 --- /dev/null +++ b/Kanban/Printing/PrintSource.swift @@ -0,0 +1,256 @@ +import Foundation + +// MARK: - The leaves + +/// One comment as paper needs it: who, when, and what they said — `Comment` with everything a +/// *window* needs stripped out (its identity, its attachments, its edited flag, its whole parsed +/// document). +/// +/// The narrowing is the point. A print is a snapshot taken once and then re-laid-out several times as +/// the user tries options in the panel, so what crosses into the printing layer should be the smallest +/// thing that can answer every option — anything richer invites the renderer to start making decisions +/// the builder should have made. +public struct PrintComment: Sendable, Equatable { + + /// `nil` renders unattributed, exactly as the pane does — "**Missing renders unattributed**; there + /// is no identity system behind it and none is implied" (`Comment.author`). + public var author: String? + + /// `nil` for a comment whose `created` was missing or unreadable — a lenient field, and the byline + /// simply says less rather than the print refusing. + public var created: Date? + + public var body: String + + public init(author: String? = nil, created: Date? = nil, body: String) { + self.author = author + self.created = created + self.body = body + } + + /// A thread, flattened — **in the thread's own order** (`CommentThread.sorted`: `created` + /// ascending, undated after dated, folder-name tie-break). + /// + /// The order arrives already correct and is never re-derived here: `PrintCommentSort.newestFirst` + /// *reverses* this sequence rather than sorting by a key of its own, which is the same discipline + /// the comments pane keeps ("The header's sort control reverses it for display and never re-sorts" + /// — `CardComments.thread`). A second sort would be a second chance to disagree with the format's + /// own chronology rule about what an undated comment means. + public static func list(of thread: CommentThread) -> [PrintComment] { + thread.comments.map { + PrintComment(author: $0.author.value, created: $0.created.value, body: $0.body) + } + } +} + +/// One card as paper needs it — the four things the component toggles can ask for, and nothing else. +public struct PrintCard: Sendable, Equatable { + + /// The title as written, or `nil` for an untitled card. The **placeholder is the builder's** + /// (`PrintDocumentBuilder.untitled`), never stored here: "Untitled" is a rendering, never a value + /// (03-board-ui.md § Card face), and putting the word in this struct would make it indistinguishable + /// from a card someone actually named that. + public var title: String? + + /// The card's `icon` — a name already resolved against the running system, or `nil` when the field + /// named no symbol this OS can draw. Resolution happens at extraction (`ItemSymbol`), so the + /// renderer never has to ask whether a glyph exists and the print of a hand-typed typo silently + /// omits the glyph rather than drawing an empty box. + public var icon: String? + + /// The reserved `labels` key, flattened to strings (`PrintCard.labels(of:)`). + public var labels: [String] + + public var body: String + + /// The card's thread, in chronological order. Empty both for a card with no comments and for a + /// print that never asked for them — the extraction reads a thread only when the options want one + /// (`PrintSource.board(_:titled:comments:)`), which is what keeps a comment-less board print from + /// paying a disk read per card. + public var comments: [PrintComment] + + public init(title: String? = nil, icon: String? = nil, labels: [String] = [], body: String = "", comments: [PrintComment] = []) { + self.title = title + self.icon = icon + self.labels = labels + self.body = body + self.comments = comments + } + + // MARK: Extraction + + /// A snapshot card, narrowed — with its thread supplied by the caller, because a thread is a disk + /// read and this type is a value. + public static func from(_ card: Card, comments: [PrintComment] = []) -> PrintCard { + PrintCard( + title: card.title.value, + // `nil` rather than the level default: a print is a document, and a `doc.text` glyph in + // front of every single card is furniture rather than information. A card whose author + // *chose* an icon gets it; the board's own defaults stay on screen where they help + // scanning (03-board-ui.md § Card face). + icon: card.icon.value.flatMap { ItemSymbol.exists($0) ? $0 : nil }, + labels: labels(of: card.document), + body: card.body, + comments: comments + ) + } + + /// **The reserved `labels` key, read as a list of words** — and the only place in the app that + /// interprets it at all. + /// + /// 01-storage-format.md § Frontmatter reserves `labels` for the tracker-integration story and this + /// version gives it no life: it is an ordinary unknown key, shown verbatim in the card window's + /// Details rows and searched by nothing (04 ▸ Search: "labels/tags and their kin are reserved, + /// inert keys this version"). Printing is the one surface that asks for it by name, because the + /// card that specifies this feature asks for it by name. + /// + /// So the reading is deliberately shallow and deliberately lenient — it is a *display* of bytes, + /// not the activation of a field: + /// + /// - A **sequence** is its scalar members, in order; nested collections are skipped rather than + /// flattened, since a list of lists is not a label row. + /// - A **single scalar** is one label, *except* that a comma-separated one splits — `labels: bug, + /// ui` is YAML's one string `"bug, ui"` and is overwhelmingly likely to be a hand-written pair. + /// This is the one inference here, and it is the friendly reading of the shape a human types. + /// - Anything else (a mapping, a null, an empty string) contributes nothing. + /// + /// Blank members are dropped and the rest keep their bytes exactly. Nothing here can throw and + /// nothing can fail — `CardDetails.display`'s posture, one key narrower. + public static func labels(of document: FrontmatterDocument) -> [String] { + guard let value = document.value(for: labelsKey) else { return [] } + switch value { + case let .sequence(members): + return members.compactMap(scalarText(of:)).filter { !$0.isEmpty } + case .mapping, .null: + return [] + default: + guard let text = scalarText(of: value), !text.isEmpty else { return [] } + return text + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + } + + /// The reserved key's spelling, in one place — 01's own name for it. + static let labelsKey = "labels" + + /// A scalar's text, or `nil` for a shape that is not a scalar. `YAMLValue.description` is the + /// engine's own rendering and is the right answer for every scalar case (a bare `2026-01-01` label + /// reads as its ISO form, which is what the file means); the collection cases are excluded here + /// rather than described, since their `description` is diagnostic syntax nobody wants on paper. + private static func scalarText(of value: YAMLValue) -> String? { + switch value { + case .null, .sequence, .mapping: nil + default: value.description.trimmingCharacters(in: .whitespacesAndNewlines) + } + } +} + +/// One lane as paper needs it: its heading and its cards, top to bottom. +public struct PrintLane: Sendable, Equatable { + + /// The lane's title, or `nil` for an untitled lane — the placeholder is the builder's, exactly as + /// a card's is. + public var title: String? + + public var cards: [PrintCard] + + public init(title: String? = nil, cards: [PrintCard]) { + self.title = title + self.cards = cards + } +} + +// MARK: - PrintSource + +/// **What is being printed, as one immutable value** — the whole input to `PrintDocumentBuilder`, and +/// the seam between the app and the printing layer. +/// +/// ### Why a snapshot of a snapshot +/// +/// `BoardModel` is already immutable, so copying out of it looks redundant until you count what a +/// print does with it: the panel's preview re-lays-out the document every time the user flips a +/// toggle, and the board underneath may reload (an agent writing, a sync landing) at any point during +/// that dialog. A print that re-read the live store between preview refreshes would show one document +/// and put another on paper. So the source is taken **once**, when ⌘P is pressed, and the print is of +/// the board as it was at that moment — which is also what a user means by "print this". +/// +/// It is also what makes the printing layer testable end to end without a board on disk, a store, or +/// a window: every rule below `PrintSource` is a function of this value and `PrintOptions`. +public struct PrintSource: Sendable, Equatable { + + /// Which of the two ⌘P targets produced this — the board window's, or one card window's. + /// + /// The builder needs to know, and cannot infer it: a one-lane board with one card is shape-identical + /// to a printed card, and the two documents differ (a board print names the board and its lanes; a + /// card print is the card). + public enum Scope: Sendable, Equatable { + case board + case card + } + + public var scope: Scope + + /// The board's display name — `AppModel.displayName(of:)`'s answer, which falls back to the folder + /// name for an untitled board. Carried for both scopes: a printed card's running head names the + /// board it came from, which is the one piece of context a loose sheet needs. + public var boardTitle: String + + /// For `.board`, the live lanes in display order. For `.card`, exactly one lane — the card's own, + /// carried so the print can say which lane it came from and so the two scopes share one shape. + public var lanes: [PrintLane] + + public init(scope: Scope, boardTitle: String, lanes: [PrintLane]) { + self.scope = scope + self.boardTitle = boardTitle + self.lanes = lanes + } + + // MARK: Extraction + + /// **A whole board, lane by lane, card by card** — `snapshot.lanes` in display order, each lane's + /// `cards` in display order, exactly as the loader ranked them (`Ranks.sortedForDisplay`). The + /// left-to-right strip becomes a top-to-bottom document by reading it in the order it is already + /// stored in; nothing here sorts anything. + /// + /// **The trash is excluded**, and by construction rather than by a filter: `BoardModel.trash` and + /// `trashedLanes` are sibling containers of `lanes`, not members of it (`BoardModel.trash`'s own + /// note — "A sibling container of `lanes`, not a lane"), so a walk of `lanes` cannot reach them. A + /// board print is a print of the board; deleted cards are deleted. + /// + /// - Parameter comments: the thread read, per card — supplied by the caller so it can be skipped + /// entirely when the options do not want comments, and memoized when they do. Comments are + /// window-scoped and outside the snapshot (01 § Enhanced schema), so there is no board-level + /// reading of them to inherit; this closure is that read. + public static func board( + _ snapshot: BoardModel, + titled boardTitle: String, + comments: (Card) -> [PrintComment] = { _ in [] } + ) -> PrintSource { + PrintSource( + scope: .board, + boardTitle: boardTitle, + lanes: snapshot.lanes.map { lane in + PrintLane( + title: lane.title.value, + cards: lane.cards.map { PrintCard.from($0, comments: comments($0)) } + ) + } + ) + } + + /// **One card**, wrapped in its lane so both scopes share one shape. + public static func card( + _ card: Card, + laneTitle: String?, + boardTitle: String, + comments: [PrintComment] = [] + ) -> PrintSource { + PrintSource( + scope: .card, + boardTitle: boardTitle, + lanes: [PrintLane(title: laneTitle, cards: [PrintCard.from(card, comments: comments)])] + ) + } +} diff --git a/Kanban/UI/Print/PrintCommand.swift b/Kanban/UI/Print/PrintCommand.swift new file mode 100644 index 0000000..701890c --- /dev/null +++ b/Kanban/UI/Print/PrintCommand.swift @@ -0,0 +1,312 @@ +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 + } +} diff --git a/Kanban/UI/Print/PrintDocumentRenderer.swift b/Kanban/UI/Print/PrintDocumentRenderer.swift new file mode 100644 index 0000000..3a2c6d2 --- /dev/null +++ b/Kanban/UI/Print/PrintDocumentRenderer.swift @@ -0,0 +1,277 @@ +import AppKit + +/// `[PrintBlock]` → the attributed text a page draws: **the drawing half of printing, and only the +/// drawing half**. +/// +/// Every decision was already made in `PrintDocumentBuilder` — which components appear, in what order, +/// which end of a thread comes first, where a sheet boundary falls — which is what keeps this file free +/// of policy, exactly as `BodyMarkupRenderer` is kept free of it by `BodyMarkup`. The parallel is not a +/// coincidence: **card bodies are rendered by that very renderer**, through the same +/// `BodyMarkup.parse`, so a printed body is typographically the same document Preview shows. Reading +/// Markdown a second way here would guarantee the two eventually disagreed about a table, a task +/// checkbox or a nested quote. +/// +/// ### Sections, not one string +/// +/// The output is an **array** of attributed strings, split at `.pageBreak`. That is what makes a page +/// break honest: each section is paginated independently by `PrintDocumentView`, so a section always +/// starts at the top of a sheet. Inserting form feeds or padding newlines into one long string would +/// have been the alternative, and TextKit does not paginate on either — it would have produced a break +/// that looked right at one paper size and drifted at every other. +@MainActor +enum PrintDocumentRenderer { + + // MARK: - Sections + + /// The document, split into independently paginated sections. + /// + /// An empty block list answers `[]` rather than one empty section — a document with nothing in it + /// has no pages, which is the answer `PrintCoordinator` refuses to print rather than spending a + /// sheet on a running head over blank paper. + static func sections(for blocks: [PrintBlock], options rawOptions: PrintOptions, cardFolder: URL? = nil) -> [NSAttributedString] { + let options = rawOptions.normalized + var sections: [NSAttributedString] = [] + var current = NSMutableAttributedString() + + for block in blocks { + if case .pageBreak = block { + if current.length > 0 { sections.append(current) } + current = NSMutableAttributedString() + continue + } + append(block, to: current, options: options, cardFolder: cardFolder) + } + if current.length > 0 { sections.append(current) } + return sections + } + + // MARK: - One block + + private static func append( + _ block: PrintBlock, + to output: NSMutableAttributedString, + options: PrintOptions, + cardFolder: URL? + ) { + let size = options.fontSize + + switch block { + case .pageBreak: + // Consumed by `sections(for:options:cardFolder:)` before it ever reaches here; switched + // exhaustively so a future block cannot be forgotten. + break + + case let .boardHeading(title): + append( + title, + font: PrintTypography.boardHeading(options), + color: PrintTypography.ink, + spacingBefore: 0, + spacingAfter: size * 0.9, + to: output + ) + + case let .laneHeading(title): + // A rule under the lane name, which is the one piece of decoration this document has and + // earns it: in a flowed print the lane heading is the only signal that one column ended and + // another began. + append( + title, + font: PrintTypography.laneHeading(options), + color: PrintTypography.ink, + spacingBefore: size * 1.4, + spacingAfter: size * 0.6, + to: output, + underlined: true + ) + + case let .cardTitle(title): + append( + title, + font: PrintTypography.cardTitle(options), + color: PrintTypography.ink, + spacingBefore: size * 1.0, + spacingAfter: size * 0.2, + to: output + ) + + case let .cardMeta(icon, labels): + appendMeta(icon: icon, labels: labels, options: options, to: output) + + case let .cardBody(body): + appendBody(body, options: options, cardFolder: cardFolder, to: output) + + case let .commentsHeading(count): + append( + commentsHeadingText(count: count), + font: PrintTypography.commentsHeading(options), + color: PrintTypography.secondaryInk, + spacingBefore: size * 0.9, + spacingAfter: size * 0.2, + to: output + ) + + case let .comment(author, created, body): + append( + byline(author: author, created: created), + font: PrintTypography.secondary(options), + color: PrintTypography.secondaryInk, + spacingBefore: size * 0.5, + spacingAfter: size * 0.1, + to: output, + indent: size * 1.5 + ) + appendBody(body, options: options, cardFolder: cardFolder, to: output, indent: size * 1.5) + } + } + + /// A card's Markdown, through the app's one Markdown pass and then re-faced. + /// + /// `cardFolder` is `nil` for a board print, and that is a real limitation rather than an oversight: + /// a body's relative image resolves against *its own card's* folder (`BodyTarget.resolve`), and a + /// board print walks many cards, so passing one folder would resolve some images against the wrong + /// card. An unresolvable relative image renders as the placeholder chip `BodyMarkupRenderer` already + /// draws for a remote one, which is the honest degrade. A card print, which has exactly one folder, + /// passes it and prints its images. + private static func appendBody( + _ body: String, + options: PrintOptions, + cardFolder: URL?, + to output: NSMutableAttributedString, + indent: CGFloat = 0 + ) { + let markup = BodyMarkup.parse(body) + let rendered = BodyMarkupRenderer.attributedString( + for: markup, + context: BodyMarkupRenderer.Context(pointSize: options.fontSize, cardFolder: cardFolder) + ) + let faced = PrintTypography.restyled(rendered, family: options.fontFamily) + guard faced.length > 0 else { return } + + guard indent > 0 else { + output.append(faced) + return + } + // A comment body sits under its byline, so it is indented with it. The indent is *added* to + // whatever the body's own paragraph styles already carry (a nested list keeps its nesting), + // which is why this adjusts the existing styles rather than installing one. + let indented = NSMutableAttributedString(attributedString: faced) + indented.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: indented.length)) { value, range, _ in + let style = ((value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() + style.firstLineHeadIndent += indent + style.headIndent += indent + indented.addAttribute(.paragraphStyle, value: style, range: range) + } + output.append(indented) + } + + /// The icon-and-labels line: the symbol, then the labels joined by a middle dot. + /// + /// The icon is a **text attachment** rather than a rendered-to-text name: an SF Symbol has no + /// spelling a reader would recognize, and `NSImage(systemSymbolName:)` is the same resolution + /// `ItemSymbol` performs everywhere else in the app. A symbol that cannot be made into an image at + /// print time contributes nothing — the line still prints its labels, which is the same + /// omit-rather-than-box degrade `ItemSymbol` promises. + /// + /// Labels are joined with " · " rather than drawn as chips. A chip is a screen affordance (a + /// coloured, rounded, hit-testable thing); on paper it is ink around a word, and 01's reserved + /// `labels` key carries no colour to draw it in anyway. + private static func appendMeta(icon: String?, labels: [String], options: PrintOptions, to output: NSMutableAttributedString) { + let font = PrintTypography.secondary(options) + let style = NSMutableParagraphStyle() + style.paragraphSpacingBefore = 0 + style.paragraphSpacing = options.fontSize * 0.45 + + let line = NSMutableAttributedString() + if let icon, let image = symbolImage(icon, size: font.pointSize) { + let attachment = NSTextAttachment() + attachment.image = image + attachment.bounds = CGRect(x: 0, y: font.descender * 0.5, width: image.size.width, height: image.size.height) + line.append(NSAttributedString(attachment: attachment)) + if !labels.isEmpty { + line.append(NSAttributedString(string: " ")) + } + } + if !labels.isEmpty { + line.append(NSAttributedString(string: labels.joined(separator: " · "))) + } + guard line.length > 0 else { return } + + line.append(NSAttributedString(string: "\n")) + line.addAttributes( + [.font: font, .foregroundColor: PrintTypography.secondaryInk, .paragraphStyle: style], + range: NSRange(location: 0, length: line.length) + ) + output.append(line) + } + + /// One SF Symbol at text size, or `nil` when this system cannot draw it. + private static func symbolImage(_ name: String, size: CGFloat) -> NSImage? { + guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil } + return image.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: size, weight: .regular)) + } + + // MARK: - Plain lines + + private static func append( + _ text: String, + font: NSFont, + color: NSColor, + spacingBefore: CGFloat, + spacingAfter: CGFloat, + to output: NSMutableAttributedString, + underlined: Bool = false, + indent: CGFloat = 0 + ) { + guard !text.isEmpty else { return } + + let style = NSMutableParagraphStyle() + style.paragraphSpacingBefore = spacingBefore + style.paragraphSpacing = spacingAfter + style.firstLineHeadIndent = indent + style.headIndent = indent + if underlined { + // A hairline under the whole measure, drawn by a text block rather than by an underline + // attribute, so it spans the column instead of only the letters — `BodyMarkupRenderer`'s + // thematic-break mechanism, reused. + let rule = NSTextBlock() + rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .maxY) + rule.setBorderColor(.separatorColor) + rule.setWidth(font.pointSize * 0.2, type: .absoluteValueType, for: .padding, edge: .maxY) + style.textBlocks = [rule] + } + + output.append(NSAttributedString(string: text + "\n", attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: style + ])) + } + + // MARK: - The words the document says about itself + + /// "3 comments" / "1 comment" — the thread's heading. + static func commentsHeadingText(count: Int) -> String { + count == 1 ? "1 comment" : "\(count) comments" + } + + /// A comment's byline. Both halves are optional and each is a lenient field, so all four + /// combinations have to read as a sentence: + /// + /// - both → "Ada Lovelace — 9 Aug 2026 at 14:30" + /// - author only → "Ada Lovelace" (a comment whose `created` was unreadable — the thread already + /// sorts those last rather than refusing them) + /// - date only → the date ("**Missing renders unattributed**" — `Comment.author`) + /// - neither → "Comment", so the body still has a line announcing it and never runs into the one + /// above it + static func byline(author: String?, created: Date?) -> String { + let name = author?.trimmingCharacters(in: .whitespacesAndNewlines) + let stamp = created.map { $0.formatted(date: .abbreviated, time: .shortened) } + switch (name?.isEmpty == false ? name : nil, stamp) { + case let (author?, stamp?): return "\(author) — \(stamp)" + case let (author?, nil): return author + case let (nil, stamp?): return stamp + case (nil, nil): return "Comment" + } + } +} diff --git a/Kanban/UI/Print/PrintDocumentView.swift b/Kanban/UI/Print/PrintDocumentView.swift new file mode 100644 index 0000000..ce34d14 --- /dev/null +++ b/Kanban/UI/Print/PrintDocumentView.swift @@ -0,0 +1,329 @@ +import AppKit + +/// **The printed page** — the view `NSPrintOperation` paginates and draws, and the one place a page +/// break becomes a real sheet boundary. +/// +/// ### Why a custom view rather than printing an `NSTextView` +/// +/// An `NSTextView` paginates itself, which is most of what this file does, and it was the obvious first +/// choice. It cannot keep the one promise the feature is built on: **"between lanes" must really start a +/// new page** (`PrintPageBreaks`). TextKit has no page-break character — a form feed is laid out as +/// whitespace, not as a boundary — so a text view could only ever have been given padding newlines, which +/// land in the right place at one paper size and drift at every other, and which would silently stop +/// working the day someone changed the margins. A break has to be a *pagination* fact, not a spacing one. +/// +/// So the document is split into sections at its breaks (`PrintDocumentRenderer.sections`), each section +/// gets its own TextKit stack, and each section's text is flowed into as many page-sized text containers +/// as it needs. A section therefore always begins at the top of a sheet, at every paper size, with any +/// margins — because a container boundary *is* a page boundary here, by construction. +/// +/// **TextKit 1 deliberately** (`NSLayoutManager`, `NSTextContainer`), and not by inertia: card bodies are +/// rendered by `BodyMarkupRenderer`, whose GFM tables are `NSTextTable`s — a TextKit 1 construct, which +/// is the same reason the card window's own body surface runs on TextKit 1 (`CardBodySurfaceView`). The +/// multiple-containers-per-layout-manager flow this file relies on is also TextKit 1's; TextKit 2 models +/// it differently and would have to be a separate design, not a search-and-replace. +/// +/// ### Header and footer are drawn here, not handed to AppKit +/// +/// `NSView` has a `pageHeader`/`pageFooter` pair and `drawPageBorder(withSize:)` to go with them. They +/// are not used: their content is a single attributed string per page with no control over placement, +/// they draw outside the imageable rect the pagination already accounts for, and their behaviour depends +/// on an `NSPrintInfo` dictionary key rather than on anything this app can state. Drawing the two lines +/// inside the page rect — with the text container's height reduced by exactly their heights — makes the +/// running head, the pagination and the folio one arithmetic instead of three that have to agree. +/// +/// ### One known limit, stated rather than hidden +/// +/// There is no widow/orphan control: a card title can fall as the last line of a page with its body +/// overleaf. `NSParagraphStyle` has no keep-with-next, so the honest fixes are a measure-and-push pass or +/// per-card sections — the second of which is exactly what `.betweenCards` already offers a user who +/// cares. Left as it is, and noted here so the next reader knows it was a decision. +@MainActor +final class PrintDocumentView: NSView { + + // MARK: - What it prints + + private let session: PrintSession + + /// The imageable area of one sheet — paper minus margins, as `NSPrintInfo` reports it. Fixed for the + /// operation: the panel's paper and orientation controls rebuild the operation rather than mutating + /// this. + private let pageSize: CGSize + + /// The running head's and foot's heights, computed once from the options' own type scale. Zero when + /// the line has nothing to say, which is what reclaims the paper rather than leaving a blank band + /// (`PrintRunningHead.Line.isEmpty`). + private var headerHeight: CGFloat = 0 + private var footerHeight: CGFloat = 0 + + /// One page: the layout manager that owns its glyphs, and which of its containers this page is. + private struct Page { + let layoutManager: NSLayoutManager + let containerIndex: Int + } + + private var pages: [Page] = [] + + /// The text storages, held only to keep them alive: a layout manager does not retain its storage, and + /// a deallocated storage takes the glyphs with it (a page that draws nothing, intermittently). + private var storages: [NSTextStorage] = [] + + /// The options the current pagination was computed from — the guard that keeps `knowsPageRange` from + /// re-flowing the whole document on every one of the preview's repeated calls when nothing changed. + private var paginatedOptions: PrintOptions? + + /// A ceiling on the page count, so a pathological layout cannot spin forever inside a modal panel. + /// It is deliberately far above any real print: a 500-card board with every comment is a few hundred + /// sheets, and a document that wants more than this has hit a bug, not a use case. + private static let pageLimit = 5000 + + /// The date the print was configured — stamped once, at construction, so every sheet of one job + /// carries the same date even if the job straddles midnight. + private let printedAt = Date() + + init(session: PrintSession, printInfo: NSPrintInfo) { + self.session = session + pageSize = Self.imageableSize(of: printInfo) + super.init(frame: CGRect(origin: .zero, size: pageSize)) + // **Forced light appearance, and it is load-bearing.** Bodies come from `BodyMarkupRenderer`, + // which sets `NSColor.labelColor` and its neighbours — dynamic colours resolved against the + // drawing appearance at draw time. In a dark-mode app that resolves to near-white, which on paper + // is a blank sheet. Pinning the view's appearance resolves every one of them the way paper needs, + // without the renderer having to know it is being printed. + appearance = NSAppearance(named: .aqua) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("PrintDocumentView is created in code") } + + /// Text goes down the page, so the view's y does too — which also makes a page's rect + /// `(pageIndex × height)` rather than a subtraction from the total. + override var isFlipped: Bool { true } + + // MARK: - Paper + + /// The imageable content size: the paper minus the four margins the print panel is showing. + /// + /// `paperSize` and the four margins rather than `imageablePageBounds`, deliberately: the latter is the + /// *printer's* hardware limit, and using it would silently override the margins the user set in the + /// panel — a document that ignored a 1-inch margin because the printer could reach further. The + /// margins are the document's, and the panel owns them. + static func imageableSize(of printInfo: NSPrintInfo) -> CGSize { + let paper = printInfo.paperSize + let width = paper.width - printInfo.leftMargin - printInfo.rightMargin + let height = paper.height - printInfo.topMargin - printInfo.bottomMargin + // A margin set larger than the paper is reachable from a hand-edited print preset; a floor keeps + // the pagination loop from meeting a container it can never fill. + return CGSize(width: max(72, width), height: max(72, height)) + } + + /// Where the text lives on a page, once the running head and foot have taken theirs. + private var textSize: CGSize { + CGSize(width: pageSize.width, height: max(24, pageSize.height - headerHeight - footerHeight)) + } + + // MARK: - Pagination + + /// **The whole pagination**, run by AppKit before each print and before each preview refresh. + /// + /// Re-flowing is guarded on the options rather than done unconditionally: the print panel calls this + /// several times per interaction, and a 500-card board's layout is not free. + override func knowsPageRange(_ range: NSRangePointer) -> Bool { + paginate() + let count = max(1, pages.count) + setFrameSize(CGSize(width: pageSize.width, height: pageSize.height * CGFloat(count))) + range.pointee = NSRange(location: 1, length: count) + return true + } + + override func rectForPage(_ page: Int) -> NSRect { + NSRect( + x: 0, + y: CGFloat(page - 1) * pageSize.height, + width: pageSize.width, + height: pageSize.height + ) + } + + /// How many sheets the document currently needs — the folio's denominator, and the summary line's + /// number. Paginates if it has to, so a caller never has to sequence the two. + func pageCount() -> Int { + paginate() + return max(1, pages.count) + } + + private func paginate() { + let options = session.options.normalized + guard paginatedOptions != options else { return } + paginatedOptions = options + + measureRunningLines(options: options) + + pages = [] + storages = [] + + let sections = PrintDocumentRenderer.sections( + for: session.blocks(), + options: options, + cardFolder: session.cardFolder + ) + + for section in sections { + let storage = NSTextStorage(attributedString: section) + let manager = NSLayoutManager() + // Font leading, so a line of 18pt heading and a line of 11pt body each take the space their + // own face asks for — the same reason the card window's body surface leaves it on. + manager.usesFontLeading = true + storage.addLayoutManager(manager) + storages.append(storage) + + let total = manager.numberOfGlyphs + var laidOut = 0 + var containerIndex = 0 + + // A section with no glyphs still gets no page: `sections(for:...)` never emits an empty one, + // and a defensive page here would print a sheet of running heads over nothing. + while laidOut < total, pages.count < Self.pageLimit { + let container = NSTextContainer(size: textSize) + // The renderer's own indents are the document's; a container inset would add a second, + // invisible one that only printing had. + container.lineFragmentPadding = 0 + container.widthTracksTextView = false + container.heightTracksTextView = false + manager.addTextContainer(container) + manager.ensureLayout(for: container) + + let glyphs = manager.glyphRange(for: container) + // A container that accepted nothing cannot be filled by another of the same size — an + // image or a table wider or taller than the page. Stopping is the only termination this + // loop can honestly have; the content that did not fit is clipped rather than looping + // forever inside a modal print panel. + guard glyphs.length > 0 else { break } + + pages.append(Page(layoutManager: manager, containerIndex: containerIndex)) + containerIndex += 1 + laidOut = glyphs.location + glyphs.length + } + } + } + + /// The two bands' heights, from the running-head font and whether either line has anything in it. + private func measureRunningLines(options: PrintOptions) { + let font = PrintTypography.runningHead(options) + let line = font.ascender - font.descender + font.leading + let gap = options.fontSize * 0.8 + + headerHeight = PrintRunningHead.header( + options: options, + boardTitle: session.boardTitle, + dateText: dateText + ).isEmpty ? 0 : line + gap + + // Measured against a representative folio rather than the real one: page 1 of 1 and page 9 of 99 + // are the same height, and the count is not known until pagination has run — which is what this + // measurement is an input to. + footerHeight = PrintRunningHead.footer( + options: options, + pageText: PrintRunningHead.pageText(page: 1, of: 1) + ).isEmpty ? 0 : line + gap + } + + // MARK: - Drawing + + override func draw(_ dirtyRect: NSRect) { + let options = session.options.normalized + guard let index = pageIndex(in: dirtyRect), pages.indices.contains(index) else { return } + + let page = pages[index] + let pageTop = CGFloat(index) * pageSize.height + let container = page.layoutManager.textContainers[page.containerIndex] + let glyphs = page.layoutManager.glyphRange(for: container) + let origin = CGPoint(x: 0, y: pageTop + headerHeight) + + page.layoutManager.drawBackground(forGlyphRange: glyphs, at: origin) + page.layoutManager.drawGlyphs(forGlyphRange: glyphs, at: origin) + + drawRunningLines(options: options, pageIndex: index, pageTop: pageTop) + } + + /// Which page is being drawn. + /// + /// `NSPrintOperation.current?.currentPage` is the authority — it is exactly what the printing + /// machinery is tracking — and the arithmetic is the fallback for the one case where there is no + /// operation: a draw on screen, which only happens if someone ever puts this view in a window. + private func pageIndex(in dirtyRect: NSRect) -> Int? { + if let page = NSPrintOperation.current?.currentPage, page > 0 { + return page - 1 + } + guard pageSize.height > 0 else { return nil } + return Int((dirtyRect.minY / pageSize.height).rounded(.down)) + } + + private func drawRunningLines(options: PrintOptions, pageIndex: Int, pageTop: CGFloat) { + let font = PrintTypography.runningHead(options) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: PrintTypography.secondaryInk + ] + + if headerHeight > 0 { + draw( + PrintRunningHead.header(options: options, boardTitle: session.boardTitle, dateText: dateText), + attributes: attributes, + in: NSRect(x: 0, y: pageTop, width: pageSize.width, height: headerHeight) + ) + } + if footerHeight > 0 { + let line = PrintRunningHead.footer( + options: options, + pageText: PrintRunningHead.pageText(page: pageIndex + 1, of: max(1, pages.count)) + ) + draw( + line, + attributes: attributes, + in: NSRect( + x: 0, + y: pageTop + pageSize.height - footerHeight, + width: pageSize.width, + height: footerHeight + ) + ) + } + } + + /// One running line, its two ends at the two ends of the measure. + /// + /// Each end is drawn separately with its own alignment rather than joined by tabs: a tab stop would + /// have to be recomputed per paper size, and a leading string long enough to reach the trailing one + /// would push it off the page instead of truncating. Two rects cannot collide destructively — the + /// worst case is two texts that meet in the middle, each truncated by its own rect. + private func draw(_ line: PrintRunningHead.Line, attributes: [NSAttributedString.Key: Any], in rect: NSRect) { + let inset = rect + + if !line.leading.isEmpty { + var leading = attributes + let style = NSMutableParagraphStyle() + style.alignment = .left + style.lineBreakMode = .byTruncatingTail + leading[.paragraphStyle] = style + NSAttributedString(string: line.leading, attributes: leading) + .draw(with: CGRect(x: inset.minX, y: inset.minY, width: inset.width * 0.6, height: inset.height), + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine]) + } + if !line.trailing.isEmpty { + var trailing = attributes + let style = NSMutableParagraphStyle() + style.alignment = .right + style.lineBreakMode = .byTruncatingTail + trailing[.paragraphStyle] = style + NSAttributedString(string: line.trailing, attributes: trailing) + .draw(with: CGRect(x: inset.minX + inset.width * 0.6, y: inset.minY, width: inset.width * 0.4, height: inset.height), + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine]) + } + } + + /// The print's date, formatted once — the running head's trailing end. + private var dateText: String { + printedAt.formatted(date: .abbreviated, time: .shortened) + } +} diff --git a/Kanban/UI/Print/PrintOptionsAccessory.swift b/Kanban/UI/Print/PrintOptionsAccessory.swift new file mode 100644 index 0000000..3861033 --- /dev/null +++ b/Kanban/UI/Print/PrintOptionsAccessory.swift @@ -0,0 +1,395 @@ +import AppKit +import SwiftUI + +// MARK: - The accessory controller + +/// **The print panel's own pane of Lanework options** — `NSPrintPanelAccessorizing`, hosting SwiftUI, with +/// the system's live preview redrawing as the controls change. +/// +/// ### Why the accessory rather than a pre-flight sheet of our own +/// +/// The scope ruling prefers it and the reason holds up: ⌘P is one of the most over-learned gestures on the +/// platform, and a sheet of ours *before* the print panel would put two dialogs between the user and a +/// sheet of paper — the second of which asks about paper, orientation and the printer, which is where a +/// user expects the *first* one to. The accessory route also gets the thing a pre-flight sheet could never +/// have: **the system's own preview**, showing the actual paginated document, updating as a toggle flips. +/// Rebuilding that inside an app sheet would mean re-implementing the preview, the paper controls, and the +/// PDF/queue destinations. +/// +/// The one thing an accessory is cramped for is **profile management**, which is why the popup here does +/// the three management gestures through named prompts (`PrintProfilePrompt`) rather than an inline +/// editable list. That is a genuine compromise and it is the right one: choosing a profile is a +/// once-per-print gesture that belongs in the flow, while naming and deleting them is rare and is fine +/// behind a prompt. +/// +/// ### The preview refresh is one KVO key, deliberately +/// +/// `keyPathsForValuesAffectingPreview()` is a KVO contract: the panel observes the key paths it returns and +/// redraws when one changes. A `@Observable` session cannot be observed that way, and mirroring thirteen +/// options as thirteen `@objc dynamic` properties would be thirteen chances to forget one — a toggle that +/// silently stopped updating the preview. So there is exactly **one** observed key, a revision counter the +/// form bumps whenever the options value changes at all. The options are `Equatable`, so "changed" is a +/// real comparison rather than a notification storm. +@MainActor +final class PrintOptionsAccessoryController: NSViewController, NSPrintPanelAccessorizing { + + private let session: PrintSession + + /// **The one key the panel observes.** See the type's note — bumped by the form whenever + /// `session.options` changes, which is what makes the preview live. + @objc dynamic private(set) var optionsRevision = 0 + + init(session: PrintSession) { + self.session = session + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("PrintOptionsAccessoryController is created in code") } + + override func loadView() { + let hosting = NSHostingView(rootView: PrintOptionsForm(session: session) { [weak self] in + // Willingly on the main actor: the form is a SwiftUI view in this controller's own view tree. + self?.optionsRevision += 1 + }) + // The panel sizes its accessory to the view it is given, and a hosting view with no frame reports + // zero. The width is the panel's own comfortable measure; the height is what the form needs. + hosting.frame = CGRect(origin: .zero, size: CGSize(width: 480, height: 430)) + view = hosting + } + + // MARK: NSPrintPanelAccessorizing + + /// The **collapsed** summary the panel shows when the accessory is not the visible pane — the answer to + /// "what will this print do" without opening anything. + /// + /// Four rows, each one a decision the user could otherwise only recover by switching back: the profile + /// they are on (with its modified state, which is the one thing a popup title cannot show once the + /// pane is hidden), what is included, where pages break, and the face. + nonisolated func localizedSummaryItems() -> [[NSPrintPanel.AccessorySummaryKey: String]] { + MainActor.assumeIsolated { summaryItems() } + } + + private func summaryItems() -> [[NSPrintPanel.AccessorySummaryKey: String]] { + let options = session.options.normalized + return [ + [ + .itemName: "Profile", + .itemDescription: session.isModified + ? "\(session.selectedProfileName) (modified)" + : session.selectedProfileName + ], + [.itemName: "Includes", .itemDescription: PrintOptionsSummary.includes(options)], + [.itemName: "Page Breaks", .itemDescription: PrintOptionsSummary.pageBreaks(options)], + [.itemName: "Type", .itemDescription: PrintOptionsSummary.type(options)] + ] + } + + /// The panel redraws its preview when this changes — see the type's note on why there is one of them. + nonisolated func keyPathsForValuesAffectingPreview() -> Set { + // `#keyPath` rather than a string literal, so a rename of the property is a compile error rather + // than a preview that quietly stopped updating. It needs the actor to form, and the panel asks this + // on the main thread like everything else it does. + MainActor.assumeIsolated { [#keyPath(optionsRevision)] } + } +} + +// MARK: - The summary's wording + +/// The sentences the collapsed summary shows, as pure functions of the options — separated from the +/// controller for the reason every rule in this feature is: a wording nobody can test drifts from the +/// controls it describes. +enum PrintOptionsSummary { + + /// "Title, labels, body" — the components, in the order they print, or "Nothing" for the state a user + /// can reach by turning all four off (`PrintOptions.describesAnyContent`). + static func includes(_ options: PrintOptions) -> String { + var parts: [String] = [] + if options.includesTitle { parts.append("title") } + if options.includesLabels { parts.append("labels") } + if options.includesBody { parts.append("body") } + if options.includesComments { + parts.append(options.commentSort == .newestFirst ? "comments (newest first)" : "comments (oldest first)") + } + guard !parts.isEmpty else { return "Nothing" } + return parts.joined(separator: ", ").capitalizedFirstLetter + } + + static func pageBreaks(_ options: PrintOptions) -> String { + switch options.pageBreaks { + case .flow: "Continuous" + case .betweenLanes: "Between lanes" + case .betweenCards: "Between cards" + } + } + + /// "Palatino 11 pt" / "System 11 pt". The size is written as an integer when it is one, because + /// "11 pt" is what a user typed and "11.0 pt" is what a `Double` remembers. + static func type(_ options: PrintOptions) -> String { + let face = options.fontFamily ?? "System" + let size = options.fontSize + let text = size == size.rounded() ? String(Int(size)) : String(format: "%.1f", size) + return "\(face) \(text) pt" + } +} + +private extension String { + var capitalizedFirstLetter: String { + guard let first else { return self } + return first.uppercased() + dropFirst() + } +} + +// MARK: - The form + +/// The accessory's controls — the card's five bullets, in the order it lists them, plus the profile row +/// that makes them reusable. +/// +/// SwiftUI inside an `NSHostingView` inside a print panel: the panel is AppKit and modal, but the controls +/// are the app's, and every other configuration surface in Lanework is SwiftUI (the style editor, the board +/// popover, the settings pane). A second UI vocabulary for one pane would be a second set of layout and +/// accessibility habits to keep honest. +private struct PrintOptionsForm: View { + + let session: PrintSession + + /// Called whenever the options value changes — bumps the controller's KVO counter, which is what makes + /// the panel's preview live (`PrintOptionsAccessoryController`). + let onOptionsChange: () -> Void + + /// The families, read once: `NSFontManager`'s list is a few hundred entries and does not change while a + /// print panel is up. + @State private var families: [String] = PrintTypography.families() + + /// The sentinel the picker uses for "the system font", since `nil` is not a `Picker` tag value. + private static let systemFace = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + profiles + Divider() + components + Divider() + breaks + Divider() + type + Divider() + runningLines + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: session.options) { _, _ in onOptionsChange() } + } + + // MARK: Profiles + + @ViewBuilder + private var profiles: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Picker("Profile", selection: profileSelection) { + ForEach(session.profiles.menuNames, id: \.self) { name in + Text(name).tag(name) + } + } + .frame(maxWidth: 240) + + Spacer(minLength: 0) + + // Save is always live: on the reserved row it is how a named profile is *created* from the + // options in front of you, which is the gesture the whole feature exists for. + Button("Save…") { save() } + Button("Rename…") { rename() } + .disabled(!session.canManageSelection) + Button("Delete") { session.deleteSelectedProfile() } + .disabled(!session.canManageSelection) + } + + if session.isModified { + Text("Modified — Save… keeps these settings under a name.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + /// The popup's binding. Reading is the session's label; writing goes through `selectProfile(named:)`, + /// which is what copies the profile's options in rather than only moving a selection. + private var profileSelection: Binding { + Binding( + get: { session.selectedProfileName }, + set: { session.selectProfile(named: $0) } + ) + } + + private func save() { + let suggested = session.canManageSelection ? session.selectedProfileName : "" + guard let name = PrintProfilePrompt.ask( + title: "Save Print Profile", + message: "Name these print settings so you can reuse them.", + defaultValue: suggested, + prompt: "Save" + ) else { return } + session.saveProfile(named: name) + } + + private func rename() { + guard let name = PrintProfilePrompt.ask( + title: "Rename Print Profile", + message: "Give '\(session.selectedProfileName)' a new name.", + defaultValue: session.selectedProfileName, + prompt: "Rename" + ) else { return } + session.renameSelectedProfile(to: name) + } + + // MARK: Components + + @ViewBuilder + private var components: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Include").font(.headline) + Toggle("Title", isOn: binding(\.includesTitle)) + Toggle("Icon & Labels", isOn: binding(\.includesLabels)) + Toggle("Body", isOn: binding(\.includesBody)) + Toggle("Comments", isOn: binding(\.includesComments)) + + Picker("Comment order", selection: binding(\.commentSort)) { + Text("Oldest first").tag(PrintCommentSort.oldestFirst) + Text("Newest first").tag(PrintCommentSort.newestFirst) + } + .pickerStyle(.radioGroup) + .padding(.leading, 18) + // Disabled rather than hidden: a control that vanishes takes the *existence* of the choice with + // it, and the sort is remembered across the toggle (`PrintOptions.commentSort`). + .disabled(!session.options.includesComments) + } + } + + // MARK: Page breaks + + @ViewBuilder + private var breaks: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Page Breaks").font(.headline) + Picker("", selection: binding(\.pageBreaks)) { + Text("Continuous").tag(PrintPageBreaks.flow) + Text("Start each lane on a new page").tag(PrintPageBreaks.betweenLanes) + Text("Start each card on a new page").tag(PrintPageBreaks.betweenCards) + } + .pickerStyle(.radioGroup) + .labelsHidden() + } + } + + // MARK: Type + + @ViewBuilder + private var type: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Type").font(.headline) + HStack(spacing: 8) { + Picker("Face", selection: faceSelection) { + Text("System").tag(Self.systemFace) + Divider() + ForEach(families, id: \.self) { family in + Text(family).tag(family) + } + } + .frame(maxWidth: 260) + + Stepper(value: sizeSelection, in: PrintOptions.fontSizeRange, step: 0.5) { + Text("Size \(sizeLabel) pt") + } + } + Text("Headings, bylines and the running head are all derived from this size.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var faceSelection: Binding { + Binding( + get: { session.options.fontFamily ?? Self.systemFace }, + set: { session.options.fontFamily = $0 == Self.systemFace ? nil : $0 } + ) + } + + private var sizeSelection: Binding { + Binding( + get: { session.options.fontSize }, + set: { session.options.fontSize = PrintOptions.clamped(fontSize: $0) } + ) + } + + private var sizeLabel: String { + let size = session.options.fontSize + return size == size.rounded() ? String(Int(size)) : String(format: "%.1f", size) + } + + // MARK: Header and footer + + @ViewBuilder + private var runningLines: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Header & Footer").font(.headline) + Toggle("Board title", isOn: binding(\.headerShowsBoardTitle)) + Toggle("Print date", isOn: binding(\.headerShowsPrintDate)) + Toggle("Page numbers", isOn: binding(\.footerShowsPageNumbers)) + Toggle("Custom line", isOn: binding(\.footerShowsCustomLine)) + TextField("", text: binding(\.footerCustomLine), prompt: Text("Footer text")) + .textFieldStyle(.roundedBorder) + .padding(.leading, 18) + .disabled(!session.options.footerShowsCustomLine) + } + } + + // MARK: One binding shape for every option + + /// A writable binding into `session.options` through a key path — thirteen controls, one mechanism, so a + /// new option is a row rather than a row plus a binding plus a chance to bind the wrong field. + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { session.options[keyPath: keyPath] }, + set: { session.options[keyPath: keyPath] = $0 } + ) + } +} + +// MARK: - The naming prompt + +/// The one-field prompt behind Save… and Rename…. +/// +/// **An `NSAlert`, run modally over the print panel**, and not a SwiftUI sheet: the accessory has no window +/// of its own to present from — it is a view inside AppKit's panel — and a nested modal session is exactly +/// what the platform provides for a dialog raised from a modal dialog. It is also the shape Finder uses for +/// the same gesture. +/// +/// The refusal path is quiet: `nil` for Cancel, and `nil` for a name the catalog will not take, which is +/// the same answer because both mean "nothing was named" (`PrintProfileCatalog.isAcceptable` states which +/// names those are — blank, and the reserved one). +@MainActor +enum PrintProfilePrompt { + + static func ask(title: String, message: String, defaultValue: String, prompt: String) -> String? { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: prompt) + alert.addButton(withTitle: "Cancel") + + let field = NSTextField(frame: CGRect(x: 0, y: 0, width: 260, height: 24)) + field.stringValue = defaultValue + field.placeholderString = "Profile name" + alert.accessoryView = field + // Without this the field is not first responder and the user has to click into it before typing. + alert.window.initialFirstResponder = field + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let name = PrintProfileCatalog.normalized(field.stringValue) + guard PrintProfileCatalog.isAcceptable(name) else { return nil } + return name + } +} diff --git a/Kanban/UI/Print/PrintSession.swift b/Kanban/UI/Print/PrintSession.swift new file mode 100644 index 0000000..e4adbec --- /dev/null +++ b/Kanban/UI/Print/PrintSession.swift @@ -0,0 +1,198 @@ +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) + } +} diff --git a/Kanban/UI/Print/PrintTypography.swift b/Kanban/UI/Print/PrintTypography.swift new file mode 100644 index 0000000..4597d68 --- /dev/null +++ b/Kanban/UI/Print/PrintTypography.swift @@ -0,0 +1,153 @@ +import AppKit + +/// **The type scale of a printed document, derived from one base choice** — "font face, size & style" +/// (the printing card's fourth bullet) answered as the scope ruling settles it: the user picks a face +/// and a body size, and everything else is a multiple of them. +/// +/// ### Why one size and not six +/// +/// A print dialog that asked separately for the heading size, the byline size and the running-head size +/// would be a typesetting program with a Print button. The ratios below are the same relationships the +/// card window's Preview already uses (`BodyMarkupRenderer` sets every indent, padding and heading step +/// as a multiple of the body point size, so the surface grows with the system text size — +/// 10-accessibility.md ▸ Text); this file is that discipline pointed at paper, where the base comes from +/// `PrintOptions.fontSize` instead of from the system. +/// +/// ### Why the face is applied as a *remap* rather than threaded through +/// +/// Bodies are rendered by `BodyMarkupRenderer`, which is the app's one Markdown-to-typography pass and +/// hardcodes the system font by design (it draws what the card window draws). Teaching it a font family +/// would put a print-only parameter into the surface that renders every card on screen. So a print sets +/// its face afterwards, by walking the finished string's `.font` runs and rebuilding each one in the +/// chosen family at its own size and with its own traits (`restyled`). One consequence is deliberate: +/// **code stays monospaced**. A fenced block set in Palatino is not what anyone means by choosing +/// Palatino. +@MainActor +enum PrintTypography { + + // MARK: - The scale + + /// The document's base — body text, comment bodies, and the measure everything else is a multiple + /// of. + static func body(_ options: PrintOptions) -> NSFont { + font(family: options.fontFamily, size: options.fontSize, weight: .regular) + } + + /// The board's name at the top of a board print. The largest thing on the page, because it is the + /// only thing that names the whole document. + static func boardHeading(_ options: PrintOptions) -> NSFont { + font(family: options.fontFamily, size: options.fontSize * 1.7, weight: .bold) + } + + /// A lane's name. Below the board and above a card, which is exactly its place in the structure. + static func laneHeading(_ options: PrintOptions) -> NSFont { + font(family: options.fontFamily, size: options.fontSize * 1.35, weight: .semibold) + } + + /// A card's title. + static func cardTitle(_ options: PrintOptions) -> NSFont { + font(family: options.fontFamily, size: options.fontSize * 1.15, weight: .semibold) + } + + /// The icon-and-labels line, and a comment's byline — the two secondary lines, at one size so the + /// page has one voice for "this is about the content, not the content". + static func secondary(_ options: PrintOptions) -> NSFont { + font(family: options.fontFamily, size: options.fontSize * 0.85, weight: .regular) + } + + /// The running head and foot. Smallest on the page: furniture that must be readable and must not + /// compete. + static func runningHead(_ options: PrintOptions) -> NSFont { + font(family: options.fontFamily, size: options.fontSize * 0.8, weight: .regular) + } + + /// The comments heading — "3 comments" over the thread. + static func commentsHeading(_ options: PrintOptions) -> NSFont { + font(family: options.fontFamily, size: options.fontSize * 0.95, weight: .semibold) + } + + // MARK: - Resolving a face + + /// A font in `family` at `size`, falling back to the system font of that size and weight. + /// + /// **The fallback is the whole leniency story** and it mirrors `ItemSymbol.name(_:fallback:)` + /// exactly: a stored profile is a value that travels between machines and OS releases, and a family + /// that is not installed here must degrade rather than refuse. `NSFont(name:size:)` against a family + /// name resolves the family's regular face on macOS; when it cannot, the system font is the answer. + static func font(family: String?, size: CGFloat, weight: NSFont.Weight) -> NSFont { + let size = max(1, size) + guard let family, !family.isEmpty else { + return NSFont.systemFont(ofSize: size, weight: weight) + } + let descriptor = NSFontDescriptor(fontAttributes: [.family: family]) + if let base = NSFont(descriptor: descriptor, size: size) { + return weight == .regular ? base : bolder(base, weight: weight) ?? base + } + return NSFont.systemFont(ofSize: size, weight: weight) + } + + /// A heavier cut of `font`, or `nil` when the family has none — a family with only one weight + /// renders a "semibold" heading in its one face, which is what a single-weight face means. + private static func bolder(_ font: NSFont, weight: NSFont.Weight) -> NSFont? { + var traits = font.fontDescriptor.symbolicTraits + traits.insert(.bold) + let descriptor = font.fontDescriptor.withSymbolicTraits(traits) + return NSFont(descriptor: descriptor, size: font.pointSize) + } + + /// Whether the running system can set text in `family` — the picker's own filter, and + /// `ItemSymbol.exists`' posture applied to type: the font set is the *machine's*, so a hardcoded + /// list would be wrong on the first machine that had a different one. + static func families() -> [String] { + NSFontManager.shared.availableFontFamilies.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + // MARK: - The remap + + /// `attributed` with every non-monospaced `.font` run rebuilt in `family`, keeping each run's own + /// size and traits. + /// + /// The traits are carried across rather than recomputed, which is what makes a body's structure + /// survive the change of face: `**bold**` stays bold, `*emphasis*` stays italic, a heading stays + /// whatever weight the renderer gave it, and the size ladder (headings larger, captions smaller) is + /// untouched because each run keeps its own point size. + /// + /// **Monospaced runs are skipped on purpose** — see the type's note. `isMonospaced` on + /// `NSFontDescriptor.symbolicTraits` is the test, which catches both the app's explicit + /// `monospacedSystemFont` code style and any face that reports itself fixed-pitch. + /// + /// A `nil` family is the identity: the string is returned untouched rather than rebuilt into the + /// system font it is already set in. + static func restyled(_ attributed: NSAttributedString, family: String?) -> NSAttributedString { + guard let family, !family.isEmpty else { return attributed } + + let output = NSMutableAttributedString(attributedString: attributed) + output.enumerateAttribute(.font, in: NSRange(location: 0, length: output.length)) { value, range, _ in + guard let font = value as? NSFont else { return } + let traits = font.fontDescriptor.symbolicTraits + guard !traits.contains(.monoSpace) else { return } + + var descriptor = NSFontDescriptor(fontAttributes: [.family: family]) + descriptor = descriptor.withSymbolicTraits(traits) + guard let replacement = NSFont(descriptor: descriptor, size: font.pointSize) else { return } + output.addAttribute(.font, value: replacement, range: range) + } + return output + } + + // MARK: - Ink + + /// **Paper is white, so ink is black** — and the app's dynamic colours are not. + /// + /// `BodyMarkupRenderer` sets `NSColor.labelColor` and friends, which resolve *at draw time against + /// the drawing appearance*: in a dark-mode app that is near-white, which on paper is nothing at all. + /// The print view therefore draws in a forced light appearance (`PrintDocumentView`), which resolves + /// every one of those dynamic colours the way a printed page needs. This constant is for the text + /// this file's own callers compose — headings, bylines, running heads — where naming the ink + /// explicitly is clearer than relying on the appearance override two files away. + static let ink = NSColor.textColor + + /// Secondary ink, for bylines and running heads. Dynamic like `ink`, resolved by the same forced + /// appearance. + static let secondaryInk = NSColor.secondaryLabelColor +} diff --git a/KanbanTests/PrintTests.swift b/KanbanTests/PrintTests.swift new file mode 100644 index 0000000..5fb162a --- /dev/null +++ b/KanbanTests/PrintTests.swift @@ -0,0 +1,1126 @@ +import AppKit +import Foundation +import Testing +@testable import Kanban + +/// Printing, reduced to the rules a unit test can hold (11-command-nexus.md ▸ File ▸ Print…). +/// +/// The typography is not the point and is not tested — that is `PrintDocumentRenderer`'s and it is +/// legitimately unverifiable without eyes, the same standing `BodyMarkupRenderer` has. What *is* tested is +/// every layer under it, each of which fails silently: +/// +/// - **Profiles** round-trip through a preferences plist. A field that stopped encoding, or a decoder that +/// threw on a shape a future build wrote, costs the user every profile they saved — and nothing on screen +/// would say so until they looked for a profile that had gone. +/// - **The document's structure** is where every option actually lands: which components appear, in what +/// order, which end of a thread comes first, and where a page break falls. All four are invisible in a +/// rendered page and obvious in a block list. +/// - **The `labels` reading** interprets a key 01-storage-format.md reserves and this version otherwise +/// leaves inert, so printing is the one surface that can be wrong about it. +/// - **The trash exclusion** is the one rule whose failure would print deleted cards. + +// MARK: - Helpers + +/// A profile store on a scratch defaults domain — profiles are app-wide and persisted, so a suite that used +/// `.standard` would rewrite the developer's own (`BoardZoomTests`' arrangement, verbatim in intent). +@MainActor +private func makeStore() -> (PrintProfileStore, UserDefaults, () -> Void) { + let name = "dev.rzen.indie.Kanban.print-tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + return (PrintProfileStore(defaults: defaults), defaults, { + UserDefaults.standard.removePersistentDomain(forName: name) + }) +} + +/// Options with every field moved off its default, so a round-trip that dropped one is visible. +private func exoticOptions() -> PrintOptions { + var options = PrintOptions() + options.includesTitle = false + options.includesLabels = false + options.includesBody = false + options.includesComments = true + options.commentSort = .newestFirst + options.pageBreaks = .betweenCards + options.fontFamily = "Palatino" + options.fontSize = 13.5 + options.headerShowsBoardTitle = false + options.headerShowsPrintDate = false + options.footerShowsPageNumbers = false + options.footerShowsCustomLine = true + options.footerCustomLine = "Confidential" + return options +} + +private func card( + _ title: String?, + body: String = "", + icon: String? = nil, + labels: [String] = [], + comments: [PrintComment] = [] +) -> PrintCard { + PrintCard(title: title, icon: icon, labels: labels, body: body, comments: comments) +} + +private func board(_ lanes: [PrintLane], titled title: String = "Roadmap") -> PrintSource { + PrintSource(scope: .board, boardTitle: title, lanes: lanes) +} + +/// A date some fixed distance from a base, so comment ordering is a fact about the sort rather than about +/// the clock. +private func stamp(_ minutes: Int) -> Date { + Date(timeIntervalSince1970: 1_760_000_000).addingTimeInterval(TimeInterval(minutes * 60)) +} + +// MARK: - Options ▸ Codable + +@Suite("Print ▸ options round-trip") +struct PrintOptionsCodableTests { + + @Test("Every field survives an encode and a decode") + func roundTrips() throws { + let options = exoticOptions() + let decoded = try JSONDecoder().decode(PrintOptions.self, from: try JSONEncoder().encode(options)) + #expect(decoded == options) + } + + @Test("The defaults round-trip too — the state a first print is configured in") + func defaultsRoundTrip() throws { + let options = PrintOptions() + let decoded = try JSONDecoder().decode(PrintOptions.self, from: try JSONEncoder().encode(options)) + #expect(decoded == options) + #expect(decoded.includesComments == false, "comments are off by default") + #expect(decoded.pageBreaks == .flow) + #expect(decoded.fontFamily == nil, "the system font is the absence of a family, not a spelling of one") + } + + /// The failure mode the lenient decoder exists to rule out: one unrecognized shape must not cost the + /// whole value. + @Test("A stored shape from another build decodes to the defaults rather than throwing") + func toleratesForeignShapes() throws { + let json = """ + {"includesTitle": false, "pageBreaks": "betweenParagraphs", "commentSort": 7, + "fontSize": "large", "somethingNew": {"a": 1}} + """ + let decoded = try JSONDecoder().decode(PrintOptions.self, from: Data(json.utf8)) + + #expect(decoded.includesTitle == false, "the field it did understand is honoured") + #expect(decoded.pageBreaks == .flow, "an unknown page-break mode reads as the default") + #expect(decoded.commentSort == .oldestFirst, "a wrongly-typed sort reads as the default") + #expect(decoded.fontSize == PrintOptions().fontSize, "a wrongly-typed size reads as the default") + #expect(decoded.includesBody, "every absent field keeps its default") + } + + @Test("A stored size outside the legal range is clamped, never drawn") + func clampsSize() throws { + for (stored, expected) in [(0.0, PrintOptions.fontSizeRange.lowerBound), + (-4.0, PrintOptions.fontSizeRange.lowerBound), + (900.0, PrintOptions.fontSizeRange.upperBound)] { + let decoded = try JSONDecoder().decode( + PrintOptions.self, + from: Data("{\"fontSize\": \(stored)}".utf8) + ) + #expect(decoded.fontSize == expected) + } + } + + @Test("Normalizing is a reading, not a rewrite — it never breaks the round-trip") + func normalizingIsARead() throws { + var options = PrintOptions() + options.footerShowsCustomLine = true + options.footerCustomLine = " " + + // The stored value keeps the toggle: a banner emptied for one print and typed back in for the next + // must not lose it. + let decoded = try JSONDecoder().decode(PrintOptions.self, from: try JSONEncoder().encode(options)) + #expect(decoded == options) + // The render's reading drops it. + #expect(decoded.normalized.footerShowsCustomLine == false) + } + + @Test("Three components off and comments off is 'nothing to print'") + func describesAnyContent() { + var options = PrintOptions() + #expect(options.describesAnyContent) + options.includesTitle = false + options.includesLabels = false + options.includesBody = false + #expect(!options.describesAnyContent) + options.includesComments = true + #expect(options.describesAnyContent) + } +} + +// MARK: - The catalog's rules + +@Suite("Print ▸ the profile catalog") +struct PrintProfileCatalogTests { + + @Test("A save appends; a save over the same name overwrites in place") + func savingAndOverwriting() { + var catalog = PrintProfileCatalog() + catalog.save(PrintOptions(), as: "Handout") + var second = PrintOptions() + second.fontSize = 20 + catalog.save(second, as: "Archive") + + #expect(catalog.names == ["Handout", "Archive"], "list order is the order they were saved in") + + var replacement = PrintOptions() + replacement.pageBreaks = .betweenLanes + catalog.save(replacement, as: "Handout") + + #expect(catalog.names == ["Handout", "Archive"], "an overwrite does not move the row") + #expect(catalog.options(named: "Handout")?.pageBreaks == .betweenLanes) + } + + @Test("Names compare case-insensitively, and the last spelling typed wins") + func namesFoldCase() { + var catalog = PrintProfileCatalog() + catalog.save(PrintOptions(), as: "Handout") + catalog.save(PrintOptions(), as: " handout ") + + #expect(catalog.names == ["handout"], "one profile, spelled the way it was last saved") + #expect(catalog.contains("HANDOUT")) + } + + @Test("The reserved name cannot be claimed, in any spelling") + func reservedNameRefused() { + var catalog = PrintProfileCatalog() + let reserved = catalog.save(PrintOptions(), as: PrintProfile.lastUsedName) + let folded = catalog.save(PrintOptions(), as: "last used") + let blank = catalog.save(PrintOptions(), as: " ") + #expect(reserved == false) + #expect(folded == false) + #expect(blank == false, "a blank name is not a name") + #expect(catalog.names.isEmpty) + } + + @Test("A rename keeps position and options; a collision is refused") + func renaming() { + var catalog = PrintProfileCatalog() + var handout = PrintOptions() + handout.fontSize = 15 + catalog.save(handout, as: "Handout") + catalog.save(PrintOptions(), as: "Archive") + + let renamed = catalog.rename("Handout", to: "Standup") + #expect(renamed) + #expect(catalog.names == ["Standup", "Archive"], "position survives the rename") + #expect(catalog.options(named: "Standup")?.fontSize == 15) + + let collision = catalog.rename("Standup", to: "Archive") + let reserved = catalog.rename("Standup", to: PrintProfile.lastUsedName) + let missing = catalog.rename("Nothing", to: "Something") + let recased = catalog.rename("Standup", to: "STANDUP") + #expect(collision == false, "a rename never swallows a sibling") + #expect(reserved == false) + #expect(missing == false) + #expect(recased, "re-casing its own name is allowed") + #expect(catalog.names == ["STANDUP", "Archive"]) + } + + @Test("A delete of a name that matches nothing is a no-op") + func deleting() { + var catalog = PrintProfileCatalog() + catalog.save(PrintOptions(), as: "Handout") + catalog.delete("Nothing") + #expect(catalog.names == ["Handout"]) + catalog.delete("handout") + #expect(catalog.names.isEmpty) + } + + /// The bytes come out of a preferences plist a human may have edited, so the sanitizing rule is what a + /// menu can render rather than a guarantee about the writer. + @Test("Construction drops blank, reserved and duplicate entries") + func sanitizesOnConstruction() { + let catalog = PrintProfileCatalog(profiles: [ + PrintProfile(name: " Handout ", options: PrintOptions()), + PrintProfile(name: "", options: PrintOptions()), + PrintProfile(name: PrintProfile.lastUsedName, options: PrintOptions()), + PrintProfile(name: "handout", options: PrintOptions()) + ]) + #expect(catalog.names == ["Handout"], "trimmed, and the first of two spellings kept") + } + + @Test("A catalog round-trips, and one malformed entry does not cost the rest") + func catalogCodable() throws { + var catalog = PrintProfileCatalog() + catalog.save(exoticOptions(), as: "Archive") + catalog.save(PrintOptions(), as: "Handout") + + let decoded = try JSONDecoder().decode(PrintProfileCatalog.self, from: try JSONEncoder().encode(catalog)) + #expect(decoded == catalog) + + let partial = """ + {"profiles": [{"name": "Kept"}, {"options": {"fontSize": 12}}, {"name": "Also kept", "options": {}}]} + """ + let lenient = try JSONDecoder().decode(PrintProfileCatalog.self, from: Data(partial.utf8)) + #expect(lenient.names == ["Kept", "Also kept"], "the nameless entry is dropped, the rest survive") + #expect(lenient.options(named: "Kept") == PrintOptions()) + } +} + +// MARK: - The store's persistence + +@Suite("Print ▸ profile persistence") +@MainActor +struct PrintProfileStoreTests { + + @Test("A saved profile survives a fresh store over the same domain") + func savePersists() { + let (store, defaults, teardown) = makeStore() + defer { teardown() } + + #expect(store.catalog.names.isEmpty, "a first launch has no profiles") + #expect(store.save(exoticOptions(), as: "Archive")) + + let reopened = PrintProfileStore(defaults: defaults) + #expect(reopened.catalog.names == ["Archive"]) + #expect(reopened.options(named: "Archive") == exoticOptions()) + } + + @Test("Renames and deletes persist too") + func managementPersists() { + let (store, defaults, teardown) = makeStore() + defer { teardown() } + + store.save(PrintOptions(), as: "Handout") + store.save(exoticOptions(), as: "Archive") + #expect(store.rename("Handout", to: "Standup")) + store.delete("Archive") + + #expect(PrintProfileStore(defaults: defaults).catalog.names == ["Standup"]) + } + + @Test("Last Used captures the options a print ran with, and survives a relaunch") + func lastUsedCaptures() { + let (store, defaults, teardown) = makeStore() + defer { teardown() } + + #expect(store.lastUsed == nil, "nothing has been printed yet") + #expect(store.options(named: PrintProfile.lastUsedName) == PrintOptions(), + "the reserved row reads as the factory defaults on a first launch") + + store.captureLastUsed(exoticOptions()) + #expect(store.lastUsed == exoticOptions()) + + let reopened = PrintProfileStore(defaults: defaults) + #expect(reopened.lastUsed == exoticOptions()) + #expect(reopened.options(named: PrintProfile.lastUsedName) == exoticOptions()) + } + + @Test("The reserved row leads the menu, then the named ones in save order") + func menuOrder() { + let (store, _, teardown) = makeStore() + defer { teardown() } + + store.save(PrintOptions(), as: "Handout") + store.save(PrintOptions(), as: "Archive") + #expect(store.menuNames == [PrintProfile.lastUsedName, "Handout", "Archive"]) + } + + @Test("A garbage preference reads as nothing stored rather than taking the surface down") + func toleratesGarbage() throws { + let name = "dev.rzen.indie.Kanban.print-garbage.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { UserDefaults.standard.removePersistentDomain(forName: name) } + + defaults.set(42, forKey: AppPreferences.printProfilesKey) + defaults.set(Data("not json".utf8), forKey: AppPreferences.printLastUsedKey) + + let store = PrintProfileStore(defaults: defaults) + #expect(store.catalog.names.isEmpty) + #expect(store.lastUsed == nil) + } + + @Test("A refused name writes nothing at all") + func refusalWritesNothing() { + let (store, defaults, teardown) = makeStore() + defer { teardown() } + + #expect(store.save(PrintOptions(), as: PrintProfile.lastUsedName) == false) + #expect(defaults.data(forKey: AppPreferences.printProfilesKey) == nil) + } +} + +// MARK: - The document + +@Suite("Print ▸ document assembly") +struct PrintDocumentBuilderTests { + + private static let source = board([ + PrintLane(title: "Doing", cards: [ + card("Fix login", body: "Some **words**.", icon: "flag", labels: ["bug", "ui"]), + card("Ship it", body: "More words.") + ]), + PrintLane(title: "Done", cards: [ + card("Old thing", body: "Done words.") + ]) + ]) + + @Test("The default document is board, lane, card, meta, body — in that order") + func defaultOrder() { + let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions()) + + #expect(blocks == [ + .boardHeading("Roadmap"), + .laneHeading("Doing"), + .cardTitle("Fix login"), + .cardMeta(icon: "flag", labels: ["bug", "ui"]), + .cardBody("Some **words**."), + .cardTitle("Ship it"), + .cardBody("More words."), + .laneHeading("Done"), + .cardTitle("Old thing"), + .cardBody("Done words.") + ]) + } + + @Test("A card with no chosen icon and no labels contributes no meta line") + func metaOmittedWhenEmpty() { + let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions()) + let metas = blocks.filter { if case .cardMeta = $0 { return true } else { return false } } + #expect(metas.count == 1, "only the card that has something to say gets the line") + } + + @Test("Each component toggle removes exactly its own block") + func componentToggles() { + var options = PrintOptions() + options.includesTitle = false + var blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options) + #expect(!blocks.contains { if case .cardTitle = $0 { return true } else { return false } }) + #expect(blocks.contains { if case .cardBody = $0 { return true } else { return false } }) + + options = PrintOptions() + options.includesLabels = false + blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options) + #expect(!blocks.contains { if case .cardMeta = $0 { return true } else { return false } }) + + options = PrintOptions() + options.includesBody = false + blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options) + #expect(!blocks.contains { if case .cardBody = $0 { return true } else { return false } }) + #expect(blocks.contains { if case .cardTitle = $0 { return true } else { return false } }) + } + + @Test("Every component off prints nothing — not a page of running heads") + func nothingIncludedIsAnEmptyDocument() { + var options = PrintOptions() + options.includesTitle = false + options.includesLabels = false + options.includesBody = false + #expect(PrintDocumentBuilder.blocks(from: Self.source, options: options).isEmpty) + } + + @Test("An untitled card and an untitled lane print the placeholder, never a blank line") + func untitledPlaceholder() { + let source = board([PrintLane(title: nil, cards: [card(nil, body: "Words.")])]) + let blocks = PrintDocumentBuilder.blocks(from: source, options: PrintOptions()) + #expect(blocks == [ + .boardHeading("Roadmap"), + .laneHeading(PrintDocumentBuilder.untitled), + .cardTitle(PrintDocumentBuilder.untitled), + .cardBody("Words.") + ]) + } + + /// The whitespace case is the card window's own emptiness rule (`BodyMarkup.isEmpty`), reused so a body + /// of one newline does not print a blank paragraph. + @Test("A whitespace-only body is no body") + func whitespaceBodyOmitted() { + let source = board([PrintLane(title: "Doing", cards: [card("Titled", body: "\n \n")])]) + let blocks = PrintDocumentBuilder.blocks(from: source, options: PrintOptions()) + #expect(blocks == [.boardHeading("Roadmap"), .laneHeading("Doing"), .cardTitle("Titled")]) + } + + @Test("An empty lane is omitted, and so is a card with nothing to show") + func emptiesDropOut() { + let source = board([ + PrintLane(title: "Empty", cards: []), + PrintLane(title: "All blank", cards: [card(nil), card(nil)]), + PrintLane(title: "Real", cards: [card("Kept", body: "Words.")]) + ]) + var options = PrintOptions() + // With titles off, the two blank cards have nothing left at all — which must take their lane with + // them rather than leaving a heading over nothing. + options.includesTitle = false + + let blocks = PrintDocumentBuilder.blocks(from: source, options: options) + #expect(blocks == [.boardHeading("Roadmap"), .laneHeading("Real"), .cardBody("Words.")]) + } + + @Test("A card print is the card — no board heading, no lane heading") + func cardScope() { + let source = PrintSource( + scope: .card, + boardTitle: "Roadmap", + lanes: [PrintLane(title: "Doing", cards: [card("Fix login", body: "Words.")])] + ) + var options = PrintOptions() + options.pageBreaks = .betweenCards + + #expect(PrintDocumentBuilder.blocks(from: source, options: options) == [ + .cardTitle("Fix login"), + .cardBody("Words.") + ]) + } +} + +// MARK: - Page breaks + +@Suite("Print ▸ page breaks") +struct PrintPageBreakTests { + + private static let source = board([ + PrintLane(title: "Doing", cards: [card("A", body: "a"), card("B", body: "b")]), + PrintLane(title: "Done", cards: [card("C", body: "c")]) + ]) + + @Test("Continuous emits no break at all") + func flow() { + let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions()) + #expect(!blocks.contains(.pageBreak)) + } + + @Test("Between lanes breaks before each lane after the first, and nowhere else") + func betweenLanes() { + var options = PrintOptions() + options.pageBreaks = .betweenLanes + + #expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [ + .boardHeading("Roadmap"), + .laneHeading("Doing"), + .cardTitle("A"), .cardBody("a"), + .cardTitle("B"), .cardBody("b"), + .pageBreak, + .laneHeading("Done"), + .cardTitle("C"), .cardBody("c") + ]) + } + + @Test("Between cards breaks before every card but the document's first") + func betweenCards() { + var options = PrintOptions() + options.pageBreaks = .betweenCards + + #expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [ + .boardHeading("Roadmap"), + .laneHeading("Doing"), + .cardTitle("A"), .cardBody("a"), + .pageBreak, + .cardTitle("B"), .cardBody("b"), + .pageBreak, + .laneHeading("Done"), + .cardTitle("C"), .cardBody("c") + ]) + } + + /// The board's name is the first lane's running-in title, not a title page — the one thing that would + /// otherwise put a lone heading on sheet one of every print with breaks on. + @Test("The board heading never earns a break of its own") + func boardHeadingIsNotATitlePage() { + for mode in [PrintPageBreaks.betweenLanes, .betweenCards] { + var options = PrintOptions() + options.pageBreaks = mode + let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options) + #expect(blocks.first == .boardHeading("Roadmap")) + #expect(blocks.dropFirst().first == .laneHeading("Doing"), "no break between the two") + } + } + + @Test("A break is never leading, never trailing, and never doubled") + func breaksAreWellFormed() { + let source = board([ + PrintLane(title: "Empty", cards: []), + PrintLane(title: "One", cards: [card("A", body: "a")]), + PrintLane(title: "Blank", cards: [card(nil)]), + PrintLane(title: "Two", cards: [card("B", body: "b")]) + ]) + var options = PrintOptions() + options.pageBreaks = .betweenCards + options.includesTitle = false + + let blocks = PrintDocumentBuilder.blocks(from: source, options: options) + #expect(blocks.first != .pageBreak) + #expect(blocks.last != .pageBreak) + for (left, right) in zip(blocks, blocks.dropFirst()) { + #expect(!(left == .pageBreak && right == .pageBreak)) + } + #expect(blocks.filter { $0 == .pageBreak }.count == 1, "the two dropped lanes take their breaks too") + } + + @Test("A single-card board never breaks, whatever the mode") + func oneCardNeverBreaks() { + let source = board([PrintLane(title: "Doing", cards: [card("A", body: "a")])]) + for mode in PrintPageBreaks.allCases { + var options = PrintOptions() + options.pageBreaks = mode + #expect(!PrintDocumentBuilder.blocks(from: source, options: options).contains(.pageBreak)) + } + } +} + +// MARK: - Comments + +@Suite("Print ▸ comments") +struct PrintCommentTests { + + private static let thread = [ + PrintComment(author: "ada", created: stamp(0), body: "first"), + PrintComment(author: "grace", created: stamp(10), body: "second"), + PrintComment(author: nil, created: stamp(20), body: "third") + ] + + private static let source = board([ + PrintLane(title: "Doing", cards: [card("A", body: "a", comments: Self.thread)]) + ]) + + @Test("Comments are off by default and print nothing") + func offByDefault() { + let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions()) + #expect(!blocks.contains { if case .comment = $0 { return true } else { return false } }) + #expect(!blocks.contains { if case .commentsHeading = $0 { return true } else { return false } }) + } + + @Test("Oldest first walks the thread's own chronology, heading first") + func oldestFirst() { + var options = PrintOptions() + options.includesComments = true + + #expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [ + .boardHeading("Roadmap"), + .laneHeading("Doing"), + .cardTitle("A"), + .cardBody("a"), + .commentsHeading(count: 3), + .comment(author: "ada", created: stamp(0), body: "first"), + .comment(author: "grace", created: stamp(10), body: "second"), + .comment(author: nil, created: stamp(20), body: "third") + ]) + } + + @Test("Newest first reverses that order and nothing else") + func newestFirst() { + var options = PrintOptions() + options.includesComments = true + options.commentSort = .newestFirst + + let bodies = PrintDocumentBuilder.blocks(from: Self.source, options: options).compactMap { block -> String? in + guard case let .comment(_, _, body) = block else { return nil } + return body + } + #expect(bodies == ["third", "second", "first"]) + } + + @Test("A card with no comments gets no heading, even with comments on") + func emptyThread() { + var options = PrintOptions() + options.includesComments = true + let source = board([PrintLane(title: "Doing", cards: [card("A", body: "a")])]) + #expect(!PrintDocumentBuilder.blocks(from: source, options: options) + .contains { if case .commentsHeading = $0 { return true } else { return false } }) + } + + /// A thread is the only content a card may have — with the title, the labels and the body all off, the + /// comments still print. + @Test("Comments alone are enough to keep a card in the document") + func commentsAloneKeepACard() { + var options = PrintOptions() + options.includesTitle = false + options.includesLabels = false + options.includesBody = false + options.includesComments = true + + #expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [ + .boardHeading("Roadmap"), + .laneHeading("Doing"), + .commentsHeading(count: 3), + .comment(author: "ada", created: stamp(0), body: "first"), + .comment(author: "grace", created: stamp(10), body: "second"), + .comment(author: nil, created: stamp(20), body: "third") + ]) + } + + @Test("A thread flattens in the loader's order — printing never re-sorts") + func flatteningKeepsThreadOrder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") + let lane = "11111111-1111-4111-8111-111111111111" + let cardID = "22222222-2222-4222-8222-222222222222" + try fixture.item(lane, "---\nschema: 1\ntitle: Doing\norder: 1024\n---\n") + try fixture.item("\(lane)/\(cardID)", "---\nschema: 1\ntitle: A\norder: 1024\n---\nBody.\n") + // Deliberately written out of chronological order, so an order that came out right could only have + // come from the sort. + try fixture.item( + "\(lane)/\(cardID)/comments/33333333-3333-4333-8333-333333333333", + "---\nschema: 1\nkind: comment\nauthor: grace\ncreated: 2026-02-02T09:00:00Z\n---\nsecond\n" + ) + try fixture.item( + "\(lane)/\(cardID)/comments/44444444-4444-4444-8444-444444444444", + "---\nschema: 1\nkind: comment\nauthor: ada\ncreated: 2026-01-01T09:00:00Z\n---\nfirst\n" + ) + + let cardFolder = fixture.url("\(lane)/\(cardID)") + let comments = PrintComment.list(of: CommentThread.load(inCard: cardFolder, path: "\(lane)/\(cardID)")) + #expect(comments.map(\.body) == ["first\n", "second\n"]) + #expect(comments.map(\.author) == ["ada", "grace"]) + } +} + +// MARK: - Extraction from a real board + +@Suite("Print ▸ what a board contributes") +@MainActor +struct PrintSourceTests { + + /// A board with two lanes, a trashed card and a trashed lane — the one arrangement whose failure would + /// print deleted cards. + private func fixture() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", "---\nschema: 1\ntitle: Roadmap\n---\n") + try fixture.item("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "---\nschema: 1\ntitle: Doing\norder: 1024\n---\n") + try fixture.item("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "---\nschema: 1\ntitle: Done\norder: 2048\n---\n") + try fixture.item( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "---\nschema: 1\ntitle: Second\norder: 2048\nicon: flag\nlabels: [bug, ui]\n---\nSecond body.\n" + ) + try fixture.item( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "---\nschema: 1\ntitle: First\norder: 1024\n---\nFirst body.\n" + ) + try fixture.item( + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "---\nschema: 1\ntitle: Shipped\norder: 1024\n---\nShipped body.\n" + ) + // The trash: one card and one lane, both of which a board print must not reach. + try fixture.item( + ".trash/ffffffff-ffff-4fff-8fff-ffffffffffff", + "---\nschema: 1\nkind: card\ntitle: Deleted card\nmodified: 2026-03-03T09:00:00Z\n---\nGone.\n" + ) + try fixture.item( + ".trash/99999999-9999-4999-8999-999999999999", + "---\nschema: 1\nkind: lane\ntitle: Deleted lane\nmodified: 2026-03-04T09:00:00Z\n---\n" + ) + return fixture + } + + @Test("Lanes and cards arrive in display order, and the trash is not reachable") + func laneAndCardOrder() throws { + let fixture = try self.fixture() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + // The fixture really does have a trash — otherwise the exclusion below proves nothing. + #expect(snapshot.trash.count == 1) + #expect(snapshot.trashedLanes.count == 1) + + let source = PrintSource.board(snapshot, titled: "Roadmap") + #expect(source.scope == .board) + #expect(source.lanes.map(\.title) == ["Doing", "Done"]) + #expect(source.lanes[0].cards.map(\.title) == ["First", "Second"], "by rank, not by folder name") + #expect(source.lanes[1].cards.map(\.title) == ["Shipped"]) + + let printed = PrintDocumentBuilder.blocks(from: source, options: PrintOptions()) + #expect(!printed.contains(.cardTitle("Deleted card"))) + #expect(!printed.contains(.laneHeading("Deleted lane"))) + } + + @Test("A card's icon, labels and body cross over; an unknown icon does not") + func cardFields() throws { + let fixture = try self.fixture() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let source = PrintSource.board(snapshot, titled: "Roadmap") + + let second = try #require(source.lanes.first?.cards.last) + #expect(second.title == "Second") + #expect(second.icon == "flag") + #expect(second.labels == ["bug", "ui"]) + #expect(second.body == "Second body.\n") + + let first = try #require(source.lanes.first?.cards.first) + #expect(first.icon == nil, "a card with no chosen icon prints none — the level default stays on screen") + #expect(first.labels.isEmpty) + } + + @Test("A card print carries its lane and its board without printing headings for them") + func cardScopeExtraction() throws { + let fixture = try self.fixture() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let lane = try #require(snapshot.lanes.first) + let card = try #require(lane.cards.first) + + let source = PrintSource.card(card, laneTitle: lane.title.value, boardTitle: "Roadmap", comments: []) + #expect(source.scope == .card) + #expect(source.boardTitle == "Roadmap") + #expect(source.lanes.map(\.title) == ["Doing"]) + #expect(PrintDocumentBuilder.blocks(from: source, options: PrintOptions()) == [ + .cardTitle("First"), + .cardBody("First body.\n") + ]) + } +} + +// MARK: - The labels reading + +@Suite("Print ▸ the reserved labels key") +struct PrintLabelsTests { + + private func labels(_ frontmatter: String) throws -> [String] { + PrintCard.labels(of: try FrontmatterDocument.parse("---\nschema: 1\n\(frontmatter)---\nBody.\n")) + } + + @Test("A sequence is its scalar members, in order") + func sequence() throws { + #expect(try labels("labels: [bug, ui, p1]\n") == ["bug", "ui", "p1"]) + #expect(try labels("labels:\n - bug\n - ui\n") == ["bug", "ui"]) + } + + @Test("A comma-separated scalar is the pair a human meant to type") + func commaSeparatedScalar() throws { + #expect(try labels("labels: bug, ui\n") == ["bug", "ui"]) + #expect(try labels("labels: bug\n") == ["bug"]) + } + + @Test("Non-string scalars read as the engine renders them") + func nonStringScalars() throws { + #expect(try labels("labels: [1, 2]\n") == ["1", "2"]) + #expect(try labels("labels: [true]\n") == ["true"]) + } + + @Test("Shapes that are not a label row contribute nothing, and never an error") + func exoticShapes() throws { + #expect(try labels("labels:\n") == [], "an explicit null") + #expect(try labels("labels: ''\n") == []) + #expect(try labels("labels: {a: 1}\n") == [], "a mapping is not a label row") + #expect(try labels("labels: [[a, b], c]\n") == ["c"], "a nested list is skipped, not flattened") + #expect(try labels("labels: [bug, '', ui]\n") == ["bug", "ui"], "blank members drop out") + #expect(try labels("title: No labels here\n") == [], "an absent key") + } +} + +// MARK: - Header and footer + +@Suite("Print ▸ the running head and foot") +struct PrintRunningHeadTests { + + @Test("Each toggle contributes exactly its own end") + func toggles() { + var options = PrintOptions() + var header = PrintRunningHead.header(options: options, boardTitle: "Roadmap", dateText: "9 Aug 2026") + #expect(header == PrintRunningHead.Line(leading: "Roadmap", trailing: "9 Aug 2026")) + + options.headerShowsBoardTitle = false + header = PrintRunningHead.header(options: options, boardTitle: "Roadmap", dateText: "9 Aug 2026") + #expect(header == PrintRunningHead.Line(leading: "", trailing: "9 Aug 2026")) + + options.headerShowsPrintDate = false + header = PrintRunningHead.header(options: options, boardTitle: "Roadmap", dateText: "9 Aug 2026") + #expect(header.isEmpty, "an empty line takes no paper at all") + } + + @Test("The folio trails, the custom line leads") + func footer() { + var options = PrintOptions() + options.footerShowsCustomLine = true + options.footerCustomLine = "Confidential" + + #expect(PrintRunningHead.footer(options: options, pageText: "Page 2 of 7") + == PrintRunningHead.Line(leading: "Confidential", trailing: "Page 2 of 7")) + + options.footerShowsPageNumbers = false + #expect(PrintRunningHead.footer(options: options, pageText: "Page 2 of 7") + == PrintRunningHead.Line(leading: "Confidential", trailing: "")) + } + + @Test("A toggle left on over an emptied field prints nothing rather than an indent of air") + func blankCustomLine() { + var options = PrintOptions() + options.footerShowsCustomLine = true + options.footerCustomLine = " " + options.footerShowsPageNumbers = false + #expect(PrintRunningHead.footer(options: options, pageText: "Page 1 of 1").isEmpty) + } + + @Test("The folio's wording") + func pageText() { + #expect(PrintRunningHead.pageText(page: 3, of: 7) == "Page 3 of 7") + } +} + +// MARK: - The menu row + +@Suite("Print ▸ menu validation") +struct PrintCommandValidationTests { + + /// Two disjuncts and nothing else — a print is a read, so neither the read-only lock nor the + /// focused-editor rule closes the row (`PrintCommand`). + @Test("Scope alone enables the row, and a card window with no card does not") + func validation() { + #expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: false)) + #expect(PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: true)) + #expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: true)) + #expect(!PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: false), "welcome, or nothing at all") + } +} + +// MARK: - Rendering and pagination, smoke-tested + +@Suite("Print ▸ rendering and pagination") +@MainActor +struct PrintRenderingTests { + + private func session(_ source: PrintSource, options: PrintOptions) -> PrintSession { + let (store, _, _) = makeStore() + store.captureLastUsed(options) + return PrintSession( + provider: PrintSourceProvider(complete: source), + profiles: store, + cardFolder: nil, + jobTitle: source.boardTitle, + boardTitle: source.boardTitle + ) + } + + private static func longBody(paragraphs: Int) -> String { + (0 ..< paragraphs) + .map { "Paragraph \($0). " + String(repeating: "Words that fill a line of a printed page. ", count: 6) } + .joined(separator: "\n\n") + } + + @Test("A page break really becomes a separately paginated section") + func breaksSplitSections() { + var options = PrintOptions() + options.pageBreaks = .betweenLanes + let source = board([ + PrintLane(title: "One", cards: [card("A", body: "a")]), + PrintLane(title: "Two", cards: [card("B", body: "b")]), + PrintLane(title: "Three", cards: [card("C", body: "c")]) + ]) + let blocks = PrintDocumentBuilder.blocks(from: source, options: options) + #expect(PrintDocumentRenderer.sections(for: blocks, options: options).count == 3) + + // …and continuously, one section. + var flowing = options + flowing.pageBreaks = .flow + let flowingBlocks = PrintDocumentBuilder.blocks(from: source, options: flowing) + #expect(PrintDocumentRenderer.sections(for: flowingBlocks, options: flowing).count == 1) + } + + @Test("An empty document renders no sections") + func emptyDocument() { + #expect(PrintDocumentRenderer.sections(for: [], options: PrintOptions()).isEmpty) + } + + @Test("Between-lanes really costs a sheet per lane") + func pageCountPerLane() { + var options = PrintOptions() + options.pageBreaks = .betweenLanes + let source = board((1 ... 4).map { PrintLane(title: "Lane \($0)", cards: [card("Card \($0)", body: "words")]) }) + + let view = PrintDocumentView(session: session(source, options: options), printInfo: NSPrintInfo()) + #expect(view.pageCount() == 4, "four short lanes, four sheets — the break is pagination, not spacing") + } + + @Test("A long body paginates rather than clipping") + func longBodyPaginates() { + let options = PrintOptions() + let short = board([PrintLane(title: "One", cards: [card("A", body: "one line")])]) + let long = board([PrintLane(title: "One", cards: [card("A", body: Self.longBody(paragraphs: 80))])]) + + #expect(PrintDocumentView(session: session(short, options: options), printInfo: NSPrintInfo()).pageCount() == 1) + let pages = PrintDocumentView(session: session(long, options: options), printInfo: NSPrintInfo()).pageCount() + #expect(pages > 1, "eighty paragraphs do not fit on one sheet") + } + + @Test("A byline reads as a sentence in all four states") + func bylines() { + #expect(PrintDocumentRenderer.byline(author: "ada", created: nil) == "ada") + #expect(PrintDocumentRenderer.byline(author: nil, created: nil) == "Comment") + #expect(PrintDocumentRenderer.byline(author: " ", created: nil) == "Comment", "a blank author is no author") + let dated = PrintDocumentRenderer.byline(author: "ada", created: stamp(0)) + #expect(dated.hasPrefix("ada — ")) + #expect(PrintDocumentRenderer.byline(author: nil, created: stamp(0)) == String(dated.dropFirst("ada — ".count))) + } + + @Test("The thread's heading counts, and says 'comment' once") + func commentsHeading() { + #expect(PrintDocumentRenderer.commentsHeadingText(count: 1) == "1 comment") + #expect(PrintDocumentRenderer.commentsHeadingText(count: 3) == "3 comments") + #expect(PrintDocumentRenderer.commentsHeadingText(count: 0) == "0 comments") + } + + @Test("The chosen face reaches the text, and code keeps its own") + func faceRemap() { + // Courier is on every Mac; asserting a family that might not be installed would be asserting a + // fixture about the machine. + let family = "Times New Roman" + guard PrintTypography.families().contains(family) else { return } + + var options = PrintOptions() + options.fontFamily = family + let blocks: [PrintBlock] = [.cardBody("Words, and `code`.")] + let section = try? #require(PrintDocumentRenderer.sections(for: blocks, options: options).first) + guard let section else { return } + + var sawFace = false + var sawMono = false + section.enumerateAttribute(.font, in: NSRange(location: 0, length: section.length)) { value, _, _ in + guard let font = value as? NSFont else { return } + if font.fontDescriptor.symbolicTraits.contains(.monoSpace) { + sawMono = true + } else if font.familyName == family { + sawFace = true + } + } + #expect(sawFace, "the body is set in the chosen family") + #expect(sawMono, "inline code stays monospaced — a fenced block in Palatino is nobody's intent") + } + + @Test("A summary line describes what the panel is about to print") + func summary() { + var options = PrintOptions() + #expect(PrintOptionsSummary.includes(options) == "Title, labels, body") + #expect(PrintOptionsSummary.pageBreaks(options) == "Continuous") + #expect(PrintOptionsSummary.type(options) == "System 11 pt") + + options.includesComments = true + options.commentSort = .newestFirst + options.fontFamily = "Palatino" + options.fontSize = 13.5 + options.pageBreaks = .betweenCards + #expect(PrintOptionsSummary.includes(options) == "Title, labels, body, comments (newest first)") + #expect(PrintOptionsSummary.pageBreaks(options) == "Between cards") + #expect(PrintOptionsSummary.type(options) == "Palatino 13.5 pt") + + options.includesTitle = false + options.includesLabels = false + options.includesBody = false + options.includesComments = false + #expect(PrintOptionsSummary.includes(options) == "Nothing") + } +} + +// MARK: - The session + +@Suite("Print ▸ the panel's session") +@MainActor +struct PrintSessionTests { + + private func session(profiles: PrintProfileStore) -> PrintSession { + PrintSession( + provider: PrintSourceProvider(complete: board([PrintLane(title: "One", cards: [card("A", body: "a")])])), + profiles: profiles, + cardFolder: nil, + jobTitle: "Roadmap", + boardTitle: "Roadmap" + ) + } + + @Test("A session opens on Last Used, which is the defaults on a first print") + func opensOnLastUsed() { + let (store, _, teardown) = makeStore() + defer { teardown() } + + #expect(session(profiles: store).options == PrintOptions()) + + store.captureLastUsed(exoticOptions()) + let second = session(profiles: store) + #expect(second.selectedProfileName == PrintProfile.lastUsedName) + #expect(second.options == exoticOptions()) + } + + @Test("Choosing a profile copies its options in; editing them afterwards does not write back") + func selectingIsACopy() { + let (store, _, teardown) = makeStore() + defer { teardown() } + store.save(exoticOptions(), as: "Archive") + + let session = self.session(profiles: store) + session.selectProfile(named: "Archive") + #expect(session.options == exoticOptions()) + #expect(!session.isModified) + + session.options.fontSize = 9 + #expect(session.isModified, "the popup says modified") + #expect(store.options(named: "Archive")?.fontSize == exoticOptions().fontSize, "the profile is untouched") + + #expect(session.saveProfile(named: "Archive")) + #expect(store.options(named: "Archive")?.fontSize == 9) + #expect(!session.isModified) + } + + @Test("The reserved row is never 'modified' — drifting from it is what it is for") + func reservedRowIsNeverModified() { + let (store, _, teardown) = makeStore() + defer { teardown() } + + let session = self.session(profiles: store) + session.options.fontSize = 30 + #expect(!session.isModified) + #expect(!session.canManageSelection, "the reserved row cannot be renamed or deleted") + } + + @Test("Deleting a profile keeps the settings in front of the user") + func deleteKeepsOptions() { + let (store, _, teardown) = makeStore() + defer { teardown() } + store.save(exoticOptions(), as: "Archive") + + let session = self.session(profiles: store) + session.selectProfile(named: "Archive") + session.deleteSelectedProfile() + + #expect(store.catalog.names.isEmpty) + #expect(session.selectedProfileName == PrintProfile.lastUsedName) + #expect(session.options == exoticOptions(), "a management gesture is not a reset") + } + + @Test("Selecting a name that resolves to nothing changes nothing") + func selectingAGhost() { + let (store, _, teardown) = makeStore() + defer { teardown() } + + let session = self.session(profiles: store) + session.options.fontSize = 20 + session.selectProfile(named: "Deleted elsewhere") + #expect(session.selectedProfileName == PrintProfile.lastUsedName) + #expect(session.options.fontSize == 20) + } + + @Test("The comment-bearing source is read only when the options ask for it") + func commentsAreReadLazily() { + let (store, _, teardown) = makeStore() + defer { teardown() } + + var reads = 0 + let withComments = board([PrintLane(title: "One", cards: [ + card("A", body: "a", comments: [PrintComment(author: "ada", created: stamp(0), body: "hi")]) + ])]) + let provider = PrintSourceProvider( + withoutComments: board([PrintLane(title: "One", cards: [card("A", body: "a")])]), + withComments: { + reads += 1 + return withComments + } + ) + let session = PrintSession( + provider: provider, + profiles: store, + cardFolder: nil, + jobTitle: "Roadmap", + boardTitle: "Roadmap" + ) + + _ = session.blocks() + _ = session.blocks() + #expect(reads == 0, "comments are off by default, so no thread is read") + + session.options.includesComments = true + _ = session.blocks() + _ = session.blocks() + #expect(reads == 1, "and once the user asks, exactly one read serves every relayout") + } +}