⌘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
260 lines
15 KiB
Swift
260 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|