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 /// 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 /// `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. 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? /// 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: 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 } /// 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 } 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?() } 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) } } }