⌘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
257 lines
12 KiB
Swift
257 lines
12 KiB
Swift
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)])]
|
|
)
|
|
}
|
|
}
|