Files
lanework/Kanban/UI/Board/BoardSearchField.swift
T
rzen 6dc84176fb Build Preview mode rendering
The card body's resting state: swift-markdown (pinned 0.8.0, smart
typography off — Preview renders the bytes on disk) parsed into a pure
BodyMarkup model with UTF-8 source offsets, rendered on one hosted
TextKit 1 NSTextView — chosen because find-in-text is NSTextFinder,
checkbox clicks reuse AppKit character hit-testing, links are .link
attributes, and NSTextTable's automatic layout is exactly the
columns-sized-to-contents rule. The GFM subset renders per 05; HTML
stays verbatim code-styled text; relative images resolve against the
card folder while remote URLs are never fetched, drawing a quiet chip
instead. Task checkboxes are live: a click flips exactly one byte
through a fresh-read, refuse-uneditable, stamp, atomic-replace write —
the app's only offset-addressed write, so a moved target refuses as
staleTarget and what the user saw decides the direction, netting one
toggle on a double-click. Empty bodies open in Edit per CardBodyMode's
opening rule, applied once; the Edit surface itself stays an honest
read-only stub until its card. FindCommand prefers the card body's
find over board search when a card window is focused.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 09:59:18 -04:00

254 lines
13 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 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
@FocusedValue(\.cardBody) private var cardBody
/// **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") {
if let findInText {
findInText()
} else {
search?.focusField?()
}
}
.keyboardShortcut("f", modifiers: .command)
.disabled(findInText == nil && (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
}
}