The board search item becomes the stock NSSearchToolbarItem — the field grows on focus

The toolbar's search field now lives in AppKit's own NSSearchToolbarItem:
em(17) is the focused width (preferredWidthForSearchField), the resting
width is the item's own, and ⌘F becomes beginSearchInteraction — one call
that expands and focuses. Escape's empty-field exit collapses the field as
the keyboard leaves. resignsFirstResponderWithCancel is off so the staged
Escape survives the cancel button. The item ships its own live overflow row
and an above-.high visibility priority, so both custom-view workarounds
retire. The palette copy stays inert through an enablement round-trip the
item's searchField setter would otherwise overwrite.

The centering half of the card is blocked on a Design ruling (03 settles
"trailing") and is not built; the two-homes width question is filed.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 11:16:58 -04:00
parent 31fee00c73
commit 5e6417e749
4 changed files with 354 additions and 53 deletions
+71 -29
View File
@@ -53,7 +53,7 @@ struct ToolbarItemSpec {
let identifier: NSToolbarItem.Identifier
let label: String
/// The SF Symbol the item draws. `nil` for a hosted control, which draws itself.
/// The SF Symbol the item draws. `nil` for the search item, whose field draws itself.
let symbol: String?
let behavior: Behavior
@@ -68,10 +68,21 @@ struct ToolbarItemSpec {
/// is how their menu rows work too, so "matching their menu items" is one mechanism rather
/// than two (03 Toolbar; 06-history-undo.md).
case responderAction(Selector)
/// A hosted control the board's search field. `make` is handed `true` when the view is
/// bound for the toolbar itself and `false` when it is the customization palette's copy, so
/// only the real one claims window-scoped wiring.
case control(width: CGFloat, make: (_ willBeInsertedIntoToolbar: Bool) -> NSView)
/// A search field in AppKit's own `NSSearchToolbarItem` the board's search
/// (03-board-ui.md Toolbar). The item owns the field's layout, so `focusedWidth` is a
/// preference rather than a constraint: it is the width the field takes *when it has the
/// keyboard*, the resting width being the item's own.
///
/// `make` is handed `true` when the field is bound for the toolbar itself and `false` when
/// it is the customization palette's copy, so only the real one claims window-scoped
/// wiring. `install` runs for that real item alone, and is where a caller wires the things
/// that need the *item* rather than the field expanding it and putting the keyboard in it
/// is one call on `NSSearchToolbarItem`, and no field can make it.
case searchField(
focusedWidth: CGFloat,
make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField,
install: (NSSearchToolbarItem) -> Void
)
}
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
@@ -103,14 +114,14 @@ struct ToolbarItemSpec {
// MARK: State
/// The item's live enablement. Responder-chain items and hosted controls answer `true`: the
/// The item's live enablement. Responder-chain items and the search item answer `true`: the
/// first is validated by the chain itself (which is the point of it), and the second has no
/// enablement of its own.
var isEnabled: Bool {
switch behavior {
case let .button(isEnabled, _): isEnabled()
case let .toggle(isEnabled, _, _): isEnabled()
case .responderAction, .control: true
case .responderAction, .searchField: true
}
}
@@ -118,7 +129,7 @@ struct ToolbarItemSpec {
var isOn: Bool? {
switch behavior {
case let .toggle(_, isOn, _): isOn()
case .button, .responderAction, .control: nil
case .button, .responderAction, .searchField: nil
}
}
@@ -128,7 +139,7 @@ struct ToolbarItemSpec {
switch behavior {
case let .button(_, perform): perform()
case let .toggle(_, isOn, setOn): setOn(!isOn())
case .responderAction, .control: break
case .responderAction, .searchField: break
}
}
}
@@ -155,10 +166,12 @@ struct ToolbarItemSpec {
/// the pair enables and disables with the menu rows by construction rather than by agreement,
/// reading the board window's `BoardUndoManager` through `NSWindow`'s own validation
/// (13-native-undo.md). A SwiftUI `Button` cannot express that.
/// - **The search item hosts a real `NSSearchField`** with explicit first-responder control, settled
/// in m5 for reasons `BoardSearchFieldController` records (F must focus it from a menu item;
/// Escape in an empty field must hand the keyboard back to the strip). A toolbar that already
/// speaks AppKit hosts it directly.
/// - **The search item is AppKit's own `NSSearchToolbarItem`**, hosting a real `NSSearchField` with
/// explicit first-responder control, settled in m5 for reasons `BoardSearchFieldController`
/// records (F must focus it from a menu item; Escape in an empty field must hand the keyboard
/// back to the strip). The item is where grow-on-focus, the cancel button's staging, and the
/// overflow row all live, and it exists only in AppKit; a toolbar that already speaks AppKit
/// hosts it directly.
/// - **The window is already proxied.** `HostedWindowController` fronts SwiftUI's window delegate
/// and installs the board's titlebar accessory; a toolbar is the same kind of thing hung on the
/// same window, through the same install-once seam.
@@ -280,8 +293,15 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return makeToggleItem(spec)
case let .responderAction(selector):
return makeResponderItem(spec, selector: selector)
case let .control(width, make):
return makeControlItem(spec, width: width, view: make(willBeInsertedIntoToolbar))
case let .searchField(focusedWidth, make, install):
return makeSearchItem(
spec,
focusedWidth: focusedWidth,
field: make(willBeInsertedIntoToolbar),
// The palette's copy is a picture of the item, not a second live one: it claims no
// window-scoped wiring, so it is handed none.
install: willBeInsertedIntoToolbar ? install : nil
)
}
}
@@ -346,21 +366,43 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return item
}
/// The board's search field, hosted. Sized rather than flexible, matching the width the field
/// shipped with in m5.
private func makeControlItem(_ spec: ToolbarItemSpec, width: CGFloat, view: NSView) -> NSToolbarItem {
view.translatesAutoresizingMaskIntoConstraints = false
view.widthAnchor.constraint(equalToConstant: width).isActive = true
let item = NSToolbarItem(itemIdentifier: spec.identifier)
/// The board's search field in the item AppKit wrote for it.
///
/// **No width constraint here.** `NSSearchToolbarItem` manages the field's layout, and its
/// header says custom width constraints "should not conflict with" the preferred width so the
/// em-based figure is handed over as a *preference*, which the item applies "whenever it gets
/// the keyboard focus". The resting width is the item's own, and grow-on-focus is what that
/// pair of facts means on screen.
///
/// **Neither the overflow row nor the visibility priority is written here, and both omissions
/// are decisions.** The item ships its own `menuFormRepresentation` a row titled from `label`
/// carrying a live AppKit action that widens the window until the field is usable where a
/// custom-view item ships a blank one that has to be replaced; assigning here (`nil` included)
/// destroys it. And its `visibilityPriority` already starts one step *above* `.high`, so the
/// nudge a custom-view item needs to stay out of the overflow would be a demotion here.
private func makeSearchItem(
_ spec: ToolbarItemSpec,
focusedWidth: CGFloat,
field: NSSearchField,
install: ((NSSearchToolbarItem) -> Void)?
) -> NSToolbarItem {
let item = NSSearchToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
item.view = view
// No menu form representation, deliberately: a search field in the overflow *menu* is a
// field nobody can type into, so AppKit's generated row label, no action, disabled is
// the honest presentation. F covers that window: an installed field that cannot take the
// keyboard falls through to the transient strip (`BoardSearchPresentation.focusField`).
// The high priority keeps the board's one default item out of the overflow to begin with.
item.visibilityPriority = .high
// Configured before assignment, as the item's header asks with one exception the header
// does not name: assignment stamps the *item's* enablement onto the field, so a field the
// caller made inert (the customization palette's copy) comes back live. The caller's answer
// is the one that counts, so it is put back.
let isFieldEnabled = field.isEnabled
item.searchField = field
field.isEnabled = isFieldEnabled
item.preferredWidthForSearchField = focusedWidth
// **Escape is staged** (04-interactions.md Search, settled: "in a non-empty field it
// clears the query, focus staying in the field; in an empty field it returns focus to the
// board"). AppKit's default is for the cancel button to clear *and* resign, which collapses
// the first two steps of that staircase into one so the field keeps the keyboard, and the
// second press is what hands it back (`BoardSearchFieldController`).
item.resignsFirstResponderWithCancel = false
install?(item)
return item
}