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