Implement live search filtering

The board's live title+body filter per 04-interactions.md § Search:

- SearchFilter — a pure value folding the query once (case- and
  diacritic-insensitive substring, locale-stable); title OR body matches,
  attachment filenames never searched; only the literal empty string is
  inactive.
- One universe: the filter threads through SelectionGrammar's order lists
  as a defaulted parameter, so ranges, Select All, arrow navigation, the
  marquee, drop zones, count badges, and the shown trash all read the same
  filtered set by construction; lanes are deliberately never filtered out
  (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the
  selection through the existing constrain primitive, run on every query
  change and as the last line of the reload resolve; the delete successor
  is filtered so ⌫ never selects a hidden neighbour.
- The field: an NSSearchField-backed toolbar item (the toolbar's sole
  default item); Edit ▸ Find ⌘F focuses it through a focused-value
  presentation; stock field-editor dispatch — Return swallowed, Tab is the
  keep-filter path to the board, board commands stay enabled except the
  caret-chord pair, now one shared caretChordsYield expression.
- Escape is staged: clear the non-empty query (focus stays), hand an empty
  field back to the board, clear an active search from board focus —
  before Escape's clear-selection meaning.
- Creating a card clears the search (the placeholder funnel); a rename
  deliberately gets no carve-out; filter reflow rides the content spring
  keyed narrowly on the query.

903 unit tests (24 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 22:49:15 -04:00
parent 7eee0934ee
commit cf87b72092
15 changed files with 1359 additions and 84 deletions
+60 -4
View File
@@ -339,7 +339,12 @@ public final class TransientBoardState {
/// against whatever snapshot is current, so the filter is never stale, and the selection is kept
/// honest against it by `ItemReferenceSet.constrained(to:)` with the visible ids as the universe
/// the same rule a reload uses, which is why "hidden cards leave the selection" needs no code
/// of its own.
/// of its own (`constrainToSearch(in:)`).
///
/// **Written through `BoardStore.searchQuery`, not here**, on every path that *narrows* it: the
/// store is what has a snapshot, and narrowing without constraining would leave a selection
/// pointing at cards nobody can see. Widening `beginPlaceholder`'s creation clear, and the
/// clear Escape performs is safe from anywhere, because a bigger universe invalidates nothing.
public var searchQuery: String = ""
// MARK: The inline editors
@@ -463,10 +468,22 @@ public final class TransientBoardState {
/// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the
/// focus is being taken by another editor before the user has said they are done.
///
/// **Creation clears the search, and this is the funnel** (04-interactions.md § Search): "a
/// brand-new card must not be born invisible". Every entry point to creation goes through here
/// N, Return on a lane, the lane header's button, a double-click on empty space so the
/// carve-out is stated once instead of four times.
///
/// **Rename deliberately gets no such line** (04, settled): "the filter stays a pure predicate
/// with one exception, not two". A rename committed under an active search re-runs the
/// predicate like any other edit, and a title that stops matching animates its card out and
/// drops it from the selection which falls out of `BoardStore.commitRename`'s ordinary write
/// and the reload's `constrainToSearch(in:)`, with nothing here to arrange it.
///
/// - Parameter anchorCardID: the card the new one is born immediately after (04's N target
/// rule), or `nil` for the lane's bottom which is what Return, the header button, and a
/// double-click on empty space all pass.
public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
searchQuery = ""
renameEditor = nil
newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
noteActiveLane(laneID)
@@ -603,9 +620,13 @@ public final class TransientBoardState {
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
/// both halves.
///
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references
/// an item, so no snapshot can invalidate either the query's *results* change with every
/// snapshot, which is precisely why the results are not stored here.
/// **`searchQuery` is re-applied rather than re-resolved.** It references no item, so no
/// snapshot can invalidate it but its *results* change with every snapshot, and a reload
/// landing under an active query can hide a selected card as surely as a query change can (an
/// agent editing a title out of the match is the case). So `constrainToSearch(in:)` runs last,
/// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one
/// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with
/// nothing to say here at all.
public func resolve(against snapshot: BoardModel) {
selection = selection.resolved(against: snapshot)
dragMembers = dragMembers.resolved(against: snapshot)
@@ -635,6 +656,41 @@ public final class TransientBoardState {
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
constrainToSearch(in: snapshot)
}
/// **Hidden cards leave the selection** (04-interactions.md § Search) the constraint rule with
/// the *filter's* universe supplied, which is the second of the two directions
/// `ItemReferenceSet.constrained(to:)`'s doc comment names.
///
/// Called on exactly two occasions, and they are the two ways the visible universe can narrow:
/// when the **query changes** (`BoardStore.searchQuery`'s setter) and when a **reload lands
/// under an active query** (`resolve(against:)` above, whose last line this is). Both hand it
/// the current snapshot, because the predicate has nothing else to run against.
///
/// **A no-op with no search running**, deliberately: with the filter off the visible universe is
/// the whole board, so constraining to it could only ever be the identity and stating that as
/// an early return rather than letting it fall out keeps the reload path free of a board-sized
/// set computation nobody needs.
///
/// The anchor and the head obey the same universe rule the reload gives them, for the same
/// reason: a range origin or a navigation cursor sitting on a card the filter hid would range or
/// step from somewhere the user cannot see. Neither has to stay *in* the selection that
/// asymmetry is `resolve`'s and survives here untouched.
///
/// **The drag and the pending cut are deliberately left alone.** 04 hides cards and says one
/// thing about the consequence that they leave the *selection*. A cut is staged content
/// waiting for a paste that may well happen after the search clears, and a drag under a live
/// filter is a gesture in flight, not a set the filter has any claim on.
public func constrainToSearch(in snapshot: BoardModel) {
let filter = SearchFilter(query: searchQuery)
guard filter.isActive else { return }
let universe = filter.visibleIDs(in: snapshot, on: selection.liveness)
selection = selection.constrained(to: universe)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.