Skip to main content

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}