Implement toolbar customization for both windows

NSToolbar through the existing HostedWindowController rather than
SwiftUI's toolbar — for reasons that are contract, not taste: 03's
transient-search clause is a decision over the toolbar's current
contents, which NSToolbar publishes and SwiftUI's API cannot answer;
Undo/Redo are the system's nil-target responder-chain actions so the
toolbar items validate exactly as the menu rows do (disabled on base
boards, alive in m8 unchanged); and the search item hosts the real
NSSearchField with explicit first-responder control. Customization is
all system furniture — Customize sheet, drag rearrange, display-mode
popup, overflow, autosaved per window kind. Board default: the search
field alone, trailing; catalog adds New Card, New Lane, Undo, Redo,
Show Trash, every action extracted from its menu command so no second
predicate exists. Card default: the Edit Body / Raw Source toggles and
Add Attachment, mirroring their commands' own predicates live via
observation tracking. Removing the search item keeps the promise —
⌘F surfaces the same field as a transient strip under the title bar,
persisting until the query clears, and an overflowed item that cannot
take the keyboard falls through to the strip too.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 12:57:42 -04:00
parent 40322247e0
commit 06ee59e24b
13 changed files with 1683 additions and 131 deletions
+24 -10
View File
@@ -304,11 +304,10 @@ struct BoardCreationCommands: View {
var body: some View {
Button("New Card") {
guard let store, let target = newCardTarget else { return }
store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
store?.beginNewCard()
}
.keyboardShortcut("n", modifiers: .command)
.disabled(newCardTarget == nil)
.disabled(store?.newCardTarget == nil)
Button("New Lane") {
store?.createLane()
@@ -316,17 +315,32 @@ struct BoardCreationCommands: View {
.keyboardShortcut("n", modifiers: [.shift, .command])
.disabled(store?.acceptsBoardMutations != true)
}
}
/// Where N would file a card, or `nil` when it cannot no focused board, a board that refuses
/// writes, an inline editor holding the keyboard, or a board with no lanes.
private var newCardTarget: NewCardTarget.Resolution? {
guard let store, store.acceptsBoardMutations else { return nil }
extension BoardStore {
/// Where N would file a card, or `nil` when it cannot a board that refuses writes, an inline
/// editor holding the keyboard, or a board with no lanes.
///
/// On the store rather than private to the menu row because **the toolbar's New Card item is the
/// same command** (03-board-ui.md Toolbar: toolbar items mirror menu commands), and a second
/// derivation of this rule would be a second chance to disagree with it the same reason the row
/// itself uses one answer for both its action and its validation.
var newCardTarget: NewCardTarget.Resolution? {
guard acceptsBoardMutations else { return nil }
return NewCardTarget.resolve(
selection: store.selection,
lastActiveLaneID: store.transient.lastActiveLaneID,
snapshot: store.snapshot
selection: selection,
lastActiveLaneID: transient.lastActiveLaneID,
snapshot: snapshot
)
}
/// N's action opening the new-card placeholder wherever `newCardTarget` says. Shared with the
/// toolbar item that mirrors the row.
func beginNewCard() {
guard let target = newCardTarget else { return }
transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
}
}
// MARK: - Board Info
+313 -80
View File
@@ -4,8 +4,9 @@ import SwiftUI
// MARK: - The window's search field, as a handle
/// The board window's search field, reduced to the three things anything outside it needs to know:
/// whether it holds the keyboard, how to give it the keyboard, and how to give the keyboard back.
/// The board window's search field, reduced to the things anything outside it needs to know:
/// whether it holds the keyboard, where it currently lives, how to give it the keyboard, and how to
/// give the keyboard back.
///
/// `BoardInfoPresentation`'s sibling in every respect one per window, `@State` in
/// `BoardWindowHost`, published through the focus system so a *menu item* can reach the frontmost
@@ -23,6 +24,15 @@ import SwiftUI
/// **It is not `isEditingInline`, and must never become it** (04 § Search, settled): "the field is a
/// *control*, not a content editor the focused-editor lockdown does not apply". Board commands
/// stay enabled and act on the selection while a query is being typed, N included.
///
/// ### The field has two homes, and F knows which one it is in
///
/// The toolbar item is the field's home and its shipped default (03-board-ui.md Toolbar: "the
/// search field, nothing else trailing, the one default item"). But the toolbar is customizable,
/// so the item can be *removed*, and 03 is explicit about what happens then: "F always summons
/// search: with the field removed from the toolbar, invoking it surfaces the field transiently until
/// the search clears." That is the whole of `invokeSearch()` below one decision, taken against
/// `isInstalledInToolbar`, which the toolbar controller keeps current.
@MainActor
@Observable
final class BoardSearchPresentation {
@@ -34,15 +44,122 @@ final class BoardSearchPresentation {
/// what makes it true for the AppKit key-view loop's Tab traversal as well as for F.
var isFocused = false
/// Makes the field first responder Edit Find's whole behaviour. `nil` until the field has
/// been made, which is also exactly when F has nothing to focus.
var focusField: (() -> Void)?
/// Whether the toolbar currently carries the search item the field's home.
///
/// Kept current by `BoardToolbar`, which reads the toolbar's contents on every configuration
/// change. It starts `true` because that is the shipped default; a saved configuration without
/// the item corrects it as soon as the toolbar joins the window.
var isInstalledInToolbar = true
/// Whether the transient host is on screen F's fallback when the item has been removed
/// (03-board-ui.md Toolbar). `BoardWindowHost` renders it; nothing else may set it.
private(set) var isTransient = false
/// Set when the transient host was raised by a F that had no field to focus yet the field
/// arrives one SwiftUI update later, and claims the keyboard when it does.
private var wantsFocusOnAppear = false
/// Makes the toolbar's field first responder, **answering whether it could**. `nil` until that
/// field has been made, which is also exactly when the toolbar has no search item to focus.
///
/// The answer matters because an installed item is not always a reachable one: pushed into the
/// system overflow by a narrow window, the field is in no window and cannot take the keyboard.
/// "F always summons search" (03-board-ui.md Toolbar), so that case falls through to the
/// transient host exactly as a removed item does an item the user cannot type into is a
/// removal by another name.
var focusField: (() -> Bool)?
/// Makes the *transient* field first responder the same job, one row lower.
var focusTransientField: (() -> Void)?
/// Returns the keyboard to the lane strip **Escape's second step** in an empty field
/// (04 § Search: "in an empty field it returns focus to the board"). Filled in by `BoardView`,
/// which owns the strip's `@FocusState`; the field cannot do this itself, because resigning
/// first responder would leave the window focused and the board's grammar keys dead.
var focusBoard: (() -> Void)?
// MARK: - F
/// What F does, given where the field lives right now the decision, as a value, so 03's
/// transient clause is testable without a toolbar.
enum Invocation: Equatable {
/// The item is installed: focus it, which is all F ever meant before the toolbar became
/// customizable.
case focusToolbarField
/// The item is gone and the transient host is not up yet: raise it, then focus.
case surfaceTransiently
/// The transient host is already up (a second F, or one during an active search): just
/// focus it. Re-raising would be a no-op the user could not tell from a re-focus, but
/// keeping it separate is what makes "surfacing" mean the transition and not the state.
case focusTransientField
}
static func invocation(isInstalledInToolbar: Bool, isTransient: Bool) -> Invocation {
if isInstalledInToolbar { return .focusToolbarField }
return isTransient ? .focusTransientField : .surfaceTransiently
}
/// **Whether the transient host has earned its place** 03's "until the search clears", read
/// honestly: a query still filtering the board keeps it, and so does the keyboard being in it.
///
/// The focus half is not a decoration. Without it, typing a query and deleting it back to empty
/// would yank the field out from under the caret mid-edit; with it, the surface survives exactly
/// as long as the user is still searching, and Escape's staged exit (clear, then hand the
/// keyboard back) dismisses it on the second press, which is the same shape the field's Escape
/// already has.
static func transientPersists(query: String, isFocused: Bool) -> Bool {
!query.isEmpty || isFocused
}
/// Edit Find's whole behaviour on a board window.
func invokeSearch() {
switch Self.invocation(isInstalledInToolbar: isInstalledInToolbar, isTransient: isTransient) {
case .focusToolbarField:
// A field in the system overflow answers `false` see `focusField`.
if focusField?() != true { surfaceTransiently() }
case .focusTransientField:
focusTransientField?()
case .surfaceTransiently:
surfaceTransiently()
}
}
private func surfaceTransiently() {
// The strip joins and leaves the window's layout, so it animates in the structural voice
// with Reduce Motion asked of AppKit rather than of the environment, because the caller is a
// menu command whose content is built outside any rendered hierarchy
// (`Motion.prefersReducedMotion`).
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
isTransient = true
}
// The host renders the field on the next update, so there is usually nothing to focus yet;
// the flag is claimed by the field as it appears.
if let focusTransientField {
focusTransientField()
} else {
wantsFocusOnAppear = true
}
}
/// Claimed once, by the transient field as it arrives.
func consumeFocusOnAppear() -> Bool {
defer { wantsFocusOnAppear = false }
return wantsFocusOnAppear
}
/// Takes the transient host back down once the search has cleared. Called by the host on every
/// query and focus change; a no-op whenever the field is in the toolbar, where there is no
/// transient host to take down.
func dismissTransientIfCleared(query: String) {
guard isTransient, !Self.transientPersists(query: query, isFocused: isFocused) else { return }
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
isTransient = false
}
wantsFocusOnAppear = false
// The field goes with the strip; a stale handle would aim F at a view that has left the
// window, and the next surfacing makes a new one.
focusTransientField = nil
}
}
/// The focused board window's search field, beside `FocusedValues.boardStore` and
@@ -71,14 +188,12 @@ extension FocusedValues {
///
/// **Validation is scope and nothing else**, `BoardInfoCommand`'s rule for its reason: searching is
/// not a mutation, so neither the read-only lock nor the focused-editor rule closes it. A board
/// window is the only context it has (the card window's Edit Find is find-in-text 05, m6), and
/// with no board in front both focused values are absent, which is the disable.
/// window is the only context it has (the card window's Edit Find is find-in-text 05), and with
/// no board in front both focused values are absent, which is the disable.
///
// m6-toolbar: "removed from the toolbar, F surfaces it transiently until the search clears"
// (03-board-ui.md Toolbar). That belongs to the toolbar-customization card, which is what first
// makes removal possible: this item's action becomes "surface the field if it is not installed,
// then focus it", and the transient host is the thing m6 adds. Until then the field is always in
// the toolbar and focusing it is the whole of F.
/// **A board window in front is now the whole of the scope**, where m5 additionally required the
/// toolbar's field to exist: with the item removed, F raises the transient host instead
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
struct FindCommand: View {
@FocusedValue(\.boardStore) private var store
@@ -97,17 +212,19 @@ struct FindCommand: View {
if let findInText {
findInText()
} else {
search?.focusField?()
search?.invokeSearch()
}
}
.keyboardShortcut("f", modifiers: .command)
.disabled(findInText == nil && (store == nil || search?.focusField == nil))
.disabled(findInText == nil && (store == nil || search == nil))
}
}
// MARK: - The field
/// The board toolbar's search field an `NSSearchField`, hosted.
/// The board's search field an `NSSearchField`, made and wired in one place for its **two homes**:
/// the toolbar item that is its default (`BoardToolbar`) and the transient host F raises when that
/// item has been removed (`BoardSearchBar`).
///
/// ### Why AppKit and not `.searchable`
///
@@ -134,6 +251,13 @@ struct FindCommand: View {
/// (`SearchFilter`) no debounce, no commit step. The predicate is pure and the boards are one
/// folder deep, so the honest cost of a keystroke is one pass over the snapshot.
///
/// ### The store is the truth, in both directions
///
/// A query cleared by Escape or by a card's creation has to reach the control, and in the toolbar
/// there is no SwiftUI update pass to carry it so the controller *observes* `searchQuery` and
/// writes it back into the field. The guard against re-assigning the user's own text is not an
/// optimisation: assigning `stringValue` resets the selection and the insertion point.
///
/// ### What nothing here does, and that is the point
///
/// **Losing focus does not clear the query.** "Tab is the keep-filter path: plain key-view traversal
@@ -145,95 +269,142 @@ struct FindCommand: View {
/// **Nothing sets `isEditingInline`.** The field is a control, so the focused-editor lockdown stays
/// off and board menu commands keep acting on the selection, N included. The one narrow exception
/// is the caret chords, which read `BoardSearchPresentation.isFocused` (`caretChordsYield`).
struct BoardSearchField: NSViewRepresentable {
@MainActor
final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
let store: BoardStore
let presentation: BoardSearchPresentation
/// Which of the field's two homes this one is the only thing that differs between them, and it
/// differs in exactly one place: which focus handle the presentation gets.
enum Home {
case toolbar
case transient
}
func makeNSView(context: Context) -> NSSearchField {
private let store: BoardStore
private let presentation: BoardSearchPresentation
/// **Weak**: the field owns this controller (`FocusReportingSearchField.controller`), so the pair
/// lives exactly as long as whoever holds the field a toolbar item, or a SwiftUI view.
private weak var field: FocusReportingSearchField?
/// Makes the field, wires it, and hands it back. The caller owns the result and, through it,
/// everything here.
static func makeField(
store: BoardStore,
presentation: BoardSearchPresentation,
home: Home
) -> NSSearchField {
let field = FocusReportingSearchField()
let controller = BoardSearchFieldController(store: store, presentation: presentation, field: field)
field.controller = controller
// The delegate and nothing else: the field's *action* is deliberately unwired, because an
// action fires on submission and this filter has no submission. Every keystroke arrives as
// `controlTextDidChange`, which is also how the stock cancel button reaches the store it
// clears the text, so it is Escape's first step arriving as an ordinary change to "".
field.delegate = context.coordinator
field.delegate = controller
field.placeholderString = "Search"
field.stringValue = store.searchQuery
field.onFocusChange = { [presentation] focused in
presentation.isFocused = focused
}
// The handle F pulls. Held weakly through the view's own lifetime by capturing the field
// itself; the presentation outlives neither the window nor the field.
presentation.focusField = { [weak field] in
guard let field, let window = field.window else { return }
window.makeFirstResponder(field)
// The handle F pulls, filed under this field's home. Held weakly through the field, which
// outlives neither its window nor its item.
switch home {
case .toolbar:
presentation.focusField = { [weak field] in
guard let field, let window = field.window else { return false }
return window.makeFirstResponder(field)
}
case .transient:
presentation.focusTransientField = { [weak field] in
guard let field, let window = field.window else { return }
window.makeFirstResponder(field)
}
}
return field
}
func updateNSView(_ field: NSSearchField, context: Context) {
context.coordinator.owner = self
// The store is the truth: a query cleared by Escape or by a card's creation has to reach the
// control, and the guard keeps the user's own typing from being re-assigned under the caret
// (which would reset the selection and the insertion point on every keystroke).
if field.stringValue != store.searchQuery {
field.stringValue = store.searchQuery
}
/// An inert field for the customization palette's copy of the search item: it looks like the
/// real one and does nothing, because a palette item that wrote to the store or claimed F's
/// handle would be a second live field.
static func makePaletteField() -> NSSearchField {
let field = NSSearchField()
field.placeholderString = "Search"
field.isEnabled = false
return field
}
func makeCoordinator() -> Coordinator {
Coordinator(owner: self)
private init(store: BoardStore, presentation: BoardSearchPresentation, field: FocusReportingSearchField) {
self.store = store
self.presentation = presentation
self.field = field
super.init()
trackQuery()
}
/// The field's delegate: the live write-through, and the two keys 04 gives meanings of its own.
final class Coordinator: NSObject, NSSearchFieldDelegate {
/// Puts the store's query back into the control when something other than typing changed it.
private func syncFromStore() {
guard let field, field.stringValue != store.searchQuery else { return }
field.stringValue = store.searchQuery
}
var owner: BoardSearchField
init(owner: BoardSearchField) {
self.owner = owner
}
func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSSearchField else { return }
owner.store.searchQuery = field.stringValue
}
func controlTextDidEndEditing(_ notification: Notification) {
owner.presentation.isFocused = false
}
/// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled).
///
/// - `cancelOperation:` Escape. A non-empty field clears the query and *keeps* the
/// keyboard; an empty one hands it back to the board. One press, one layer, which is the
/// same shape `BoardView.handleEscape` gives the board side (and the third step of the
/// same staircase: with board focus and an active search, Escape clears the search).
/// - `insertNewline:` Return. "The filter is live, there is nothing to submit it never
/// reaches the board's rename/create grammar." Returning `true` is that no-op: the key is
/// consumed here and the board never sees it.
///
/// Everything else falls through to the field editor untouched, which is the whole of "stock
/// `NSSearchField` behavior, no pass-throughs".
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
switch selector {
case #selector(NSResponder.cancelOperation(_:)):
if owner.store.searchQuery.isEmpty {
owner.presentation.focusBoard?()
} else {
owner.store.clearSearch()
control.stringValue = ""
}
return true
case #selector(NSResponder.insertNewline(_:)):
return true
default:
return false
/// Re-arms itself after every change, `WindowToolbarController.trackValidationState`'s shape and
/// for its reason: `withObservationTracking` is one-shot, and the field outlives any single
/// notification. It stops for good when the field has gone.
private func trackQuery() {
withObservationTracking {
_ = store.searchQuery
} onChange: { [weak self] in
Task { @MainActor in
guard let self, self.field != nil else { return }
self.syncFromStore()
self.trackQuery()
}
}
}
// MARK: NSSearchFieldDelegate
func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSSearchField else { return }
store.searchQuery = field.stringValue
}
func controlTextDidEndEditing(_ notification: Notification) {
presentation.isFocused = false
}
/// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled).
///
/// - `cancelOperation:` Escape. A non-empty field clears the query and *keeps* the
/// keyboard; an empty one hands it back to the board. One press, one layer, which is the
/// same shape `BoardView.handleEscape` gives the board side (and the third step of the
/// same staircase: with board focus and an active search, Escape clears the search).
/// - `insertNewline:` Return. "The filter is live, there is nothing to submit it never
/// reaches the board's rename/create grammar." Returning `true` is that no-op: the key is
/// consumed here and the board never sees it.
///
/// Everything else falls through to the field editor untouched, which is the whole of "stock
/// `NSSearchField` behavior, no pass-throughs".
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
switch selector {
case #selector(NSResponder.cancelOperation(_:)):
if store.searchQuery.isEmpty {
presentation.focusBoard?()
} else {
store.clearSearch()
control.stringValue = ""
}
return true
case #selector(NSResponder.insertNewline(_:)):
return true
default:
return false
}
}
}
/// An `NSSearchField` that says when it takes the keyboard.
/// An `NSSearchField` that says when it takes the keyboard, and keeps its controller alive.
///
/// The gain is reported here rather than through `controlTextDidBeginEditing` because that
/// notification is about an *edit session*, and the caret-chord rule is about focus: a field the
@@ -241,13 +412,75 @@ struct BoardSearchField: NSViewRepresentable {
/// is the delegate's `controlTextDidEndEditing`, which fires when the field editor goes the
/// symmetric hook (`resignFirstResponder`) is the field editor's rather than the control's and never
/// reaches this class.
private final class FocusReportingSearchField: NSSearchField {
///
/// `controller` is the one strong link in the pair: `NSControl.delegate` is weak, and a controller
/// nobody held would be gone before the first keystroke.
final class FocusReportingSearchField: NSSearchField {
var onFocusChange: ((Bool) -> Void)?
var controller: BoardSearchFieldController?
override func becomeFirstResponder() -> Bool {
let accepted = super.becomeFirstResponder()
if accepted { onFocusChange?(true) }
return accepted
}
}
// MARK: - The transient host
/// The search field one row below its home what F raises when the toolbar item has been removed
/// (03-board-ui.md Toolbar: "with the field removed from the toolbar, invoking it surfaces the
/// field transiently until the search clears").
///
/// **A strip in the window's content, not an item pushed back into the toolbar.** Re-inserting the
/// item would fight the thing the user just did the arrangement is theirs, and it is autosaved
/// and would leave the Customize sheet describing a toolbar that is about to change under it. A
/// find-bar-shaped strip below the title bar is the platform's own answer for a search surface that
/// comes and goes, and it leaves the toolbar exactly as customized.
///
/// It renders the *same* field as the toolbar item, made by the same factory: one implementation of
/// Escape's staging, of the live write-through, and of the focus reporting, whichever home it is in.
struct BoardSearchBar: View {
let store: BoardStore
let presentation: BoardSearchPresentation
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
Spacer(minLength: 0)
BoardSearchFieldView(store: store, presentation: presentation)
.frame(width: 220)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.bar)
Divider()
}
}
}
/// The field, hosted for SwiftUI the transient host's half of the two homes.
private struct BoardSearchFieldView: NSViewRepresentable {
let store: BoardStore
let presentation: BoardSearchPresentation
func makeNSView(context: Context) -> NSSearchField {
BoardSearchFieldController.makeField(store: store, presentation: presentation, home: .transient)
}
/// **Where the F that raised this bar lands.** The invocation happened one update ago, before
/// this view existed, so the keyboard is claimed here after SwiftUI has put the field in a
/// window, which `makeNSView` cannot promise.
func updateNSView(_ field: NSSearchField, context: Context) {
guard presentation.consumeFocusOnAppear() else { return }
Task { @MainActor in
guard let window = field.window else { return }
window.makeFirstResponder(field)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
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")
}
// 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, 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 "the pair disabled on boards without undo
/// matching their menu items" (03) is not a predicate written here it is the same validation, and
/// on a base-edition board (no undo stack until m8 wires native undo) both are disabled for the same
/// reason the menu rows are.
///
/// 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]
static func specs(store: BoardStore, search: BoardSearchPresentation) -> [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() }
)
),
.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,
behavior: .control(width: 220) { [weak store] willBeInserted in
guard willBeInserted, let store else {
return BoardSearchFieldController.makePaletteField()
}
return BoardSearchFieldController.makeField(
store: store,
presentation: search,
home: .toolbar
)
}
),
]
}
/// 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) -> WindowToolbarController {
let controller = WindowToolbarController(
identifier: identifier,
specs: specs(store: store, search: search),
defaults: defaultItems
)
controller.onInstalledItemsChanged = { [weak search] identifiers in
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
}
return controller
}
}
+34 -23
View File
@@ -203,36 +203,47 @@ struct ShowTrashCommand: View {
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched the
/// board it names is still right there.
///
/// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
/// Show Trash item is this same command with a different face (03-board-ui.md Toolbar: "toggle
/// state matching the View menu checkmark"), and the consequence above has to be true of both.
private var isVisible: Binding<Bool> {
Binding(
get: { store?.transient.isTrashVisible ?? false },
set: { shown in
guard let store else { return }
// The re-divide is a *user-initiated structural change* every lane compresses or
// relaxes as the trash's one unit joins or leaves the division so it animates in
// the structural voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a
// re-divide trigger", a lane add's behaviour exactly). It is also one of the few
// structural changes that never touches disk, which is why it wears its own
// transaction here instead of arriving through the reload seam like the rest.
//
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's
// content is built outside any rendered hierarchy, where the environment's
// accessibility values are not reliably populated (`Motion.prefersReducedMotion`).
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
store.transient.isTrashVisible = shown
// Dropped inside the same transaction: the rows it pointed at are leaving under
// this very animation, and a selection that cleared outside it would be the
// highlight easing on its own which 03 § Motion rules out ("the selection
// highlight rides whatever transaction is active").
if !shown, store.selection.liveness == .trashed {
store.clearSelection()
}
}
}
set: { shown in store?.setTrashVisible(shown) }
)
}
}
extension BoardStore {
/// Show/Hide Trash, with its one consequence **the whole of the View-menu row's behaviour**,
/// shared with the toolbar item that mirrors it.
func setTrashVisible(_ shown: Bool) {
// The re-divide is a *user-initiated structural change* every lane compresses or relaxes
// as the trash's one unit joins or leaves the division so it animates in the structural
// voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a re-divide trigger", a
// lane add's behaviour exactly). It is also one of the few structural changes that never
// touches disk, which is why it wears its own transaction here instead of arriving through
// the reload seam like the rest.
//
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's content
// is built outside any rendered hierarchy, where the environment's accessibility values are
// not reliably populated (`Motion.prefersReducedMotion`) and a toolbar item has no
// environment at all.
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
transient.isTrashVisible = shown
// Dropped inside the same transaction: the rows it pointed at are leaving under this
// very animation, and a selection that cleared outside it would be the highlight easing
// on its own which 03 § Motion rules out ("the selection highlight rides whatever
// transaction is active").
if !shown, selection.liveness == .trashed {
clearSelection()
}
}
}
}
// MARK: - The alert
extension View {
+107
View File
@@ -0,0 +1,107 @@
import AppKit
// MARK: - Identifiers
extension NSToolbarItem.Identifier {
static let cardEditBody = Self("card.editBody")
static let cardRawSource = Self("card.rawSource")
static let cardAddAttachment = Self("card.addAttachment")
}
// MARK: - The card window's toolbar
/// The card window's toolbar (03-board-ui.md Toolbar; 05-card-window.md Window).
///
/// "**Card window default: Edit Body · Raw Source · Add Attachment** the window's three committed
/// functions, all discoverable from its toolbar; the catalog is the same trio." So the default set
/// *is* the catalog here, and Customize offers rearrangement and removal rather than a choice of
/// items which is exactly what a window with three functions should offer.
///
/// ### The three items are the three menu rows, predicates included
///
/// - **Edit Body** is "a single toggle button (on-state in Edit mirroring the View Edit Body
/// checkmark)", and it disables while source mode is active. That clause is not restated here: the
/// predicate is `EditBodyCommand.isEnabled(body:rawSource:)`, the menu row's own, handed to the
/// item verbatim. The pathfinder's segmented Preview|Edit is retired, so this is one button.
/// - **Raw Source** is "likewise a toggle showing on-state", and its two directions are not
/// symmetric: on enters, off *applies* (`CardRawSourceSession.applyAndLeave`). A refused Apply
/// leaves source mode open, and the item's on-state simply fails to clear the same way the menu
/// row's checkmark does, because both read `isActive`.
/// - **Add Attachment** "stays enabled in every mode attachment operations never touch
/// `index.md`, so they're safe alongside a raw edit". Its predicate is likewise the row's own
/// (`AddAttachmentCommand.isEnabled`), which is scope plus the read-only lock and says nothing
/// about the body's mode.
///
/// Labels are the menu titles minus a trailing ellipsis, so File Add Attachment labels as **Add
/// Attachment** (03's own example).
@MainActor
enum CardToolbar {
static let identifier = "dev.rzen.indie.Kanban.card"
/// "The catalog is the same trio" so the defaults are the catalog, in 03's order.
static let defaultItems: [NSToolbarItem.Identifier] = [
.cardEditBody,
.cardRawSource,
.cardAddAttachment,
]
static func specs(
body: CardBodyPresentation,
rawSource: CardRawSourceSession,
attachments: CardAttachments
) -> [ToolbarItemSpec] {
[
.mirroring(
menuTitle: "Edit Body",
identifier: .cardEditBody,
symbol: "square.and.pencil",
behavior: .toggle(
isEnabled: { [weak body, weak rawSource] in
EditBodyCommand.isEnabled(body: body, rawSource: rawSource)
},
isOn: { [weak body] in body?.mode == .edit },
setOn: { [weak body] isOn in body?.setMode(isOn ? .edit : .preview) }
)
),
.mirroring(
menuTitle: "Raw Source",
identifier: .cardRawSource,
symbol: "doc.plaintext",
behavior: .toggle(
isEnabled: { [weak rawSource] in rawSource != nil },
isOn: { [weak rawSource] in rawSource?.isActive == true },
setOn: { [weak rawSource] isOn in
guard let rawSource else { return }
if isOn {
rawSource.enter()
} else {
rawSource.applyAndLeave()
}
}
)
),
.mirroring(
menuTitle: "Add Attachment…",
identifier: .cardAddAttachment,
symbol: "paperclip",
behavior: .button(
isEnabled: { [weak attachments] in AddAttachmentCommand.isEnabled(attachments) },
perform: { [weak attachments] in attachments?.add() }
)
),
]
}
static func controller(
body: CardBodyPresentation,
rawSource: CardRawSourceSession,
attachments: CardAttachments
) -> WindowToolbarController {
WindowToolbarController(
identifier: identifier,
specs: specs(body: body, rawSource: rawSource, attachments: attachments),
defaults: defaultItems
)
}
}
+17
View File
@@ -132,12 +132,15 @@ enum Motion {
enum Appearance: Equatable {
/// Scales up from `from` while fading in (and the reverse on the way out).
case scaleAndFade(from: CGFloat)
/// Slides in from `edge` while fading in, and leaves the way it came.
case slideAndFade(from: Edge)
/// Opacity only 10-accessibility.md's "crossfade" variant.
case crossfade
var transition: AnyTransition {
switch self {
case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
case let .slideAndFade(edge): .move(edge: edge).combined(with: .opacity)
case .crossfade: .opacity
}
}
@@ -165,6 +168,20 @@ enum Motion {
laneAppearance(reduced: reduced).transition
}
/// The transient search bar arriving and leaving F's fallback when the search field has been
/// removed from the toolbar (03-board-ui.md Toolbar: "with the field removed from the toolbar,
/// invoking it surfaces the field transiently until the search clears").
///
/// It comes from the top edge because that is where the field lives when it is installed: the
/// bar is the toolbar's item arriving one row lower, not a new kind of surface.
static func transientSearchAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .slideAndFade(from: .top)
}
static func transientSearchTransition(reduced: Bool) -> AnyTransition {
transientSearchAppearance(reduced: reduced).transition
}
// MARK: - The AppKit face
/// The lane-resize window animation's duration, for `NSAnimationContext` which takes a number