import AppKit import Observation 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. /// /// `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. @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 /// 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)? /// 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)? } /// 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, m6), 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. struct FindCommand: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardSearch) private var search var body: some View { Button("Find") { search?.focusField?() } .keyboardShortcut("f", modifiers: .command) .disabled(store == nil || search?.focusField == nil) } } // MARK: - The field /// The board toolbar's search field — an `NSSearchField`, hosted. /// /// ### 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. /// /// ### 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`). struct BoardSearchField: NSViewRepresentable { let store: BoardStore let presentation: BoardSearchPresentation func makeNSView(context: Context) -> NSSearchField { let field = FocusReportingSearchField() // 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.placeholderString = "Search" 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) } 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 } } func makeCoordinator() -> Coordinator { Coordinator(owner: self) } /// The field's delegate: the live write-through, and the two keys 04 gives meanings of its own. final class Coordinator: NSObject, NSSearchFieldDelegate { 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 } } } } /// An `NSSearchField` that says when it takes the keyboard. /// /// 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. private final class FocusReportingSearchField: NSSearchField { var onFocusChange: ((Bool) -> Void)? override func becomeFirstResponder() -> Bool { let accepted = super.becomeFirstResponder() if accepted { onFocusChange?(true) } return accepted } }