The app learns Appearance — Auto, Light, Dark from the View menu and a toolbar pull-down
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
This commit is contained in:
@@ -27,8 +27,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// It starts nothing the board-open path waits on: the listener writes cached facts that a
|
||||
/// *later* composition may read, and never reaches into a session that is already open
|
||||
/// (`ProEntitlement`).
|
||||
///
|
||||
/// **The appearance override applies here too, for the same reason.** `AppearanceStore.init`
|
||||
/// only reads; this is the one call that hands its answer to `NSApp` — the global side effect
|
||||
/// `KanbanApp.init` must not carry, since a unit-test host runs that `init` on every launch
|
||||
/// (`AppearanceStore.applyCurrent`).
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
appModel?.entitlement.start()
|
||||
appModel?.appearance.applyCurrent()
|
||||
}
|
||||
|
||||
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
|
||||
|
||||
@@ -127,6 +127,26 @@ public enum AppPreferences {
|
||||
/// stale-from-a-future-build value indistinguishable from a legal one downstream.
|
||||
public static let boardZoomLevelKey = "boardZoomLevel"
|
||||
|
||||
// MARK: The appearance override
|
||||
|
||||
/// **View ▸ Appearance** (11-command-nexus.md) — Auto / Light / Dark, app-wide and persisted
|
||||
/// across restarts (03-board-ui.md ▸ Toolbar). Read and written by `AppearanceStore`, which owns
|
||||
/// the override's rules; the key is declared here with its neighbours for `WindowID`'s reason.
|
||||
///
|
||||
/// **Absent key = Auto.** Setting Auto removes the key rather than writing a third spelling of it
|
||||
/// (the remove-at-default family — a default lane width and an empty rename both do the same), and
|
||||
/// a stored string that is neither "light" nor "dark" — a hand edit, a future build's value read by
|
||||
/// an older one — degrades to Auto rather than refusing to resolve.
|
||||
public static let appearanceKey = "appearance"
|
||||
|
||||
/// The stored override, read the same lenient way `AppearanceStore.init` does. Not itself on that
|
||||
/// type's read path — it takes its own injectable `defaults` rather than always reading
|
||||
/// `.standard` — but declared here with a reader for the shape every other preference in this enum
|
||||
/// keeps (`showComments`'s).
|
||||
public static var appearance: AppAppearance? {
|
||||
UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
|
||||
}
|
||||
|
||||
/// The cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) —
|
||||
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
|
||||
///
|
||||
@@ -271,6 +291,14 @@ public final class AppModel {
|
||||
/// outside every scene's environment, reach it through this object.
|
||||
public let zoom: BoardZoomStore
|
||||
|
||||
/// The app-wide appearance override (11-command-nexus.md ▸ View ▸ Appearance; 03-board-ui.md ▸
|
||||
/// Toolbar). Owned here for `zoom`'s reason exactly: app-scoped, persisted beside it, and reached
|
||||
/// by the View-menu picker and the board-toolbar item alike — both live outside a board's own
|
||||
/// environment (the menu bar entirely, the toolbar through `WindowToolbarController`), so an
|
||||
/// `@Observable` object both can hold is the only thing keeping them from becoming two answers to
|
||||
/// one question.
|
||||
public let appearance: AppearanceStore
|
||||
|
||||
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
||||
@@ -688,6 +716,7 @@ public final class AppModel {
|
||||
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
||||
styleRecents = StyleRecents(defaults: preferences)
|
||||
zoom = BoardZoomStore(defaults: preferences)
|
||||
appearance = AppearanceStore(defaults: preferences)
|
||||
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
||||
// Reads the cached facts and nothing else — no StoreKit API is touched until
|
||||
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - View ▸ Appearance
|
||||
|
||||
/// View ▸ Appearance — Auto / Light / Dark (11-command-nexus.md ▸ View; 03-board-ui.md ▸ Toolbar).
|
||||
///
|
||||
/// **App-wide and always enabled**, unlike `ZoomCommands` beside it in the View menu: appearance is a
|
||||
/// preference about how *every* window in the app draws, welcome included, so this row needs no
|
||||
/// `@FocusedValue` scoping and no board window in front — `NewBoardCommand`'s posture (everywhere, no
|
||||
/// focus required) rather than `ZoomCommands`' (board windows only).
|
||||
///
|
||||
/// A `Picker` rather than three independent toggles: SwiftUI renders one placed directly in a
|
||||
/// menu-bar command group as a submenu — "Appearance" as its title, "Auto" / "Light" / "Dark" as its
|
||||
/// rows, a checkmark on whichever is selected — which is the three-way exclusive choice a trio of
|
||||
/// `Toggle`s cannot express (nothing stops more than one, or none, from reading as checked). `nil` is
|
||||
/// the Auto tag; `AppearanceStore.setOverride` is the single write path the board-toolbar item shares
|
||||
/// (`BoardZoomStore.step`'s rule — a toolbar item is a menu command with a different face, never a
|
||||
/// second implementation of it).
|
||||
struct AppearanceCommands: View {
|
||||
|
||||
/// A plain `let` rather than an `@Environment` read, `ZoomCommands`' reason: menu commands live in
|
||||
/// the menu bar, outside every scene's environment; the row re-renders on a change because
|
||||
/// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern).
|
||||
let appModel: AppModel
|
||||
|
||||
var body: some View {
|
||||
Picker("Appearance", selection: Binding(
|
||||
get: { appModel.appearance.override },
|
||||
set: { appModel.appearance.setOverride($0) }
|
||||
)) {
|
||||
Text("Auto").tag(nil as AppAppearance?)
|
||||
Text("Light").tag(AppAppearance.light as AppAppearance?)
|
||||
Text("Dark").tag(AppAppearance.dark as AppAppearance?)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -775,6 +775,7 @@ struct BoardWindowHost: View {
|
||||
store: store,
|
||||
search: boardSearch,
|
||||
zoom: appModel.zoom,
|
||||
appearance: appModel.appearance,
|
||||
session: appModel.dragSession
|
||||
))
|
||||
}
|
||||
|
||||
@@ -83,6 +83,18 @@ struct ToolbarItemSpec {
|
||||
make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField,
|
||||
install: (NSSearchToolbarItem) -> Void
|
||||
)
|
||||
/// A pull-down of mutually exclusive choices — **Appearance** (03-board-ui.md ▸ Toolbar): an
|
||||
/// `NSMenuToolbarItem`, item image plus indicator, whose menu lists `options` in order.
|
||||
/// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit
|
||||
/// opens the menu rather than polled — the same freshness every other menu row in the app
|
||||
/// gets (`validateMenuItem(_:)`) — and `select(_:)` is a chosen row's whole action. The one
|
||||
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows, not in the
|
||||
/// item itself, the way `responderAction`'s lives in the responder chain rather than here.
|
||||
case picker(
|
||||
options: [(title: String, symbol: String?)],
|
||||
selected: () -> Int?,
|
||||
select: (Int) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
|
||||
@@ -121,7 +133,7 @@ struct ToolbarItemSpec {
|
||||
switch behavior {
|
||||
case let .button(isEnabled, _): isEnabled()
|
||||
case let .toggle(isEnabled, _, _): isEnabled()
|
||||
case .responderAction, .searchField: true
|
||||
case .responderAction, .searchField, .picker: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,17 +141,18 @@ struct ToolbarItemSpec {
|
||||
var isOn: Bool? {
|
||||
switch behavior {
|
||||
case let .toggle(_, isOn, _): isOn()
|
||||
case .button, .responderAction, .searchField: nil
|
||||
case .button, .responderAction, .searchField, .picker: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Firing the item: a button performs, a toggle flips. A no-op for the two kinds AppKit drives
|
||||
/// itself.
|
||||
/// Firing the item: a button performs, a toggle flips. A no-op for the kinds AppKit drives itself
|
||||
/// or that fire from somewhere other than the item's own primary action (`.picker`'s dropdown
|
||||
/// rows).
|
||||
func activate() {
|
||||
switch behavior {
|
||||
case let .button(_, perform): perform()
|
||||
case let .toggle(_, isOn, setOn): setOn(!isOn())
|
||||
case .responderAction, .searchField: break
|
||||
case .responderAction, .searchField, .picker: break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +315,8 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
// window-scoped wiring, so it is handed none.
|
||||
install: willBeInsertedIntoToolbar ? install : nil
|
||||
)
|
||||
case let .picker(options, _, _):
|
||||
return makePickerItem(spec, options: options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +421,41 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
return item
|
||||
}
|
||||
|
||||
/// A pull-down of mutually exclusive options — **Appearance**, so far the one item of this shape.
|
||||
///
|
||||
/// `selected`/`select` are deliberately not captured here: every row's action and every row's
|
||||
/// validation re-fetch the spec fresh from `specs[identifier]` (`pickerItemFired(_:)`,
|
||||
/// `validateMenuItem(_:)`), the same indirection `itemFired(_:)` and `toggleFired(_:)` already use
|
||||
/// for their own specs — so a spec rebuilt between two menu presentations is never read stale.
|
||||
private func makePickerItem(_ spec: ToolbarItemSpec, options: [(title: String, symbol: String?)]) -> NSToolbarItem {
|
||||
let item = NSMenuToolbarItem(itemIdentifier: spec.identifier)
|
||||
decorate(item, with: spec)
|
||||
// "Pull-down: item image + indicator" — the item's own glyph draws at rest, the indicator
|
||||
// chevron shows there is a menu, and the rows are what actually name Auto/Light/Dark.
|
||||
item.showsIndicator = true
|
||||
|
||||
let menu = NSMenu()
|
||||
for (index, option) in options.enumerated() {
|
||||
let menuItem = NSMenuItem(
|
||||
title: option.title,
|
||||
action: #selector(pickerItemFired(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
menuItem.target = self
|
||||
// The row's position in `options`, not an identifier of its own — `select(_:)` and
|
||||
// `selected()` both speak in this same index, which is what lets one closure pair stand
|
||||
// for every row rather than one closure per option.
|
||||
menuItem.tag = index
|
||||
menuItem.representedObject = spec.identifier.rawValue
|
||||
if let symbol = option.symbol {
|
||||
menuItem.image = NSImage(systemSymbolName: symbol, accessibilityDescription: option.title)
|
||||
}
|
||||
menu.addItem(menuItem)
|
||||
}
|
||||
item.menu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
/// The three strings every item carries: the toolbar label, the palette label (the same string —
|
||||
/// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads.
|
||||
private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) {
|
||||
@@ -444,13 +494,30 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
revalidate()
|
||||
}
|
||||
|
||||
/// A row in a `.picker` item's own dropdown — **Appearance**'s Auto/Light/Dark, fired straight
|
||||
/// from the menu rather than through `itemFired(_:)`, since the item has no primary action of its
|
||||
/// own (`ToolbarItemSpec.activate()` is a no-op for `.picker`).
|
||||
@objc private func pickerItemFired(_ sender: NSMenuItem) {
|
||||
guard let raw = sender.representedObject as? String,
|
||||
let spec = specs[NSToolbarItem.Identifier(raw)],
|
||||
case let .picker(_, _, select) = spec.behavior
|
||||
else { return }
|
||||
select(sender.tag)
|
||||
revalidate()
|
||||
}
|
||||
|
||||
/// The overflow menu's copy of an item validates like the item itself — including the checkmark,
|
||||
/// which is where a toggle's on-state goes when the menu is its face (03 ▸ Toolbar: "the system
|
||||
/// overflow").
|
||||
/// overflow") — and where a `.picker` row's checkmark goes too, against its own index rather than
|
||||
/// against `isOn` (which answers `nil` for the whole item, having no single on-state to give).
|
||||
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
||||
guard let raw = menuItem.representedObject as? String,
|
||||
let spec = specs[NSToolbarItem.Identifier(raw)]
|
||||
else { return true }
|
||||
if case let .picker(_, selected, _) = spec.behavior {
|
||||
menuItem.state = selected() == menuItem.tag ? .on : .off
|
||||
return true
|
||||
}
|
||||
if let isOn = spec.isOn {
|
||||
menuItem.state = isOn ? .on : .off
|
||||
}
|
||||
|
||||
@@ -240,9 +240,10 @@ struct KanbanApp: App {
|
||||
ToolbarCommands()
|
||||
|
||||
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
||||
// items live in — which is where 11-command-nexus.md files Show Trash. Two dividers split it
|
||||
// items live in — which is where 11-command-nexus.md files Show Trash. Three dividers split it
|
||||
// by scope, which is the only grouping the inventory implies: the board's toggle, then the
|
||||
// board's zoom ladder, then the card window's view-state rows.
|
||||
// board's zoom ladder, then the card window's view-state rows, then the app-wide appearance
|
||||
// override — last, because unlike everything above it, it needs no window in front at all.
|
||||
//
|
||||
// "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window
|
||||
// menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a
|
||||
@@ -257,6 +258,10 @@ struct KanbanApp: App {
|
||||
Divider()
|
||||
|
||||
CardViewCommands()
|
||||
|
||||
Divider()
|
||||
|
||||
AppearanceCommands(appModel: appModel)
|
||||
}
|
||||
|
||||
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
||||
|
||||
@@ -11,16 +11,18 @@ extension NSToolbarItem.Identifier {
|
||||
static let boardShowTrash = Self("board.showTrash")
|
||||
static let boardZoomIn = Self("board.zoomIn")
|
||||
static let boardZoomOut = Self("board.zoomOut")
|
||||
static let boardAppearance = Self("board.appearance")
|
||||
}
|
||||
|
||||
// MARK: - The board window's toolbar
|
||||
|
||||
/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
|
||||
///
|
||||
/// ### The default is one item, and the catalog is five more
|
||||
/// ### The default is the search field and Appearance, and the catalog is the rest
|
||||
///
|
||||
/// "**Board window default: the search field, nothing else** — trailing, the one default item; the
|
||||
/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`.
|
||||
/// "**Board window default: the search field and Appearance** — both **centered**, the titlebar's
|
||||
/// view-controls cluster." The flexible space ahead of the pair is what keeps them off the leading
|
||||
/// edge before `centeredItemIdentifiers` takes over their placement.
|
||||
///
|
||||
/// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
|
||||
/// Show Trash (toggle state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
||||
@@ -54,8 +56,14 @@ enum BoardToolbar {
|
||||
/// than one window's — Finder's behaviour, and the reason the identifier is a constant.
|
||||
static let identifier = "dev.rzen.indie.Kanban.board"
|
||||
|
||||
/// "The search field, nothing else — trailing, the one default item."
|
||||
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch]
|
||||
/// "The search field and Appearance — both centered, immediately after the field
|
||||
/// (03-board-ui.md ▸ Toolbar, the view-controls cluster)."
|
||||
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch, .boardAppearance]
|
||||
|
||||
/// The Appearance picker's rows, in menu order — the one place index and meaning are joined, so
|
||||
/// `specs(...)`'s `selected`/`select` closures and this array can never name two different
|
||||
/// orderings of the same three choices.
|
||||
private static let appearanceOptions: [AppAppearance?] = [nil, .light, .dark]
|
||||
|
||||
/// - Parameters:
|
||||
/// - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the
|
||||
@@ -64,12 +72,15 @@ enum BoardToolbar {
|
||||
/// `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms
|
||||
/// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live
|
||||
/// at the top rung.
|
||||
/// - appearance: the app-wide appearance override, `zoom`'s reason exactly — not the store's,
|
||||
/// and `@Observable` so the picker's checkmark, read when its menu opens, is never stale.
|
||||
/// - session: the app's drag session, for the same guard the menu rows carry
|
||||
/// (`ZoomCommands.isEnabled`).
|
||||
static func specs(
|
||||
store: BoardStore,
|
||||
search: BoardSearchPresentation,
|
||||
zoom: BoardZoomStore,
|
||||
appearance: AppearanceStore,
|
||||
session: DragSession
|
||||
) -> [ToolbarItemSpec] {
|
||||
[
|
||||
@@ -141,6 +152,31 @@ enum BoardToolbar {
|
||||
setOn: { [weak store] shown in store?.setTrashVisible(shown) }
|
||||
)
|
||||
),
|
||||
// A pull-down rather than a toggle: Auto/Light/Dark is a three-way exclusive choice, not
|
||||
// an on/off bit. The one default (and centered) catalog item beside the field
|
||||
// (`defaultItems`, `controller(...)`'s `centeredItemIdentifiers`), always enabled — an
|
||||
// appearance override needs no board state, exactly as the View-menu row needs no board
|
||||
// window (`AppearanceCommands`).
|
||||
.mirroring(
|
||||
menuTitle: "Appearance",
|
||||
identifier: .boardAppearance,
|
||||
symbol: "circle.lefthalf.filled",
|
||||
behavior: .picker(
|
||||
options: [
|
||||
(title: "Auto", symbol: nil),
|
||||
(title: "Light", symbol: nil),
|
||||
(title: "Dark", symbol: nil),
|
||||
],
|
||||
selected: { [weak appearance] in
|
||||
guard let appearance else { return nil }
|
||||
return appearanceOptions.firstIndex(of: appearance.override)
|
||||
},
|
||||
select: { [weak appearance] index in
|
||||
guard appearanceOptions.indices.contains(index) else { return }
|
||||
appearance?.setOverride(appearanceOptions[index])
|
||||
}
|
||||
)
|
||||
),
|
||||
.staticLabel(
|
||||
"Search",
|
||||
identifier: .boardSearch,
|
||||
@@ -180,18 +216,21 @@ enum BoardToolbar {
|
||||
store: BoardStore,
|
||||
search: BoardSearchPresentation,
|
||||
zoom: BoardZoomStore,
|
||||
appearance: AppearanceStore,
|
||||
session: DragSession
|
||||
) -> WindowToolbarController {
|
||||
let controller = WindowToolbarController(
|
||||
identifier: identifier,
|
||||
specs: specs(store: store, search: search, zoom: zoom, session: session),
|
||||
specs: specs(store: store, search: search, zoom: zoom, appearance: appearance, session: session),
|
||||
defaults: defaultItems
|
||||
)
|
||||
// Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement
|
||||
// grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install
|
||||
// and sits outside the autosaved configuration, so it reaches machines that saved an
|
||||
// arrangement under the old trailing default. `defaultItems` is untouched.
|
||||
controller.toolbar.centeredItemIdentifiers = [.boardSearch]
|
||||
// arrangement under the old trailing default. `defaultItems` is untouched. Appearance joined
|
||||
// the cluster after search (`defaultItems`'s own order), so the two center together as one
|
||||
// group — search first, Appearance beside it — rather than as two independently-placed items.
|
||||
controller.toolbar.centeredItemIdentifiers = [.boardSearch, .boardAppearance]
|
||||
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
||||
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user