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}