Print boards and cards with configurable components and named print profiles
⌘P had no story: KanbanApp removed the platform's Print row outright on 11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line retires. File ▸ Print… now prints the board in front — lanes left to right, each lane's cards top to bottom, as a linear document rather than a picture of the strip — or, from a card window, that card. The trash is unreachable by construction: it is a sibling container of `lanes`, not a lane. The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is one Codable value carrying the printing card's five bullets — which components (title, icon+labels line, rendered body, comments off by default with either reading order), page breaks, one base face and size every other size derives from, and a toggleable running head and foot. `PrintSource` is what is being printed, frozen at ⌘P so the panel's repeated relayouts and a board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the pair into a block list, which is where every decision a rendered page hides becomes something a test can hold: component order, comment ordering, and page-break markers that are markers rather than whitespace. Empty is empty all the way up — a card with nothing to print consumes no page break, and a lane whose cards all dropped out takes its heading with it. A page break is a pagination fact, not a spacing one. TextKit has no page-break character, so `PrintDocumentView` splits the document into sections at its breaks and flows each into as many page-sized text containers as it needs: a container boundary *is* a sheet boundary, at any paper size with any margins. Bodies come from the app's one Markdown pass — `BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the chosen family reaches the text and fixed-pitch code keeps its own, and drawn under a forced light appearance so the card window's dynamic label colours do not print white. Options ride in the print panel's own accessory rather than a pre-flight sheet of ours, which buys the system's live preview of the real paginated document; the preview refreshes through one KVO revision counter rather than thirteen mirrored properties. Profiles persist app-side in UserDefaults, never in board files — a print profile is how this user likes to read, not what a board is (`BoardZoomStore`'s argument). A name is a profile's identity, folded case-insensitively; "Last Used" is reserved in every spelling, kept out of the stored list, and captured when an operation actually ran, so a cancelled print rewrites nothing. Both decoders are total: one unrecognized key must not cost a user every profile they saved. DESIGN/11-command-nexus.md gains the Print row and loses the sentence saying it would never have one. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -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 | 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 | 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 | 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 |
|
| 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 | 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 |
|
| 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
|
## 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
|
## Changes from Kanban
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,23 @@ public enum AppPreferences {
|
|||||||
/// stale-from-a-future-build value indistinguishable from a legal one downstream.
|
/// stale-from-a-future-build value indistinguishable from a legal one downstream.
|
||||||
public static let boardZoomLevelKey = "boardZoomLevel"
|
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
|
// MARK: The appearance override
|
||||||
|
|
||||||
/// **View ▸ Appearance** (11-command-nexus.md) — Auto / Light / Dark, app-wide and persisted
|
/// **View ▸ Appearance** (11-command-nexus.md) — Auto / Light / Dark, app-wide and persisted
|
||||||
@@ -289,6 +306,12 @@ public final class AppModel {
|
|||||||
/// one question.
|
/// one question.
|
||||||
public let appearance: AppearanceStore
|
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).
|
/// 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
|
/// 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)
|
styleRecents = StyleRecents(defaults: preferences)
|
||||||
zoom = BoardZoomStore(defaults: preferences)
|
zoom = BoardZoomStore(defaults: preferences)
|
||||||
appearance = AppearanceStore(defaults: preferences)
|
appearance = AppearanceStore(defaults: preferences)
|
||||||
|
printProfiles = PrintProfileStore(defaults: preferences)
|
||||||
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
||||||
// Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first
|
// 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
|
// menu pass — a launch that restores boards never shows welcome, and a submenu that filled
|
||||||
|
|||||||
@@ -285,6 +285,10 @@ struct CardWindowHost: View {
|
|||||||
/// reason: two card windows on one board have two different selections, and the menu bar reaches
|
/// reason: two card windows on one board have two different selections, and the menu bar reaches
|
||||||
/// the frontmost one through the focus system.
|
/// the frontmost one through the focus system.
|
||||||
@State private var attachments = CardAttachments()
|
@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
|
/// 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
|
/// snapshot the store applies — a cache that died with the view would regenerate every thumbnail
|
||||||
/// on every reload (`AttachmentThumbnailCache`).
|
/// 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
|
// know a card window is in front at all (11-command-nexus.md scopes all three to the card
|
||||||
// window).
|
// window).
|
||||||
.focusedSceneValue(\.cardComments, session.comments)
|
.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
|
// 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
|
// 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
|
// 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.
|
// would open nothing.
|
||||||
attachments.cardFolder = folder
|
attachments.cardFolder = folder
|
||||||
session.comments.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
|
// 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⟩'").
|
// renamed mid-session is announced under its new name ("New comment on '⟨card⟩'").
|
||||||
|
|||||||
+15
-5
@@ -306,11 +306,21 @@ struct KanbanApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "No Print story in v1 (⌘P unused)" (11-command-nexus.md ▸ Standard macOS furniture) — the
|
// File ▸ Print… (⌘P) — **the app's own row**, replacing the system's nil-target one
|
||||||
// system's default Print item is removed outright rather than left dead, since a menu item
|
// (11-command-nexus.md's Print row; the "No Print story in v1 (⌘P unused)" line it retired).
|
||||||
// with nothing behind it is exactly what the Nexus's "a command absent here doesn't exist"
|
//
|
||||||
// rules out in the other direction too.
|
// `replacing: .printItem` rather than an addition, for the same reason Undo/Redo replace the
|
||||||
CommandGroup(replacing: .printItem) {}
|
// 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 ▸
|
// 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
|
// Standard macOS furniture) — this app's whole Help menu, since there is no other content to
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Double> = 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<T: Decodable>(_ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: Encodable>(_ 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<Value: Decodable>(_ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)])]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
// `#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<String> {
|
||||||
|
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<String> {
|
||||||
|
Binding(
|
||||||
|
get: { session.options.fontFamily ?? Self.systemFace },
|
||||||
|
set: { session.options.fontFamily = $0 == Self.systemFace ? nil : $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sizeSelection: Binding<Double> {
|
||||||
|
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<Value>(_ keyPath: WritableKeyPath<PrintOptions, Value>) -> Binding<Value> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user