View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
201 lines
10 KiB
Swift
201 lines
10 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")
|
|
}
|
|
|
|
// 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
|
|
///
|
|
/// "**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`.
|
|
///
|
|
/// "**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, nothing else — trailing, the one default item."
|
|
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch]
|
|
|
|
/// - 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.
|
|
/// - 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,
|
|
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) }
|
|
)
|
|
),
|
|
.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,
|
|
session: DragSession
|
|
) -> WindowToolbarController {
|
|
let controller = WindowToolbarController(
|
|
identifier: identifier,
|
|
specs: specs(store: store, search: search, zoom: zoom, 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]
|
|
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
|
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
|
}
|
|
return controller
|
|
}
|
|
}
|