import Foundation // MARK: - The three enumerated choices /// Where a printed board starts a new page — "whether to insert page breaking between cards or /// between lanes", the card's own second bullet, with the third answer the one the card leaves /// implicit: *nowhere*. /// /// **Every case is a promise about paper, not about spacing.** `.betweenLanes` really starts a new /// sheet at each lane, and `.betweenCards` really starts one at each card — a "break" that only /// added vertical air would be the option lying about the one thing it exists to control /// (`PrintDocumentView` is where the promise is kept: a break splits the document into separately /// paginated sections rather than inserting whitespace into one). /// /// `.flow` is the default because it is the cheapest print: a ten-lane board under `.betweenLanes` /// is ten sheets minimum, which is the right answer only when the user asked for it. public enum PrintPageBreaks: String, Codable, Sendable, CaseIterable { /// One continuous document; page boundaries fall wherever the text runs out of sheet. case flow /// A fresh sheet at each lane. Cards inside a lane still flow. case betweenLanes /// A fresh sheet at each card — which implies one at each lane too, since a lane begins with a /// card. The finest grain the option offers, and the most paper. case betweenCards } /// Which end of a thread a printed card's comments start from — "whether to include comments and /// how to sort them" (the card's third bullet). /// /// The two names are the *reading order* rather than a sort direction, deliberately: the thread's /// own order is chronology (`CommentThread.sorted` — `created` ascending, undated last), so this /// chooses whether that order is walked forwards or backwards and never re-sorts by anything else. /// It mirrors the comments pane's own header control, whose persisted bit is spelled the same way /// (`AppPreferences.commentsNewestFirstKey`) — but it is a *separate* value: how this user likes to /// read a thread on screen and how they want it laid out on paper are two preferences, and binding /// them would make a print profile silently rewrite a window. public enum PrintCommentSort: String, Codable, Sendable, CaseIterable { case oldestFirst case newestFirst } // MARK: - PrintOptions /// **Everything a print asks about a document, as one value type** — the card's five bullets /// (components, page breaks, comments, styling, header/footer) with nothing about *paper* in it: /// sheet size, orientation, margins, copies and the printer itself are `NSPrintInfo`'s and stay /// there, because they are the system's questions and the print panel already asks them better than /// we could. /// /// ### Why a plain `Codable` struct and not an `@Observable` bag /// /// This is what a **print profile** is (`PrintProfile`), and a profile has to round-trip through /// `UserDefaults` byte-for-byte — save, quit, relaunch, restore. A reference type would also make /// "the options this print is using" and "the options that profile holds" the same object, which is /// exactly wrong: choosing a profile *copies* its values into the live sheet, and editing the sheet /// afterwards must not rewrite the profile behind the user's back. Value semantics are that rule, /// for free. /// /// ### The decoder is total, on purpose /// /// Every field decodes through `decodeIfPresent` onto its default, and out-of-range numbers are /// clamped rather than rejected. A stored profile is a file in a preferences plist that a future /// build may have written, an older build may be reading, and a human may have hand-edited — the /// storage layer's own leniency doctrine (01-storage-format.md § Frontmatter: lenient fields /// degrade, they never refuse) applied to app-side state. The failure mode this rules out is the /// one that matters: a single unknown key must not cost the user every profile they saved. public struct PrintOptions: Codable, Sendable, Equatable { // MARK: Components — "which constituent components/datapoints to include" /// The card's title line. On by default: a printed card with no title is a page of prose with no /// idea what it is about. public var includesTitle = true /// The card's **icon and labels line** — its `icon` symbol followed by whatever the reserved /// `labels` key carries (`PrintCard.labels(of:)`). /// /// One toggle for the pair rather than two, because they are one *line* on paper: an icon with /// the labels switched off is a glyph alone on a line, which is furniture rather than /// information. 01-storage-format.md § Frontmatter reserves `labels` and this version interprets /// nothing by it (05-card-window.md ▸ Details: "ordinary unknown keys in this version"), so what /// prints is what the file says, flattened — never a chip, never a colour. public var includesLabels = true /// The card's body, **rendered** — the Markdown subset Preview draws, through the same parse /// (05-card-window.md ▸ Preview; `BodyMarkup`). Never the raw source: a print of the bytes is /// what ⌥⌘E is for, and a reader holding paper wants the document, not its markup. public var includesBody = true // MARK: Comments — "whether to include comments and how to sort them" /// **Off by default.** A thread is conversation *about* a card, and the overwhelmingly common /// print is the card; a board print with comments on is also the one shape that costs a disk read /// per card (`CommentThread` is window-scoped and outside the snapshot — 01 § Enhanced schema), /// which is a cost nobody should pay without asking. public var includesComments = false /// Which end the thread starts from when `includesComments` is on. Ignored entirely when it is /// off — kept rather than made optional so toggling comments back on restores the choice the user /// last made instead of resetting it. public var commentSort: PrintCommentSort = .oldestFirst // MARK: Page breaks public var pageBreaks: PrintPageBreaks = .flow // MARK: Styling — "font face, size & style" /// The base font family, or `nil` for the system font. /// /// **A family name, not a font.** Weight and slant are the document's to decide — a heading is /// bold because it is a heading, emphasis is italic because the author wrote `*it*` — so what a /// user picks here is the *face* the whole document is set in, and every derived style keeps its /// own traits inside it (`PrintTypography.restyled`). A name the running system cannot resolve /// degrades to the system font, `ItemSymbol.exists`' posture applied to type: a profile written /// on a machine with Palatino installed must still print on one without it. public var fontFamily: String? /// The body point size. **The one size the document has**: headings, the labels line, comment /// bylines and the header/footer are all multiples of it (`PrintTypography`), which is the /// "body-vs-headings derive from one base choice" ruling — a print dialog with six size fields is /// a typesetting program, and this is a print dialog. public var fontSize: Double = 11 /// The legal range, and the reason it is a range at all: a size of 0 draws nothing and a size of /// 400 draws one letter per page, and both are reachable from a hand-edited plist. public static let fontSizeRange: ClosedRange = 6 ... 36 // MARK: Header and footer — "page footer/header" /// The board's title, in the running head. public var headerShowsBoardTitle = true /// The date the print was run, in the running head. **The print's date, not the board's /// `modified`**: a printout's own question is "how old is this piece of paper". public var headerShowsPrintDate = true /// "Page 3 of 7", in the running foot. On by default — a stapled board print with no folios is /// a pile. public var footerShowsPageNumbers = true /// A line of the user's own in the running foot — a project code, a distribution note, a /// confidentiality banner. /// /// Two fields rather than one so switching the line off keeps the text: the toggle is a /// *decision* and the string is *content*, and losing the content on every toggle would make the /// pair useless for the case it exists for (a banner used on some prints and not others). public var footerShowsCustomLine = false public var footerCustomLine = "" public init() {} // MARK: - Normalizing /// `size` inside `fontSizeRange` — the one normalization that happens **on the way in**, because /// a stored size is the likeliest defect in this whole structure and the only one that can make a /// page undrawable (`AppPreferences.boardZoomLevelKey`'s own trap: an unset or hand-edited number /// that renders a document of hairlines). public static func clamped(fontSize size: Double) -> Double { guard size.isFinite else { return PrintOptions().fontSize } return min(max(size, fontSizeRange.lowerBound), fontSizeRange.upperBound) } /// **The render's reading of these options**, not a rewrite of them — applied by the builder and /// the renderer, never by the decoder, so `decode(encode(x)) == x` holds for every value a user /// can reach. /// /// It flattens the two "content without a reason to exist" cases into their honest form: a blank /// custom line is the same as not having one, and a whitespace family name is the same as the /// system font. Both stay *readings* — the stored profile keeps whatever the user typed /// (`footerCustomLine`'s own note), so a banner emptied for one print and typed back in for the /// next never loses its toggle. public var normalized: PrintOptions { var copy = self copy.fontSize = Self.clamped(fontSize: fontSize) if let family = copy.fontFamily, family.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { copy.fontFamily = nil } if copy.footerCustomLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { copy.footerShowsCustomLine = false } return copy } /// Whether anything at all would print with these options — the Print button's own floor. /// /// All three component toggles off is a legal state a user can reach one click at a time, and it /// prints a document of nothing but running heads. The command does not disable on it (a user /// mid-configuration must not have the button taken away), but the builder answers it honestly: /// `PrintDocumentBuilder.blocks` returns an empty document, and the operation refuses rather than /// spending paper. public var describesAnyContent: Bool { includesTitle || includesLabels || includesBody || includesComments } // MARK: - Codable private enum CodingKeys: String, CodingKey { case includesTitle, includesLabels, includesBody case includesComments, commentSort case pageBreaks case fontFamily, fontSize case headerShowsBoardTitle, headerShowsPrintDate case footerShowsPageNumbers, footerShowsCustomLine, footerCustomLine } /// See the type's doc comment: every field falls back to its default, and the two enumerations /// fall back to theirs rather than failing the decode, so a value written by a build that knows a /// fourth page-break mode reads as `.flow` here instead of taking the whole profile down with it. public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) var options = PrintOptions() /// A key read three ways at once: absent, present-but-the-wrong-type, and present-and-good — /// the first two answering the default. `try?` around `decodeIfPresent` is what collapses the /// middle case, and the double optional it produces is why this is a function rather than an /// expression repeated eleven times. func read(_ type: T.Type, _ key: CodingKeys) -> T? { guard let decoded = try? container.decodeIfPresent(type, forKey: key) else { return nil } return decoded } options.includesTitle = read(Bool.self, .includesTitle) ?? options.includesTitle options.includesLabels = read(Bool.self, .includesLabels) ?? options.includesLabels options.includesBody = read(Bool.self, .includesBody) ?? options.includesBody options.includesComments = read(Bool.self, .includesComments) ?? options.includesComments options.commentSort = read(String.self, .commentSort) .flatMap(PrintCommentSort.init(rawValue:)) ?? options.commentSort options.pageBreaks = read(String.self, .pageBreaks) .flatMap(PrintPageBreaks.init(rawValue:)) ?? options.pageBreaks options.fontFamily = read(String.self, .fontFamily) options.fontSize = read(Double.self, .fontSize) ?? options.fontSize options.headerShowsBoardTitle = read(Bool.self, .headerShowsBoardTitle) ?? options.headerShowsBoardTitle options.headerShowsPrintDate = read(Bool.self, .headerShowsPrintDate) ?? options.headerShowsPrintDate options.footerShowsPageNumbers = read(Bool.self, .footerShowsPageNumbers) ?? options.footerShowsPageNumbers options.footerShowsCustomLine = read(Bool.self, .footerShowsCustomLine) ?? options.footerShowsCustomLine options.footerCustomLine = read(String.self, .footerCustomLine) ?? options.footerCustomLine options.fontSize = Self.clamped(fontSize: options.fontSize) self = options } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(includesTitle, forKey: .includesTitle) try container.encode(includesLabels, forKey: .includesLabels) try container.encode(includesBody, forKey: .includesBody) try container.encode(includesComments, forKey: .includesComments) try container.encode(commentSort.rawValue, forKey: .commentSort) try container.encode(pageBreaks.rawValue, forKey: .pageBreaks) try container.encodeIfPresent(fontFamily, forKey: .fontFamily) try container.encode(fontSize, forKey: .fontSize) try container.encode(headerShowsBoardTitle, forKey: .headerShowsBoardTitle) try container.encode(headerShowsPrintDate, forKey: .headerShowsPrintDate) try container.encode(footerShowsPageNumbers, forKey: .footerShowsPageNumbers) try container.encode(footerShowsCustomLine, forKey: .footerShowsCustomLine) try container.encode(footerCustomLine, forKey: .footerCustomLine) } }