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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user