import CoreGraphics import Foundation import Observation /// The board's zoom level: one number, app-wide, persisted (11-command-nexus.md ▸ View ▸ Actual Size /// — "app-wide and persisted across restarts"; 03-board-ui.md ▸ Layout — zoom). /// /// `StyleRecents`' shape exactly, for its reasons. The *rules* — what the rungs are, how stepping /// terminates, what an illegal stored value becomes — are `BoardZoom`'s pure functions; this object is /// their persistence and their observability, and `defaults` is injectable so a test drives a suite of /// its own rather than the user's. /// /// ### Why app-wide, and not per board /// /// A zoom level describes how this user likes to read, not what a board is. It has no business in a /// lane's frontmatter (where `width` lives — that *is* board data, shared with every collaborator and /// every agent), and no business in `BoardRegistry` either: a board opened on a laptop and on a studio /// display wants the same *preference* applied, not a per-board memory of a window that no longer /// exists. It sits beside Show Comments and Comments Beside Body, which are app-wide and persisted for /// the same reason. /// /// ### Why `@Observable` rather than `@AppStorage` /// /// Two consumers need change notification that a property wrapper in a view cannot give them. The /// board toolbar's validation is *observed*, not polled — `WindowToolbarController.trackValidationState` /// re-arms `withObservationTracking` over each spec's `isEnabled`, so Zoom In greys out at the top rung /// only if the level it reads is observable. And menu commands live outside every scene's environment, /// so they receive `AppModel` as a plain `let` and re-render only because it is `@Observable`. One /// observable holder serves both, and the level has exactly one home rather than a mirror per surface. @MainActor @Observable public final class BoardZoomStore { /// The current rung — always a member of `BoardZoom.levels`, guaranteed by construction: the /// initialiser normalises what it reads and `setLevel` normalises what it is given, so no reader /// anywhere has to ask whether its level is legal. public private(set) var level: CGFloat @ObservationIgnored private let defaults: UserDefaults /// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason — a test /// must be able to hold its own without touching the user's. public init(defaults: UserDefaults = .standard) { self.defaults = defaults // `object(forKey:)` rather than `double(forKey:)` so an unset key arrives as nil rather than as // 0, and `normalize` rather than `?? 1.0` so a hand-edited or stale value lands on a rung too. // Either way the answer is legal; the distinction only decides *which* legal value an absent // key becomes, and both roads lead to Actual Size. level = BoardZoom.normalize((defaults.object(forKey: AppPreferences.boardZoomLevelKey) as? Double) ?? Double(BoardZoom.actualSize)) } // MARK: - Moving /// Sets the level to the nearest legal rung and persists it. /// /// The single write path — Zoom In, Zoom Out, Actual Size and the two toolbar buttons all arrive /// here, which is what keeps the menu row and its toolbar twin from being two implementations of /// one command (the `BoardStore.setTrashVisible` precedent). /// /// **An unchanged level writes nothing and publishes nothing**, and unlike `StyleRecents.record`'s /// unconditional write that guard is load-bearing rather than an optimisation. `@Observable` /// notifies on *every* set, equal or not, so an ungated assignment would invalidate the board /// window's zoom environment on a no-op — Actual Size when already at 100%, ⌘+ at the top rung — /// and re-run the whole strip to draw exactly what it was drawing. A board nobody zoomed must not /// pay for the feature existing. /// /// The *announcement* is deliberately not gated with it (`step(_:)`): a user who pressed ⌘+ at the /// top rung is still owed the answer. public func setLevel(_ newValue: CGFloat) { let normalized = BoardZoom.normalize(Double(newValue)) guard normalized != level else { return } level = normalized defaults.set(Double(normalized), forKey: AppPreferences.boardZoomLevelKey) } /// One rung up; a fixed point at the top. public func zoomIn() { setLevel(BoardZoom.stepIn(from: level)) } /// One rung down; a fixed point at the bottom. public func zoomOut() { setLevel(BoardZoom.stepOut(from: level)) } /// Back to 100%, where the strip renders exactly what it rendered before zoom existed. public func actualSize() { setLevel(BoardZoom.actualSize) } // MARK: - Reading public var canZoomIn: Bool { BoardZoom.canZoomIn(level) } public var canZoomOut: Bool { BoardZoom.canZoomOut(level) } public var isActualSize: Bool { BoardZoom.isActualSize(level) } /// The level as the strip's views take it. /// /// Internal rather than `public` like its neighbours, and the asymmetry is the type's, not an /// oversight: `BoardZoomContext` is an environment value, which is a UI-layer concern the way /// `dragSession` is — nothing outside this module has any business rendering a board. var context: BoardZoomContext { BoardZoomContext(level: level) } /// "125%" — the announcement's value (10-accessibility.md ▸ Text scaling). public var percentLabel: String { BoardZoom.percentLabel(level) } }