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
+95 -18
View File
@@ -38,10 +38,19 @@ import SwiftUI
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### The live search filter
///
/// The filter itself is a pure predicate (`SearchFilter`) and the field is the toolbar's
/// (`BoardSearchField`, installed by `BoardWindowHost`); what belongs to this file is the two places
/// the board *reads* it the arrow grammar's order lists and jump containers, so navigation walks
/// the filtered board, and Escape's middle step. Everything else follows from `LaneView`'s and
/// `TrashLaneView`'s own narrowing, because the drop zones, the marquee and the file-drop targets
/// all read what those two rendered.
///
/// ### What is deliberately not here yet
///
/// The toolbar and search belong to later milestone cards; so do external Finder file drops, which
/// join the very drop delegates this file already attaches (see `BoardDrops.swift`).
/// External Finder file drops join the very drop delegates this file already attaches (see
/// `BoardDrops.swift`).
struct BoardView: View {
let store: BoardStore
@@ -60,6 +69,11 @@ struct BoardView: View {
/// needs the board's own window ref, which is the host's identity and not the board's.
let openCard: (ItemID) -> Void
/// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
/// fill in `focusBoard` Escape's "in an empty field it returns focus to the board" needs the
/// strip's own `@FocusState`, which nothing outside this view can reach.
let search: BoardSearchPresentation
/// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
/// Styling Controls).
@Environment(AppModel.self) private var appModel
@@ -161,7 +175,15 @@ struct BoardView: View {
.focusable()
.focusEffectDisabled()
.focused($isBoardFocused)
.onAppear { isBoardFocused = true }
.onAppear {
isBoardFocused = true
// **Escape's second step, wired from the side that can perform it** (04 § Search): the
// field can resign first responder on its own, but only the strip can *take* the
// keyboard, and a window with a resigned field and an unfocused board would swallow
// every grammar key. `@FocusState`'s setter is nonmutating, so the closure writes the
// same storage this view reads.
search.focusBoard = { isBoardFocused = true }
}
.onChange(of: store.isEditingInline) { _, editing in
// An editor took focus and has now given it back. Without this the strip stays unfocused
// after every rename and Return silently stops working.
@@ -274,6 +296,19 @@ struct BoardView: View {
// It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
// drawn beside it is never inside an animated transaction (03 § Motion again).
.animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal)
// **The search filter's reflow**, keyed on **the query** and nothing else 03-board-ui.md
// § Motion names it in the narrow-keys list ("on the search query (filter reflow)") and in
// the *content* voice rather than the structural one: "search filtering and undo/redo
// restore, deliberately paired so a restore reads like the search filter leavers and
// arrivers run their transition, survivors reflow under one gentle spring". The leavers and
// arrivers are the card and row transitions already attached inside the lanes and the trash
// column; this is the survivors' spring around them.
//
// **Every way the query changes rides it**, which is the reason the key is the query rather
// than the transaction being wrapped at each mutation: typing, the field's Escape, the
// board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
// here without any of them knowing about motion.
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchQuery)
}
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
@@ -409,6 +444,18 @@ struct BoardView: View {
store.transient.isTrashVisible
}
/// The trash's rows as the column is showing them the shown trash "participates in the filter
/// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen
/// (`TrashLaneView.entries` applies the identical predicate to the identical rows).
///
/// Read by the three keyboard destinations that reach into the column the arrow origin's
/// order list, /'s container, and 's jump so none of them can walk onto a row the
/// filter took away.
private var trashEntries: [TrashEntry] {
let filter = store.searchFilter
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
}
// MARK: - The drag
/// What each of this window's drop targets and its lanes' autoscroll drivers is handed.
@@ -565,12 +612,20 @@ struct BoardView: View {
}
/// **Escape steps outward one layer per press** (04 Grammar): abandon an open editor, else
/// clear the selection.
/// clear the search, else clear the selection.
///
/// The middle step clearing an active search and returning focus to the board is m5's, and
/// it slots between these two once the search field exists.
/// **The search takes Escape before its clear-selection meaning** (04 § Search, settled): "with
/// *board* focus and an active search, one press clears the search and the full board returns
/// search takes Escape before its clear-selection meaning, which applies only when no search is
/// active." So a board-focused Escape under a query returns the board and *keeps* the selection;
/// a second press then deselects. One press, one layer, all the way out.
///
/// The editors handle Escape themselves while they hold focus; this branch is the outer net for
/// This is the third step of a staircase whose first two are the field's own a non-empty field
/// clears its query and keeps the keyboard, an empty one hands the keyboard back here and the
/// two halves never both fire, because exactly one of the field and the strip holds focus (see
/// `BoardSearchField`).
///
/// The editors handle Escape themselves while they hold focus; that branch is the outer net for
/// the case where focus has drifted off the field with an editor still open, and it abandons
/// both kinds because at most one can be open at a time.
private func handleEscape() -> KeyPress.Result {
@@ -579,6 +634,10 @@ struct BoardView: View {
store.transient.discardRename()
return .handled
}
if !store.searchQuery.isEmpty {
store.clearSearch()
return .handled
}
guard !store.selection.isEmpty else { return .ignored }
store.clearSelection()
return .handled
@@ -657,6 +716,9 @@ struct BoardView: View {
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
/// every trash entry in its sorted order card and lane entries alike" (04 The trash). The
/// per-kind lists are the *range*'s business, not the walk's.
///
/// Both lists are the **filtered** board (04 § Search: "arrow nav read[s] it"), so the
/// fallback lands on the last *visible* member rather than on a card the query hid.
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
let selection = store.selection
guard !selection.isEmpty else { return nil }
@@ -667,10 +729,10 @@ struct BoardView: View {
case .live:
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
isLaneDomain = kind == .lane
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot)
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter)
case .trashed:
isLaneDomain = false
list = TrashModel.entries(of: store.snapshot).map(\.id)
list = trashEntries.map(\.id)
}
if let head = store.transient.selectionHead, list.contains(head) {
@@ -692,7 +754,7 @@ struct BoardView: View {
if mode == .jump, direction == .left || direction == .right {
return jumpToEndLane(direction)
}
guard let first = Self.firstCard(scanning: liveLanes) else { return .handled }
guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled }
replaceSelection(with: first, on: .live)
return .handled
}
@@ -763,7 +825,10 @@ struct BoardView: View {
to: next.id,
kind: next.kind,
on: next.side,
in: store.snapshot
in: store.snapshot,
// The span is the *filtered* board's, so a range under a search collects exactly the
// rows between the two endpoints that are on screen (04 § Search: "ranges read it").
filter: store.searchFilter
) else { return .handled }
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
return .handled
@@ -785,13 +850,16 @@ struct BoardView: View {
var lane: ItemID?
switch side {
case .trashed:
container = TrashModel.entries(of: store.snapshot).map(\.id)
container = trashEntries.map(\.id)
case .live:
guard let home = store.snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
}) else { return .handled }
lane = home.id
container = home.cards.filter { !$0.isDeleted }.map(\.id)
// The container is what the lane is *showing*: a jump to "the lane's first card" under
// a search means its first surviving card, not one the filter animated out.
let filter = store.searchFilter
container = home.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
}
guard let target = direction == .up ? container.first : container.last else { return .handled }
@@ -811,14 +879,17 @@ struct BoardView: View {
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
if direction == .right, isTrashVisible, let first = TrashModel.entries(of: store.snapshot).first {
if direction == .right, isTrashVisible, let first = trashEntries.first {
replaceSelection(with: first.id, on: .trashed)
return .handled
}
let lanes = liveLanes
let filter = store.searchFilter
// A lane the search emptied is scanned past exactly as an empty one is the jump lands on
// the first lane that is *showing* a card, which is what the user can see.
let target = direction == .right
? Self.firstCard(scanning: lanes.reversed())
: Self.firstCard(scanning: lanes)
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
: Self.firstCard(scanning: lanes, filter: filter)
guard let target else { return .handled }
replaceSelection(with: target, on: .live)
return .handled
@@ -890,9 +961,15 @@ struct BoardView: View {
/// The first rendered card of the first lane that has one the scan every "first/last lane"
/// destination shares, run over the lane order forwards or reversed.
private static func firstCard(scanning lanes: some Sequence<Lane>) -> ItemID? {
///
/// "Rendered" includes the search filter, so a lane whose cards the query all hid is scanned
/// past like an empty one `liveCards(in:filter:)`'s membership, one lane at a time.
private static func firstCard(
scanning lanes: some Sequence<Lane>,
filter: SearchFilter = .inactive
) -> ItemID? {
for lane in lanes {
if let card = lane.cards.first(where: { !$0.isDeleted }) { return card.id }
if let card = lane.cards.first(where: { !$0.isDeleted && filter.matches($0) }) { return card.id }
}
return nil
}