View ▸ Appearance (11-command-nexus.md): three radio-exclusive rows, app-wide, persisted, needing no window in front — the View menu's new last group. AppearanceStore owns the override's rules (absent key = Auto, lenient reads degrade to Auto, remove-at-default) with an injectable apply seam so test hosts never touch NSApp; the one real apply hands NSApp.appearance its answer in applicationDidFinishLaunching, the global side effect KanbanApp.init must not carry. The board toolbar gains its first .picker item — an NSMenuToolbarItem whose rows re-fetch their spec fresh, checkmark read at menu-open like every other menu row — and Appearance joins the search field as the second default item, centered beside it (03-board-ui.md ▸ Toolbar, ratified 2026-08-07). Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
131 lines
6.7 KiB
Swift
131 lines
6.7 KiB
Swift
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))
|
|
}
|
|
}
|