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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user