Skip to main content

egaku/
picker.rs

1//! `FuzzyPicker<T>` — the fleet-shared modal fuzzy-picker primitive.
2//!
3//! A modal overlay that fuzzy-filters a caller-supplied list of items,
4//! ranks them by `(match quality, frecency)`, lets the operator navigate
5//! with up/down (the consumer maps Ctrl-N/Ctrl-P or the arrows → the
6//! [`PickerEvent::NavDown`] / [`PickerEvent::NavUp`] events), and
7//! commits the highlighted item with Enter (→ [`PickerEvent::Accept`])
8//! or cancels with Esc (→ [`PickerEvent::Cancel`]).
9//!
10//! This is the GENERIC version of mado's proven Ctrl-S session picker
11//! (`mado::session_picker::SessionPickerState` driven by the
12//! `mado::ux::modes::Overlay` FSM): a session/tab switcher, a command
13//! palette (`:command-mode`), and a plain fuzzy chooser are all *one*
14//! shape — a fuzzy-filter input + a ranked item list + nav +
15//! accept/cancel — so the shape lives here, once, and every pleme-io GPU
16//! app (mado, namimado, …) reuses it.
17//!
18//! ## Composition, not a fork
19//!
20//! The picker is built ON egaku's existing primitives rather than
21//! re-implementing them:
22//! * [`crate::TextInput`] owns the query line — cursor, grapheme-aware
23//!   insert/backspace, the whole edit surface. The picker does not
24//!   re-derive text editing.
25//! * The fuzzy scorer ([`fuzzy_score`]) is the same subsequence-fuzzy
26//!   ranking mado's praca uses (contiguity + word-boundary bonuses,
27//!   light length penalty) — copied here (egaku is a leaf widget crate
28//!   and cannot depend on praca) so a frecency-ranked picker and a plain
29//!   fuzzy picker share one ranking.
30//! * The wrapping up/down nav mirrors mado's `SessionPickerState`.
31//!
32//! ## Generic over the item key
33//!
34//! The caller owns what `T` is — a `SessionId`, a command, a tab id, a
35//! URL. The picker is data-source-agnostic: it filters and ranks
36//! [`PickerItem`]s by their `label` and optional frecency `score`, and
37//! on accept hands back the chosen `key: T`. Nothing in the picker
38//! knows what `T` means.
39//!
40//! ## Typed modal FSM — `(state, event) -> (state, effects)`
41//!
42//! [`PickerState`] is a two-arm sum (`Closed` / `Open`), NOT a bool
43//! flag. [`PickerState::on_event`] is the pure, total transition: it
44//! matches on the state enum with **no wildcard arm** — a new state
45//! would be a compile error until every transition is decided (the same
46//! forcing function mado's `Overlay::on_event` carries). It returns a
47//! [`PickerStep`] = `(next state, effects)`; the consumer drives events
48//! (from key input) and acts on the effects ([`PickerEffect::Accepted`]
49//! → do the switch/run; [`PickerEffect::Cancelled`] → close).
50//!
51//! ## Render-backend-agnostic
52//!
53//! The picker is pure state + a [`FuzzyPicker::view`] accessor that
54//! returns a [`PickerView`] (the query string, the visible
55//! filtered+ranked rows, and the selected index). A GPU consumer
56//! (mado/namimado via garasu/egaku) draws that overlay; the
57//! terminal-rendered path (egaku-term) draws the same data as text.
58//! There is no `wgpu` in here — exactly like [`crate::ScrollKinetics`]
59//! is pure physics, this is pure state.
60
61use crate::input::TextInput;
62
63/// One row the picker can display + commit. Plain data — the caller
64/// owns `key`; the picker only reads `label` (for fuzzy filtering +
65/// display) and the optional frecency `score`.
66#[derive(Debug, Clone, PartialEq)]
67pub struct PickerItem<T> {
68    /// The value handed back on accept ([`PickerEffect::Accepted`]).
69    pub key: T,
70    /// The display + fuzzy-match text (e.g. `"🌊 tide  mado"`).
71    pub label: String,
72    /// Optional frecency / priority weight. Higher sorts first. Used as
73    /// a tiebreak when fuzzy match quality is equal, and as the sole
74    /// ordering when the query is empty (everything matches → rank by
75    /// frecency). `None` is treated as `0.0`.
76    pub score: Option<f64>,
77}
78
79impl<T> PickerItem<T> {
80    /// A picker row with no frecency weight (a plain fuzzy chooser).
81    #[must_use]
82    pub fn new(key: T, label: impl Into<String>) -> Self {
83        Self {
84            key,
85            label: label.into(),
86            score: None,
87        }
88    }
89
90    /// A picker row carrying a frecency / priority weight.
91    #[must_use]
92    pub fn with_score(key: T, label: impl Into<String>, score: f64) -> Self {
93        Self {
94            key,
95            label: label.into(),
96            score: Some(score),
97        }
98    }
99}
100
101/// The modal state of the picker overlay. A two-arm sum, never a bool
102/// flag: `Closed` is "the keyboard belongs to the app"; `Open` is "the
103/// picker captures input". The `Open` payload carries no item data —
104/// the items live in [`FuzzyPicker`]; this is the routing state only.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum PickerState {
107    /// The overlay is closed; events other than `Open` are inert.
108    Closed,
109    /// The overlay is open and capturing input.
110    Open,
111}
112
113impl PickerState {
114    /// Every state variant, in declaration order. The total-match
115    /// forcing function in [`Self::on_event`] is what actually guards
116    /// completeness; `ALL` exists so a registry/coverage test can
117    /// iterate the states mechanically (mirrors mado's
118    /// `Overlay::ALL`).
119    pub const ALL: [PickerState; 2] = [PickerState::Closed, PickerState::Open];
120}
121
122/// Events the consumer feeds the FSM (typically from decoded key
123/// input). The picker has no `Key(KeyCode)` arm — the consumer does the
124/// key→event mapping (Ctrl-N/Ctrl-P/arrows → Nav, Enter → Accept,
125/// Esc → Cancel, a printable → `Type`), keeping the picker
126/// key-binding-agnostic.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum PickerEvent {
129    /// Open the overlay (the consumer has already populated the items
130    /// via [`FuzzyPicker::set_items`] / construction).
131    Open,
132    /// A printable character typed into the query.
133    Type(char),
134    /// Backspace one grapheme off the query.
135    Backspace,
136    /// Move the highlight up one row (wraps to the bottom at the top).
137    NavUp,
138    /// Move the highlight down one row (wraps to the top at the bottom).
139    NavDown,
140    /// Commit the highlighted row.
141    Accept,
142    /// Dismiss the overlay without committing.
143    Cancel,
144}
145
146/// Typed side effects the consumer executes after a transition. The
147/// `Accepted(T)` arm carries the chosen key so the consumer (which owns
148/// what `T` means) can act on it — switch a session, run a command,
149/// open a tab.
150#[derive(Debug, Clone, PartialEq)]
151pub enum PickerEffect<T> {
152    /// The overlay opened; the renderer should start drawing.
153    Opened,
154    /// The query changed; `visible` is the new count of filtered+ranked
155    /// rows (so a consumer can resize/relayout without re-querying the
156    /// view). The full rows are read via [`FuzzyPicker::view`].
157    Filtered {
158        /// Number of rows matching the current query.
159        visible: usize,
160    },
161    /// The highlight moved to `index` (an index into the filtered view).
162    Moved {
163        /// New selected index within the filtered rows.
164        index: usize,
165    },
166    /// The highlighted row was committed; `key` is its [`PickerItem::key`].
167    Accepted {
168        /// The committed item's caller-owned key.
169        key: T,
170    },
171    /// The overlay was dismissed without committing.
172    Cancelled,
173}
174
175/// One transition's output: the next state + the effects to run.
176#[derive(Debug, Clone, PartialEq)]
177pub struct PickerStep<T> {
178    /// The state after the transition.
179    pub state: PickerState,
180    /// Effects the consumer executes (in order).
181    pub effects: Vec<PickerEffect<T>>,
182}
183
184/// The generic modal fuzzy picker. Holds the full item set, the live
185/// query (a [`TextInput`]), the current filtered+ranked view, and the
186/// highlight. Drive it through [`Self::on_event`]; render it through
187/// [`Self::view`].
188///
189/// `T` is the caller's key type. `T: Clone` is required so [`Accept`]
190/// can hand the chosen key back in a [`PickerEffect::Accepted`] without
191/// consuming the item (the picker keeps its list for a possible reopen).
192///
193/// [`Accept`]: PickerEvent::Accept
194#[derive(Debug, Clone)]
195pub struct FuzzyPicker<T> {
196    state: PickerState,
197    query: TextInput,
198    /// The full, caller-supplied item set (unfiltered).
199    items: Vec<PickerItem<T>>,
200    /// Indices into `items`, filtered to the current query and ranked
201    /// `(match quality desc, frecency desc, original order)`. This is
202    /// the picker's view onto `items`.
203    filtered: Vec<usize>,
204    /// Highlight position within `filtered`.
205    selected: usize,
206}
207
208/// The render-facing snapshot: everything a GPU or terminal renderer
209/// needs to draw the overlay, and nothing it doesn't. Borrows from the
210/// picker — no allocation, no clone of `T`.
211#[derive(Debug)]
212pub struct PickerView<'a, T> {
213    /// The current query text (draw it in the filter input box).
214    pub query: &'a str,
215    /// The cursor byte-offset within `query` (for a caret).
216    pub cursor: usize,
217    /// The filtered + ranked rows, in display order.
218    pub rows: Vec<&'a PickerItem<T>>,
219    /// The highlighted index within `rows` (`0` when `rows` is empty).
220    pub selected: usize,
221}
222
223impl<T: Clone> FuzzyPicker<T> {
224    /// A closed picker over `items`. The view is pre-filtered for the
225    /// empty query (everything, frecency-ranked) so a consumer that
226    /// opens it immediately renders a populated list.
227    #[must_use]
228    pub fn new(items: Vec<PickerItem<T>>) -> Self {
229        let mut p = Self {
230            state: PickerState::Closed,
231            query: TextInput::new(),
232            items,
233            filtered: Vec::new(),
234            selected: 0,
235        };
236        p.recompute();
237        p
238    }
239
240    /// An empty closed picker (items supplied later via
241    /// [`Self::set_items`]).
242    #[must_use]
243    pub fn empty() -> Self {
244        Self::new(Vec::new())
245    }
246
247    /// The current FSM state.
248    #[must_use]
249    pub fn state(&self) -> PickerState {
250        self.state
251    }
252
253    /// Whether the overlay is open (gates rendering + input capture).
254    #[must_use]
255    pub fn is_open(&self) -> bool {
256        self.state == PickerState::Open
257    }
258
259    /// The current query text.
260    #[must_use]
261    pub fn query(&self) -> &str {
262        self.query.text()
263    }
264
265    /// The number of rows matching the current query.
266    #[must_use]
267    pub fn visible_count(&self) -> usize {
268        self.filtered.len()
269    }
270
271    /// The highlighted item's key, if any row is visible.
272    #[must_use]
273    pub fn selected_key(&self) -> Option<&T> {
274        self.filtered
275            .get(self.selected)
276            .map(|&i| &self.items[i].key)
277    }
278
279    /// The highlighted item, if any row is visible.
280    #[must_use]
281    pub fn selected_item(&self) -> Option<&PickerItem<T>> {
282        self.filtered.get(self.selected).map(|&i| &self.items[i])
283    }
284
285    /// Replace the full item set (e.g. the consumer re-listed from a
286    /// live data source). Re-filters against the current query and
287    /// resets the highlight to the top — mirrors
288    /// `SessionPickerState::set_results`.
289    pub fn set_items(&mut self, items: Vec<PickerItem<T>>) {
290        self.items = items;
291        self.selected = 0;
292        self.recompute();
293    }
294
295    /// The render-facing snapshot of the current state.
296    #[must_use]
297    pub fn view(&self) -> PickerView<'_, T> {
298        PickerView {
299            query: self.query.text(),
300            cursor: self.query.cursor(),
301            rows: self.filtered.iter().map(|&i| &self.items[i]).collect(),
302            selected: self.selected,
303        }
304    }
305
306    /// Drive one FSM transition and apply it to this picker, returning
307    /// the typed effects with their live payloads. The pure
308    /// [`PickerState::on_event`] decides the next *routing state* +
309    /// whether an event acts (the total-match table); this engine half
310    /// then carries out the data mutation the event implies (query
311    /// edit + re-filter, highlight move) and synthesizes the payloaded
312    /// effects ([`PickerEffect::Filtered`] / `Moved` / `Accepted`) that
313    /// depend on the live item list — the same "engine applies the
314    /// step" split mado's `InputEngine::apply_overlay_step` uses.
315    pub fn on_event(&mut self, event: PickerEvent) -> Vec<PickerEffect<T>> {
316        let was_open = self.state == PickerState::Open;
317        let step: PickerStep<T> = self.state.on_event(&event);
318        self.state = step.state;
319        match event {
320            PickerEvent::Open => {
321                // Reset the query to empty + reseed the empty-query view.
322                self.query.select_all();
323                self.query.delete_selection();
324                self.selected = 0;
325                self.recompute();
326                vec![PickerEffect::Opened]
327            }
328            PickerEvent::Type(c) if was_open => {
329                self.query.insert_char(c);
330                self.selected = 0;
331                self.recompute();
332                vec![PickerEffect::Filtered {
333                    visible: self.filtered.len(),
334                }]
335            }
336            PickerEvent::Backspace if was_open => {
337                self.query.delete_back();
338                self.selected = 0;
339                self.recompute();
340                vec![PickerEffect::Filtered {
341                    visible: self.filtered.len(),
342                }]
343            }
344            PickerEvent::NavUp if was_open => {
345                self.move_up();
346                vec![PickerEffect::Moved {
347                    index: self.selected,
348                }]
349            }
350            PickerEvent::NavDown if was_open => {
351                self.move_down();
352                vec![PickerEffect::Moved {
353                    index: self.selected,
354                }]
355            }
356            PickerEvent::Accept if was_open => {
357                // Snapshot the chosen key; the FSM has already moved us
358                // to Closed. No row highlighted → a clean close with no
359                // Accepted effect (an empty Vec, never a panic).
360                match self.selected_key().cloned() {
361                    Some(key) => vec![PickerEffect::Accepted { key }],
362                    None => vec![],
363                }
364            }
365            // Cancel, or any event on a Closed picker: defer to the pure
366            // table's effects (Cancelled, or nothing).
367            _ => step.effects,
368        }
369    }
370
371    /// Move the highlight down one row (wraps to the top).
372    fn move_down(&mut self) {
373        if !self.filtered.is_empty() {
374            self.selected = (self.selected + 1) % self.filtered.len();
375        }
376    }
377
378    /// Move the highlight up one row (wraps to the bottom).
379    fn move_up(&mut self) {
380        if !self.filtered.is_empty() {
381            self.selected = if self.selected == 0 {
382                self.filtered.len() - 1
383            } else {
384                self.selected - 1
385            };
386        }
387    }
388
389    /// Recompute the filtered+ranked index set against the current
390    /// query. Empty query → every item, frecency-ranked. Non-empty →
391    /// fuzzy-filtered, ranked `(match quality desc, frecency desc,
392    /// original order)`.
393    fn recompute(&mut self) {
394        let q = self.query.text();
395        let mut scored: Vec<(usize, i32)> = if q.is_empty() {
396            // Everything matches; rank by frecency alone.
397            (0..self.items.len()).map(|i| (i, 0)).collect()
398        } else {
399            self.items
400                .iter()
401                .enumerate()
402                .filter_map(|(i, it)| fuzzy_score(q, &it.label).map(|s| (i, s)))
403                .collect()
404        };
405        // Sort by match quality (desc), then frecency (desc), then
406        // original order (stable index) for determinism.
407        scored.sort_by(|&(ia, sa), &(ib, sb)| {
408            sb.cmp(&sa)
409                .then_with(|| {
410                    let fa = self.items[ia].score.unwrap_or(0.0);
411                    let fb = self.items[ib].score.unwrap_or(0.0);
412                    fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
413                })
414                .then_with(|| ia.cmp(&ib))
415        });
416        self.filtered = scored.into_iter().map(|(i, _)| i).collect();
417        if self.selected >= self.filtered.len() {
418            self.selected = self.filtered.len().saturating_sub(1);
419        }
420    }
421}
422
423impl PickerState {
424    /// The pure, total transition: `(state, event) -> (state, effects)`.
425    /// No I/O, no locks, no data mutation — only the next routing state
426    /// + the routing-only effects (`Opened` / `Cancelled`). The outer
427    /// match carries **no wildcard arm** on the state enum: a new
428    /// [`PickerState`] is a compile error until every transition is
429    /// decided (the forcing function from mado's `Overlay::on_event`).
430    ///
431    /// The payloaded effects (`Filtered` / `Moved` / `Accepted`) depend
432    /// on the live item list, which this generic-free table has no
433    /// access to — so they are synthesized by the engine half
434    /// ([`FuzzyPicker::on_event`]). Keeping one total-match table here
435    /// (routing) and one engine half there (data) is exactly mado's
436    /// `Overlay::on_event` / `InputEngine::apply_overlay_step` split.
437    #[must_use]
438    pub fn on_event<T>(self, event: &PickerEvent) -> PickerStep<T> {
439        match self {
440            // ── Closed: the keyboard belongs to the app. Only `Open`
441            // does anything; every other event is inert by construction
442            // (the consuming arms simply do not exist here — the
443            // Esc-eating law from mado, made structural). ──
444            PickerState::Closed => match event {
445                PickerEvent::Open => PickerStep {
446                    state: PickerState::Open,
447                    effects: vec![PickerEffect::Opened],
448                },
449                PickerEvent::Type(_)
450                | PickerEvent::Backspace
451                | PickerEvent::NavUp
452                | PickerEvent::NavDown
453                | PickerEvent::Accept
454                | PickerEvent::Cancel => PickerStep {
455                    state: PickerState::Closed,
456                    effects: vec![],
457                },
458            },
459            // ── Open: the picker captures input. ──
460            PickerState::Open => match event {
461                // Re-open is idempotent (reseeds via the engine half).
462                PickerEvent::Open => PickerStep {
463                    state: PickerState::Open,
464                    effects: vec![PickerEffect::Opened],
465                },
466                // The payloaded Filtered / Moved / Accepted effects are
467                // synthesized by the engine half (it owns the live item
468                // list + post-filter counts); the routing table only
469                // decides the next state.
470                PickerEvent::Type(_) | PickerEvent::Backspace => PickerStep {
471                    state: PickerState::Open,
472                    effects: vec![],
473                },
474                PickerEvent::NavUp | PickerEvent::NavDown => PickerStep {
475                    state: PickerState::Open,
476                    effects: vec![],
477                },
478                PickerEvent::Accept => PickerStep {
479                    state: PickerState::Closed,
480                    effects: vec![],
481                },
482                PickerEvent::Cancel => PickerStep {
483                    state: PickerState::Closed,
484                    effects: vec![PickerEffect::Cancelled],
485                },
486            },
487        }
488    }
489}
490
491/// Case-insensitive subsequence fuzzy scorer — the same ranking mado's
492/// praca uses, copied here because egaku is a leaf widget crate and
493/// cannot depend on praca.
494///
495/// Returns `Some(score)` if every char of `needle` appears in
496/// `haystack` in order, else `None`. Higher is better. Rewards:
497/// * contiguous runs of matched chars (`+run`, growing within a run),
498/// * a match at the haystack start (`+8`),
499/// * matches right after a separator (`/`, `-`, `_`, `.`, space) — word
500///   boundaries (`+6`),
501///
502/// and lightly penalizes a longer haystack so a tight match on a short
503/// label outranks the same subsequence buried in a long one. An empty
504/// needle scores `0` against any haystack (matches everything) — the
505/// picker routes the empty-query case to frecency-only before reaching
506/// here.
507#[must_use]
508pub fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
509    let needle = needle.to_lowercase();
510    let haystack_lc = haystack.to_lowercase();
511    let n: Vec<char> = needle.chars().collect();
512    if n.is_empty() {
513        return Some(0);
514    }
515    let hay: Vec<char> = haystack_lc.chars().collect();
516
517    let is_sep = |c: char| matches!(c, '/' | '-' | '_' | '.' | ' ');
518
519    let mut ni = 0usize;
520    let mut score = 0i32;
521    let mut run = 0i32;
522    for (hi, &hc) in hay.iter().enumerate() {
523        if ni < n.len() && hc == n[ni] {
524            run += 1;
525            score += run; // contiguity reward grows within a run
526            if hi == 0 {
527                score += 8; // start-of-string bonus
528            } else if is_sep(hay[hi - 1]) {
529                score += 6; // word-boundary bonus
530            }
531            ni += 1;
532        } else {
533            run = 0;
534        }
535    }
536    if ni == n.len() {
537        // light length penalty: tighter haystack wins ties.
538        Some(score - (i32::try_from(hay.len()).unwrap_or(i32::MAX) / 16))
539    } else {
540        None
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    // ── Generic-T proof: a String command-palette picker AND a numeric
549    // id picker exercise the SAME code, proving data-source-agnosticism.
550
551    fn commands() -> Vec<PickerItem<String>> {
552        vec![
553            PickerItem::new("git.commit".to_string(), "git commit"),
554            PickerItem::new("git.push".to_string(), "git push"),
555            PickerItem::new("deploy".to_string(), "deploy"),
556            PickerItem::new("docker.build".to_string(), "docker build"),
557        ]
558    }
559
560    fn ids() -> Vec<PickerItem<u64>> {
561        vec![
562            PickerItem::with_score(10, "alpha", 1.0),
563            PickerItem::with_score(20, "beta", 5.0),
564            PickerItem::with_score(30, "gamma", 2.0),
565        ]
566    }
567
568    // ── FSM totality: every (state, event) pair is decided. ──
569
570    fn all_events() -> Vec<PickerEvent> {
571        vec![
572            PickerEvent::Open,
573            PickerEvent::Type('x'),
574            PickerEvent::Backspace,
575            PickerEvent::NavUp,
576            PickerEvent::NavDown,
577            PickerEvent::Accept,
578            PickerEvent::Cancel,
579        ]
580    }
581
582    #[test]
583    fn fsm_is_total_every_state_every_event() {
584        // No (state, event) pair panics or is missing — on_event returns
585        // a step for all 2 × 7 = 14 combinations.
586        for state in PickerState::ALL {
587            for ev in all_events() {
588                let step: PickerStep<()> = state.on_event(&ev);
589                // The next state is always a valid variant.
590                assert!(PickerState::ALL.contains(&step.state));
591            }
592        }
593    }
594
595    #[test]
596    fn closed_only_open_acts() {
597        for ev in all_events() {
598            let step: PickerStep<()> = PickerState::Closed.on_event(&ev);
599            match ev {
600                PickerEvent::Open => {
601                    assert_eq!(step.state, PickerState::Open);
602                    assert_eq!(step.effects, vec![PickerEffect::Opened]);
603                }
604                _ => {
605                    assert_eq!(step.state, PickerState::Closed);
606                    assert!(step.effects.is_empty(), "closed picker ignores {ev:?}");
607                }
608            }
609        }
610    }
611
612    // ── Open / type / filter ── (String generic) ──
613
614    #[test]
615    fn open_shows_all_then_type_filters() {
616        let mut p = FuzzyPicker::new(commands());
617        assert!(!p.is_open());
618        let fx = p.on_event(PickerEvent::Open);
619        assert!(p.is_open());
620        assert_eq!(fx, vec![PickerEffect::Opened]);
621        // Empty query → all four.
622        assert_eq!(p.visible_count(), 4);
623
624        // Type "git" → only the two git commands remain, fewer visible.
625        p.on_event(PickerEvent::Type('g'));
626        p.on_event(PickerEvent::Type('i'));
627        let fx = p.on_event(PickerEvent::Type('t'));
628        assert_eq!(p.query(), "git");
629        assert_eq!(p.visible_count(), 2);
630        // The Type effect reports the post-filter visible count.
631        assert_eq!(fx, vec![PickerEffect::Filtered { visible: 2 }]);
632        let rows = p.view().rows;
633        assert!(rows.iter().all(|r| r.label.starts_with("git")));
634    }
635
636    #[test]
637    fn backspace_widens_filter() {
638        let mut p = FuzzyPicker::new(commands());
639        p.on_event(PickerEvent::Open);
640        for c in "git".chars() {
641            p.on_event(PickerEvent::Type(c));
642        }
643        assert_eq!(p.visible_count(), 2);
644        let fx = p.on_event(PickerEvent::Backspace);
645        assert_eq!(p.query(), "gi");
646        // "gi" still only matches the git pair here.
647        assert_eq!(fx, vec![PickerEffect::Filtered { visible: 2 }]);
648        // Backspace to empty → all four.
649        p.on_event(PickerEvent::Backspace);
650        p.on_event(PickerEvent::Backspace);
651        assert_eq!(p.query(), "");
652        assert_eq!(p.visible_count(), 4);
653    }
654
655    #[test]
656    fn empty_query_shows_all_frecency_ranked() {
657        // ids() carry frecency: beta(5) > gamma(2) > alpha(1).
658        let mut p = FuzzyPicker::new(ids());
659        p.on_event(PickerEvent::Open);
660        assert_eq!(p.query(), "");
661        assert_eq!(p.visible_count(), 3);
662        let rows = p.view().rows;
663        // Frecency-ranked: beta, gamma, alpha.
664        assert_eq!(rows[0].key, 20);
665        assert_eq!(rows[1].key, 30);
666        assert_eq!(rows[2].key, 10);
667    }
668
669    // ── Frecency boosts ranking on a tie ──
670
671    #[test]
672    fn frecency_breaks_fuzzy_ties() {
673        // Two labels that score identically for the query "a"; the one
674        // with higher frecency must rank first.
675        let items = vec![
676            PickerItem::with_score(1u64, "a-low", 1.0),
677            PickerItem::with_score(2u64, "a-high", 9.0),
678        ];
679        let mut p = FuzzyPicker::new(items);
680        p.on_event(PickerEvent::Open);
681        p.on_event(PickerEvent::Type('a'));
682        let rows = p.view().rows;
683        // Both match "a" at the start equally → frecency decides.
684        assert_eq!(rows[0].key, 2, "higher frecency ranks first on a tie");
685        assert_eq!(rows[1].key, 1);
686    }
687
688    // ── Nav wraps + clamps ──
689
690    #[test]
691    fn nav_down_up_wraps() {
692        let mut p = FuzzyPicker::new(ids());
693        p.on_event(PickerEvent::Open);
694        // 3 rows; selected starts at 0.
695        assert_eq!(p.view().selected, 0);
696        p.on_event(PickerEvent::NavDown);
697        assert_eq!(p.view().selected, 1);
698        p.on_event(PickerEvent::NavDown);
699        assert_eq!(p.view().selected, 2);
700        // Wrap down from the bottom → top.
701        p.on_event(PickerEvent::NavDown);
702        assert_eq!(p.view().selected, 0);
703        // Wrap up from the top → bottom.
704        p.on_event(PickerEvent::NavUp);
705        assert_eq!(p.view().selected, 2);
706    }
707
708    #[test]
709    fn nav_on_empty_results_is_noop() {
710        let mut p = FuzzyPicker::new(commands());
711        p.on_event(PickerEvent::Open);
712        // A query matching nothing.
713        for c in "zzzz".chars() {
714            p.on_event(PickerEvent::Type(c));
715        }
716        assert_eq!(p.visible_count(), 0);
717        p.on_event(PickerEvent::NavDown);
718        p.on_event(PickerEvent::NavUp);
719        assert_eq!(p.view().selected, 0);
720        assert!(p.selected_key().is_none());
721    }
722
723    #[test]
724    fn typing_resets_highlight_to_top() {
725        let mut p = FuzzyPicker::new(ids());
726        p.on_event(PickerEvent::Open);
727        p.on_event(PickerEvent::NavDown);
728        assert_eq!(p.view().selected, 1);
729        // Any edit resets the highlight to the top, like
730        // SessionPickerState::set_results.
731        p.on_event(PickerEvent::Type('a'));
732        assert_eq!(p.view().selected, 0);
733    }
734
735    // ── Accept / Cancel ──
736
737    #[test]
738    fn accept_emits_selected_key_and_closes() {
739        let mut p = FuzzyPicker::new(commands());
740        p.on_event(PickerEvent::Open);
741        // Filter to "deploy", which becomes the only / top row.
742        for c in "deploy".chars() {
743            p.on_event(PickerEvent::Type(c));
744        }
745        assert_eq!(p.selected_key().map(String::as_str), Some("deploy"));
746        let fx = p.on_event(PickerEvent::Accept);
747        assert!(!p.is_open(), "accept closes the overlay");
748        assert_eq!(
749            fx,
750            vec![PickerEffect::Accepted {
751                key: "deploy".to_string()
752            }]
753        );
754    }
755
756    #[test]
757    fn accept_with_no_rows_closes_without_accepted() {
758        let mut p = FuzzyPicker::new(commands());
759        p.on_event(PickerEvent::Open);
760        for c in "zzzz".chars() {
761            p.on_event(PickerEvent::Type(c));
762        }
763        assert_eq!(p.visible_count(), 0);
764        let fx = p.on_event(PickerEvent::Accept);
765        assert!(!p.is_open());
766        // Nothing to accept → no Accepted effect, just a clean close.
767        assert!(
768            !fx.iter()
769                .any(|e| matches!(e, PickerEffect::Accepted { .. })),
770            "no Accepted when no row is highlighted"
771        );
772    }
773
774    #[test]
775    fn reopen_clears_the_query_and_reseeds() {
776        let mut p = FuzzyPicker::new(commands());
777        p.on_event(PickerEvent::Open);
778        for c in "git".chars() {
779            p.on_event(PickerEvent::Type(c));
780        }
781        assert_eq!(p.query(), "git");
782        assert_eq!(p.visible_count(), 2);
783        // Re-open (e.g. Ctrl-S pressed again while open) resets the
784        // query to empty + reseeds the full frecency view.
785        let fx = p.on_event(PickerEvent::Open);
786        assert_eq!(fx, vec![PickerEffect::Opened]);
787        assert!(p.is_open());
788        assert_eq!(p.query(), "");
789        assert_eq!(p.visible_count(), 4);
790    }
791
792    #[test]
793    fn cancel_emits_cancelled_and_closes() {
794        let mut p = FuzzyPicker::new(ids());
795        p.on_event(PickerEvent::Open);
796        let fx = p.on_event(PickerEvent::Cancel);
797        assert!(!p.is_open());
798        assert_eq!(fx, vec![PickerEffect::Cancelled]);
799    }
800
801    #[test]
802    fn numeric_id_accept_round_trips_the_key() {
803        // Generic-T proof on a numeric key: accept hands back the u64.
804        let mut p = FuzzyPicker::new(ids());
805        p.on_event(PickerEvent::Open);
806        // Empty query, frecency-ranked → top is beta (key 20).
807        let fx = p.on_event(PickerEvent::Accept);
808        assert_eq!(fx, vec![PickerEffect::Accepted { key: 20u64 }]);
809    }
810
811    // ── set_items re-filters + resets ──
812
813    #[test]
814    fn set_items_refilters_and_resets() {
815        let mut p = FuzzyPicker::new(commands());
816        p.on_event(PickerEvent::Open);
817        p.on_event(PickerEvent::NavDown);
818        p.set_items(vec![PickerItem::new("x".to_string(), "xenon")]);
819        assert_eq!(p.visible_count(), 1);
820        assert_eq!(p.view().selected, 0);
821        assert_eq!(p.selected_key().map(String::as_str), Some("x"));
822        assert_eq!(p.selected_item().map(|i| i.label.as_str()), Some("xenon"));
823    }
824
825    // ── view() is the render-agnostic surface ──
826
827    #[test]
828    fn view_exposes_query_cursor_rows_selected() {
829        let mut p = FuzzyPicker::new(ids());
830        p.on_event(PickerEvent::Open);
831        p.on_event(PickerEvent::Type('b'));
832        let v = p.view();
833        assert_eq!(v.query, "b");
834        assert_eq!(v.cursor, 1);
835        assert_eq!(v.selected, 0);
836        assert!(v.rows.iter().any(|r| r.label == "beta"));
837    }
838
839    // ── fuzzy_score behaviors (mirrors praca's contract) ──
840
841    #[test]
842    fn fuzzy_subsequence_and_miss() {
843        assert!(fuzzy_score("dpl", "deploy").is_some());
844        assert!(fuzzy_score("xyz", "deploy").is_none());
845    }
846
847    #[test]
848    fn fuzzy_empty_needle_matches_all() {
849        assert_eq!(fuzzy_score("", "anything"), Some(0));
850    }
851
852    #[test]
853    fn fuzzy_is_case_insensitive() {
854        assert!(fuzzy_score("GIT", "git commit").is_some());
855    }
856
857    #[test]
858    fn fuzzy_start_bonus_outranks_mid() {
859        let start = fuzzy_score("g", "git").unwrap();
860        let mid = fuzzy_score("g", "a-g").unwrap();
861        // Start-of-string bonus (+8) beats the word-boundary bonus (+6).
862        assert!(start > mid, "start {start} should beat boundary {mid}");
863    }
864}