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:
@@ -1907,7 +1907,9 @@ public final class BoardStore {
|
||||
public func delete(_ ids: Set<ItemID>) {
|
||||
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot)
|
||||
// The successor is drawn from what the lane is *showing*, so a delete under an active search
|
||||
// walks the filtered lane rather than selecting a card the query has hidden.
|
||||
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
@@ -2122,8 +2124,52 @@ public final class BoardStore {
|
||||
|
||||
/// Whether an inline title editor is open — `TransientBoardState.isEditingInline`, which owns
|
||||
/// what it means and why every mutating command reads it.
|
||||
///
|
||||
/// **The search field is not one of these**, and that is 04-interactions.md § Search's settled
|
||||
/// dispatch rule as one absence: "the field is a *control*, not a content editor — the
|
||||
/// focused-editor lockdown does not apply", so board menu commands stay enabled and act on the
|
||||
/// selection while the user types a query. The narrow exception — the caret chords — is the
|
||||
/// menu items' own (`caretChordsYield`), not this flag's.
|
||||
public var isEditingInline: Bool { transient.isEditingInline }
|
||||
|
||||
// MARK: - The live search filter
|
||||
|
||||
/// The search field's text (04-interactions.md § Search), and **the one funnel every change to
|
||||
/// it goes through**.
|
||||
///
|
||||
/// The setter is where the filter's one consequence lives: narrowing the query narrows what the
|
||||
/// board shows, and "hidden cards leave the selection" — so every write re-applies
|
||||
/// `TransientBoardState.constrainToSearch(in:)` against the current snapshot. Putting it here
|
||||
/// rather than at the field's binding is what makes it true for Escape's clear and for any later
|
||||
/// caller equally, without either having to remember.
|
||||
///
|
||||
/// **The equality guard is not an optimisation.** `NSSearchField` reports its text on events
|
||||
/// that did not change it, and a re-entrant assignment during a live keystroke would re-run the
|
||||
/// constraint (harmlessly) and re-fire observation (not harmlessly — the strip's animated
|
||||
/// transaction is keyed on this value).
|
||||
public var searchQuery: String {
|
||||
get { transient.searchQuery }
|
||||
set {
|
||||
guard newValue != transient.searchQuery else { return }
|
||||
transient.searchQuery = newValue
|
||||
transient.constrainToSearch(in: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// The query as the predicate, for the selection grammar's order lists — read wherever the board
|
||||
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
|
||||
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
|
||||
|
||||
/// Clears the search — **Escape's middle step** (04 § Search's staged Escape: "with *board*
|
||||
/// focus and an active search, one press clears the search and the full board returns"), and the
|
||||
/// search field's own Escape in a non-empty field.
|
||||
///
|
||||
/// Widening, so it constrains nothing; it goes through the setter anyway so there is exactly one
|
||||
/// place the query is written on the store.
|
||||
public func clearSearch() {
|
||||
searchQuery = ""
|
||||
}
|
||||
|
||||
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
|
||||
///
|
||||
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
|
||||
@@ -2153,7 +2199,9 @@ public final class BoardStore {
|
||||
selection: selection,
|
||||
anchor: transient.selectionAnchor,
|
||||
snapshot: snapshot,
|
||||
togglesOnRepeat: togglesOnRepeat
|
||||
togglesOnRepeat: togglesOnRepeat,
|
||||
// A ⇧-range walks the *filtered* board (04 § Search); the other two branches ignore it.
|
||||
filter: searchFilter
|
||||
)
|
||||
guard !outcome.selection.isEmpty else {
|
||||
clearSelection()
|
||||
@@ -2186,16 +2234,17 @@ public final class BoardStore {
|
||||
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
|
||||
/// but it has no business discarding ones that are still standing inside what it selected.
|
||||
///
|
||||
// m5-search: "filter-respecting, like every surface" (04 ▸ The map). The universe here is
|
||||
// `SelectionGrammar`'s order lists, which is where the filter threads in — one change, and both
|
||||
// this command and every ⇧-range narrow together.
|
||||
/// **"All visible cards" means the filter's survivors** — "filter-respecting, like every
|
||||
/// surface" (04 ▸ The map). The universe is `SelectionGrammar`'s order lists, which is where the
|
||||
/// filter threads in, so this command and every ⇧-range narrow together by construction.
|
||||
public func selectAll() {
|
||||
let filter = searchFilter
|
||||
if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty,
|
||||
let kind = SelectionGrammar.kind(of: selection, in: snapshot) {
|
||||
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot)), on: .trashed)
|
||||
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot, filter: filter)), on: .trashed)
|
||||
return
|
||||
}
|
||||
apply(Set(SelectionGrammar.liveCards(in: snapshot)), on: .live)
|
||||
apply(Set(SelectionGrammar.liveCards(in: snapshot, filter: filter)), on: .live)
|
||||
}
|
||||
|
||||
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - SearchFilter
|
||||
|
||||
/// The live search filter, as a pure predicate over a query and an item (`SearchFilterTests`) —
|
||||
/// 04-interactions.md § Search's one sentence of behaviour and nothing else:
|
||||
///
|
||||
/// > live filter: cards whose title *and* body both miss the query animate out; case/diacritic-
|
||||
/// > insensitive substring. Scope is **title + body only** (settled) — attachment filenames are not
|
||||
/// > searched.
|
||||
///
|
||||
/// ### Why it is a value rather than a function
|
||||
///
|
||||
/// The needle is folded **once** per filter and matched against each item's folded haystack, so a
|
||||
/// board-wide pass folds the query once rather than once per card. That is the only reason this is a
|
||||
/// type: everything else about it is a free function's job, and the value stays `Equatable` so a
|
||||
/// view can key an animated transaction on it as readily as on the raw query.
|
||||
///
|
||||
/// ### One predicate, every surface
|
||||
///
|
||||
/// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee,
|
||||
/// ranges, arrow nav, and lane count badges all read it." They read it *here* — the masonry through
|
||||
/// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the
|
||||
/// trash through `TrashLaneView.entries`, and the selection through
|
||||
/// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does
|
||||
/// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2).
|
||||
///
|
||||
/// ### What is *not* the predicate's business
|
||||
///
|
||||
/// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the
|
||||
/// query stays on the board showing an empty body and a `0` badge, because the width division is
|
||||
/// layout and the filter is content. `matches(_: Lane)` exists only for the trash, whose rows are
|
||||
/// tombstoned lanes as often as they are cards and which filter "like any lane" by their own
|
||||
/// title + body (03-board-ui.md § Trash).
|
||||
public struct SearchFilter: Sendable, Equatable {
|
||||
|
||||
/// The query exactly as typed — kept so a caller can key a transaction or a test on it.
|
||||
public let query: String
|
||||
|
||||
/// The query folded once. **Empty means the filter is off**, which is the whole of "empty query
|
||||
/// = everything visible": every `matches` below short-circuits to `true`.
|
||||
private let needle: String
|
||||
|
||||
public init(query: String) {
|
||||
self.query = query
|
||||
needle = Self.folded(query)
|
||||
}
|
||||
|
||||
/// No search — the default every threaded parameter carries, so a call site with no query to
|
||||
/// supply keeps reading exactly as it did before the filter existed.
|
||||
public static let inactive = SearchFilter(query: "")
|
||||
|
||||
/// Whether a search is running at all.
|
||||
///
|
||||
/// **Only the empty string is inactive**: whitespace is a legitimate substring (a user typing
|
||||
/// `fix ` mid-word means it), and trimming would be a rule the design does not state, applied to
|
||||
/// a live filter where the user sees the result of every keystroke immediately.
|
||||
public var isActive: Bool { !needle.isEmpty }
|
||||
|
||||
// MARK: - The predicate
|
||||
|
||||
/// **Title OR body**, which is the positive reading of 04's "cards whose title *and* body both
|
||||
/// miss the query animate out": a card is hidden only when neither field contains the query, so
|
||||
/// it is shown when either does.
|
||||
///
|
||||
/// An absent title is not a miss to be excused — "Untitled" is a rendering, never a value
|
||||
/// (03-board-ui.md § Card face), so a query only ever matches text the file actually holds.
|
||||
public func matches(title: String?, body: String) -> Bool {
|
||||
guard isActive else { return true }
|
||||
if let title, Self.folded(title).contains(needle) { return true }
|
||||
return Self.folded(body).contains(needle)
|
||||
}
|
||||
|
||||
/// A card. **Its attachment filenames are not consulted** — scope is title + body only
|
||||
/// (04 § Search, settled), and that is enforced here by construction rather than by remembering
|
||||
/// not to add `card.attachments` to the line above.
|
||||
public func matches(_ card: Card) -> Bool {
|
||||
matches(title: card.title.value, body: card.body)
|
||||
}
|
||||
|
||||
/// A lane, by its own title and description — the trash's lane entries, and nothing on the board
|
||||
/// itself (see the type's doc comment).
|
||||
public func matches(_ lane: Lane) -> Bool {
|
||||
matches(title: lane.title.value, body: lane.body)
|
||||
}
|
||||
|
||||
/// A trash row, **by its own title and body**, whichever kind it is: "shown, it participates in
|
||||
/// the filter like any lane" (03-board-ui.md § Trash), and a lane entry is a row like a card row.
|
||||
///
|
||||
/// A lane entry is deliberately *not* matched through its cards: the entry is one restorable
|
||||
/// thing, and a lane surfacing because a card buried inside it matched would be a row the user
|
||||
/// cannot act on the way the match suggests.
|
||||
public func matches(_ entry: TrashEntry) -> Bool {
|
||||
switch entry {
|
||||
case let .card(card, _): matches(card)
|
||||
case let .lane(lane, _): matches(lane)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The visible universe
|
||||
|
||||
/// Every id the filter leaves visible on `side` — **the universe
|
||||
/// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection"
|
||||
/// (`TransientBoardState.constrainToSearch(in:)`).
|
||||
///
|
||||
/// It is shaped exactly like `ItemReferenceSet.idUniverse(of:on:)` and means the same thing one
|
||||
/// step narrower: that one answers "what does the board *have*", this one "what does the board
|
||||
/// *show*". Two differences, both stated above and neither incidental:
|
||||
///
|
||||
/// - **Live lanes are all in it.** The filter hides cards, so a lane is visible whatever its
|
||||
/// cards do — a lane selection survives a query that empties its body.
|
||||
/// - **The trashed side is the trash's rows**, filtered — `TrashModel.entries`' absolute
|
||||
/// ancestor walk, which already excludes the cards a tombstoned lane subsumes. Those have no
|
||||
/// row, so they are visible to nobody and belong in no universe a selection is held to.
|
||||
public func visibleIDs(in snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
|
||||
switch side {
|
||||
case .live:
|
||||
var ids: Set<ItemID> = []
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
ids.insert(lane.id)
|
||||
for card in lane.cards where !card.isDeleted && matches(card) {
|
||||
ids.insert(card.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
case .trashed:
|
||||
return Set(TrashModel.entries(of: snapshot).lazy.filter { matches($0) }.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Folding
|
||||
|
||||
/// Case- and diacritic-insensitive, and **locale-stable**: `locale: nil` selects the canonical,
|
||||
/// locale-independent mapping rather than the user's, so a board does not filter differently in
|
||||
/// a Turkish locale than in an English one. The board is a file on disk, shared across machines
|
||||
/// and agents; a predicate whose answer depended on System Settings would be a different filter
|
||||
/// for every user of the same board.
|
||||
///
|
||||
/// Both sides are folded with the same call, which is what makes a plain `contains` a correct
|
||||
/// insensitive substring test — folding one side only would compare a folded needle against an
|
||||
/// unfolded haystack and miss every accented match.
|
||||
private static func folded(_ text: String) -> String {
|
||||
text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
|
||||
}
|
||||
}
|
||||
@@ -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]) }
|
||||
|
||||
@@ -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