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
240 lines
12 KiB
Swift
240 lines
12 KiB
Swift
import AppKit
|
|
|
|
// MARK: - Identifiers
|
|
|
|
extension NSToolbarItem.Identifier {
|
|
static let boardSearch = Self("board.search")
|
|
static let boardNewCard = Self("board.newCard")
|
|
static let boardNewLane = Self("board.newLane")
|
|
static let boardUndo = Self("board.undo")
|
|
static let boardRedo = Self("board.redo")
|
|
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 the search field and Appearance, and the catalog is the rest
|
|
///
|
|
/// "**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
|
|
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
|
|
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
|
|
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
|
|
/// true of the code: removing every item removes nothing but a shortcut to a menu row.
|
|
///
|
|
/// "The board popover deliberately has **no toolbar item** — the window-title widget is its
|
|
/// committed home" — so there is no Board Info entry here, and its absence is pinned by a test.
|
|
///
|
|
/// ### Undo and Redo are the responder chain's, exactly as the menu's are
|
|
///
|
|
/// The app ships no Undo/Redo rows of its own: those are the standard Edit-menu items, nil-target
|
|
/// `undo:`/`redo:` resolved up the responder chain (`KanbanApp.menuCommands`). The toolbar items
|
|
/// carry the same actions with the same nil target, so "matching their menu items" (03) is not a
|
|
/// predicate written here — it is literally the same validation. Both reach the board window, whose
|
|
/// `windowWillReturnUndoManager` hands back the session's `BoardUndoManager`, and both therefore
|
|
/// enable exactly when that board has a step to cross and no read-only lock stands
|
|
/// (13-native-undo.md ▸ Rules). **Every board has undo in every tier**, so there is no tier-shaped
|
|
/// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which
|
|
/// only a Pro subscription binds.
|
|
///
|
|
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
|
|
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
|
|
/// these two are built from static labels (`ToolbarItemSpec.staticLabel`).
|
|
@MainActor
|
|
enum BoardToolbar {
|
|
|
|
/// Shared by every board window, which is what makes the user's arrangement the *app's* rather
|
|
/// 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 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
|
|
/// store's, unlike every other predicate here, because the level is not a board's
|
|
/// (03-board-ui.md ▸ Layout — zoom) — and it must be `@Observable` rather than read from
|
|
/// `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] {
|
|
[
|
|
.mirroring(
|
|
menuTitle: "New Card",
|
|
identifier: .boardNewCard,
|
|
symbol: "doc.badge.plus",
|
|
behavior: .button(
|
|
isEnabled: { [weak store] in store?.newCardTarget != nil },
|
|
perform: { [weak store] in store?.beginNewCard() }
|
|
)
|
|
),
|
|
.mirroring(
|
|
menuTitle: "New Lane",
|
|
identifier: .boardNewLane,
|
|
symbol: "rectangle.stack.badge.plus",
|
|
behavior: .button(
|
|
isEnabled: { [weak store] in store?.acceptsBoardMutations == true },
|
|
perform: { [weak store] in store?.createLane() }
|
|
)
|
|
),
|
|
// The zoom pair — plain buttons, because that is what the menu rows are. There is no
|
|
// percentage readout and no popup: a control that *displays* the level would need a
|
|
// custom view and a new `ToolbarItemSpec.Behavior` case, and the level already has a
|
|
// spoken voice (`AccessibilityPhrases.zoomLevel`) and a visible one (the board itself).
|
|
.mirroring(
|
|
menuTitle: "Zoom In",
|
|
identifier: .boardZoomIn,
|
|
symbol: "plus.magnifyingglass",
|
|
behavior: .button(
|
|
isEnabled: { [weak store, weak zoom, weak session] in
|
|
guard let zoom, let session else { return false }
|
|
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomIn
|
|
},
|
|
perform: { [weak zoom] in zoom?.step(.in) }
|
|
)
|
|
),
|
|
.mirroring(
|
|
menuTitle: "Zoom Out",
|
|
identifier: .boardZoomOut,
|
|
symbol: "minus.magnifyingglass",
|
|
behavior: .button(
|
|
isEnabled: { [weak store, weak zoom, weak session] in
|
|
guard let zoom, let session else { return false }
|
|
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomOut
|
|
},
|
|
perform: { [weak zoom] in zoom?.step(.out) }
|
|
)
|
|
),
|
|
.staticLabel(
|
|
"Undo",
|
|
identifier: .boardUndo,
|
|
symbol: "arrow.uturn.backward",
|
|
behavior: .responderAction(NSSelectorFromString("undo:"))
|
|
),
|
|
.staticLabel(
|
|
"Redo",
|
|
identifier: .boardRedo,
|
|
symbol: "arrow.uturn.forward",
|
|
behavior: .responderAction(NSSelectorFromString("redo:"))
|
|
),
|
|
.mirroring(
|
|
menuTitle: "Show Trash",
|
|
identifier: .boardShowTrash,
|
|
symbol: "trash",
|
|
behavior: .toggle(
|
|
isEnabled: { [weak store] in store != nil },
|
|
isOn: { [weak store] in store?.transient.isTrashVisible == true },
|
|
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,
|
|
symbol: nil,
|
|
// The width in *characters* rather than points (10-accessibility.md ▸ Text scaling:
|
|
// "no fixed point sizes") — the same 17 ems the transient bar's field takes
|
|
// (`BoardSearchBar`). It is the **focused** width here: `NSSearchToolbarItem` grows
|
|
// the field to its preferred width when the keyboard arrives and lets it settle
|
|
// back to the item's natural width when the keyboard leaves, so the two homes match
|
|
// once the user is typing rather than at rest.
|
|
behavior: .searchField(
|
|
focusedWidth: BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize),
|
|
make: { [weak store] willBeInserted in
|
|
guard willBeInserted, let store else {
|
|
return BoardSearchFieldController.makePaletteField()
|
|
}
|
|
return BoardSearchFieldController.makeField(
|
|
store: store,
|
|
presentation: search,
|
|
home: .toolbar
|
|
)
|
|
},
|
|
// The toolbar half of the field's home that only the *item* can answer: ⌘F's
|
|
// expand-and-focus, and the collapse Escape's second step asks for.
|
|
install: { item in
|
|
BoardSearchFieldController.adopt(item, presentation: search)
|
|
}
|
|
)
|
|
),
|
|
]
|
|
}
|
|
|
|
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
|
|
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
|
|
/// toolbar, invoking it surfaces the field transiently until the search clears").
|
|
static func controller(
|
|
store: BoardStore,
|
|
search: BoardSearchPresentation,
|
|
zoom: BoardZoomStore,
|
|
appearance: AppearanceStore,
|
|
session: DragSession
|
|
) -> WindowToolbarController {
|
|
let controller = WindowToolbarController(
|
|
identifier: identifier,
|
|
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. 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)
|
|
}
|
|
return controller
|
|
}
|
|
}
|