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
+76 -20
View File
@@ -109,13 +109,18 @@ public enum SelectionGrammar {
/// (04-interactions.md § Selection: "single click selects the lane (click again to
/// unselect)", and the header "toggles like empty space (settled)"). A card face passes
/// `false`: Finder does not deselect a file by clicking it twice, and neither do we.
/// - filter: the live search filter (04 § Search). Only the -branch reads it a range walks
/// what is *on the board*, which under a search is the survivors because the other two
/// name their target outright and a click on something the user can see needs no permission
/// from the predicate.
public static func click(
_ target: SelectionTarget,
modifier: ClickModifier,
selection: ItemReferenceSet,
anchor: ItemID?,
snapshot: BoardModel,
togglesOnRepeat: Bool = false
togglesOnRepeat: Bool = false,
filter: SearchFilter = .inactive
) -> Outcome {
switch modifier {
case .plain:
@@ -123,7 +128,7 @@ public enum SelectionGrammar {
case .command:
return command(target, selection: selection, snapshot: snapshot)
case .shift:
return shift(target, selection: selection, anchor: anchor, snapshot: snapshot)
return shift(target, selection: selection, anchor: anchor, snapshot: snapshot, filter: filter)
}
}
@@ -197,10 +202,18 @@ public enum SelectionGrammar {
_ target: SelectionTarget,
selection: ItemReferenceSet,
anchor: ItemID?,
snapshot: BoardModel
snapshot: BoardModel,
filter: SearchFilter
) -> Outcome {
guard let anchor,
let span = range(from: anchor, to: target.id, kind: target.kind, on: target.side, in: snapshot)
let span = range(
from: anchor,
to: target.id,
kind: target.kind,
on: target.side,
in: snapshot,
filter: filter
)
else {
return plain(target, selection: selection, togglesOnRepeat: false)
}
@@ -224,14 +237,20 @@ public enum SelectionGrammar {
/// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain
/// click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous).
///
/// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search
/// hid is absent from the list, so a range aimed at it answers `nil` and each caller degrades
/// exactly as it does for a card an agent deleted. The span between two *visible* endpoints
/// likewise collects only survivors 04 § Search's "ranges read [the filter]".
public static func range(
from: ItemID,
to: ItemID,
kind: SelectionKind,
on side: Liveness,
in snapshot: BoardModel
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> Set<ItemID>? {
let list = order(of: kind, on: side, in: snapshot)
let list = order(of: kind, on: side, in: snapshot, filter: filter)
guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
return Set(start <= end ? list[start...end] : list[end...start])
}
@@ -241,15 +260,26 @@ public enum SelectionGrammar {
/// The list a -range walks for one (side, kind) pair **the single place a "what's on the
/// board, in what order" question is answered** for the pointer.
///
// m5-search: the filter "is the single source of truth for what's on the board ranges all
// read it" (04-interactions.md § Search). It threads in here and in `MarqueeTargetRegistry`'s
// membership, and nowhere else every range and every Select All is stated in terms of these
// four lists.
public static func order(of kind: SelectionKind, on side: Liveness, in snapshot: BoardModel) -> [ItemID] {
/// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere
/// else** the filter "is the single source of truth for what's on the board ranges all
/// read it" (04-interactions.md § Search), and every range, every Select All and every arrow
/// walk is stated in terms of these four lists, so one parameter narrows all of them together.
///
/// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
///
/// **The lane list takes no filter**, because a card query hides no lane see `SearchFilter`.
public static func order(
of kind: SelectionKind,
on side: Liveness,
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> [ItemID] {
switch (side, kind) {
case (.live, .card): liveCards(in: snapshot)
case (.live, .card): liveCards(in: snapshot, filter: filter)
case (.live, .lane): liveLanes(in: snapshot)
case (.trashed, _): trashEntries(of: kind, in: snapshot)
case (.trashed, _): trashEntries(of: kind, in: snapshot, filter: filter)
}
}
@@ -259,10 +289,14 @@ public enum SelectionGrammar {
///
/// The snapshot's arrays are already in display order (`Ranks.sortedForDisplay`), so the flatten
/// is one walk `NewCardTarget.resolve`'s walk, in list form.
public static func liveCards(in snapshot: BoardModel) -> [ItemID] {
///
/// **The filter narrows the walk in place**, which is what makes a search-time -range and
/// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same
/// predicate to the same cards, one lane at a time, and this is that collection flattened.
public static func liveCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
var ids: [ItemID] = []
for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted {
for card in lane.cards where !card.isDeleted && filter.matches(card) {
ids.append(card.id)
}
}
@@ -271,6 +305,11 @@ public enum SelectionGrammar {
/// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md §
/// Trash collapses each into one entry), so they are absent from the live lane order entirely.
///
/// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the
/// query empties is still a lane on the board the width division is layout, and the badge
/// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one
/// part of the board grammar a search does not narrow.
public static func liveLanes(in snapshot: BoardModel) -> [ItemID] {
snapshot.lanes.filter { !$0.isDeleted }.map(\.id)
}
@@ -286,9 +325,16 @@ public enum SelectionGrammar {
/// the deliberate pointer twin of the keyboard's rule: a -arrow onto a lane entry is *inert*
/// because its next step is ambiguous, while a click names an unambiguous same-kind target and
/// so the range simply skips.
public static func trashEntries(of kind: SelectionKind, in snapshot: BoardModel) -> [ItemID] {
///
/// **Filtered like any lane** (03-board-ui.md § Trash) the same predicate `TrashLaneView`
/// applies to the same rows, so a trash-side range walks exactly what the column is showing.
public static func trashEntries(
of kind: SelectionKind,
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> [ItemID] {
TrashModel.entries(of: snapshot)
.filter { $0.isLaneEntry == (kind == .lane) }
.filter { $0.isLaneEntry == (kind == .lane) && filter.matches($0) }
.map(\.id)
}
@@ -346,7 +392,17 @@ public enum SelectionGrammar {
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
/// `BoardStore.delete` and by nothing on the reload path.
public static func successor(afterDeleting ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
///
/// **The container is what the lane is *showing*.** Under a search the successor must be a card
/// the user can see "nothing invisible stays selected" is the trash's phrasing of a rule the
/// filter obeys too and picking a hidden neighbour would hand the selection straight back to
/// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the
/// filter narrows the container, and repeated walks down the *filtered* lane.
public static func successor(
afterDeleting ids: Set<ItemID>,
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> ItemID? {
guard !ids.isEmpty else { return nil }
let selection = ItemReferenceSet(ids: ids, liveness: .live)
guard let kind = kind(of: selection, in: snapshot) else { return nil }
@@ -358,12 +414,12 @@ public enum SelectionGrammar {
case .card:
// The last selected card in flatten order names the lane; its lane's rendered cards are
// the container the successor is drawn from.
guard let last = liveCards(in: snapshot).last(where: { ids.contains($0) }),
guard let last = liveCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
let lane = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
})
else { return nil }
container = lane.cards.filter { !$0.isDeleted }.map(\.id)
container = lane.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
}
let doomed = container.indices.filter { ids.contains(container[$0]) }