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) } }