import AppKit import Observation // MARK: - AppAppearance /// The app-wide appearance override — Auto (follow system) / Light / Dark (11-command-nexus.md ▸ /// View ▸ Appearance; 03-board-ui.md ▸ Toolbar). /// /// There is no `.auto` case: following the system is the *absence* of an override, which is what makes /// "no stored key" the one honest spelling of it (`AppearanceStore`, `AppPreferences.appearanceKey`) — /// a third case would need its own reading of what it means to override with "don't override". public enum AppAppearance: String, CaseIterable, Sendable { case light case dark } // MARK: - AppearanceStore /// The app's one appearance override, app-wide and persisted (11-command-nexus.md ▸ View ▸ Appearance; /// 03-board-ui.md ▸ Toolbar). /// /// `BoardZoomStore`'s shape exactly, and for its reasons. Two consumers need change notification a /// property wrapper in a view cannot give them: the board toolbar's picker item, whose checkmarks are /// read fresh whenever AppKit opens its menu rather than polled (`WindowToolbarController`), and the /// View-menu picker, which lives outside every scene's environment and reaches `AppModel` as a plain /// `let` the same way `ZoomCommands` reaches `zoom`. An `@Observable` object over an injectable /// `UserDefaults` is what serves both without either one mirroring the other's state. /// /// ### Why not `@AppStorage`, like Show Comments /// /// Show Comments has exactly one write path and exactly one thing reading it back — the checkbox /// itself. This preference has two independent controls that must never drift the way the toolbar's /// zoom buttons and the View-menu zoom rows must not (`BoardZoomStore.setLevel`'s rule), and the /// toolbar's is AppKit underneath — an `NSMenuToolbarItem` cannot bind to `@AppStorage` at all. /// /// ### Why the write path applies live, unlike zoom's /// /// A zoom level only ever feeds a board's own drawing, so persisting it is enough — the board reads it /// back through the environment. An appearance override is a statement about the whole app's chrome, /// every open window included, so the setter both persists *and* calls the apply seam in the same /// beat: there is no reload and no window that has to be told twice. @MainActor @Observable public final class AppearanceStore { /// The current override. `nil` is Auto — the app follows the system appearance. public private(set) var override: AppAppearance? @ObservationIgnored private let defaults: UserDefaults /// The one seam that touches `NSApp` — injected so a test can prove the setter's whole contract /// (persist, then apply) without a live application object, `BoardZoomStore.defaults`'s reason /// turned toward AppKit rather than `UserDefaults`. @ObservationIgnored private let apply: (NSAppearance.Name?) -> Void /// - Parameters: /// - defaults: the domain to persist in — injected for `BoardZoomStore`'s reason: a test holds /// its own rather than touching the developer's real appearance. /// - apply: what "make it so" means. Defaulted to the real thing; a test hands in a recording /// closure instead so it never touches `NSApp`. public init( defaults: UserDefaults = .standard, apply: @escaping (NSAppearance.Name?) -> Void = { name in NSApp.appearance = name.map { NSAppearance(named: $0) } ?? nil } ) { self.defaults = defaults self.apply = apply // A string, not an enum-backed scalar: the key is absent for Auto (the remove-at-default // idiom a default lane width and an empty rename already use), and any value that survives to // here but is neither "light" nor "dark" — a hand edit, a future build's spelling read by an // older one — degrades to Auto rather than refusing to resolve. `AppAppearance.init(rawValue:)` // already answers `nil` for anything it does not recognise, so the lenient read costs nothing // beyond the `flatMap`. override = defaults.string(forKey: AppPreferences.appearanceKey).flatMap(AppAppearance.init(rawValue:)) } // MARK: - The pure resolver /// What an override means to AppKit — no `NSApp`, no live application, provable with nothing but /// the enum (`BoardZoom.normalize`'s reason: the rule is a function, and the object around it is /// only that function's persistence and observability). /// /// `nonisolated`, unlike everything else here: it touches no actor-isolated state, and marking it /// so is what lets a plain (non-`@MainActor`) test call it directly, the same freedom /// `BoardZoom.normalize` has by living outside `BoardZoomStore` entirely. public nonisolated static func appearanceName(for override: AppAppearance?) -> NSAppearance.Name? { switch override { case .light: .aqua case .dark: .darkAqua case nil: nil } } // MARK: - Writing /// Sets the override, persists it, and applies it — the single write path the View-menu picker and /// the board-toolbar item share (`BoardZoomStore.setLevel`'s rule: the two faces of one command /// must never become two implementations of it). /// /// **Auto removes the key** rather than writing a third spelling of it: the preference is meant to /// read as "no override on file" to anyone who inspects it, the same bargain a default lane width /// and an empty rename already keep. /// /// **An unchanged value writes and applies nothing**, `BoardZoomStore.setLevel`'s own guard and for /// the same load-bearing reason: `@Observable` notifies on every assignment, equal or not, so an /// ungated write would invalidate every observer of `override` — the picker's checkmarks, the /// toolbar controller's tracked validation — on a no-op, and hand the apply seam a repeat call for /// nothing every one of its callers would have to tolerate. public func setOverride(_ newValue: AppAppearance?) { guard newValue != override else { return } override = newValue if let newValue { defaults.set(newValue.rawValue, forKey: AppPreferences.appearanceKey) } else { defaults.removeObject(forKey: AppPreferences.appearanceKey) } apply(Self.appearanceName(for: newValue)) } /// Re-applies the stored override — launch's whole job /// (`AppDelegate.applicationDidFinishLaunching`). `init` above already read the value; this is the /// method that hands it to AppKit, kept separate from `init` so building a store — including in a /// test, including as `AppModel`'s own construction — is never itself a global side effect. public func applyCurrent() { apply(Self.appearanceName(for: override)) } }