gwm/tui/state/filter.rs
1//! Inline fuzzy-filter state for the worktree list (issue #21, extracted
2//! from `tui::app::App` per #124 / #102, closes #104).
3//!
4//! Two concerns live here:
5//!
6//! 1. **Buffer + active flag** — the live `/` prompt: the user opens it
7//! with `/`, types into `query`, then commits (Enter, sticky filter)
8//! or cancels (Esc, clear). The `App` orchestrator wraps the
9//! transitions so it can update the status-bar copy and the sidebar
10//! cache; the pure state lives here.
11//!
12//! 2. **Memoised matched-indices cache** — the load-bearing reason for
13//! the extraction (#104). The prior `App::filtered_indices` was
14//! recomputed 3–5× per render frame (every `tui/ui.rs` call site:
15//! list height, visible rows, title hint, footer counter, selection
16//! resolver). On a repo with a non-trivial worktree list and a
17//! typed query, that's the same `nucleo_matcher::Pattern::parse +
18//! Matcher + score` pass repeating per frame for no observable
19//! reason. The cache here stores the result alongside the
20//! `worktrees.len()` it was computed against; any mutation that
21//! changes the query OR the worktrees length invalidates it, so the
22//! closure runs once per query/list change instead of once per
23//! frame.
24
25use crate::worktree::WorktreeInfo;
26use nucleo_matcher::{
27 pattern::{CaseMatching, Normalization, Pattern},
28 Config as NucleoConfig, Matcher, Utf32Str,
29};
30
31/// Pure fuzzy-match function over a slice of `WorktreeInfo`. Extracted
32/// from `App::filtered_indices` so it can be unit-tested without an
33/// `App`, and so the `FilterState::filtered_indices` memo path stays
34/// agnostic of the matching algorithm. Empty query is the identity over
35/// the input slice. Otherwise, returns the indices of every worktree
36/// whose `name` scores against the `nucleo_matcher` pattern, ranked by
37/// descending score with stable tie-breaking on original index.
38///
39/// The matching contract is identical to the pre-extraction behaviour
40/// (see #21): exact substring > prefix > subsequence, smart case,
41/// smart Unicode normalisation.
42pub fn fuzzy_match_indices(query: &str, worktrees: &[WorktreeInfo]) -> Vec<usize> {
43 if query.is_empty() {
44 return (0..worktrees.len()).collect();
45 }
46 let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
47 let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
48 let mut buf: Vec<char> = Vec::new();
49 let mut scored: Vec<(u32, usize)> = Vec::with_capacity(worktrees.len());
50 for (i, w) in worktrees.iter().enumerate() {
51 let hay = Utf32Str::new(&w.name, &mut buf);
52 if let Some(score) = pattern.score(hay, &mut matcher) {
53 scored.push((score, i));
54 }
55 }
56 scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
57 scored.into_iter().map(|(_, i)| i).collect()
58}
59
60/// Inline fuzzy-filter state machine + memoised matched-indices cache.
61/// `Default` opens the filter in the closed / empty / cold-cache state.
62#[derive(Debug, Default)]
63pub struct FilterState {
64 /// `true` while the user is typing in the `/` bar. Toggles by
65 /// [`Self::open`] / [`Self::close_keep`] / [`Self::close_cancel`]
66 /// (the close methods describe the sticky-vs-clear contract).
67 pub active: bool,
68 /// Live query buffer. Empty = no filter active; the visible list is
69 /// the identity over `App.worktrees`. Private so the mutation
70 /// surface stays funnelled through `push_char` / `pop_char` /
71 /// `set_query` / `clear` (each of which maintains the cache-
72 /// invalidation contract); external readers go through
73 /// [`Self::query`].
74 query: String,
75 /// Cached matched-indices vec from the last call to
76 /// [`Self::filtered_indices`]. `None` = cold cache (must recompute).
77 /// Any buffer mutation, explicit [`Self::invalidate`], or worktrees-
78 /// length change clears it.
79 cached_indices: Option<Vec<usize>>,
80 /// Worktrees vec length the cache was computed against. If it
81 /// changes between calls, the cache auto-invalidates — defence in
82 /// depth so a caller that mutates `App.worktrees` without
83 /// remembering to call `invalidate()` can never read indices that
84 /// point past the new vec.
85 cache_worktrees_len: usize,
86}
87
88impl FilterState {
89 pub fn new() -> Self {
90 Self::default()
91 }
92
93 /// Read access to the live query buffer. Returned as `&str` so the
94 /// caller can't grow / shrink the underlying `String` and bypass the
95 /// cache-invalidation contract on `push_char` / `pop_char` /
96 /// `set_query` / `clear`. Use `query().len()` if you need the byte
97 /// length and `query().is_empty()` for the "no filter" check.
98 pub fn query(&self) -> &str {
99 &self.query
100 }
101
102 /// Append a character to the query buffer and invalidate the cache.
103 /// Called by the event loop on every keypress while `active`.
104 pub fn push_char(&mut self, c: char) {
105 self.query.push(c);
106 self.cached_indices = None;
107 }
108
109 /// Pop the last character off the query buffer. Backspace handler.
110 /// No-op on an empty buffer (the user already cleared the filter;
111 /// the second backspace must not toggle `active` off — Esc does
112 /// that). Invalidates the cache iff a character actually came off.
113 pub fn pop_char(&mut self) {
114 if self.query.pop().is_some() {
115 self.cached_indices = None;
116 }
117 }
118
119 /// Overwrite the query buffer wholesale. Invalidates the cache.
120 /// Used by tests and by any future caller that wants to set the
121 /// filter programmatically (e.g. a "restore session" path).
122 pub fn set_query(&mut self, q: String) {
123 self.query = q;
124 self.cached_indices = None;
125 }
126
127 /// Clear the buffer, close the bar, and invalidate the cache.
128 /// Used by [`Self::close_cancel`] and as a standalone reset.
129 pub fn clear(&mut self) {
130 self.query.clear();
131 self.active = false;
132 self.cached_indices = None;
133 }
134
135 /// Open the filter bar. Preserves the existing query so the user can
136 /// refine an already-sticky filter; `close_cancel` is how they start
137 /// fresh. Does NOT invalidate the cache: opening the bar doesn't
138 /// change what's filtered, only that the next keypress targets the
139 /// buffer.
140 pub fn open(&mut self) {
141 self.active = true;
142 }
143
144 /// Close the filter bar but keep the query — the sticky-filter path
145 /// (Enter). Cache survives: the filter set didn't change, only the
146 /// input target. Subsequent reads stay cached.
147 pub fn close_keep(&mut self) {
148 self.active = false;
149 }
150
151 /// Close the filter bar AND clear the query — the cancel path (Esc).
152 /// Delegates to `clear` so the cache invalidation contract stays in
153 /// one place.
154 pub fn close_cancel(&mut self) {
155 self.clear();
156 }
157
158 /// Explicit cache flush. Called by `App::refresh` after it mutates
159 /// `App.worktrees`, so the next render recomputes against the fresh
160 /// list. (The `cache_worktrees_len` auto-invalidation catches len
161 /// changes too, but `refresh` may produce a vec of the same length
162 /// with different contents — clearing here is the safe play.)
163 pub fn invalidate(&mut self) {
164 self.cached_indices = None;
165 }
166
167 /// Memoised lookup of the matched-indices vec. On cold cache, runs
168 /// `compute(&self.query, worktrees)` and stores the result; on hot
169 /// cache (no buffer mutation since the previous call AND same
170 /// worktrees length), returns the cached slice directly.
171 ///
172 /// Returns a borrowed slice so the call sites in `tui/ui.rs` don't
173 /// pay for a clone on the hot path. The `compute` closure shape
174 /// matches [`fuzzy_match_indices`] — the App passes that fn in,
175 /// keeping `FilterState` agnostic of the matcher (and trivial to
176 /// unit-test with a counting closure).
177 pub fn filtered_indices<F>(&mut self, worktrees: &[WorktreeInfo], compute: F) -> &[usize]
178 where
179 F: FnOnce(&str, &[WorktreeInfo]) -> Vec<usize>,
180 {
181 let len_changed = self.cache_worktrees_len != worktrees.len();
182 let stale = self.cached_indices.is_none() || len_changed;
183 if stale {
184 let fresh = compute(&self.query, worktrees);
185 self.cached_indices = Some(fresh);
186 self.cache_worktrees_len = worktrees.len();
187 }
188 // Safe: `stale` branch above guarantees `Some` before we reach here.
189 self
190 .cached_indices
191 .as_deref()
192 .expect("cached_indices populated above on cold/stale cache")
193 }
194
195 /// `&self`-friendly read for callers that already know the cache is
196 /// warm (or are willing to recompute via `compute` without storing).
197 /// Used by `App::selected()` which is `&self` for ergonomics — most
198 /// callers of `selected` hold a shared borrow and can't take the
199 /// `&mut` needed by [`Self::filtered_indices`]. Returns an owned
200 /// `Vec<usize>` because the caller may hit the recompute branch and
201 /// we can't surface a temporary as `&[usize]`. Cheap when the cache
202 /// is hot (the per-frame render already populated it); equivalent to
203 /// the pre-extraction cost when cold.
204 pub fn snapshot_indices<F>(&self, worktrees: &[WorktreeInfo], compute: F) -> Vec<usize>
205 where
206 F: FnOnce(&str, &[WorktreeInfo]) -> Vec<usize>,
207 {
208 let len_changed = self.cache_worktrees_len != worktrees.len();
209 match self.cached_indices.as_deref() {
210 Some(cached) if !len_changed => cached.to_vec(),
211 _ => compute(&self.query, worktrees),
212 }
213 }
214}