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:
2026-08-08 22:42:11 -04:00
parent cdc91d669d
commit 7651e40318
17 changed files with 3997 additions and 6 deletions
+2 -1
View File
@@ -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
+24
View File
@@ -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
+22
View File
@@ -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
View File
@@ -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
+196
View File
@@ -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
}
}
+259
View File
@@ -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)
}
}
+200
View File
@@ -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 ?? [])
}
}
+161
View File
@@ -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
}
}
+72
View File
@@ -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)"
}
}
+256
View File
@@ -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)])]
)
}
}
+312
View File
@@ -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
}
}
+277
View File
@@ -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"
}
}
}
+329
View File
@@ -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)
}
}
+395
View File
@@ -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
}
}
+198
View File
@@ -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)
}
}
+153
View File
@@ -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