Files
lanework/Kanban/UI/Board/BoardSearchField.swift
T
rzen 5e6417e749 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
2026-08-01 11:16:58 -04:00

549 lines
28 KiB
Swift

import AppKit
import Observation
import SwiftUI
// MARK: - The window's search field, as a handle
/// 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
/// board window's field (Edit ▸ Find ⌘F) without anyone keeping a which-window-is-key register.
///
/// ### Why the focus flag is here rather than on the store
///
/// Two consumers need it and neither is inside the field: Edit ▸ Find (which focuses it) and the
/// **caret-chord commands**, which "disable while the field is focused" (04-interactions.md ▸
/// Grammar's caret-chords rule, ▸ Search). Both are menu items, and a menu item reaches a window
/// through `@FocusedValue`. It deliberately does *not* live on `BoardStore` beside `searchQuery`:
/// the query is the board's (every window on it filters alike), while the focus is one window's
/// keyboard — the same split `BoardInfoPresentation` makes for the popover flag.
///
/// **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 {
/// Whether the search field holds keyboard focus — the caret-chord commands' one input.
///
/// Written by the field itself, on `becomeFirstResponder` and on the field editor ending. It is
/// therefore *the field's* answer rather than an inference from SwiftUI focus state, which is
/// what makes it true for the AppKit key-view loop's Tab traversal as well as for ⌘F.
var isFocused = false
/// 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
/// Puts the keyboard in the toolbar's field, **answering whether it could**. `nil` until the
/// item that hosts that field has been made, which is also exactly when the toolbar has no
/// search item to focus.
///
/// It is `NSSearchToolbarItem.beginSearchInteraction()` underneath (`adopt`): one call that
/// expands the item to its focused width *and* moves the keyboard in, rather than a bare
/// `makeFirstResponder` that would focus a field still at its resting width.
///
/// 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
/// `FocusedValues.boardInfo` — see `FocusedBoardStoreKey` for why board-window menu items reach
/// their window this way.
struct FocusedBoardSearchKey: FocusedValueKey {
typealias Value = BoardSearchPresentation
}
extension FocusedValues {
var boardSearch: BoardSearchPresentation? {
get { self[FocusedBoardSearchKey.self] }
set { self[FocusedBoardSearchKey.self] = newValue }
}
}
// MARK: - Edit ▸ Find
/// Edit ▸ Find (⌘F) — "Board window: board search" (11-command-nexus.md; 04-interactions.md
/// § Search: "Search field invoked with ⌘F").
///
/// **Focus, not toggle.** ⌘I toggles the board popover because a shortcut that could only ever open
/// would leave that surface with no keyboard way out; the search field's way out is Escape's staged
/// exit, which the field owns, so ⌘F only ever means "put the keyboard here" — and pressing it with
/// the field already focused is a no-op the user cannot tell from a re-focus.
///
/// **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), and with
/// no board in front both focused values are absent, which is the disable.
///
/// **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.
/// **The card window's find routes by focus** (05-card-window.md ▸ Preview, comments clause): the
/// body's substrate when the body has the keyboard, the whole rendered thread when the comments pane
/// does, and that editor's own stock find bar when the composer or an inline session does. The rule
/// itself is `CardWindowFind.route`, pure and pinned; this row only asks it.
struct FindCommand: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardSearch) private var search
@FocusedValue(\.cardBody) private var cardBody
@FocusedValue(\.cardComments) private var comments
/// **The card window wins when it is the focused scene**, which is the whole of 11's split
/// ("Board window: board search; card window: find-in-text"): the two never both publish, so
/// this reads as a preference only because a scene value is `nil` in the scene that does not
/// have one. The card side is the standard find bar over its body surface
/// (`CardBodyPresentation.findInText`); the board side focuses the search field.
private var findInText: (() -> Void)? { cardBody?.findInText }
var body: some View {
Button("Find") {
// The pane answers first and says whether it took the key: with the comments pane focused
// — or its find bar already up — ⌘F is the thread's, and every other case falls through to
// the body exactly as it did before the pane existed.
if comments?.invokeFind(hasBody: findInText != nil) == true {
return
}
if let findInText {
findInText()
} else {
search?.invokeSearch()
}
}
.keyboardShortcut("f", modifiers: .command)
.disabled(findInText == nil && comments == nil && (store == nil || search == nil))
}
}
// MARK: - The field
/// 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`
///
/// Two requirements SwiftUI's modifier does not meet, both normative:
///
/// - **Explicit focus control.** ⌘F must put the keyboard in this field from a *menu item*, and
/// Escape in an empty field must hand the keyboard back to the strip. `.searchable` owns its own
/// focus and offers no handle on either half; a first responder is what both need, so the field
/// has to be a view something can hold.
/// - **Stock `NSSearchField` key behaviour.** 04-interactions.md § Search settles the dispatch as
/// "every key with the field focused acts on the field — stock `NSSearchField` behavior, no
/// pass-throughs", which is a promise about *AppKit's* text-field key handling: the field editor
/// takes the arrows as caret motion, ⇧-arrows as text selection, ⌫ as backspace, ⌘A/⌘X/⌘C/⌘V as
/// the text clipboard, and ⌘Z as the field's own text undo. Hosting the real control is how that
/// sentence is implemented rather than reimplemented.
///
/// Only the two keys the design gives *different* meanings are intercepted (`doCommandBy` below):
/// Return, which is a swallowed no-op because a live filter has nothing to submit, and Escape,
/// whose staging is the design's own and not the cancel button's.
///
/// ### Live per keystroke
///
/// `controlTextDidChange` writes straight through to `BoardStore.searchQuery`, which is the filter
/// (`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
/// moves focus to the board with the query intact, and the whole board grammar then applies over the
/// *filtered* board; ⌘F returns to the field" (04 § Search). Tab is the key-view loop's, untouched;
/// the query survives because only two things ever clear it — Escape and creation — and neither is
/// a blur.
///
/// **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`).
@MainActor
final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
/// 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, and who fills it in.
/// The transient field fills its own in, because a field inside a SwiftUI view is the whole of
/// that home; the toolbar's is the *item's* to give (`adopt`), because expanding the item and
/// putting the keyboard in it is one call on `NSSearchToolbarItem` and the field cannot make it.
enum Home {
case toolbar
case transient
}
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?
/// The item this field lives in, in the toolbar home — `nil` in the transient one, which has no
/// item and so nothing to collapse. **Weak**: the item owns the field, which owns this.
private weak var item: NSSearchToolbarItem?
/// 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 = controller
field.placeholderString = "Search"
field.stringValue = store.searchQuery
field.onFocusChange = { [presentation] focused in
presentation.isFocused = focused
}
// 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:
// Filled in by `adopt` when the item that hosts this field is built: ⌘F's handle there
// is the item's expand-and-focus, which is not the field's to offer.
break
case .transient:
presentation.focusTransientField = { [weak field] in
guard let field, let window = field.window else { return }
window.makeFirstResponder(field)
}
}
return field
}
/// Binds a field made for the toolbar to the `NSSearchToolbarItem` that hosts it — everything
/// about that home the field alone cannot answer.
///
/// ⌘F becomes `beginSearchInteraction()`, which expands the item to its focused width and moves
/// the keyboard into the field in one call. **It still answers whether it could**: an item
/// pushed into the system overflow has no window, and a field in no window cannot take the
/// keyboard, so ⌘F falls through to the transient strip exactly as a removed item does
/// (`BoardSearchPresentation.focusField`).
///
/// Called for the installed item only. The customization palette's copy is inert
/// (`makePaletteField`), so there is no controller here to adopt it and no handle for it to
/// claim — a palette item that answered ⌘F would be a second live field.
static func adopt(_ item: NSSearchToolbarItem, presentation: BoardSearchPresentation) {
let field = item.searchField
(field as? FocusReportingSearchField)?.controller?.item = item
presentation.focusField = { [weak item, weak field] in
guard let item, let field, field.window != nil else { return false }
item.beginSearchInteraction()
return true
}
}
/// 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.
///
/// Three things make it inert and each is load-bearing: no delegate, so a keystroke reaches no
/// store; no controller, so nothing observes the query; and no `adopt`, so ⌘F's handle stays the
/// installed item's. The disable is the part a *user* can see —
/// `NSSearchToolbarItem` overwrites it on assignment, and `WindowToolbarController` puts it back.
static func makePaletteField() -> NSSearchField {
let field = NSSearchField()
field.placeholderString = "Search"
field.isEnabled = false
return field
}
private init(store: BoardStore, presentation: BoardSearchPresentation, field: FocusReportingSearchField) {
self.store = store
self.presentation = presentation
self.field = field
super.init()
trackQuery()
}
/// 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
}
/// 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?()
// The keyboard has left, so the field goes back to its resting width with it —
// grow-on-focus, undone. A no-op in the transient home, which has no item.
item?.endSearchInteraction()
} 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, 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
/// user has Tabbed or ⌘F'd into but not yet typed in already owns ⌘←/⌘→ as line-start/end. The loss
/// 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.
///
/// `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
/// Reduce Transparency — **this bar is the board's one glass underlay** ("glass underlays go
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
/// becomes the opaque window background (`Accommodations.Underlay`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
Spacer(minLength: 0)
BoardSearchFieldView(store: store, presentation: presentation)
// A field wide enough for a query, in characters rather than points, so it grows
// with the text it holds (10-accessibility.md's full-relative-scaling rule).
.frame(width: BoardMetrics.em(17, bodyPointSize: pointSize))
}
.padding(.horizontal, BoardMetrics.stripGap(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
.background(Accommodations.underlay(reduceTransparency: reduceTransparency).style)
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)
}
}
}