Skip to main content

kimun_notes/components/search_list/
mod.rs

1//! `SearchList`: the one module behind every query-input-over-an-async-loaded
2//! list surface in the TUI. See CONTEXT.md.
3
4#[cfg(test)]
5mod adapters;
6mod host;
7mod load;
8mod resolving;
9mod seams;
10
11pub use resolving::{ResolvingRowSource, Unresolvable};
12pub use seams::{
13    Emit, Filter, Loaded, RowSource, SearchRow, StaticRowSource, SuggestionItem, SuggestionSource,
14    VaultSuggestions,
15};
16
17use crate::components::autocomplete::{
18    AutocompleteController, AutocompleteMode, HandleKeyOutcome, TriggerOptions,
19};
20use crate::components::single_line_input::{InputOutcome, SingleLineInput};
21use crate::keys::key_combo::KeyCombo;
22use crate::settings::icons::Icons;
23use crate::settings::themes::Theme;
24use load::LoadEngine;
25use ratatui::crossterm::event::KeyEvent;
26use ratatui::{
27    Frame,
28    layout::Rect,
29    style::Style,
30    widgets::{List, ListItem, ListState},
31};
32use seams::Loaded as LoadedInner;
33use std::sync::Arc;
34
35fn fuzzy_indices<R: SearchRow>(rows: &[R], query: &str) -> Vec<usize> {
36    use nucleo::pattern::{CaseMatching, Normalization, Pattern};
37    use nucleo::{Matcher, Utf32Str};
38    let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
39    let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
40    let mut scored: Vec<(usize, u32)> = rows
41        .iter()
42        .enumerate()
43        .filter_map(|(i, r)| {
44            let hay = r.match_text()?;
45            let mut buf = Vec::new();
46            let h = Utf32Str::new(hay, &mut buf);
47            pat.score(h, &mut matcher).map(|s| (i, s))
48        })
49        .collect();
50    scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
51    scored.into_iter().map(|(i, _)| i).collect()
52}
53
54/// Which half of a [`SearchList`] owns the keyboard. See CONTEXT.md
55/// (**List focus**). In [`Focus::Input`] typing filters the list; in
56/// [`Focus::List`] plain letters are verbs (`j`/`k` navigate, surface-registered
57/// letters act on the selected row) and never type into the query.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Focus {
60    Input,
61    List,
62}
63
64/// Verdict returned by [`SearchList::handle_key`].
65#[derive(Debug, PartialEq, Eq)]
66pub enum KeyReaction {
67    Consumed,
68    Submit,
69    Cancel,
70    Intercepted(crate::keys::key_combo::KeyCombo),
71    /// A surface-registered list-focus verb fired on the selected row. The
72    /// engine attaches NO meaning to the char — the caller maps it to an
73    /// action (see [`SearchListBuilder::list_verb`]).
74    ListVerb(char),
75    Unhandled,
76}
77
78pub struct SearchList<R: SearchRow> {
79    source: Arc<dyn RowSource<R>>,
80    rows: Vec<R>,
81    /// Indices into `rows` in display order (after filtering/ranking).
82    display: Vec<usize>,
83    /// A synthetic, query-fresh, filter-exempt row pinned at visible position 0
84    /// (the "Create: <q>" affordance / saved-searches virtual entry). Held
85    /// separately from `rows` so it works regardless of delivery (one-shot
86    /// `Replace` or streamed `Push`) and refreshes on every query change. See
87    /// [`RowSource::leading_row`].
88    leading: Option<R>,
89    /// Index into the VISIBLE sequence `[leading?] ++ display` of the selected
90    /// item.
91    selected: Option<usize>,
92    /// Viewport offset: visible position of the first row on screen. Owned
93    /// here (not by a per-frame `ListState`) so mouse-wheel scrolling can move
94    /// the viewport directly; `render` writes it back after ratatui clamps it
95    /// to keep the selection visible.
96    offset: usize,
97    filter: Filter<R>,
98    query: String,
99    loader: LoadEngine<R>,
100    input: SingleLineInput,
101    autocomplete: Option<AutocompleteController>,
102    /// Key combos the caller wants to intercept before the engine acts.
103    intercept: Vec<KeyCombo>,
104    icons: Icons,
105    list_rect: Rect,
106    /// The host panel's full bounds, for wheel hit-testing: scroll events
107    /// anywhere within it scroll the list — header, query box, preview —
108    /// while clicks still hit-test against `list_rect` only. Empty (the
109    /// default) falls back to `list_rect`, so hosts that never record it
110    /// keep scroll-over-the-list-only behavior.
111    panel_rect: Rect,
112    /// A host-owned scrollable sub-region within the panel (e.g. an expanded
113    /// note preview). Wheel events inside it are routed back to the host as
114    /// [`SearchMouse::ContentScrollUp`]/[`ContentScrollDown`] instead of
115    /// scrolling the list — the sub-region wins over `panel_rect`. Empty (the
116    /// default) means no sub-region; hosts re-record it every render so it is
117    /// never stale.
118    ///
119    /// [`ContentScrollDown`]: SearchMouse::ContentScrollDown
120    content_rect: Rect,
121    /// Load generation whose rows are currently held. When a newer generation
122    /// (a requery / reload) delivers its first event, `poll` clears the stale
123    /// rows before applying it — required for streamed (`Push`) sources, which
124    /// would otherwise append onto a superseded load's rows.
125    applied_generation: u64,
126    /// Set when a SavedSearch suggestion was just accepted: the search's name,
127    /// for the host to pin as the saved-search breadcrumb. Read once via
128    /// [`take_accepted_saved_search`](Self::take_accepted_saved_search).
129    accepted_saved_search: Option<String>,
130    /// Visible position of the last left-click, so "click the selected row
131    /// again activates" only fires on a true click-click — never on a click
132    /// landing on an auto- or keyboard-made selection.
133    last_click_pos: Option<usize>,
134    /// Render the query input with §9 syntax highlighting (the FIND drawer
135    /// and the telescope modal; plain inputs like the sidebar filter skip it).
136    highlight_query: bool,
137    /// Which half owns the keyboard. See [`Focus`] and CONTEXT.md.
138    focus: Focus,
139    /// Whether the list-focus state machine is active for this surface. `true`
140    /// iff the surface opens on the list OR registers at least one verb;
141    /// otherwise `Esc` cancels immediately, byte-identical to a plain input
142    /// list, so surfaces that never opt in keep their exact keystroke behavior.
143    focus_enabled: bool,
144    /// Surface-registered list-focus verb chars. When one is pressed in
145    /// [`Focus::List`], `handle_key` returns [`KeyReaction::ListVerb`]; the
146    /// engine attaches no meaning to them.
147    list_verbs: Vec<char>,
148}
149
150/// Mouse interaction result from [`SearchList::handle_mouse`].
151#[derive(Debug, PartialEq, Eq)]
152pub enum SearchMouse {
153    Selected(usize),
154    Activated(usize),
155    /// Right-click on a row: selected, and the host should open its context
156    /// menu for it.
157    Context(usize),
158    Scrolled,
159    /// The wheel landed inside the host's content sub-region (see
160    /// [`SearchList::set_content_rect`]); the host owns that view's scroll,
161    /// so the engine routed the event instead of moving the list.
162    ContentScrollUp,
163    ContentScrollDown,
164    None,
165}
166
167pub struct SearchListBuilder<R: SearchRow> {
168    source: Arc<dyn RowSource<R>>,
169    redraw: Arc<dyn Fn() + Send + Sync>,
170    initial_query: String,
171    filter: Filter<R>,
172    autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
173    intercept: Vec<KeyCombo>,
174    icons: Icons,
175    debounce: Option<std::time::Duration>,
176    highlight_query: bool,
177    opening_focus: Focus,
178    list_verbs: Vec<char>,
179}
180
181impl<R: SearchRow> SearchList<R> {
182    pub fn builder(
183        source: impl RowSource<R>,
184        redraw: Arc<dyn Fn() + Send + Sync>,
185    ) -> SearchListBuilder<R> {
186        SearchListBuilder {
187            source: Arc::new(source),
188            redraw,
189            initial_query: String::new(),
190            filter: Filter::SourceOrder,
191            autocomplete: None,
192            intercept: Vec::new(),
193            icons: Icons::new(false),
194            debounce: None,
195            highlight_query: false,
196            opening_focus: Focus::Input,
197            list_verbs: Vec::new(),
198        }
199    }
200
201    /// The async path: kick off the initial load; rows land on a later `poll`.
202    fn new(b: SearchListBuilder<R>) -> Self {
203        let mut list = Self::assemble(b);
204        list.loader.start(list.source.clone(), list.query.clone());
205        list
206    }
207
208    /// The synchronous path: apply an in-memory row set and seed the initial
209    /// selection here and now — no async load, no channel, no redraw
210    /// round-trip. The [`LoadEngine`] stays idle (`is_loading()` is false
211    /// immediately), and the source's `load` is never invoked, so a caller can
212    /// read `selected_row()`/`rows()` on the very next line. For static sources
213    /// (`reload_on_query() == false`), where the query is a local filter over
214    /// the built rows. See [`StaticRowSource`].
215    ///
216    /// [`StaticRowSource`]: crate::components::search_list::StaticRowSource
217    fn with_rows(b: SearchListBuilder<R>, rows: Vec<R>) -> Self {
218        let mut list = Self::assemble(b);
219        list.rows = rows;
220        list.recompute_and_seed();
221        list
222    }
223
224    /// Build the struct with an idle loader (no load started). The two entry
225    /// points ([`new`](Self::new)/[`with_rows`](Self::with_rows)) diverge on
226    /// what they do next: spawn an async load, or seed rows synchronously.
227    fn assemble(b: SearchListBuilder<R>) -> Self {
228        let loader = LoadEngine::new(b.redraw.clone());
229        let input = SingleLineInput::with_value(&b.initial_query);
230        let debounce = b.debounce;
231        let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
232            let mut ac =
233                AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
234                    disambiguate_header: false,
235                    apply_exclusion_zone: false,
236                    // The controller derives `allow_saved_search` from its mode
237                    // at detect time, so this seed value is not load-bearing.
238                    ..TriggerOptions::default()
239                });
240            if let Some(d) = debounce {
241                ac = ac.with_debounce(d);
242            }
243            ac.set_redraw_callback(b.redraw.clone());
244            ac
245        });
246        Self {
247            source: b.source,
248            rows: Vec::new(),
249            display: Vec::new(),
250            leading: None,
251            selected: None,
252            offset: 0,
253            filter: b.filter,
254            query: b.initial_query,
255            loader,
256            input,
257            highlight_query: b.highlight_query,
258            last_click_pos: None,
259            autocomplete,
260            intercept: b.intercept,
261            icons: b.icons,
262            list_rect: Rect::default(),
263            panel_rect: Rect::default(),
264            content_rect: Rect::default(),
265            applied_generation: 0,
266            accepted_saved_search: None,
267            focus: b.opening_focus,
268            // List focus is active when the surface opens on the list or
269            // registers verbs; otherwise the surface keeps plain Esc→Cancel.
270            focus_enabled: b.opening_focus == Focus::List || !b.list_verbs.is_empty(),
271            list_verbs: b.list_verbs,
272        }
273    }
274
275    /// Which half currently owns the keyboard. See [`Focus`].
276    pub fn focus(&self) -> Focus {
277        self.focus
278    }
279
280    pub fn poll(&mut self) {
281        let drained = self.loader.drain();
282        if !drained.is_empty() {
283            // A newer load delivered its first event(s): drop the prior load's
284            // rows so a streamed source starts from a clean slate (one-shot
285            // `Replace` overwrites anyway, but `Push` would otherwise append).
286            let current_gen = self.loader.generation();
287            if current_gen != self.applied_generation {
288                self.rows.clear();
289                self.selected = None;
290                self.offset = 0;
291                self.applied_generation = current_gen;
292            }
293            for ev in drained {
294                match ev {
295                    LoadedInner::Replace(rows) => {
296                        self.rows = rows;
297                    }
298                    LoadedInner::Push(row) => {
299                        self.rows.push(row);
300                    }
301                    LoadedInner::Done => {}
302                }
303            }
304            self.recompute_and_seed();
305        }
306        if let Some(ac) = &mut self.autocomplete {
307            ac.poll_results();
308        }
309    }
310
311    /// Recompute the display order, then seed the selection to the first row
312    /// when nothing is selected yet (e.g. after the first load or a filter that
313    /// repopulated the list). The single place display + initial selection are
314    /// brought in sync.
315    fn recompute_and_seed(&mut self) {
316        self.recompute_display();
317        if self.selected.is_none() && self.visible_len() > 0 {
318            self.selected = Some(0);
319        }
320    }
321
322    /// Build a host snapshot from the current input state.
323    /// Only reads `self.input` so the result can be stored in a local
324    /// before taking `&mut self.autocomplete`, resolving the borrow conflict.
325    fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
326        let value = self.input.value().to_string();
327        let cursor_byte = self.input.cursor_byte();
328        let col = value[..cursor_byte.min(value.len())].chars().count();
329        host::SearchBoxHostSnapshot {
330            lines: vec![value],
331            cursor: (0, col),
332            caret_pos: self.input.last_caret_pos(),
333        }
334    }
335
336    fn clamp_selection(&mut self) {
337        let len = self.visible_len();
338        self.selected = if len == 0 {
339            None
340        } else {
341            Some(self.selected.unwrap_or(0).min(len - 1))
342        };
343    }
344
345    /// `1` when a leading row is pinned at visible position 0, else `0`.
346    fn leading_offset(&self) -> usize {
347        self.leading.is_some() as usize
348    }
349
350    /// Length of the visible sequence `[leading?] ++ display`.
351    pub fn visible_len(&self) -> usize {
352        self.leading_offset() + self.display.len()
353    }
354
355    /// Number of real matches — the visible rows minus the synthetic leading
356    /// affordance ("Create: …"), for result-count displays.
357    pub fn match_count(&self) -> usize {
358        self.display.len()
359    }
360
361    /// Row at visible position `pos` in `[leading?] ++ display`.
362    fn visible_row(&self, pos: usize) -> Option<&R> {
363        if self.leading.is_some() && pos == 0 {
364            self.leading.as_ref()
365        } else {
366            self.rows
367                .get(*self.display.get(pos - self.leading_offset())?)
368        }
369    }
370
371    /// The source-delivered rows only (NOT the leading row). Prefer
372    /// [`visible_len`](Self::visible_len)/[`visible_rows`](Self::visible_rows)
373    /// for visible counts.
374    pub fn rows(&self) -> &[R] {
375        &self.rows
376    }
377
378    pub fn selected_row(&self) -> Option<&R> {
379        self.selected.and_then(|p| self.visible_row(p))
380    }
381
382    pub fn visible_rows(&self) -> Vec<&R> {
383        (0..self.visible_len())
384            .filter_map(|p| self.visible_row(p))
385            .collect()
386    }
387
388    pub fn query(&self) -> &str {
389        &self.query
390    }
391
392    /// Take the name of a just-accepted saved search, if any. The host calls
393    /// this after a `Consumed` key to learn whether to pin (or refresh) the
394    /// saved-search breadcrumb. Returns `None` once read.
395    pub fn take_accepted_saved_search(&mut self) -> Option<String> {
396        self.accepted_saved_search.take()
397    }
398
399    /// The visible text in the query input widget. Test-only: lets callers
400    /// assert the input bar reflects a programmatic query change.
401    #[cfg(test)]
402    pub(crate) fn input_value(&self) -> &str {
403        self.input.value()
404    }
405    pub fn is_loading(&self) -> bool {
406        self.loader.loading
407    }
408
409    /// Set the query programmatically: updates the visible input widget (cursor
410    /// to end) AND the query string, then starts a load (for `reload_on_query`
411    /// sources) or recomputes the display. This is the setter every external
412    /// caller wants — a saved search applied, a sort directive rewritten — so
413    /// the input bar always reflects the query. The interactive keystroke path
414    /// uses [`sync_query_from_input`](Self::sync_query_from_input) instead,
415    /// because the input widget already holds the typed text (and its cursor
416    /// must not jump back to the end on every keystroke).
417    pub fn set_query(&mut self, q: impl Into<String>) {
418        let q = q.into();
419        self.input.set_value(q.clone());
420        self.query = q;
421        self.requery();
422    }
423
424    /// Pull the query string FROM the input widget without touching the widget
425    /// (so the cursor stays put), then reload/recompute. The keystroke and
426    /// autocomplete-accept paths use this after they have already mutated the
427    /// input in place.
428    fn sync_query_from_input(&mut self) {
429        self.query = self.input.value().to_string();
430        self.requery();
431    }
432
433    /// Start a fresh load for `reload_on_query` sources, else recompute the
434    /// local display. The generation guard in `LoadEngine` drops stale results.
435    fn requery(&mut self) {
436        if self.source.reload_on_query() {
437            self.loader.start(self.source.clone(), self.query.clone());
438        }
439        // Recompute now so the query-fresh leading row (and local filter, for
440        // non-reload sources) reflect the new query in this frame. Reload
441        // sources refresh again when their load drains in poll().
442        self.recompute_and_seed();
443    }
444
445    /// Re-run the source load for the current query (e.g. after a mutation).
446    pub fn reload(&mut self) {
447        self.loader.start(self.source.clone(), self.query.clone());
448    }
449
450    /// Mutate rows in place. `mutate` is called for each row and returns `true`
451    /// for each row it changed; if any did, the display order is recomputed
452    /// (re-filter, no re-sort) so an active filter stays correct. Returns
453    /// whether anything changed.
454    ///
455    /// This is the one seam that touches rows outside the [`RowSource`]; every
456    /// other change rebuilds from the source. Structural changes (add/remove/
457    /// reorder) must still reload. See `adr/0010`.
458    pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
459        let mut changed = false;
460        for row in &mut self.rows {
461            if mutate(row) {
462                changed = true;
463            }
464        }
465        if changed {
466            self.recompute_display();
467        }
468        changed
469    }
470
471    /// Select the visible row at `pos` (clamped to the visible range); clears
472    /// the selection when the list is empty. The index-based counterpart to
473    /// [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev),
474    /// for surfaces that point the cursor at a specific row (the Sources view's
475    /// citation jump).
476    pub fn select(&mut self, pos: usize) {
477        let n = self.visible_len();
478        self.selected = if n == 0 { None } else { Some(pos.min(n - 1)) };
479    }
480
481    pub fn select_next(&mut self) {
482        let n = self.visible_len();
483        if n == 0 {
484            return;
485        }
486        self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
487    }
488
489    pub fn select_prev(&mut self) {
490        if self.visible_len() == 0 {
491            return;
492        }
493        self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
494    }
495
496    /// Largest useful viewport offset: the first visible position from which
497    /// the rows through the end still fill the recorded list rect. Scrolling
498    /// past it would leave blank space below the last row, so
499    /// [`scroll_down`](Self::scroll_down) clamps to it.
500    fn max_scroll_offset(&self) -> usize {
501        let viewport = self.list_rect.height as usize;
502        let n = self.visible_len();
503        if viewport == 0 || n == 0 {
504            return 0;
505        }
506        let mut budget = viewport;
507        let mut first = n;
508        while first > 0 {
509            let h = self
510                .visible_row(first - 1)
511                .map(|r| r.visual_height() as usize)
512                .unwrap_or(1);
513            if h > budget {
514                break;
515            }
516            budget -= h;
517            first -= 1;
518        }
519        first.min(n - 1)
520    }
521
522    /// Scroll the viewport one row down, carrying the selection along so the
523    /// selected row keeps its on-screen position. No-op once the last row is
524    /// in view — the shared mouse-wheel behavior for every list surface.
525    pub fn scroll_down(&mut self) {
526        let n = self.visible_len();
527        if n == 0 || self.offset >= self.max_scroll_offset() {
528            return;
529        }
530        self.offset += 1;
531        self.selected = self.selected.map(|i| (i + 1).min(n - 1));
532    }
533
534    /// Scroll the viewport one row up, carrying the selection along so the
535    /// selected row keeps its on-screen position. No-op at the top.
536    pub fn scroll_up(&mut self) {
537        if self.offset == 0 {
538            return;
539        }
540        self.offset -= 1;
541        self.selected = self.selected.map(|i| i.saturating_sub(1));
542    }
543
544    /// The current viewport offset. Test-only: lets scroll tests assert the
545    /// viewport moved while the selection kept its screen position.
546    #[cfg(test)]
547    pub(crate) fn scroll_offset(&self) -> usize {
548        self.offset
549    }
550
551    pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
552        use ratatui::crossterm::event::{KeyCode, KeyModifiers};
553
554        // Caller-registered intercepts get first crack — before autocomplete or
555        // any built-in binding.
556        if let Some(combo) = crate::keys::key_event_to_combo(key)
557            && self.intercept.contains(&combo)
558        {
559            return KeyReaction::Intercepted(combo);
560        }
561
562        // Autocomplete popup gets first crack when open. Build snapshot before
563        // taking &mut self.autocomplete to avoid borrow-checker conflict
564        // (snapshot only reads self.input).
565        if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
566            let snap = self.autocomplete_snapshot();
567            if let Some(ac) = &mut self.autocomplete {
568                match ac.handle_key(*key, &snap) {
569                    HandleKeyOutcome::Accepted(action) => {
570                        self.input.replace_range_bytes(
571                            action.range.clone(),
572                            &action.new_text,
573                            action.new_cursor_byte,
574                        );
575                        // Stash any accepted SavedSearch name for the host's
576                        // breadcrumb (`None` for every other kind). The host
577                        // reads it on this same `Consumed`, so a plain assign
578                        // never clobbers an unread value.
579                        self.accepted_saved_search = action.saved_search_name;
580                        self.sync_query_from_input();
581                        return KeyReaction::Consumed;
582                    }
583                    HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
584                        return KeyReaction::Consumed;
585                    }
586                    HandleKeyOutcome::NotHandled => {}
587                }
588            }
589        }
590
591        // Arrows navigate and Enter submits in BOTH foci (arrows as today).
592        match key.code {
593            KeyCode::Up => {
594                self.select_prev();
595                return KeyReaction::Consumed;
596            }
597            KeyCode::Down => {
598                self.select_next();
599                return KeyReaction::Consumed;
600            }
601            KeyCode::Enter => return KeyReaction::Submit,
602            _ => {}
603        }
604        // Esc: with the list-focus machine active, the first Esc moves Input →
605        // List focus (Consumed); from List focus (and for surfaces that never
606        // opted in) Esc is Cancel, so their keystroke behavior is unchanged.
607        if key.code == KeyCode::Esc {
608            if self.focus_enabled && self.focus == Focus::Input {
609                self.focus = Focus::List;
610                self.close_autocomplete();
611                return KeyReaction::Consumed;
612            }
613            return KeyReaction::Cancel;
614        }
615        // Drop Ctrl/Alt-modified chars so combos don't leak as text (both foci;
616        // registered intercepts already claimed theirs above).
617        if let KeyCode::Char(_) = key.code {
618            let non_shift = key.modifiers - KeyModifiers::SHIFT;
619            if !non_shift.is_empty() {
620                return KeyReaction::Unhandled;
621            }
622        }
623        // List focus: plain letters are verbs, never query text.
624        if self.focus == Focus::List {
625            if let KeyCode::Char(c) = key.code {
626                return match c {
627                    // `i` / `/` return to the input (cursor there, typing filters).
628                    'i' | '/' => {
629                        self.focus = Focus::Input;
630                        KeyReaction::Consumed
631                    }
632                    'j' => {
633                        self.select_next();
634                        KeyReaction::Consumed
635                    }
636                    'k' => {
637                        self.select_prev();
638                        KeyReaction::Consumed
639                    }
640                    _ if self.list_verbs.contains(&c) => KeyReaction::ListVerb(c),
641                    // Unregistered letters do NOTHING — never type into the query.
642                    _ => KeyReaction::Consumed,
643                };
644            }
645            // Other keys (Tab, function keys, …) are the surface's to handle.
646            return KeyReaction::Unhandled;
647        }
648        let outcome = self.input.handle_key(key);
649        // Sync/refresh/close the autocomplete popup based on the input outcome.
650        // Build snapshot before taking &mut self.autocomplete (same borrow trick).
651        let snap = self.autocomplete_snapshot();
652        match outcome {
653            InputOutcome::Changed => {
654                if let Some(ac) = &mut self.autocomplete {
655                    ac.sync(&snap);
656                }
657            }
658            InputOutcome::Consumed => {
659                if let Some(ac) = &mut self.autocomplete {
660                    ac.refresh_if_open(&snap);
661                }
662            }
663            InputOutcome::Cancel | InputOutcome::Submit => {
664                if let Some(ac) = &mut self.autocomplete {
665                    ac.close();
666                }
667            }
668            InputOutcome::NotConsumed => {}
669        }
670        match outcome {
671            InputOutcome::Changed => {
672                self.sync_query_from_input();
673                KeyReaction::Consumed
674            }
675            InputOutcome::Consumed => KeyReaction::Consumed,
676            InputOutcome::Submit => KeyReaction::Submit,
677            InputOutcome::Cancel => KeyReaction::Cancel,
678            InputOutcome::NotConsumed => KeyReaction::Unhandled,
679        }
680    }
681
682    pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
683        // The query input signals focus only when the panel is focused AND the
684        // input half owns the keyboard; in list focus it renders unfocused
685        // (dimmed, cursor hidden). For surfaces that never opt into list focus
686        // `self.focus` is always `Input`, so this is byte-identical to today.
687        let focused = focused && self.focus == Focus::Input;
688        let base = Style::default()
689            .fg(theme.fg.to_ratatui())
690            .bg(theme.bg_panel.to_ratatui());
691        if self.highlight_query {
692            let line =
693                crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
694            self.input.render_line(f, area, line, base, 0, focused);
695        } else {
696            self.input.render(f, area, base, 0, focused);
697        }
698    }
699
700    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
701        self.poll();
702        let sel = self.selected;
703        let items: Vec<ListItem> = (0..self.visible_len())
704            .filter_map(|pos| {
705                self.visible_row(pos)
706                    .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
707            })
708            .collect();
709        let mut state = ListState::default().with_offset(self.offset);
710        state.select(self.selected);
711        let list =
712            List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
713        f.render_stateful_widget(list, area, &mut state);
714        // Read the offset back: ratatui clamps it and keeps the selection in
715        // view (keyboard moves included), so the stored offset always matches
716        // what is actually on screen.
717        self.offset = state.offset();
718        self.list_rect = area;
719        let _ = focused;
720    }
721
722    /// Override the rect used for mouse hit-testing. The recorded rect must be
723    /// the area where list ITEMS actually render — row 0 is the first item, NOT
724    /// a block border. Hosts that draw the list inside a bordered block pass the
725    /// block's INNER rect; borderless hosts pass the list area directly. The
726    /// recorded rect and the rendered-items rect MUST be identical, so
727    /// [`handle_mouse`] maps a click at `row` to visual offset `row - rect.y`.
728    ///
729    /// [`handle_mouse`]: Self::handle_mouse
730    pub fn set_list_rect(&mut self, rect: Rect) {
731        self.list_rect = rect;
732    }
733
734    /// Record the host panel's full bounds so the wheel scrolls the list from
735    /// anywhere within the panel — header, query box, preview — not just over
736    /// the list items. Hosts call this each render with the same rect they
737    /// were drawn into. Never set = wheel hit-tests `list_rect` only.
738    pub fn set_panel_rect(&mut self, rect: Rect) {
739        self.panel_rect = rect;
740    }
741
742    /// Record a host-owned scrollable sub-region (e.g. an expanded preview):
743    /// wheel events inside it are routed back to the host as
744    /// [`SearchMouse::ContentScrollUp`]/[`ContentScrollDown`] instead of
745    /// scrolling the list. Hosts re-record it every render (empty when the
746    /// sub-region is not drawn) so the hit-test never sees a stale rect.
747    ///
748    /// [`ContentScrollDown`]: SearchMouse::ContentScrollDown
749    pub fn set_content_rect(&mut self, rect: Rect) {
750        self.content_rect = rect;
751    }
752
753    /// Test-only: the recorded content sub-region (empty when none is on
754    /// screen), so host tests can hit-test against where the preview was
755    /// drawn.
756    #[cfg(test)]
757    pub(crate) fn content_rect(&self) -> Rect {
758        self.content_rect
759    }
760
761    pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
762        if let Some(ac) = &mut self.autocomplete {
763            ac.poll_results();
764            let caret = self.input.last_caret_pos();
765            if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
766                state.anchor = anchor;
767            }
768            if let Some(state) = ac.state() {
769                crate::components::autocomplete::render(f, state, clamp, theme);
770            }
771        }
772    }
773
774    /// Close an open autocomplete popup. [`handle_mouse`] does this for every
775    /// event it sees ("any mouse interaction dismisses the popup"); hosts that
776    /// consume a mouse event WITHOUT routing it through the engine call this
777    /// to keep that rule intact.
778    ///
779    /// [`handle_mouse`]: Self::handle_mouse
780    pub fn close_autocomplete(&mut self) {
781        if let Some(ac) = &mut self.autocomplete {
782            ac.close();
783        }
784    }
785
786    /// Test-only: true when the autocomplete popup is open, so host tests
787    /// can assert the any-mouse-interaction-dismisses rule.
788    #[cfg(test)]
789    pub(crate) fn autocomplete_is_open(&self) -> bool {
790        self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
791    }
792
793    pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
794        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
795        use ratatui::layout::Position;
796        // Any mouse interaction dismisses an open autocomplete popup (matches
797        // the old modal: a click on the preview/border closes a stale popup).
798        self.close_autocomplete();
799        let pos = Position {
800            x: m.column,
801            y: m.row,
802        };
803        // The wheel is hit-tested against the host's panel bounds (when
804        // recorded), so scrolling works from anywhere within the panel;
805        // clicks below keep hit-testing the list rect only.
806        if matches!(
807            m.kind,
808            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
809        ) {
810            // The host's content sub-region wins over the panel bounds: a
811            // wheel inside it is the host's to handle (it scrolls its own
812            // view), so route it back instead of moving the list.
813            if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
814                return if m.kind == MouseEventKind::ScrollUp {
815                    SearchMouse::ContentScrollUp
816                } else {
817                    SearchMouse::ContentScrollDown
818                };
819            }
820            let bounds = if self.panel_rect.is_empty() {
821                self.list_rect
822            } else {
823                self.panel_rect
824            };
825            if !bounds.contains(pos) {
826                return SearchMouse::None;
827            }
828            if m.kind == MouseEventKind::ScrollUp {
829                self.scroll_up();
830            } else {
831                self.scroll_down();
832            }
833            return SearchMouse::Scrolled;
834        }
835        let r = self.list_rect;
836        if !r.contains(pos) {
837            return SearchMouse::None;
838        }
839        match m.kind {
840            MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
841                let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
842                let target_visual = m.row - r.y; // 0-based visual offset; row 0 = first item
843                let mut acc: u16 = 0;
844                let mut hit: Option<usize> = None;
845                // Walk the VISIBLE sequence (leading row at position 0, then the
846                // display rows) starting at the viewport offset — screen row 0
847                // is the item at `offset`, not visible position 0 — so visual
848                // offsets map to the positions actually on screen.
849                for pos in self.offset..self.visible_len() {
850                    let h = self
851                        .visible_row(pos)
852                        .map(|r| r.visual_height())
853                        .unwrap_or(1);
854                    if target_visual < acc + h {
855                        hit = Some(pos);
856                        break;
857                    }
858                    acc += h;
859                }
860                if let Some(pos) = hit {
861                    let prev = self.selected;
862                    let prev_click = self.last_click_pos.replace(pos);
863                    self.selected = Some(pos);
864                    return if right_click {
865                        SearchMouse::Context(pos)
866                    } else if prev == Some(pos) && prev_click == Some(pos) {
867                        // Activate only on click-click: the row was already
868                        // selected BY A CLICK, not by auto-select or keys.
869                        SearchMouse::Activated(pos)
870                    } else {
871                        SearchMouse::Selected(pos)
872                    };
873                }
874                SearchMouse::None
875            }
876            _ => SearchMouse::None,
877        }
878    }
879
880    fn recompute_display(&mut self) {
881        let q = self.query.trim();
882        // The leading row is query-fresh: rebuilt on every poll AND on every
883        // local-filter `set_query`, so it never goes stale.
884        self.leading = self.source.leading_row(q);
885        let mut idx: Vec<usize> = match &self.filter {
886            Filter::SourceOrder => (0..self.rows.len()).collect(),
887            Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
888            Filter::Fuzzy => fuzzy_indices(&self.rows, q),
889            Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
890            Filter::Rank(f) => {
891                let f = f.clone();
892                f(&self.rows, q)
893            }
894        };
895        // Filter-exempt rows (match_text() == None: Up / Create / virtual pinned)
896        // are always present; prepend any that the filter dropped.
897        for i in 0..self.rows.len() {
898            if self.rows[i].match_text().is_none() && !idx.contains(&i) {
899                idx.insert(0, i);
900            }
901        }
902        self.display = idx;
903        self.clamp_selection();
904    }
905
906    #[cfg(test)]
907    pub(crate) async fn poll_until_idle(&mut self) {
908        // In-memory sources settle on the first poll (no sleep paid). Vault-backed
909        // sources run their read on a worker/blocking thread, which can starve
910        // under the full parallel suite — so once still loading, sleep a little
911        // between polls and use a generous ceiling. Early-breaks the instant the
912        // load lands, keeping the common (in-memory) path fast.
913        for _ in 0..600 {
914            tokio::task::yield_now().await;
915            self.poll();
916            if !self.is_loading() {
917                break;
918            }
919            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
920        }
921        self.poll();
922    }
923}
924
925impl<R: SearchRow> SearchListBuilder<R> {
926    pub fn initial_query(mut self, q: impl Into<String>) -> Self {
927        self.initial_query = q.into();
928        self
929    }
930    pub fn filter(mut self, f: Filter<R>) -> Self {
931        self.filter = f;
932        self
933    }
934    pub fn autocomplete(
935        mut self,
936        suggestions: Arc<dyn SuggestionSource>,
937        mode: AutocompleteMode,
938    ) -> Self {
939        self.autocomplete = Some((suggestions, mode));
940        self
941    }
942    pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
943        self.intercept = v;
944        self
945    }
946    /// Render the query input with §9 syntax highlighting.
947    pub fn highlight_query(mut self) -> Self {
948        self.highlight_query = true;
949        self
950    }
951    pub fn icons(mut self, icons: Icons) -> Self {
952        self.icons = icons;
953        self
954    }
955    /// The focus the surface opens on (default [`Focus::Input`]). Opening on
956    /// [`Focus::List`] also activates the list-focus state machine (so `Esc`
957    /// cancels from the list rather than flipping into it).
958    pub fn opening_focus(mut self, focus: Focus) -> Self {
959        self.opening_focus = focus;
960        self
961    }
962    /// Register a plain letter as a list-focus verb. In [`Focus::List`],
963    /// pressing it returns [`KeyReaction::ListVerb`] with the char; the engine
964    /// attaches no meaning — the caller decides the action. Registering any
965    /// verb activates the list-focus state machine. `j`/`k`/`i`/`/` are
966    /// reserved (navigation and focus switching) and win over a same-letter
967    /// verb.
968    pub fn list_verb(mut self, c: char) -> Self {
969        self.list_verbs.push(c);
970        self
971    }
972    /// Override the autocomplete controller's debounce. Tests use
973    /// `Duration::ZERO` to get suggestions without waiting on the debounce timer.
974    pub fn debounce(mut self, d: std::time::Duration) -> Self {
975        self.debounce = Some(d);
976        self
977    }
978    pub fn build(self) -> SearchList<R> {
979        SearchList::new(self)
980    }
981
982    /// Build synchronously over a known, in-memory row set: the rows are
983    /// applied and the initial selection seeded before this returns — no async
984    /// load, no channel, no redraw round-trip. For static sources
985    /// (`reload_on_query() == false`); the source's `load` is never called, so
986    /// most static consumers pair this with [`StaticRowSource`]. The redraw
987    /// callback passed to [`builder`](SearchList::builder) is never fired on
988    /// this path.
989    ///
990    /// [`StaticRowSource`]: crate::components::search_list::StaticRowSource
991    pub fn build_with_rows(self, rows: Vec<R>) -> SearchList<R> {
992        SearchList::with_rows(self, rows)
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::adapters::{
999        ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
1000        VecSource, VecSourceWithLead,
1001    };
1002    use super::*;
1003    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1004
1005    fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
1006        std::sync::Arc::new(|| {})
1007    }
1008
1009    fn key(c: KeyCode) -> KeyEvent {
1010        KeyEvent::new(c, KeyModifiers::NONE)
1011    }
1012
1013    fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
1014        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1015        MouseEvent {
1016            kind: MouseEventKind::Down(MouseButton::Left),
1017            column: col,
1018            row,
1019            modifiers: KeyModifiers::NONE,
1020        }
1021    }
1022
1023    #[derive(Clone, Debug, PartialEq)]
1024    struct TallRow {
1025        name: String,
1026        height: u16,
1027    }
1028    impl SearchRow for TallRow {
1029        fn to_list_item(
1030            &self,
1031            _t: &crate::settings::themes::Theme,
1032            _i: &crate::settings::icons::Icons,
1033            _s: bool,
1034        ) -> ratatui::widgets::ListItem<'static> {
1035            ratatui::widgets::ListItem::new(self.name.clone())
1036        }
1037        fn visual_height(&self) -> u16 {
1038            self.height
1039        }
1040        fn match_text(&self) -> Option<&str> {
1041            Some(&self.name)
1042        }
1043    }
1044    struct TallSource(Vec<TallRow>);
1045    #[async_trait::async_trait]
1046    impl RowSource<TallRow> for TallSource {
1047        async fn load(&self, _q: &str, emit: Emit<TallRow>) {
1048            emit.replace(self.0.clone());
1049        }
1050    }
1051
1052    /// The wheel is routed to the host (ContentScroll*) inside the recorded
1053    /// content sub-region — which wins over the panel bounds — and scrolls
1054    /// the list everywhere else within the panel.
1055    #[tokio::test]
1056    async fn wheel_in_content_rect_routes_to_host() {
1057        use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
1058        let rows: Vec<TallRow> = (0..10)
1059            .map(|i| TallRow {
1060                name: format!("r{}", i),
1061                height: 1,
1062            })
1063            .collect();
1064        let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
1065        list.poll_until_idle().await;
1066        let rect = |y: u16, h: u16| ratatui::layout::Rect {
1067            x: 0,
1068            y,
1069            width: 20,
1070            height: h,
1071        };
1072        // Panel covers rows 0..10; list draws in 0..4; content region 5..10.
1073        list.set_panel_rect(rect(0, 10));
1074        list.set_list_rect(rect(0, 4));
1075        list.set_content_rect(rect(5, 5));
1076        let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
1077            kind,
1078            column: 2,
1079            row,
1080            modifiers: KeyModifiers::NONE,
1081        };
1082
1083        // Inside the content region: routed to the host, list untouched.
1084        let m = wheel(MouseEventKind::ScrollDown, 6);
1085        assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
1086        assert_eq!(list.offset, 0, "list viewport must not move");
1087        let m = wheel(MouseEventKind::ScrollUp, 6);
1088        assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
1089
1090        // Over the list (panel bounds, outside content): the list scrolls.
1091        let m = wheel(MouseEventKind::ScrollDown, 2);
1092        assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1093
1094        // Cleared sub-region: the wheel falls back to the panel-wide scroll.
1095        list.set_content_rect(ratatui::layout::Rect::default());
1096        let m = wheel(MouseEventKind::ScrollDown, 6);
1097        assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1098    }
1099
1100    #[tokio::test]
1101    async fn mouse_maps_visual_row_to_display_index_by_height() {
1102        // Row 0 occupies 3 visual rows, row 1 occupies 1. The recorded list rect
1103        // is the rendered-items area: row 0 == the FIRST item (no border row).
1104        let src = TallSource(vec![
1105            TallRow {
1106                name: "a".into(),
1107                height: 3,
1108            },
1109            TallRow {
1110                name: "b".into(),
1111                height: 1,
1112            },
1113        ]);
1114        let mut list = SearchList::builder(src, noop_redraw()).build();
1115        list.poll_until_idle().await;
1116        // Force the recorded list rect (render not run in test): items start at y=0.
1117        list.set_list_rect(ratatui::layout::Rect {
1118            x: 0,
1119            y: 0,
1120            width: 20,
1121            height: 10,
1122        });
1123        // "a" occupies rows 0..=2; row 3 is the FIRST row of "b".
1124        let m = mouse_down_at(2, 3);
1125        assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
1126        assert_eq!(list.selected_row().unwrap().name, "b");
1127        // A click at row 1 = within "a" (rows 0..=2) -> display index 0.
1128        let m = mouse_down_at(2, 1);
1129        list.handle_mouse(&m);
1130        assert_eq!(list.selected_row().unwrap().name, "a");
1131    }
1132
1133    // Mouse-wheel scrolling moves the VIEWPORT, carrying the selection along
1134    // so the selected row keeps its on-screen position (selected - offset is
1135    // invariant) — unlike keyboard navigation, which moves the selection.
1136    #[tokio::test]
1137    async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
1138        let src = VecSource {
1139            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1140            reload: true,
1141        };
1142        let mut list = SearchList::builder(src, noop_redraw()).build();
1143        list.poll_until_idle().await;
1144        // Viewport shows 4 of the 10 rows.
1145        list.set_list_rect(ratatui::layout::Rect {
1146            x: 0,
1147            y: 0,
1148            width: 20,
1149            height: 4,
1150        });
1151        // Move the selection to screen row 2 first.
1152        list.select_next();
1153        list.select_next();
1154        assert_eq!(list.selected_row().unwrap().name, "row2");
1155
1156        let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1157            kind,
1158            column: 1,
1159            row: 1,
1160            modifiers: KeyModifiers::NONE,
1161        };
1162        use ratatui::crossterm::event::MouseEventKind;
1163
1164        // Scroll down: viewport and selection move together.
1165        assert_eq!(
1166            list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1167            SearchMouse::Scrolled
1168        );
1169        assert_eq!(list.scroll_offset(), 1);
1170        assert_eq!(list.selected_row().unwrap().name, "row3");
1171
1172        // Scroll back up: both return.
1173        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1174        assert_eq!(list.scroll_offset(), 0);
1175        assert_eq!(list.selected_row().unwrap().name, "row2");
1176
1177        // At the top, scrolling up is a no-op (selection does NOT move).
1178        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1179        assert_eq!(list.scroll_offset(), 0);
1180        assert_eq!(list.selected_row().unwrap().name, "row2");
1181
1182        // Scrolling down clamps once the last row is in view: 10 rows in a
1183        // 4-row viewport → max offset 6.
1184        for _ in 0..20 {
1185            list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1186        }
1187        assert_eq!(list.scroll_offset(), 6);
1188        assert_eq!(list.selected_row().unwrap().name, "row8");
1189        // The selection kept its screen row through the clamped scroll.
1190        // (row2 at offset 0 → screen row 2; row8 at offset 6 → screen row 2.)
1191    }
1192
1193    // The wheel hit-tests the recorded PANEL rect: scrolling over the host's
1194    // header/query box (outside the list rect) still scrolls the list. Without
1195    // a panel rect it falls back to the list rect only.
1196    #[tokio::test]
1197    async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1198        let src = VecSource {
1199            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1200            reload: true,
1201        };
1202        let mut list = SearchList::builder(src, noop_redraw()).build();
1203        list.poll_until_idle().await;
1204        // List items render at y 5..9; the panel spans y 0..20.
1205        list.set_list_rect(ratatui::layout::Rect {
1206            x: 0,
1207            y: 5,
1208            width: 20,
1209            height: 4,
1210        });
1211        let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1212            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1213            column: 1,
1214            row,
1215            modifiers: KeyModifiers::NONE,
1216        };
1217        // No panel rect: a scroll over the header (y=1) misses.
1218        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1219        assert_eq!(list.scroll_offset(), 0);
1220        list.set_panel_rect(ratatui::layout::Rect {
1221            x: 0,
1222            y: 0,
1223            width: 20,
1224            height: 20,
1225        });
1226        // With the panel rect, the same scroll-over-header scrolls the list.
1227        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1228        assert_eq!(list.scroll_offset(), 1);
1229        // Clicks still hit-test the LIST rect only: a click on the header
1230        // (inside the panel, outside the list) selects nothing.
1231        let before = list.selected_row().unwrap().name.clone();
1232        assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1233        assert_eq!(list.selected_row().unwrap().name, before);
1234    }
1235
1236    // Regression: the click hit-test must account for the viewport offset —
1237    // after wheel scrolling, screen row 0 is the item at `offset`, not
1238    // visible position 0.
1239    #[tokio::test]
1240    async fn click_after_scroll_selects_the_clicked_row() {
1241        let src = VecSource {
1242            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1243            reload: true,
1244        };
1245        let mut list = SearchList::builder(src, noop_redraw()).build();
1246        list.poll_until_idle().await;
1247        list.set_list_rect(ratatui::layout::Rect {
1248            x: 0,
1249            y: 0,
1250            width: 20,
1251            height: 4,
1252        });
1253        let scroll_down = ratatui::crossterm::event::MouseEvent {
1254            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1255            column: 1,
1256            row: 1,
1257            modifiers: KeyModifiers::NONE,
1258        };
1259        for _ in 0..3 {
1260            list.handle_mouse(&scroll_down);
1261        }
1262        assert_eq!(list.scroll_offset(), 3);
1263        // Screen row 2 shows visible position offset + 2 = 5.
1264        assert!(matches!(
1265            list.handle_mouse(&mouse_down_at(2, 2)),
1266            SearchMouse::Selected(5)
1267        ));
1268        assert_eq!(list.selected_row().unwrap().name, "row5");
1269        // Screen row 0 shows the item at the offset itself.
1270        list.handle_mouse(&mouse_down_at(2, 0));
1271        assert_eq!(list.selected_row().unwrap().name, "row3");
1272    }
1273
1274    // The synchronous build seam: `build_with_rows` applies the rows and seeds
1275    // the selection in the same call — no poll, no spawn, `is_loading()` false
1276    // immediately. This is the static-source path (StaticRowSource); the row
1277    // set is readable on the very next line.
1278    #[tokio::test]
1279    async fn build_with_rows_applies_synchronously_without_a_poll() {
1280        let list = SearchList::builder(StaticRowSource, noop_redraw())
1281            .filter(Filter::Fuzzy)
1282            .build_with_rows(vec![TestRow::new("alpha"), TestRow::new("beta")]);
1283        // No poll, no settle: the rows and the seeded selection are live now.
1284        assert!(!list.is_loading(), "static build is not loading");
1285        assert_eq!(list.rows().len(), 2);
1286        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1287    }
1288
1289    #[tokio::test]
1290    async fn initial_load_populates_rows() {
1291        let src = VecSource {
1292            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1293            reload: true,
1294        };
1295        let mut list = SearchList::builder(src, noop_redraw()).build();
1296        list.poll_until_idle().await;
1297        assert_eq!(list.rows().len(), 2);
1298        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1299    }
1300
1301    #[tokio::test]
1302    async fn requery_supersedes_and_reloads() {
1303        let src = VecSource {
1304            rows: vec![
1305                TestRow::new("alpha"),
1306                TestRow::new("alps"),
1307                TestRow::new("beta"),
1308            ],
1309            reload: true,
1310        };
1311        let mut list = SearchList::builder(src, noop_redraw()).build();
1312        list.poll_until_idle().await;
1313        assert_eq!(list.rows().len(), 3);
1314        list.set_query("alp");
1315        list.poll_until_idle().await;
1316        assert_eq!(list.rows().len(), 2); // alpha, alps
1317        assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1318    }
1319
1320    #[tokio::test]
1321    async fn arrows_navigate_and_enter_submits() {
1322        let src = VecSource {
1323            rows: vec![TestRow::new("a"), TestRow::new("b")],
1324            reload: true,
1325        };
1326        let mut list = SearchList::builder(src, noop_redraw()).build();
1327        list.poll_until_idle().await;
1328        assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1329        assert_eq!(list.selected_row().unwrap().name, "b");
1330        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1331        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1332    }
1333
1334    #[tokio::test]
1335    async fn typing_a_char_changes_query() {
1336        let src = VecSource {
1337            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1338            reload: true,
1339        };
1340        let mut list = SearchList::builder(src, noop_redraw()).build();
1341        list.poll_until_idle().await;
1342        assert_eq!(
1343            list.handle_key(&key(KeyCode::Char('a'))),
1344            KeyReaction::Consumed
1345        );
1346        list.poll_until_idle().await;
1347        assert_eq!(list.query(), "a");
1348    }
1349
1350    #[tokio::test]
1351    async fn rank_filter_orders_by_closure() {
1352        let src = VecSource {
1353            rows: vec![
1354                TestRow::new("todo"),
1355                TestRow::new("today"),
1356                TestRow::new("misc"),
1357            ],
1358            reload: false,
1359        };
1360        let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1361            let mut idx: Vec<usize> = (0..rows.len())
1362                .filter(|&i| rows[i].name.contains(q))
1363                .collect();
1364            idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1365            idx
1366        });
1367        let mut list = SearchList::builder(src, noop_redraw())
1368            .filter(Filter::Rank(rank))
1369            .build();
1370        list.poll_until_idle().await;
1371        list.set_query("today");
1372        list.poll();
1373        assert_eq!(list.selected_row().unwrap().name, "today");
1374    }
1375
1376    #[tokio::test]
1377    async fn fuzzy_filter_narrows_local_set() {
1378        let src = VecSource {
1379            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1380            reload: false,
1381        };
1382        let mut list = SearchList::builder(src, noop_redraw())
1383            .filter(Filter::Fuzzy)
1384            .build();
1385        list.poll_until_idle().await;
1386        list.set_query("alp");
1387        list.poll();
1388        assert_eq!(list.visible_rows().len(), 1);
1389        assert_eq!(list.selected_row().unwrap().name, "alpha");
1390    }
1391
1392    #[tokio::test]
1393    async fn streamed_rows_arrive_then_done_and_filter_locally() {
1394        let src = ScriptedStreamSource {
1395            batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1396        };
1397        let mut list = SearchList::builder(src, noop_redraw())
1398            .filter(Filter::Fuzzy)
1399            .build();
1400        list.poll_until_idle().await;
1401        assert_eq!(list.rows().len(), 2);
1402        assert!(!list.is_loading());
1403        list.set_query("alp");
1404        list.poll();
1405        assert_eq!(list.visible_rows().len(), 1);
1406    }
1407
1408    #[tokio::test]
1409    async fn source_order_unfiltered_passthrough() {
1410        let src = VecSource {
1411            rows: vec![TestRow::new("a"), TestRow::new("b")],
1412            reload: true,
1413        };
1414        let mut list = SearchList::builder(src, noop_redraw()).build(); // default Filter::SourceOrder
1415        list.poll_until_idle().await;
1416        assert_eq!(list.visible_rows().len(), 2);
1417        assert_eq!(list.selected_row().unwrap().name, "a");
1418    }
1419
1420    #[tokio::test]
1421    async fn intercepted_combo_returns_intercepted_without_acting() {
1422        let src = VecSource {
1423            rows: vec![TestRow::new("a")],
1424            reload: true,
1425        };
1426        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1427        let mut list = SearchList::builder(src, noop_redraw())
1428            .intercept(vec![combo])
1429            .build();
1430        list.poll_until_idle().await;
1431        // Enter is intercepted: engine returns Intercepted, does NOT submit/act.
1432        assert_eq!(
1433            list.handle_key(&key(KeyCode::Enter)),
1434            KeyReaction::Intercepted(combo)
1435        );
1436    }
1437
1438    #[tokio::test]
1439    async fn autocomplete_accept_rewrites_query_without_vault() {
1440        struct Mem;
1441        #[async_trait::async_trait]
1442        impl crate::components::search_list::SuggestionSource for Mem {
1443            async fn notes_by_prefix(
1444                &self,
1445                _p: &str,
1446                _n: usize,
1447            ) -> Vec<crate::components::search_list::SuggestionItem> {
1448                vec![]
1449            }
1450            async fn tags_by_prefix(
1451                &self,
1452                p: &str,
1453                _n: usize,
1454            ) -> Vec<crate::components::search_list::SuggestionItem> {
1455                if "projects".starts_with(p) {
1456                    vec![crate::components::search_list::SuggestionItem::plain(
1457                        "projects",
1458                    )]
1459                } else {
1460                    vec![]
1461                }
1462            }
1463        }
1464        let src = VecSource {
1465            rows: vec![],
1466            reload: true,
1467        };
1468        let mut list = SearchList::builder(src, noop_redraw())
1469            .autocomplete(
1470                std::sync::Arc::new(Mem),
1471                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1472            )
1473            .debounce(std::time::Duration::ZERO)
1474            .build();
1475        for c in ['#', 'p', 'r', 'o'] {
1476            let _ = list.handle_key(&key(KeyCode::Char(c)));
1477        }
1478        for _ in 0..50 {
1479            tokio::task::yield_now().await;
1480            list.poll();
1481        }
1482        let _ = list.handle_key(&key(KeyCode::Tab));
1483        assert_eq!(list.query(), "#projects");
1484    }
1485
1486    // Accepting a SavedSearch suggestion expands the whole field to the
1487    // stored query AND exposes the accepted name (for the breadcrumb) via
1488    // `take_accepted_saved_search`.
1489    #[tokio::test]
1490    async fn accepting_saved_search_expands_query_and_exposes_name() {
1491        struct Mem;
1492        #[async_trait::async_trait]
1493        impl crate::components::search_list::SuggestionSource for Mem {
1494            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1495                vec![]
1496            }
1497            async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1498                vec![]
1499            }
1500            async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1501                if "todo-week".starts_with(p) {
1502                    vec![SuggestionItem {
1503                        display: "todo-week".into(),
1504                        secondary: Some("#todo ^modified".into()),
1505                    }]
1506                } else {
1507                    vec![]
1508                }
1509            }
1510        }
1511        let src = VecSource {
1512            rows: vec![],
1513            reload: true,
1514        };
1515        let mut list = SearchList::builder(src, noop_redraw())
1516            .autocomplete(
1517                std::sync::Arc::new(Mem),
1518                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1519            )
1520            .debounce(std::time::Duration::ZERO)
1521            .build();
1522        for c in ['?', 't', 'o'] {
1523            let _ = list.handle_key(&key(KeyCode::Char(c)));
1524        }
1525        for _ in 0..50 {
1526            tokio::task::yield_now().await;
1527            list.poll();
1528        }
1529        let _ = list.handle_key(&key(KeyCode::Tab));
1530        // Whole field expanded to the stored query.
1531        assert_eq!(list.query(), "#todo ^modified");
1532        // The accepted name is exposed once, then cleared.
1533        assert_eq!(
1534            list.take_accepted_saved_search().as_deref(),
1535            Some("todo-week")
1536        );
1537        assert_eq!(list.take_accepted_saved_search(), None);
1538    }
1539
1540    // Regression: Enter (not just Tab) must accept an open autocomplete popup,
1541    // and the engine must report Consumed — NOT Submit — so a host does not
1542    // mistake the accept for a list submit. (A QueryPanel Enter pre-check used
1543    // to swallow this, breaking accept-on-Enter in the right sidebar.)
1544    #[tokio::test]
1545    async fn enter_accepts_open_popup_and_reports_consumed() {
1546        struct Mem;
1547        #[async_trait::async_trait]
1548        impl crate::components::search_list::SuggestionSource for Mem {
1549            async fn notes_by_prefix(
1550                &self,
1551                _p: &str,
1552                _n: usize,
1553            ) -> Vec<crate::components::search_list::SuggestionItem> {
1554                vec![]
1555            }
1556            async fn tags_by_prefix(
1557                &self,
1558                p: &str,
1559                _n: usize,
1560            ) -> Vec<crate::components::search_list::SuggestionItem> {
1561                if "projects".starts_with(p) {
1562                    vec![crate::components::search_list::SuggestionItem::plain(
1563                        "projects",
1564                    )]
1565                } else {
1566                    vec![]
1567                }
1568            }
1569        }
1570        let src = VecSource {
1571            rows: vec![],
1572            reload: true,
1573        };
1574        let mut list = SearchList::builder(src, noop_redraw())
1575            .autocomplete(
1576                std::sync::Arc::new(Mem),
1577                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1578            )
1579            .debounce(std::time::Duration::ZERO)
1580            .build();
1581        for c in ['#', 'p', 'r', 'o'] {
1582            let _ = list.handle_key(&key(KeyCode::Char(c)));
1583        }
1584        for _ in 0..50 {
1585            tokio::task::yield_now().await;
1586            list.poll();
1587        }
1588        // Popup is open: Enter accepts the suggestion and reports Consumed.
1589        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1590        assert_eq!(list.query(), "#projects");
1591        // Popup now closed: a second Enter falls through to Submit.
1592        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1593    }
1594
1595    // Regression (P0): a STREAMED source (sidebar shape) supplies a query-fresh
1596    // leading row. It must appear at visible position 0 even though rows arrive
1597    // via Push (never Replace), be present when the query matches no streamed
1598    // row, and refresh when the query changes (reload_on_query() == false).
1599    #[tokio::test]
1600    async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1601        let src = ScriptedStreamLeadSource {
1602            items: vec!["alpha".into(), "beta".into()],
1603        };
1604        let mut list = SearchList::builder(src, noop_redraw())
1605            .filter(Filter::Fuzzy)
1606            .initial_query("zz")
1607            .build();
1608        list.poll_until_idle().await;
1609        // Leading present even though "zz" matches no streamed Item.
1610        let vis = list.visible_rows();
1611        assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1612        assert_eq!(list.visible_len(), 1); // just the leading; no Item matches
1613        // Query-fresh: changing the query rebuilds the leading and re-filters.
1614        list.set_query("alp");
1615        list.poll();
1616        let vis = list.visible_rows();
1617        assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1618        assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1619        assert_eq!(list.visible_len(), 2);
1620        // Empty query: leading disappears, both Items show.
1621        list.set_query("");
1622        list.poll();
1623        assert!(
1624            list.visible_rows()
1625                .iter()
1626                .all(|r| matches!(r, StreamRow::Item(_)))
1627        );
1628        assert_eq!(list.visible_len(), 2);
1629    }
1630
1631    // Regression guard for the saved-searches virtual entry: a one-shot
1632    // (Replace) source with a leading row still pins it at position 0.
1633    #[tokio::test]
1634    async fn oneshot_source_leading_row_still_works() {
1635        let src = VecSourceWithLead {
1636            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1637        };
1638        let mut list = SearchList::builder(src, noop_redraw())
1639            .filter(Filter::Fuzzy)
1640            .initial_query("alp")
1641            .build();
1642        list.poll_until_idle().await;
1643        let vis = list.visible_rows();
1644        assert_eq!(vis[0].name, "create:alp");
1645        assert_eq!(vis[1].name, "alpha");
1646        assert_eq!(list.visible_len(), 2);
1647    }
1648
1649    // Selection walks the VISIBLE sequence: position 0 is the leading row, and
1650    // select_next steps from the leading to the first real row.
1651    #[tokio::test]
1652    async fn selection_includes_leading_at_position_zero() {
1653        let src = VecSourceWithLead {
1654            rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1655        };
1656        let mut list = SearchList::builder(src, noop_redraw())
1657            .filter(Filter::Fuzzy)
1658            .initial_query("alp")
1659            .build();
1660        list.poll_until_idle().await;
1661        // Auto-selected position 0 -> the leading.
1662        assert_eq!(list.selected_row().unwrap().name, "create:alp");
1663        list.handle_key(&key(KeyCode::Down));
1664        assert_eq!(list.selected_row().unwrap().name, "alpha");
1665    }
1666
1667    // A source with NO leading row has no off-by-one: visible_len == display.
1668    #[tokio::test]
1669    async fn no_leading_row_visible_len_matches_display() {
1670        let src = VecSource {
1671            rows: vec![TestRow::new("a"), TestRow::new("b")],
1672            reload: true,
1673        };
1674        let mut list = SearchList::builder(src, noop_redraw()).build();
1675        list.poll_until_idle().await;
1676        assert_eq!(list.visible_len(), 2);
1677        assert_eq!(list.visible_rows().len(), 2);
1678        assert_eq!(list.selected_row().unwrap().name, "a");
1679    }
1680
1681    // update_rows re-runs the active fuzzy filter after the mutation so rows
1682    // that no longer match the query drop out of the visible view.
1683    #[tokio::test]
1684    async fn update_rows_refilters_visible_view() {
1685        let source = VecSource {
1686            rows: vec![
1687                TestRow::new("alpha"),
1688                TestRow::new("beta"),
1689                TestRow::new("gamma"),
1690            ],
1691            reload: false,
1692        };
1693        let mut list = SearchList::builder(source, noop_redraw())
1694            .filter(Filter::Fuzzy)
1695            .build();
1696        list.poll_until_idle().await;
1697
1698        // With query "alp", only "alpha" should be visible.
1699        list.set_query("alp");
1700        list.poll();
1701        assert_eq!(
1702            list.visible_rows()
1703                .iter()
1704                .map(|r| r.name.as_str())
1705                .collect::<Vec<_>>(),
1706            vec!["alpha"],
1707            "before update: only 'alpha' matches 'alp'"
1708        );
1709
1710        // Rename "alpha" to something that no longer contains "alp".
1711        let changed = list.update_rows(|r| {
1712            if r.name == "alpha" {
1713                r.name = "renamed".to_string();
1714                true
1715            } else {
1716                false
1717            }
1718        });
1719        assert!(changed);
1720
1721        // The visible view must now be empty: "renamed" does not match "alp".
1722        assert_eq!(
1723            list.visible_rows().len(),
1724            0,
1725            "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
1726        );
1727    }
1728
1729    #[tokio::test]
1730    async fn update_rows_mutates_in_place_and_recomputes() {
1731        let source = VecSource {
1732            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1733            reload: false,
1734        };
1735        let mut list = SearchList::builder(source, noop_redraw()).build();
1736        list.poll_until_idle().await;
1737
1738        // Mutate the row named "alpha".
1739        let changed = list.update_rows(|r| {
1740            if r.name == "alpha" {
1741                r.name = "renamed".to_string();
1742                true
1743            } else {
1744                false
1745            }
1746        });
1747        assert!(changed, "a row was changed");
1748        assert!(
1749            list.rows().iter().any(|r| r.name == "renamed"),
1750            "the mutation is visible in rows()"
1751        );
1752
1753        // A no-op mutation reports no change and does not panic.
1754        let changed_again = list.update_rows(|_| false);
1755        assert!(!changed_again, "no row changed");
1756    }
1757
1758    // Regression guard (Fix A): for reload_on_query == true sources that also
1759    // expose a leading row, set_query must rebuild the leading row synchronously
1760    // in the same frame — before any poll/drain. The old code skipped
1761    // recompute_and_seed() for reload sources, so the leading row lagged until
1762    // the async load landed. This test must FAIL without the fix (the leading
1763    // row still shows the old query immediately after set_query).
1764    #[tokio::test]
1765    async fn reload_source_leading_row_updates_synchronously_on_set_query() {
1766        let src = ReloadWithLeadSource {
1767            rows: vec![
1768                TestRow::new("alpha"),
1769                TestRow::new("beta"),
1770                TestRow::new("gamma"),
1771            ],
1772        };
1773        let mut list = SearchList::builder(src, noop_redraw()).build();
1774        list.poll_until_idle().await;
1775        // Sanity: no leading row for empty query.
1776        assert!(list.leading.is_none(), "no leading row for empty query");
1777
1778        // Change query — do NOT poll/drain after this.
1779        list.set_query("alp");
1780
1781        // The leading row must reflect the NEW query immediately (synchronously).
1782        let vis = list.visible_rows();
1783        assert!(
1784            !vis.is_empty(),
1785            "visible_rows must not be empty right after set_query"
1786        );
1787        assert_eq!(
1788            vis[0].name, "create:alp",
1789            "leading row must show new query synchronously, before any poll/drain"
1790        );
1791
1792        // The async load will also arrive, but the leading row must already be
1793        // correct without waiting for it.
1794        list.poll_until_idle().await;
1795        let vis = list.visible_rows();
1796        assert_eq!(
1797            vis[0].name, "create:alp",
1798            "leading row correct after drain too"
1799        );
1800        // Only "alpha" matches "alp" from the server-side filter.
1801        assert_eq!(vis.len(), 2, "leading + alpha");
1802        assert_eq!(vis[1].name, "alpha");
1803    }
1804
1805    // Regression guard: local-filter sources (reload_on_query == false) must
1806    // reseed the selection back to row 0 when a filter change repopulates the
1807    // list after having emptied it.
1808    //
1809    // The gate in `poll()` (only recompute when drain is non-empty) must NOT
1810    // suppress the reseed for local filters, because they go through
1811    // `requery()` → `recompute_and_seed()` directly — no loader drain.
1812    #[tokio::test]
1813    async fn local_filter_reseed_after_empty_then_repopulate() {
1814        let src = VecSource {
1815            rows: vec![
1816                TestRow::new("alpha"),
1817                TestRow::new("beta"),
1818                TestRow::new("gamma"),
1819            ],
1820            reload: false,
1821        };
1822        let mut list = SearchList::builder(src, noop_redraw())
1823            .filter(Filter::Fuzzy)
1824            .build();
1825        list.poll_until_idle().await;
1826
1827        // Sanity: initial load selected the first row.
1828        assert!(
1829            list.selected_row().is_some(),
1830            "should have a selection after initial load"
1831        );
1832
1833        // Apply a filter that matches nothing → visible list is empty → selection cleared.
1834        list.set_query("zzznomatch");
1835        assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
1836        assert!(
1837            list.selected_row().is_none(),
1838            "selection must be None when list is empty"
1839        );
1840
1841        // Widen the filter so rows come back (no drain will happen — local filter).
1842        list.set_query("alp");
1843        assert!(
1844            list.visible_len() > 0,
1845            "at least 'alpha' should match 'alp'"
1846        );
1847        // The selection MUST be reseeded to Some(0) — the subtlety the gating
1848        // would regress if recompute_and_seed() weren't called from requery().
1849        assert!(
1850            list.selected_row().is_some(),
1851            "selection must be reseeded to first visible row after repopulation"
1852        );
1853        assert_eq!(
1854            list.selected_row().unwrap().name,
1855            "alpha",
1856            "first visible row must be selected after reseeding"
1857        );
1858    }
1859
1860    // ── List focus ──────────────────────────────────────────────────────
1861
1862    async fn focus_list(verbs: &[char]) -> SearchList<TestRow> {
1863        let src = VecSource {
1864            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1865            reload: false,
1866        };
1867        let mut b = SearchList::builder(src, noop_redraw()).filter(Filter::Fuzzy);
1868        for &c in verbs {
1869            b = b.list_verb(c);
1870        }
1871        let mut list = b.build();
1872        list.poll_until_idle().await;
1873        list
1874    }
1875
1876    // Registering a verb activates the machine: the first Esc flips Input→List
1877    // (Consumed, not Cancel); a second Esc (now in List focus) Cancels.
1878    #[tokio::test]
1879    async fn esc_enters_list_focus_then_cancels() {
1880        let mut list = focus_list(&['l']).await;
1881        assert_eq!(list.focus(), Focus::Input);
1882        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Consumed);
1883        assert_eq!(list.focus(), Focus::List);
1884        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1885        assert_eq!(list.focus(), Focus::List, "Cancel does not change focus");
1886    }
1887
1888    // Surfaces that never opt in keep byte-identical Esc→Cancel and stay Input.
1889    #[tokio::test]
1890    async fn esc_cancels_immediately_when_focus_disabled() {
1891        let mut list = focus_list(&[]).await;
1892        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1893        assert_eq!(list.focus(), Focus::Input);
1894    }
1895
1896    // `i` and `/` return from List focus to Input focus.
1897    #[tokio::test]
1898    async fn i_and_slash_return_to_input_focus() {
1899        for ret in ['i', '/'] {
1900            let mut list = focus_list(&['l']).await;
1901            list.handle_key(&key(KeyCode::Esc)); // → List
1902            assert_eq!(list.focus(), Focus::List);
1903            assert_eq!(
1904                list.handle_key(&key(KeyCode::Char(ret))),
1905                KeyReaction::Consumed
1906            );
1907            assert_eq!(list.focus(), Focus::Input);
1908            assert_eq!(list.query(), "", "switching focus must not type a char");
1909        }
1910    }
1911
1912    // In List focus `j`/`k` navigate (arrows keep working too).
1913    #[tokio::test]
1914    async fn list_focus_j_k_navigate() {
1915        let mut list = focus_list(&['l']).await;
1916        list.handle_key(&key(KeyCode::Esc)); // → List
1917        assert_eq!(list.selected_row().unwrap().name, "alpha");
1918        assert_eq!(
1919            list.handle_key(&key(KeyCode::Char('j'))),
1920            KeyReaction::Consumed
1921        );
1922        assert_eq!(list.selected_row().unwrap().name, "beta");
1923        assert_eq!(
1924            list.handle_key(&key(KeyCode::Char('k'))),
1925            KeyReaction::Consumed
1926        );
1927        assert_eq!(list.selected_row().unwrap().name, "alpha");
1928    }
1929
1930    // A registered verb fires as ListVerb; an unregistered letter does NOTHING
1931    // (Consumed, query untouched) — it never types into the query.
1932    #[tokio::test]
1933    async fn registered_verb_fires_unregistered_letter_does_nothing() {
1934        let mut list = focus_list(&['l', 'o']).await;
1935        list.handle_key(&key(KeyCode::Esc)); // → List
1936        assert_eq!(
1937            list.handle_key(&key(KeyCode::Char('l'))),
1938            KeyReaction::ListVerb('l')
1939        );
1940        assert_eq!(
1941            list.handle_key(&key(KeyCode::Char('o'))),
1942            KeyReaction::ListVerb('o')
1943        );
1944        // 'z' is not registered: swallowed, query stays empty.
1945        assert_eq!(
1946            list.handle_key(&key(KeyCode::Char('z'))),
1947            KeyReaction::Consumed
1948        );
1949        assert_eq!(list.query(), "");
1950    }
1951
1952    // In Input focus, verb letters type into the query exactly as before —
1953    // the verb is inert until the user Esc-es into the list.
1954    #[tokio::test]
1955    async fn verbs_are_inert_in_input_focus() {
1956        let mut list = focus_list(&['l', 'o']).await;
1957        assert_eq!(list.focus(), Focus::Input);
1958        assert_eq!(
1959            list.handle_key(&key(KeyCode::Char('l'))),
1960            KeyReaction::Consumed
1961        );
1962        list.poll_until_idle().await;
1963        assert_eq!(list.query(), "l", "verb letters still type in Input focus");
1964    }
1965
1966    // Opening on the list starts in List focus; a plain letter with no verb
1967    // registered does nothing (never types).
1968    #[tokio::test]
1969    async fn opening_focus_list_starts_in_list() {
1970        let src = VecSource {
1971            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1972            reload: false,
1973        };
1974        let mut list = SearchList::builder(src, noop_redraw())
1975            .filter(Filter::Fuzzy)
1976            .opening_focus(Focus::List)
1977            .build();
1978        list.poll_until_idle().await;
1979        assert_eq!(list.focus(), Focus::List);
1980        assert_eq!(
1981            list.handle_key(&key(KeyCode::Char('a'))),
1982            KeyReaction::Consumed
1983        );
1984        assert_eq!(list.query(), "");
1985        // `i` drops to the input where typing filters again.
1986        list.handle_key(&key(KeyCode::Char('i')));
1987        assert_eq!(list.focus(), Focus::Input);
1988        list.handle_key(&key(KeyCode::Char('a')));
1989        list.poll_until_idle().await;
1990        assert_eq!(list.query(), "a");
1991    }
1992
1993    // Registered intercepts fire in BOTH foci.
1994    #[tokio::test]
1995    async fn intercept_fires_in_both_foci() {
1996        let src = VecSource {
1997            rows: vec![TestRow::new("a")],
1998            reload: false,
1999        };
2000        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
2001        let mut list = SearchList::builder(src, noop_redraw())
2002            .intercept(vec![combo])
2003            .list_verb('l')
2004            .build();
2005        list.poll_until_idle().await;
2006        // Input focus: intercepted.
2007        assert_eq!(
2008            list.handle_key(&key(KeyCode::Enter)),
2009            KeyReaction::Intercepted(combo)
2010        );
2011        // Flip to List focus, intercept still fires.
2012        list.handle_key(&key(KeyCode::Esc));
2013        assert_eq!(list.focus(), Focus::List);
2014        assert_eq!(
2015            list.handle_key(&key(KeyCode::Enter)),
2016            KeyReaction::Intercepted(combo)
2017        );
2018    }
2019}