From cf87b72092362dd747d70a37b7ee77687809e27f Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 27 Jul 2026 22:49:15 -0400 Subject: [PATCH] Implement live search filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/BoardWindowHost.swift | 31 +- Kanban/KanbanApp.swift | 13 + Kanban/LiveStore/BoardStore.swift | 63 ++- Kanban/LiveStore/SearchFilter.swift | 145 ++++++ Kanban/LiveStore/SelectionGrammar.swift | 96 +++- Kanban/LiveStore/TransientBoardState.swift | 64 ++- Kanban/UI/Board/BoardCommands.swift | 53 +- Kanban/UI/Board/BoardSearchField.swift | 241 +++++++++ Kanban/UI/Board/BoardView.swift | 113 ++++- Kanban/UI/Board/LaneView.swift | 12 +- Kanban/UI/Board/SelectionClicks.swift | 13 + Kanban/UI/Board/TrashLaneView.swift | 16 +- Kanban/UI/Motion.swift | 30 +- KanbanTests/SearchFilterTests.swift | 551 +++++++++++++++++++++ README.md | 2 + 15 files changed, 1359 insertions(+), 84 deletions(-) create mode 100644 Kanban/LiveStore/SearchFilter.swift create mode 100644 Kanban/UI/Board/BoardSearchField.swift create mode 100644 KanbanTests/SearchFilterTests.swift diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 4575615..9d44b04 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -50,6 +50,12 @@ struct BoardWindowHost: View { /// the board half of a card window's `(board, card)` identity — see `CardOpener`. @State private var cardOpener = CardOpener() + /// This window's toolbar search field, as a handle (`BoardSearchPresentation`). `@State` for + /// `boardInfo`'s reason — one per window — and published the same way, because Edit ▸ Find ⌘F + /// and the caret-chord commands are menu-bar items that have to reach the frontmost board + /// window's field. + @State private var boardSearch = BoardSearchPresentation() + @State private var phase: Phase = .opening private enum Phase { @@ -89,13 +95,32 @@ struct BoardWindowHost: View { store: store, window: { windowController.window }, confirmations: trashConfirmations, - openCard: openCard + openCard: openCard, + search: boardSearch ) } + // **The board window's toolbar: the search field, nothing else** (03-board-ui.md ▸ + // Toolbar, "trailing, the one default item; the titlebar stays clean"). It is a toolbar + // rather than a strip inside the content because that is where 03 puts it, and it hosts + // an `NSSearchField` rather than `.searchable` for the reasons `BoardSearchField` + // records — explicit first-responder control, and stock key behaviour. + // + // m6-toolbar: the rest of 03's toolbar story is the customization card's — the + // Customize palette, the New Card / New Lane / Undo / Redo / Show Trash catalog, and + // with it ⌘F's transient surfacing of a *removed* field. That work replaces this + // declaration with an identified, customizable toolbar; the item itself does not move. + .toolbar { + ToolbarItem(placement: .primaryAction) { + BoardSearchField(store: store, presentation: boardSearch) + .frame(width: 220) + } + } // "The board in front", for the menu items that act on it (`LaneWidthCommands`), and - // beside it the window's own popover flag, which is what File ▸ Board Info toggles, and - // its purge-alert host, which the trash's two confirmed commands raise. + // beside it the window's own popover flag, which is what File ▸ Board Info toggles, its + // purge-alert host, which the trash's two confirmed commands raise, and its search + // field, which Edit ▸ Find focuses and the caret-chord commands yield to. .focusedSceneValue(\.boardStore, store) + .focusedSceneValue(\.boardSearch, boardSearch) // The window's identity beside its store — File ▸ Duplicate flushes a *session*, which // is keyed on the window rather than on the board it is showing. .focusedSceneValue(\.boardWindowRef, ref) diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index b41fcb7..9731131 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -159,6 +159,19 @@ struct KanbanApp: App { TrashCommands() } + // The Edit menu's one row of ours: Find (⌘F), placed after the standard Cut/Copy/Paste/ + // Select All group, which is where macOS puts Find. Undo/Redo and the clipboard items are + // the system's and the board answers them as a responder (`ClipboardCommands.swift`) — a + // second item sharing one of those titles is what titles-are-API forbids. + // + // m6-card-window: Find Next / Find Previous (⌘G/⇧⌘G) join here, scoped to the card window's + // find bar and "disabled in the board window — board search is a live filter, not a cursor" + // (11-command-nexus.md). They wait for the window that owns them rather than shipping as two + // permanently disabled rows. + CommandGroup(after: .pasteboard) { + FindCommand() + } + // The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own // items live in — which is where 11-command-nexus.md files Show Trash, alongside the card // window's Edit Body / Raw Source / History still to come. diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 0a11f37..ce41ba5 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1907,7 +1907,9 @@ public final class BoardStore { public func delete(_ ids: Set) { 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 diff --git a/Kanban/LiveStore/SearchFilter.swift b/Kanban/LiveStore/SearchFilter.swift new file mode 100644 index 0000000..7059563 --- /dev/null +++ b/Kanban/LiveStore/SearchFilter.swift @@ -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 { + switch side { + case .live: + var ids: Set = [] + 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) + } +} diff --git a/Kanban/LiveStore/SelectionGrammar.swift b/Kanban/LiveStore/SelectionGrammar.swift index f4c3e46..0dbd64a 100644 --- a/Kanban/LiveStore/SelectionGrammar.swift +++ b/Kanban/LiveStore/SelectionGrammar.swift @@ -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? { - 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, 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, + 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]) } diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index 31f7d34..5a85a0d 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -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. diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index 807f3aa..5a438db 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -79,6 +79,34 @@ extension BoardStore { } } +/// **The caret-chords rule, as one expression** (04-interactions.md ▸ Grammar, settled): +/// +/// > Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation +/// > whenever *any* text control has keyboard focus — inline title editors, the board search field, +/// > board-popover fields (rename, git identity, remote), and card-window fields — because an +/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the +/// > standard line-start/end caret chords. +/// +/// Four text surfaces, answered four ways, and only two of them are here: +/// +/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a +/// broader lockdown that already covers these two items. +/// - **The board popover's fields** are covered by disabling while the popover is open at all — +/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane +/// move belongs under it. +/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has +/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard +/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do. +/// - **Card-window fields** need nothing: those windows never publish a `boardStore`, so both items +/// are already scopeless there. +/// +/// Stated once because the two command groups must not drift: a rule with two implementations is a +/// rule with two chances to forget a surface. +@MainActor +func caretChordsYield(boardInfo: BoardInfoPresentation?, search: BoardSearchPresentation?) -> Bool { + boardInfo?.isPresented == true || search?.isFocused == true +} + // MARK: - Open Card /// Board ▸ Open Card (⌘↩) — 11-command-nexus.md's first Board row, and **the one board command @@ -205,32 +233,30 @@ struct MoveCardCommands: View { /// /// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled): /// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a -/// field ever sees the key. The inline title editors are covered by `acceptsBoardMutations`; the -/// board popover's fields are covered by disabling while the popover is open at all — coarser than -/// per-field focus, but the popover is a configuration surface (04's carve-out) and no lane move -/// belongs under it. The search field (m5-search) and the card window's fields (whose windows never -/// publish a `boardStore` in the first place) extend the same rule with their own cards. +/// field ever sees the key. Which surfaces that covers, and how each is answered, is +/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the width pair below. struct MoveLaneCommands: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardInfo) private var boardInfo + @FocusedValue(\.boardSearch) private var search var body: some View { Button("Move Left") { move(by: -1) } .keyboardShortcut(.leftArrow, modifiers: .command) - .disabled(caretChordsYield || destination(-1) == nil) + .disabled(yieldsCaretChords || destination(-1) == nil) Button("Move Right") { move(by: 1) } .keyboardShortcut(.rightArrow, modifiers: .command) - .disabled(caretChordsYield || destination(1) == nil) + .disabled(yieldsCaretChords || destination(1) == nil) } - private var caretChordsYield: Bool { - boardInfo?.isPresented == true + private var yieldsCaretChords: Bool { + caretChordsYield(boardInfo: boardInfo, search: search) } /// The sole selected live lane and the display slot one step would put it in — `nil` when there @@ -431,23 +457,24 @@ struct LaneWidthCommands: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardInfo) private var boardInfo + @FocusedValue(\.boardSearch) private var search var body: some View { Button("Increase Lane Width") { step(by: 1) } .keyboardShortcut(.rightArrow, modifiers: [.option, .command]) - .disabled(caretChordsYield || selectedLanes.isEmpty) + .disabled(yieldsCaretChords || selectedLanes.isEmpty) Button("Decrease Lane Width") { step(by: -1) } .keyboardShortcut(.leftArrow, modifiers: [.option, .command]) - .disabled(caretChordsYield || !canDecrease) + .disabled(yieldsCaretChords || !canDecrease) } - private var caretChordsYield: Bool { - boardInfo?.isPresented == true + private var yieldsCaretChords: Bool { + caretChordsYield(boardInfo: boardInfo, search: search) } /// The selected live lanes, in snapshot order — the batch, and the items' validation. diff --git a/Kanban/UI/Board/BoardSearchField.swift b/Kanban/UI/Board/BoardSearchField.swift new file mode 100644 index 0000000..43aeca9 --- /dev/null +++ b/Kanban/UI/Board/BoardSearchField.swift @@ -0,0 +1,241 @@ +import AppKit +import Observation +import SwiftUI + +// MARK: - The window's search field, as a handle + +/// The board window's search field, reduced to the three things anything outside it needs to know: +/// whether it holds the keyboard, how to give it the keyboard, and how to give the keyboard back. +/// +/// `BoardInfoPresentation`'s sibling in every respect — one per window, `@State` in +/// `BoardWindowHost`, published through the focus system so a *menu item* can reach the frontmost +/// board window's field (Edit ▸ Find ⌘F) without anyone keeping a which-window-is-key register. +/// +/// ### Why the focus flag is here rather than on the store +/// +/// Two consumers need it and neither is inside the field: Edit ▸ Find (which focuses it) and the +/// **caret-chord commands**, which "disable while the field is focused" (04-interactions.md ▸ +/// Grammar's caret-chords rule, ▸ Search). Both are menu items, and a menu item reaches a window +/// through `@FocusedValue`. It deliberately does *not* live on `BoardStore` beside `searchQuery`: +/// the query is the board's (every window on it filters alike), while the focus is one window's +/// keyboard — the same split `BoardInfoPresentation` makes for the popover flag. +/// +/// **It is not `isEditingInline`, and must never become it** (04 § Search, settled): "the field is a +/// *control*, not a content editor — the focused-editor lockdown does not apply". Board commands +/// stay enabled and act on the selection while a query is being typed, ⌘N included. +@MainActor +@Observable +final class BoardSearchPresentation { + + /// Whether the search field holds keyboard focus — the caret-chord commands' one input. + /// + /// Written by the field itself, on `becomeFirstResponder` and on the field editor ending. It is + /// therefore *the field's* answer rather than an inference from SwiftUI focus state, which is + /// what makes it true for the AppKit key-view loop's Tab traversal as well as for ⌘F. + var isFocused = false + + /// Makes the field first responder — Edit ▸ Find's whole behaviour. `nil` until the field has + /// been made, which is also exactly when ⌘F has nothing to focus. + var focusField: (() -> Void)? + + /// Returns the keyboard to the lane strip — **Escape's second step** in an empty field + /// (04 § Search: "in an empty field it returns focus to the board"). Filled in by `BoardView`, + /// which owns the strip's `@FocusState`; the field cannot do this itself, because resigning + /// first responder would leave the window focused and the board's grammar keys dead. + var focusBoard: (() -> Void)? +} + +/// The focused board window's search field, beside `FocusedValues.boardStore` and +/// `FocusedValues.boardInfo` — see `FocusedBoardStoreKey` for why board-window menu items reach +/// their window this way. +struct FocusedBoardSearchKey: FocusedValueKey { + typealias Value = BoardSearchPresentation +} + +extension FocusedValues { + var boardSearch: BoardSearchPresentation? { + get { self[FocusedBoardSearchKey.self] } + set { self[FocusedBoardSearchKey.self] = newValue } + } +} + +// MARK: - Edit ▸ Find + +/// Edit ▸ Find (⌘F) — "Board window: board search" (11-command-nexus.md; 04-interactions.md +/// § Search: "Search field invoked with ⌘F"). +/// +/// **Focus, not toggle.** ⌘I toggles the board popover because a shortcut that could only ever open +/// would leave that surface with no keyboard way out; the search field's way out is Escape's staged +/// exit, which the field owns, so ⌘F only ever means "put the keyboard here" — and pressing it with +/// the field already focused is a no-op the user cannot tell from a re-focus. +/// +/// **Validation is scope and nothing else**, `BoardInfoCommand`'s rule for its reason: searching is +/// not a mutation, so neither the read-only lock nor the focused-editor rule closes it. A board +/// window is the only context it has (the card window's Edit ▸ Find is find-in-text — 05, m6), and +/// with no board in front both focused values are absent, which is the disable. +/// +// m6-toolbar: "removed from the toolbar, ⌘F surfaces it transiently until the search clears" +// (03-board-ui.md ▸ Toolbar). That belongs to the toolbar-customization card, which is what first +// makes removal possible: this item's action becomes "surface the field if it is not installed, +// then focus it", and the transient host is the thing m6 adds. Until then the field is always in +// the toolbar and focusing it is the whole of ⌘F. +struct FindCommand: View { + + @FocusedValue(\.boardStore) private var store + @FocusedValue(\.boardSearch) private var search + + var body: some View { + Button("Find") { + search?.focusField?() + } + .keyboardShortcut("f", modifiers: .command) + .disabled(store == nil || search?.focusField == nil) + } +} + +// MARK: - The field + +/// The board toolbar's search field — an `NSSearchField`, hosted. +/// +/// ### Why AppKit and not `.searchable` +/// +/// Two requirements SwiftUI's modifier does not meet, both normative: +/// +/// - **Explicit focus control.** ⌘F must put the keyboard in this field from a *menu item*, and +/// Escape in an empty field must hand the keyboard back to the strip. `.searchable` owns its own +/// focus and offers no handle on either half; a first responder is what both need, so the field +/// has to be a view something can hold. +/// - **Stock `NSSearchField` key behaviour.** 04-interactions.md § Search settles the dispatch as +/// "every key with the field focused acts on the field — stock `NSSearchField` behavior, no +/// pass-throughs", which is a promise about *AppKit's* text-field key handling: the field editor +/// takes the arrows as caret motion, ⇧-arrows as text selection, ⌫ as backspace, ⌘A/⌘X/⌘C/⌘V as +/// the text clipboard, and ⌘Z as the field's own text undo. Hosting the real control is how that +/// sentence is implemented rather than reimplemented. +/// +/// Only the two keys the design gives *different* meanings are intercepted (`doCommandBy` below): +/// Return, which is a swallowed no-op because a live filter has nothing to submit, and Escape, +/// whose staging is the design's own and not the cancel button's. +/// +/// ### Live per keystroke +/// +/// `controlTextDidChange` writes straight through to `BoardStore.searchQuery`, which is the filter +/// (`SearchFilter`) — no debounce, no commit step. The predicate is pure and the boards are one +/// folder deep, so the honest cost of a keystroke is one pass over the snapshot. +/// +/// ### What nothing here does, and that is the point +/// +/// **Losing focus does not clear the query.** "Tab is the keep-filter path: plain key-view traversal +/// moves focus to the board with the query intact, and the whole board grammar then applies over the +/// *filtered* board; ⌘F returns to the field" (04 § Search). Tab is the key-view loop's, untouched; +/// the query survives because only two things ever clear it — Escape and creation — and neither is +/// a blur. +/// +/// **Nothing sets `isEditingInline`.** The field is a control, so the focused-editor lockdown stays +/// off and board menu commands keep acting on the selection, ⌘N included. The one narrow exception +/// is the caret chords, which read `BoardSearchPresentation.isFocused` (`caretChordsYield`). +struct BoardSearchField: NSViewRepresentable { + + let store: BoardStore + let presentation: BoardSearchPresentation + + func makeNSView(context: Context) -> NSSearchField { + let field = FocusReportingSearchField() + // The delegate and nothing else: the field's *action* is deliberately unwired, because an + // action fires on submission and this filter has no submission. Every keystroke arrives as + // `controlTextDidChange`, which is also how the stock cancel button reaches the store — it + // clears the text, so it is Escape's first step arriving as an ordinary change to "". + field.delegate = context.coordinator + field.placeholderString = "Search" + field.onFocusChange = { [presentation] focused in + presentation.isFocused = focused + } + // The handle ⌘F pulls. Held weakly through the view's own lifetime by capturing the field + // itself; the presentation outlives neither the window nor the field. + presentation.focusField = { [weak field] in + guard let field, let window = field.window else { return } + window.makeFirstResponder(field) + } + return field + } + + func updateNSView(_ field: NSSearchField, context: Context) { + context.coordinator.owner = self + // The store is the truth: a query cleared by Escape or by a card's creation has to reach the + // control, and the guard keeps the user's own typing from being re-assigned under the caret + // (which would reset the selection and the insertion point on every keystroke). + if field.stringValue != store.searchQuery { + field.stringValue = store.searchQuery + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(owner: self) + } + + /// The field's delegate: the live write-through, and the two keys 04 gives meanings of its own. + final class Coordinator: NSObject, NSSearchFieldDelegate { + + var owner: BoardSearchField + + init(owner: BoardSearchField) { + self.owner = owner + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSSearchField else { return } + owner.store.searchQuery = field.stringValue + } + + func controlTextDidEndEditing(_ notification: Notification) { + owner.presentation.isFocused = false + } + + /// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled). + /// + /// - `cancelOperation:` — Escape. A non-empty field clears the query and *keeps* the + /// keyboard; an empty one hands it back to the board. One press, one layer, which is the + /// same shape `BoardView.handleEscape` gives the board side (and the third step of the + /// same staircase: with board focus and an active search, Escape clears the search). + /// - `insertNewline:` — Return. "The filter is live, there is nothing to submit — it never + /// reaches the board's rename/create grammar." Returning `true` is that no-op: the key is + /// consumed here and the board never sees it. + /// + /// Everything else falls through to the field editor untouched, which is the whole of "stock + /// `NSSearchField` behavior, no pass-throughs". + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.cancelOperation(_:)): + if owner.store.searchQuery.isEmpty { + owner.presentation.focusBoard?() + } else { + owner.store.clearSearch() + control.stringValue = "" + } + return true + case #selector(NSResponder.insertNewline(_:)): + return true + default: + return false + } + } + } +} + +/// An `NSSearchField` that says when it takes the keyboard. +/// +/// The gain is reported here rather than through `controlTextDidBeginEditing` because that +/// notification is about an *edit session*, and the caret-chord rule is about focus: a field the +/// user has Tabbed or ⌘F'd into but not yet typed in already owns ⌘←/⌘→ as line-start/end. The loss +/// is the delegate's `controlTextDidEndEditing`, which fires when the field editor goes — the +/// symmetric hook (`resignFirstResponder`) is the field editor's rather than the control's and never +/// reaches this class. +private final class FocusReportingSearchField: NSSearchField { + + var onFocusChange: ((Bool) -> Void)? + + override func becomeFirstResponder() -> Bool { + let accepted = super.becomeFirstResponder() + if accepted { onFocusChange?(true) } + return accepted + } +} diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 7a6e566..4029302 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -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) -> 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, + 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 } diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 863a3a9..746aa4e 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -583,11 +583,17 @@ struct LaneView: View { /// layout that re-admitted them on every flip would flap the board under the cursor /// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands. /// - /// This is also the collection m5's search filter narrows, which is what keeps the count badge - /// honest for free — see `countBadge`. + /// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search): + /// "cards whose title *and* body both miss the query animate out". This is the one collection + /// that narrowing, which is what makes the filter "the single source of truth for what's on the + /// board" true of this lane's every surface at once — the masonry, the count badge (see + /// `countBadge`), the drop zones' resting layout, the marquee registration and the Finder + /// file-drop targets all read this list or the registry it populates, so none of them needs a + /// rule of its own. private var renderedCards: [Card] { let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL) - return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) } + let filter = store.searchFilter + return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) && filter.matches($0) } } // MARK: - Selection diff --git a/Kanban/UI/Board/SelectionClicks.swift b/Kanban/UI/Board/SelectionClicks.swift index a84f577..4aa0266 100644 --- a/Kanban/UI/Board/SelectionClicks.swift +++ b/Kanban/UI/Board/SelectionClicks.swift @@ -91,6 +91,19 @@ extension View { /// The frame is measured in `BoardView.stripSpace`, the one space every marquee coordinate lives /// in — the band's own points come from a drag gesture in the same space, so no conversion /// happens anywhere. + /// + /// **This is also how the search filter reaches the band and the arrows** (04-interactions.md + /// § Search, "marquee, … arrow nav … all read it"): a card the filter hides is never built, so + /// it registers nothing, and the two surfaces that navigate by drawn frames narrow with the + /// masonry rather than re-running the predicate. + /// + /// One bounded honesty about that: a card leaving under the filter's transition stays registered + /// until the transition ends (`onDisappear` fires when the view really goes, not when the query + /// changed), so for the length of one content-reflow spring a fading card is still sweepable and + /// still an arrow's neighbour. It is on screen for exactly that span, and it has already left the + /// selection (`TransientBoardState.constrainToSearch(in:)` runs at the keystroke), so the window + /// is visible rather than phantom — accepted rather than closed by teaching three input sites a + /// predicate the layout already applied. @MainActor func marqueeTarget( _ id: ItemID, diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index dd8bf0f..a8cd3b3 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -34,8 +34,9 @@ import SwiftUI /// /// ### What is still a later card's /// -/// The **search filter** ("shown, it participates in the filter like any lane") and **⌘C copy-out** -/// are still owed. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the +/// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like +/// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads +/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the /// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with /// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The /// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in @@ -83,11 +84,14 @@ struct TrashLaneView: View { /// The rows the column shows. /// - // m5-search: the shown trash "participates in the filter like any lane", so the search predicate - // narrows this collection exactly as it narrows `LaneView.renderedCards` — and the count badge - // follows for free, because it reads this same value. + /// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so + /// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards` + /// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — and the + /// count badge follows for free, because it reads this same value. Hidden, the column renders + /// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all. private var entries: [TrashEntry] { - TrashModel.entries(of: store.snapshot) + let filter = store.searchFilter + return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) } } // MARK: - Header diff --git a/Kanban/UI/Motion.swift b/Kanban/UI/Motion.swift index 8c177d7..843185d 100644 --- a/Kanban/UI/Motion.swift +++ b/Kanban/UI/Motion.swift @@ -109,8 +109,14 @@ enum Motion { /// restore reads like the search filter — leavers and arrivers run their transition, survivors /// reflow under one gentle spring". /// - /// Named ahead of both its call sites (m5's search, m7's undo) for the same reason `delete` is: - /// the figure is settled, and a duration that has nowhere to live gets spelled at a call site. + /// **The search filter is its call site** (`BoardView.laneStrip`), where it wraps a transaction + /// keyed on the query and nothing else — 03's own narrow key for this reflow. The leavers and + /// arrivers it talks about are `cardTransition`, already attached to every card slot and trash + /// row, so the two halves of the sentence are two modifiers rather than one bespoke animation. + /// + // m7-undo: the restore is the other half of the pair. It arrives as a bracketed wholesale + // reload, so it reaches this voice through `reloadAnimation` rather than through a transaction + // of its own — see `reloadAnimates(origin:endsBracketedOperation:)`. static func contentReflow(reduced: Bool) -> Animation? { reduced ? nil : .smooth(duration: Duration.contentReflow) } @@ -145,8 +151,9 @@ enum Motion { reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane) } - /// A card arriving or leaving — a create, a delete, a Put Back, and (m5) a search filter's - /// leavers and arrivers. The trash's rows wear it too: they are cards, and 10 requires the trash + /// A card arriving or leaving — a create, a delete, a Put Back, and the search filter's leavers + /// and arrivers ("Search-hiding rides the same structural transition — hiding is removal, not a + /// special fade"). The trash's rows wear it too: they are cards, and 10 requires the trash /// animations to have a reduced variant like everything else. static func cardTransition(reduced: Bool) -> AnyTransition { cardAppearance(reduced: reduced).transition @@ -243,12 +250,15 @@ enum Motion { /// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is /// applied in, or `nil` for the reloads that snap. /// - // m5-drag, m5-search: the voice is the *general* structural spring for every app-mediated - // reload, because the reload seam knows an operation echoed but not which one — 03 gives delete - // 0.25 s and a drop commit its own dialect, and neither is reachable from an origin tag. The - // per-operation figures (`delete`, `dragReflow`) become reachable when the operations that own - // them run their own transactions around the gesture, which is m5's card; this seam stays the - // floor under them. + // m5-drag: the voice is the *general* structural spring for every app-mediated reload, because + // the reload seam knows an operation echoed but not which one — 03 gives delete 0.25 s and a + // drop commit its own dialect, and neither is reachable from an origin tag. The per-operation + // figures (`delete`, `dragReflow`) become reachable when the operations that own them run their + // own transactions around the gesture; this seam stays the floor under them. + // + // The search filter needed none of that and never reaches here: a query change is not a reload + // at all — it is transient state, so its transaction is wrapped where it happens + // (`BoardView.laneStrip`, `contentReflow`). static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? { guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil } return structural(reduced: reduced) diff --git a/KanbanTests/SearchFilterTests.swift b/KanbanTests/SearchFilterTests.swift new file mode 100644 index 0000000..38f2e39 --- /dev/null +++ b/KanbanTests/SearchFilterTests.swift @@ -0,0 +1,551 @@ +import Foundation +import Testing +@testable import Kanban + +/// 04-interactions.md § Search, from the predicate outward. +/// +/// The design gives the filter one sentence of behaviour and one sentence of *reach*, and the two +/// need different kinds of test. The predicate is pure, so the first suite below reads like a truth +/// table — folding, title-or-body, the empty query, and the scope line that keeps attachment +/// filenames out. The reach is the interesting half: "the filter is the single source of truth for +/// what's on the board … ranges, arrow nav, and lane count badges all read it", which is a claim +/// about the *order lists* and about the selection, and the second and third suites pin it there. +/// +/// The boards are **real loads off real temp trees**, as in `SelectionGrammarTests`: every rule here +/// reads a body, an `isDeleted`, an attachment listing or `TrashModel`'s sort, and a hand-built +/// `BoardModel` would let all four drift from what the loader actually produces. `WriterFixture`, +/// `Ident` and `Item` live in `WriterTestSupport.swift`. + +// MARK: - Fixtures + +private enum More { + static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +} + +/// A card or lane whose title and body are **independently controlled** — which `Item.rich` cannot +/// be, since its body quotes its title, and a title-or-body test needs the two to disagree. +private func item(order: String, title: String?, body: String) -> String { + var lines = ["---", "schema: 1"] + if let title { lines.append("title: \(title)") } + lines.append("order: \(order)") + lines.append("---") + return lines.joined(separator: "\n") + "\n" + body + "\n" +} + +private func tombstoned( + order: String, + title: String, + body: String, + deleted: String = "2026-03-05T10:00:00Z" +) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + deleted: \(deleted) + --- + \(body) + + """ +} + +/// Three lanes and five cards, built so every clause of the predicate has a card that isolates it: +/// +/// | card | title | body | what it proves | +/// |---|---|---|---| +/// | `card1` | Fix login | (no "login") | the title half, and case folding | +/// | `card2` | Résumé polish | (no match) | diacritic folding | +/// | `card3` | *untitled* | "…the login service." | the body half, with no title at all | +/// | `card4` | Unrelated | "Nothing here." | the miss — plus an attachment named `budget.csv` | +/// | `card5` | Archive | "Old material." | a second miss, in a third lane | +/// +/// A query of `login` therefore leaves exactly `card1` and `card3` standing, in two different lanes +/// — which is what makes a flatten-order range across the gap worth asserting. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane.")) + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + item(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.") + ) + try fixture.item( + "\(Ident.lane1)/\(Ident.card2)", + item(order: "2048", title: "Résumé polish", body: "Tighten the wording.") + ) + try fixture.item(Ident.lane2, item(order: "2048", title: "Doing", body: "Work in progress.")) + try fixture.item( + "\(Ident.lane2)/\(Ident.card3)", + item(order: "1024", title: nil, body: "Deploy the login service.") + ) + try fixture.item( + "\(Ident.lane2)/\(Ident.card4)", + item(order: "2048", title: "Unrelated", body: "Nothing here.") + ) + // A real attachment, so the scope rule is tested against a card the loader really did list files + // for rather than against an empty array that would pass by accident. + try fixture.file("\(Ident.lane2)/\(Ident.card4)/attachments/budget.csv", Data("a,b\n".utf8)) + try fixture.item(Ident.lane3, item(order: "3072", title: "Done", body: "Shipped.")) + try fixture.item( + "\(Ident.lane3)/\(More.card5)", + item(order: "1024", title: "Archive", body: "Old material.") + ) + return fixture +} + +/// A trash holding both row kinds, one of each matching `login` — the shape "participates in the +/// filter like any lane" needs, since a lane entry is filtered by its *own* title and body. +/// +/// `TrashModel`'s sort is newest first, so the row order is `[card1, laneX, card2]`. +@MainActor +private func makeTrashBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane.")) + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + tombstoned(order: "1024", title: "Fix login", body: "Auth.", deleted: "2026-03-05T10:00:00Z") + ) + try fixture.item( + "\(Ident.lane1)/\(Ident.card2)", + tombstoned(order: "2048", title: "Polish", body: "Wording.", deleted: "2026-03-05T06:00:00Z") + ) + try fixture.item( + More.laneX, + tombstoned(order: "2048", title: "Old login lane", body: "Retired.", deleted: "2026-03-05T08:00:00Z") + ) + return fixture +} + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) +private let lane3 = ItemID(rawValue: Ident.lane3) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let card3 = ItemID(rawValue: Ident.card3) +private let card4 = ItemID(rawValue: Ident.card4) +private let card5 = ItemID(rawValue: More.card5) +private let laneX = ItemID(rawValue: More.laneX) + +private func load(_ fixture: WriterFixture) throws -> BoardModel { + try BoardLoader.load(boardRoot: fixture.root).model +} + +private func card(_ id: ItemID, in model: BoardModel) throws -> Card { + try #require(model.lanes.flatMap(\.cards).first { $0.id == id }) +} + +/// One reload, start to settled — the only way transient state gets re-resolved. +@MainActor +private func reload(_ store: BoardStore, origin: WatchOrigin = .foreign) async { + store.handleWatcherEvent(.treeChanged(origin)) + await store.awaitQuiescence() +} + +// MARK: - The predicate + +@MainActor +@Suite("SearchFilter — the predicate") +struct SearchFilterPredicateTests { + + @Test("An empty query is no filter at all: every card matches and nothing is hidden") + func emptyQueryShowsEverything() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + let filter = SearchFilter(query: "") + #expect(!filter.isActive) + #expect(SearchFilter.inactive == filter) + for card in model.lanes.flatMap(\.cards) { + #expect(filter.matches(card)) + } + } + + @Test("Whitespace is a substring like any other — only the empty string turns the filter off") + func whitespaceIsALegitimateQuery() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + // "Trim the query" is a rule the design does not state, and a live filter shows the result of + // every keystroke — so a trailing space mid-word narrows rather than resetting to everything. + let filter = SearchFilter(query: "fix ") + #expect(filter.isActive) + #expect(filter.matches(try card(card1, in: model))) + #expect(!filter.matches(try card(card4, in: model))) + } + + @Test("Matching is case-insensitive, in either direction") + func caseInsensitive() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let first = try card(card1, in: try load(fixture)) + + #expect(SearchFilter(query: "FIX LOGIN").matches(first)) + #expect(SearchFilter(query: "fix login").matches(first)) + #expect(SearchFilter(query: "AUTH FLOW").matches(first)) + } + + @Test("Matching is diacritic-insensitive, in either direction") + func diacriticInsensitive() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let polish = try card(card2, in: try load(fixture)) + + // Both directions, because folding one side only would pass the first and fail the second. + #expect(SearchFilter(query: "resume").matches(polish)) + #expect(SearchFilter(query: "RÉSUMÉ").matches(polish)) + #expect(SearchFilter(query: "Résumé").matches(polish)) + } + + @Test("A card matches on its title OR its body; missing both is what hides it") + func titleOrBody() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + let filter = SearchFilter(query: "login") + + // The title half: card1's body says nothing about logins. + let byTitle = try card(card1, in: model) + #expect(byTitle.title.value == "Fix login") + #expect(!byTitle.body.contains("login")) + #expect(filter.matches(byTitle)) + + // The body half, on a card with no title at all — "Untitled" is a rendering, never a value, + // so nothing about the placeholder can be searched. + let byBody = try card(card3, in: model) + #expect(byBody.title.value == nil) + #expect(filter.matches(byBody)) + #expect(!SearchFilter(query: "untitled").matches(byBody)) + + // The miss: "cards whose title *and* body both miss the query animate out". + #expect(!filter.matches(try card(card4, in: model))) + } + + @Test("Attachment filenames are not searched — scope is title + body only") + func attachmentNamesAreOutOfScope() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let unrelated = try card(card4, in: try load(fixture)) + + // The listing is real, so the exclusion below is a decision rather than an empty array. + #expect(unrelated.attachments == ["budget.csv"]) + #expect(!SearchFilter(query: "budget").matches(unrelated)) + #expect(!SearchFilter(query: "csv").matches(unrelated)) + } + + @Test("A lane matches by its own title and body — the trash's rows, not the board's lanes") + func lanesMatchByTheirOwnText() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + let todo = try #require(model.lanes.first { $0.id == lane1 }) + + #expect(SearchFilter(query: "todo").matches(todo)) + #expect(SearchFilter(query: "inbox").matches(todo)) + // A lane is not matched through its cards: `card1` says "login", the lane does not. + #expect(!SearchFilter(query: "login").matches(todo)) + } + + @Test("The visible universe keeps every live lane and only the matching cards") + func visibleUniverse() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + let visible = SearchFilter(query: "login").visibleIDs(in: model, on: .live) + // Lanes are never hidden by a card query — a lane the filter empties is still a lane on the + // board, so a lane selection survives a query that empties its body. + #expect(visible.isSuperset(of: [lane1, lane2, lane3])) + #expect(visible.intersection([card1, card2, card3, card4, card5]) == [card1, card3]) + } +} + +// MARK: - The order lists + +@MainActor +@Suite("SearchFilter — the order lists") +struct SearchFilterOrderTests { + + @Test("The live card order narrows to the survivors, in flatten order") + func liveCardsNarrow() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + #expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5]) + #expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3]) + #expect(SelectionGrammar.order( + of: .card, + on: .live, + in: model, + filter: SearchFilter(query: "login") + ) == [card1, card3]) + } + + @Test("The lane order is untouched by a query — a card filter hides no lane") + func laneOrderIsUnfiltered() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + // `lane3`'s only card misses the query, and the lane is still in the order: the width + // division is layout, and a `0` badge is the honest report. + #expect(SelectionGrammar.order( + of: .lane, + on: .live, + in: model, + filter: SearchFilter(query: "login") + ) == [lane1, lane2, lane3]) + } + + @Test("A ⇧-range under a query spans only the survivors between its endpoints") + func rangesWalkTheFilteredBoard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + // Unfiltered, card1 → card3 sweeps card2 up with it. + #expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, on: .live, in: model) + == [card1, card2, card3]) + + // Filtered, the span between the same two endpoints is the two that are on screen. + #expect(SelectionGrammar.range( + from: card1, + to: card3, + kind: .card, + on: .live, + in: model, + filter: SearchFilter(query: "login") + ) == [card1, card3]) + } + + @Test("A hidden endpoint is a missing one: the range degrades exactly as it does for a deleted card") + func aHiddenEndpointHasNoRange() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + #expect(SelectionGrammar.range( + from: card1, + to: card2, + kind: .card, + on: .live, + in: model, + filter: SearchFilter(query: "login") + ) == nil) + } + + @Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text") + func trashEntriesNarrow() throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + #expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2]) + #expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX]) + + let filter = SearchFilter(query: "login") + #expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1]) + // The lane row matches on its *own* title, not on the card buried inside it. + #expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX]) + #expect(SelectionGrammar.trashEntries( + of: .lane, + in: model, + filter: SearchFilter(query: "polish") + ).isEmpty) + } + + @Test("The delete successor is drawn from what the lane is showing") + func successorSkipsHiddenSiblings() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let model = try load(fixture) + + // Unfiltered, deleting the untitled card lands on its lane's next card. + #expect(SelectionGrammar.successor(afterDeleting: [card3], in: model) == card4) + // Under `login`, card4 is hidden — and there is nothing else visible in that lane, so the + // honest answer is nothing rather than a card the query animated out. + #expect(SelectionGrammar.successor( + afterDeleting: [card3], + in: model, + filter: SearchFilter(query: "login") + ) == nil) + } +} + +// MARK: - The store seam + +@MainActor +@Suite("SearchFilter — the board's universe") +struct SearchFilterStoreTests { + + @Test("Select All under a query selects the visible cards only") + func selectAllIsFilterRespecting() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.selectAll() + #expect(store.selection.ids == [card1, card2, card3, card4, card5]) + + store.searchQuery = "login" + store.selectAll() + #expect(store.selection.ids == [card1, card3]) + } + + @Test("Select All on the trash side reads the filter too") + func selectAllInTheTrashIsFilterRespecting() async throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.isTrashVisible = true + store.select([card2], liveness: .trashed) + store.selectAll() + #expect(store.selection.ids == [card1, card2]) + + store.select([card1], liveness: .trashed) + store.searchQuery = "login" + store.selectAll() + #expect(store.selection.ids == [card1]) + } + + @Test("Hidden cards leave the selection the moment the query narrows — anchor and head with them") + func aQueryChangeConstrainsTheSelection() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1, card2], liveness: .live, anchor: card1, head: card2) + store.searchQuery = "login" + + #expect(store.selection.ids == [card1]) + // The anchor is still visible, so a ⇧-click still ranges from it; the head was hidden, so + // the arrows re-derive from the set's last member on the next press. + #expect(store.transient.selectionAnchor == card1) + #expect(store.transient.selectionHead == nil) + } + + @Test("A lane selection survives a query that empties the lane") + func laneSelectionsSurviveTheFilter() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([lane3], liveness: .live) + store.searchQuery = "login" + #expect(store.selection.ids == [lane3]) + } + + @Test("Clearing the query widens the board and disturbs nothing") + func clearingTheSearchRestoresTheBoard() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.searchQuery = "login" + store.select([card1], liveness: .live) + store.clearSearch() + + #expect(store.searchQuery.isEmpty) + #expect(store.selection.ids == [card1]) + #expect(SelectionGrammar.liveCards(in: store.snapshot, filter: store.searchFilter).count == 5) + } + + @Test("A reload landing under an active query re-applies the filter to the selection") + func aReloadUnderAQueryConstrains() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.searchQuery = "login" + store.select([card1, card3], liveness: .live, anchor: card1, head: card3) + + // An agent edits the title out of the match. The card is still there — this is not a vanish, + // so only the *filter's* universe can eject it. + try fixture.item( + "\(Ident.lane1)/\(Ident.card1)", + item(order: "1024", title: "Fix auth", body: "The auth flow breaks on retry.") + ) + await reload(store) + + #expect(store.snapshot.lanes.flatMap(\.cards).contains { $0.id == card1 }) + #expect(store.selection.ids == [card3]) + #expect(store.transient.selectionAnchor == nil) + #expect(store.transient.selectionHead == card3) + } + + @Test("Creating a card clears the search — a brand-new card must not be born invisible") + func creationClearsTheSearch() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.searchQuery = "login" + // The one funnel every creation entry point goes through — ⌘N, Return on a lane, the header + // button, a double-click on empty space. + store.transient.beginPlaceholder(inLane: lane3) + + #expect(store.searchQuery.isEmpty) + #expect(store.transient.newCardPlaceholder?.laneID == lane3) + } + + @Test("Rename gets no carve-out: the query stands, and a card renamed out of it leaves the selection") + func renameDoesNotClearTheSearch() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.searchQuery = "login" + store.select([card1], liveness: .live) + + store.transient.beginRename(of: card1, currentTitle: "Fix login") + #expect(store.searchQuery == "login") + + store.transient.updateRenameDraft("Fix auth") + store.commitRename() + await reload(store, origin: .appMediated) + + // "A title that stops matching animates the card out and drops it from the selection, + // exactly as an agent's edit would" — and the filter stays a pure predicate, so nothing here + // is arranged by the rename path itself. + #expect(store.searchQuery == "login") + #expect(try card(card1, in: store.snapshot).title.value == "Fix auth") + #expect(store.selection.isEmpty) + } + + @Test("A rename that keeps the card matching keeps it selected") + func aStillMatchingRenameKeepsItsCard() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.searchQuery = "login" + store.select([card1], liveness: .live) + + store.transient.beginRename(of: card1, currentTitle: "Fix login") + store.transient.updateRenameDraft("Fix login again") + store.commitRename() + await reload(store, origin: .appMediated) + + #expect(store.selection.ids == [card1]) + } + + @Test("Deleting under a query walks the filtered lane") + func deleteSelectsAVisibleSuccessor() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // `Todo` shows both its cards under this query, so the successor is the visible neighbour. + store.searchQuery = "the" + store.select([card1], liveness: .live) + store.delete([card1]) + #expect(store.selection.ids == [card2]) + } +} diff --git a/README.md b/README.md index 2510f70..72d01cc 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Lanework is in early development. This list tracks what has actually shipped and - **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live. +- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and the shown trash filters like any other lane, its card rows and lane rows each by their own text. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would. + - **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu. - **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched.