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, OrderFn, RowSource, SearchRow, StaticRowSource, SuggestionItem,
14    SuggestionSource, VaultSuggestions, YankTarget,
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
35/// Fuzzy-ranks the rows named by `base` (indices into `rows`, in the order
36/// ties should keep) against `query`; absent = no match.
37fn fuzzy_indices<R: SearchRow>(rows: &[R], base: &[usize], query: &str) -> Vec<usize> {
38    use nucleo::pattern::{CaseMatching, Normalization, Pattern};
39    use nucleo::{Matcher, Utf32Str};
40    let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
41    let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
42    let mut scored: Vec<(usize, u32)> = base
43        .iter()
44        .filter_map(|&i| {
45            let hay = rows[i].match_text()?;
46            let mut buf = Vec::new();
47            let h = Utf32Str::new(hay, &mut buf);
48            pat.score(h, &mut matcher).map(|s| (i, s))
49        })
50        .collect();
51    // Stable: equal scores keep `base` order, so an `order_by` still decides
52    // among ties.
53    scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
54    scored.into_iter().map(|(i, _)| i).collect()
55}
56
57/// Which half of a [`SearchList`] owns the keyboard. See CONTEXT.md
58/// (**List focus**). In [`Focus::Input`] typing filters the list; in
59/// [`Focus::List`] plain letters are verbs (`j`/`k` navigate, surface-registered
60/// letters act on the selected row) and never type into the query.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Focus {
63    Input,
64    List,
65}
66
67/// Verdict returned by [`SearchList::handle_key`].
68#[derive(Debug, PartialEq, Eq)]
69pub enum KeyReaction {
70    Consumed,
71    Submit,
72    Cancel,
73    Intercepted(crate::keys::key_combo::KeyCombo),
74    /// A surface-registered list-focus verb fired on the selected row. The
75    /// engine attaches NO meaning to the char — the caller maps it to an
76    /// action (see [`SearchListBuilder::list_verb`]).
77    ListVerb(char),
78    /// The yank chord fired: the selected row's [`YankTarget`], or `None` when
79    /// nothing is selected or the row has nothing worth copying.
80    ///
81    /// SearchList decides *that* the chord is a yank and *what* it would copy;
82    /// it does not touch the clipboard, because it holds no `AppTx` and emits
83    /// nothing on its own. Callers hand this straight to
84    /// [`crate::components::yank_row`].
85    Yank(Option<YankTarget>),
86    Unhandled,
87}
88
89pub struct SearchList<R: SearchRow> {
90    source: Arc<dyn RowSource<R>>,
91    rows: Vec<R>,
92    /// Indices into `rows` in display order (after filtering/ranking).
93    display: Vec<usize>,
94    /// A synthetic, query-fresh, filter-exempt row pinned at visible position 0
95    /// (the `Create: <q>` affordance / saved-searches virtual entry). Held
96    /// separately from `rows` so it works regardless of delivery (one-shot
97    /// `Replace` or streamed `Push`) and refreshes on every query change. See
98    /// [`RowSource::leading_row`].
99    leading: Option<R>,
100    /// Index into the VISIBLE sequence `[leading?] ++ display` of the selected
101    /// item.
102    selected: Option<usize>,
103    /// Whether `selected` is the user's own choice rather than the seed
104    /// `recompute_and_seed` puts on the first row. A chosen selection tracks
105    /// its row across a re-sort; a seeded one keeps the top slot, so a streamed
106    /// listing highlights the top of the `order_by` order at every frame
107    /// instead of whichever row the source happened to emit first.
108    selection_pinned: bool,
109    /// Viewport offset: visible position of the first row on screen. Owned
110    /// here (not by a per-frame `ListState`) so mouse-wheel scrolling can move
111    /// the viewport directly; `render` writes it back after ratatui clamps it
112    /// to keep the selection visible.
113    offset: usize,
114    filter: Filter<R>,
115    /// Optional total order applied before the local filter; see
116    /// [`SearchListBuilder::order_by`].
117    order: Option<OrderFn<R>>,
118    query: String,
119    loader: LoadEngine<R>,
120    input: SingleLineInput,
121    autocomplete: Option<AutocompleteController>,
122    /// Key combos the caller wants to intercept before the engine acts.
123    intercept: Vec<KeyCombo>,
124    /// The chords that yank the selected row's [`YankTarget`]. Defaults to
125    /// [`crate::keys::default_yank_combo`]; surfaces that hold the user's
126    /// [`KeyBindings`](crate::keys::KeyBindings) pass the resolved combos, so a
127    /// rebinding reaches the list. Empty = the user unbound it.
128    yank_combos: Vec<KeyCombo>,
129    icons: Icons,
130    list_rect: Rect,
131    /// The host panel's full bounds, for wheel hit-testing: scroll events
132    /// anywhere within it scroll the list — header, query box, preview —
133    /// while clicks still hit-test against `list_rect` only. Empty (the
134    /// default) falls back to `list_rect`, so hosts that never record it
135    /// keep scroll-over-the-list-only behavior.
136    panel_rect: Rect,
137    /// A host-owned scrollable sub-region within the panel (e.g. an expanded
138    /// note preview). Wheel events inside it are routed back to the host as
139    /// [`SearchMouse::ContentScrollUp`]/[`ContentScrollDown`] instead of
140    /// scrolling the list — the sub-region wins over `panel_rect`. Empty (the
141    /// default) means no sub-region; hosts re-record it every render so it is
142    /// never stale.
143    ///
144    /// [`ContentScrollDown`]: SearchMouse::ContentScrollDown
145    content_rect: Rect,
146    /// Where the query input was drawn by the last [`Self::render_query`].
147    /// A click there gives the input the keyboard.
148    input_rect: Rect,
149    /// Load generation whose rows are currently held. When a newer generation
150    /// (a requery / reload) delivers its first event, `poll` clears the stale
151    /// rows before applying it — required for streamed (`Push`) sources, which
152    /// would otherwise append onto a superseded load's rows.
153    applied_generation: u64,
154    /// Set when a SavedSearch suggestion was just accepted: the search's name,
155    /// for the host to pin as the saved-search breadcrumb. Read once via
156    /// [`take_accepted_saved_search`](Self::take_accepted_saved_search).
157    accepted_saved_search: Option<String>,
158    /// Visible position of the last left-click, so "click the selected row
159    /// again activates" only fires on a true click-click — never on a click
160    /// landing on an auto- or keyboard-made selection.
161    last_click_pos: Option<usize>,
162    /// When the last left-click landed — a second one on the same row within
163    /// [`DOUBLE_CLICK`](crate::components::DOUBLE_CLICK) is a double-click.
164    last_click_at: Option<std::time::Instant>,
165    /// The last left press came back [`SearchMouse::Activated`] — so a fast
166    /// press after it completes a double-click whose first half already
167    /// acted (see `DoubleClicked::repeat`).
168    last_click_activated: bool,
169    /// Render the query input with syntax highlighting (the FIND drawer
170    /// and the telescope modal; plain inputs like the sidebar filter skip it).
171    highlight_query: bool,
172    /// Which half owns the keyboard. See [`Focus`] and CONTEXT.md.
173    focus: Focus,
174    /// Whether the list-focus state machine is active for this surface. `true`
175    /// iff the surface opens on the list OR registers at least one verb;
176    /// otherwise `Esc` cancels immediately, byte-identical to a plain input
177    /// list, so surfaces that never opt in keep their exact keystroke behavior.
178    focus_enabled: bool,
179    /// Surface-registered list-focus verb chars. When one is pressed in
180    /// [`Focus::List`], `handle_key` returns [`KeyReaction::ListVerb`]; the
181    /// engine attaches no meaning to them.
182    list_verbs: Vec<char>,
183}
184
185/// Mouse interaction result from [`SearchList::handle_mouse`].
186#[derive(Debug, PartialEq, Eq)]
187pub enum SearchMouse {
188    Selected(usize),
189    Activated(usize),
190    /// A fast double-click on a row (both presses within
191    /// [`DOUBLE_CLICK`](crate::components::DOUBLE_CLICK)). Hosts whose
192    /// [`Activated`](Self::Activated) already means "open" treat both the
193    /// same; hosts where it toggles a preview open the row instead.
194    ///
195    /// `repeat`: the pair's first press already came back `Activated` (it
196    /// re-clicked a row an earlier, slower click had selected). A host where
197    /// both mean the same thing must not act twice; it ignores the repeat.
198    DoubleClicked {
199        pos: usize,
200        repeat: bool,
201    },
202    /// Right-click on a row: selected, and the host should open its context
203    /// menu for it.
204    Context(usize),
205    Scrolled,
206    /// The wheel landed inside the host's content sub-region (see
207    /// [`SearchList::set_content_rect`]); the host owns that view's scroll,
208    /// so the engine routed the event instead of moving the list.
209    ContentScrollUp,
210    ContentScrollDown,
211    /// A left click on the query input: it now owns the keyboard (list
212    /// focus, where the surface has one, went back to the input).
213    InputFocused,
214    /// The event landed on the open autocomplete popup. `edited`: a click
215    /// accepted a suggestion into the query — the host reacts exactly as to
216    /// a key [`KeyReaction::Consumed`] (breadcrumb, preview re-anchor).
217    Autocomplete {
218        edited: bool,
219    },
220    None,
221}
222
223pub struct SearchListBuilder<R: SearchRow> {
224    source: Arc<dyn RowSource<R>>,
225    redraw: Arc<dyn Fn() + Send + Sync>,
226    initial_query: String,
227    filter: Filter<R>,
228    order: Option<OrderFn<R>>,
229    autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
230    intercept: Vec<KeyCombo>,
231    yank_combos: Vec<KeyCombo>,
232    icons: Icons,
233    debounce: Option<std::time::Duration>,
234    highlight_query: bool,
235    opening_focus: Focus,
236    list_verbs: Vec<char>,
237}
238
239impl<R: SearchRow> SearchList<R> {
240    pub fn builder(
241        source: impl RowSource<R>,
242        redraw: Arc<dyn Fn() + Send + Sync>,
243    ) -> SearchListBuilder<R> {
244        SearchListBuilder {
245            source: Arc::new(source),
246            redraw,
247            initial_query: String::new(),
248            filter: Filter::SourceOrder,
249            order: None,
250            autocomplete: None,
251            intercept: Vec::new(),
252            yank_combos: vec![crate::keys::default_yank_combo()],
253            icons: Icons::new(false),
254            debounce: None,
255            highlight_query: false,
256            opening_focus: Focus::Input,
257            list_verbs: Vec::new(),
258        }
259    }
260
261    /// The async path: kick off the initial load; rows land on a later `poll`.
262    fn new(b: SearchListBuilder<R>) -> Self {
263        let mut list = Self::assemble(b);
264        list.loader.start(list.source.clone(), list.query.clone());
265        list
266    }
267
268    /// The synchronous path: apply an in-memory row set and seed the initial
269    /// selection here and now — no async load, no channel, no redraw
270    /// round-trip. The [`LoadEngine`] stays idle (`is_loading()` is false
271    /// immediately), and the source's `load` is never invoked, so a caller can
272    /// read `selected_row()`/`rows()` on the very next line. For static sources
273    /// (`reload_on_query() == false`), where the query is a local filter over
274    /// the built rows. See [`StaticRowSource`].
275    ///
276    /// [`StaticRowSource`]: crate::components::search_list::StaticRowSource
277    fn with_rows(b: SearchListBuilder<R>, rows: Vec<R>) -> Self {
278        let mut list = Self::assemble(b);
279        list.rows = rows;
280        list.recompute_and_seed();
281        list
282    }
283
284    /// Build the struct with an idle loader (no load started). The two entry
285    /// points ([`new`](Self::new)/[`with_rows`](Self::with_rows)) diverge on
286    /// what they do next: spawn an async load, or seed rows synchronously.
287    fn assemble(b: SearchListBuilder<R>) -> Self {
288        let loader = LoadEngine::new(b.redraw.clone());
289        let input = SingleLineInput::with_value(&b.initial_query);
290        let debounce = b.debounce;
291        let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
292            let mut ac =
293                AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
294                    disambiguate_header: false,
295                    apply_exclusion_zone: false,
296                    // The controller derives `allow_saved_search` from its mode
297                    // at detect time, so this seed value is not load-bearing.
298                    ..TriggerOptions::default()
299                });
300            if let Some(d) = debounce {
301                ac = ac.with_debounce(d);
302            }
303            ac.set_redraw_callback(b.redraw.clone());
304            ac
305        });
306        Self {
307            source: b.source,
308            rows: Vec::new(),
309            display: Vec::new(),
310            leading: None,
311            selected: None,
312            selection_pinned: false,
313            offset: 0,
314            filter: b.filter,
315            order: b.order,
316            query: b.initial_query,
317            loader,
318            input,
319            highlight_query: b.highlight_query,
320            last_click_pos: None,
321            last_click_at: None,
322            last_click_activated: false,
323            autocomplete,
324            intercept: b.intercept,
325            yank_combos: b.yank_combos,
326            icons: b.icons,
327            list_rect: Rect::default(),
328            panel_rect: Rect::default(),
329            content_rect: Rect::default(),
330            input_rect: Rect::default(),
331            applied_generation: 0,
332            accepted_saved_search: None,
333            focus: b.opening_focus,
334            // List focus is active when the surface opens on the list or
335            // registers verbs; otherwise the surface keeps plain Esc→Cancel.
336            focus_enabled: b.opening_focus == Focus::List || !b.list_verbs.is_empty(),
337            list_verbs: b.list_verbs,
338        }
339    }
340
341    /// Which half currently owns the keyboard. See [`Focus`].
342    pub fn focus(&self) -> Focus {
343        self.focus
344    }
345
346    pub fn poll(&mut self) {
347        let drained = self.loader.drain();
348        if !drained.is_empty() {
349            // A newer load delivered its first event(s): drop the prior load's
350            // rows so a streamed source starts from a clean slate (one-shot
351            // `Replace` overwrites anyway, but `Push` would otherwise append).
352            let current_gen = self.loader.generation();
353            if current_gen != self.applied_generation {
354                self.rows.clear();
355                self.selected = None;
356                self.selection_pinned = false;
357                self.offset = 0;
358                self.applied_generation = current_gen;
359            }
360            // Pushes only append, so row indices stay valid across the drain
361            // and the selection can follow its row wherever `order` puts it.
362            // A `Replace` swaps the whole set; indices mean nothing after it.
363            let keep = if drained
364                .iter()
365                .any(|ev| matches!(ev, LoadedInner::Replace(_)))
366            {
367                None
368            } else {
369                self.selection_to_carry()
370            };
371            for ev in drained {
372                match ev {
373                    LoadedInner::Replace(rows) => {
374                        self.rows = rows;
375                    }
376                    LoadedInner::Push(row) => {
377                        self.rows.push(row);
378                    }
379                    LoadedInner::Done => {}
380                }
381            }
382            self.recompute_and_seed();
383            self.reselect(keep);
384        }
385        if let Some(ac) = &mut self.autocomplete {
386            ac.poll_results();
387        }
388    }
389
390    /// Recompute the display order, then seed the selection to the first row
391    /// when nothing is selected yet (e.g. after the first load or a filter that
392    /// repopulated the list). The single place display + initial selection are
393    /// brought in sync.
394    fn recompute_and_seed(&mut self) {
395        self.recompute_display();
396        if self.selected.is_none() && self.visible_len() > 0 {
397            self.selected = Some(0);
398        }
399    }
400
401    /// Build a host snapshot from the current input state.
402    /// Only reads `self.input` so the result can be stored in a local
403    /// before taking `&mut self.autocomplete`, resolving the borrow conflict.
404    fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
405        let value = self.input.value().to_string();
406        let cursor_byte = self.input.cursor_byte();
407        let col = value[..cursor_byte.min(value.len())].chars().count();
408        host::SearchBoxHostSnapshot {
409            lines: vec![value],
410            cursor: (0, col),
411            caret_pos: self.input.last_caret_pos(),
412        }
413    }
414
415    fn clamp_selection(&mut self) {
416        let len = self.visible_len();
417        self.selected = if len == 0 {
418            None
419        } else {
420            Some(self.selected.unwrap_or(0).min(len - 1))
421        };
422    }
423
424    /// `1` when a leading row is pinned at visible position 0, else `0`.
425    fn leading_offset(&self) -> usize {
426        self.leading.is_some() as usize
427    }
428
429    /// Length of the visible sequence `[leading?] ++ display`.
430    pub fn visible_len(&self) -> usize {
431        self.leading_offset() + self.display.len()
432    }
433
434    /// Number of real matches — the visible rows minus the synthetic leading
435    /// affordance ("Create: …"), for result-count displays.
436    pub fn match_count(&self) -> usize {
437        self.display.len()
438    }
439
440    /// Row at visible position `pos` in `[leading?] ++ display`.
441    fn visible_row(&self, pos: usize) -> Option<&R> {
442        if self.leading.is_some() && pos == 0 {
443            self.leading.as_ref()
444        } else {
445            self.rows
446                .get(*self.display.get(pos - self.leading_offset())?)
447        }
448    }
449
450    /// The source-delivered rows only (NOT the leading row). Prefer
451    /// [`visible_len`](Self::visible_len)/[`visible_rows`](Self::visible_rows)
452    /// for visible counts.
453    pub fn rows(&self) -> &[R] {
454        &self.rows
455    }
456
457    pub fn selected_row(&self) -> Option<&R> {
458        self.selected.and_then(|p| self.visible_row(p))
459    }
460
461    pub fn visible_rows(&self) -> Vec<&R> {
462        (0..self.visible_len())
463            .filter_map(|p| self.visible_row(p))
464            .collect()
465    }
466
467    pub fn query(&self) -> &str {
468        &self.query
469    }
470
471    /// Take the name of a just-accepted saved search, if any. The host calls
472    /// this after a `Consumed` key to learn whether to pin (or refresh) the
473    /// saved-search breadcrumb. Returns `None` once read.
474    pub fn take_accepted_saved_search(&mut self) -> Option<String> {
475        self.accepted_saved_search.take()
476    }
477
478    /// The visible text in the query input widget. Test-only: lets callers
479    /// assert the input bar reflects a programmatic query change.
480    #[cfg(test)]
481    pub(crate) fn input_value(&self) -> &str {
482        self.input.value()
483    }
484    pub fn is_loading(&self) -> bool {
485        self.loader.loading
486    }
487
488    /// Set the query programmatically: updates the visible input widget (cursor
489    /// to end) AND the query string, then starts a load (for `reload_on_query`
490    /// sources) or recomputes the display. This is the setter every external
491    /// caller wants — a saved search applied, a sort directive rewritten — so
492    /// the input bar always reflects the query. The interactive keystroke path
493    /// uses `sync_query_from_input` instead,
494    /// because the input widget already holds the typed text (and its cursor
495    /// must not jump back to the end on every keystroke).
496    pub fn set_query(&mut self, q: impl Into<String>) {
497        let q = q.into();
498        self.input.set_value(q.clone());
499        self.query = q;
500        self.requery();
501    }
502
503    /// Pull the query string FROM the input widget without touching the widget
504    /// (so the cursor stays put), then reload/recompute. The keystroke and
505    /// autocomplete-accept paths use this after they have already mutated the
506    /// input in place.
507    fn sync_query_from_input(&mut self) {
508        self.query = self.input.value().to_string();
509        self.requery();
510    }
511
512    /// Start a fresh load for `reload_on_query` sources, else recompute the
513    /// local display. The generation guard in `LoadEngine` drops stale results.
514    fn requery(&mut self) {
515        if self.source.reload_on_query() {
516            self.loader.start(self.source.clone(), self.query.clone());
517        }
518        // Recompute now so the query-fresh leading row (and local filter, for
519        // non-reload sources) reflect the new query in this frame. Reload
520        // sources refresh again when their load drains in poll().
521        self.recompute_and_seed();
522    }
523
524    /// Re-run the source load for the current query (e.g. after a mutation).
525    pub fn reload(&mut self) {
526        self.loader.start(self.source.clone(), self.query.clone());
527    }
528
529    /// Replace (or clear) the row order and re-sort the rows already loaded.
530    /// A recompute, not a reload: the source is not consulted. A selection the
531    /// user chose stays on the row it was on; a seeded one keeps the top slot
532    /// of the NEW order — and stays a seed, so a still-streaming listing goes
533    /// on re-seeding it (the `selection_pinned` split, as in `poll`).
534    pub fn set_order(&mut self, cmp: Option<OrderFn<R>>) {
535        let keep = self.selection_to_carry();
536        self.order = cmp;
537        self.recompute_display();
538        self.reselect(keep);
539    }
540
541    /// The row a recompute has to put the selection back on: the row the user
542    /// chose, or `None` for a seed — a seed belongs to the top of the order,
543    /// wherever the recompute puts it. The one rule behind `poll`, `set_order`
544    /// and `update_rows`.
545    fn selection_to_carry(&self) -> Option<usize> {
546        self.selection_pinned
547            .then(|| self.selected_row_index())
548            .flatten()
549    }
550
551    /// Index into `rows` of the selected row, if the selection is on a real
552    /// (non-leading) row.
553    fn selected_row_index(&self) -> Option<usize> {
554        let pos = self.selected?.checked_sub(self.leading_offset())?;
555        self.display.get(pos).copied()
556    }
557
558    /// Move the selection back onto the row at `rows[row]` after the display
559    /// was recomputed, if that row is still visible; otherwise leave it where
560    /// the recompute clamped it.
561    fn reselect(&mut self, row: Option<usize>) {
562        if let Some(row) = row
563            && let Some(pos) = self.display.iter().position(|&i| i == row)
564        {
565            self.selected = Some(pos + self.leading_offset());
566        }
567    }
568
569    /// Mutate rows in place. `mutate` is called for each row and returns `true`
570    /// for each row it changed; if any did, the display is recomputed — which
571    /// re-filters AND re-sorts, so an edit to a field the `order_by` reads
572    /// moves its row. Returns whether anything changed.
573    ///
574    /// The selection is carried the same way every other recompute carries it:
575    /// a chosen row stays chosen wherever it lands, a seed keeps the top slot.
576    ///
577    /// This is the one seam that touches rows outside the [`RowSource`]; every
578    /// other change rebuilds from the source. Structural changes (add/remove)
579    /// must still reload; a reorder is [`set_order`](Self::set_order).
580    /// `SearchList` stays ignorant of the row type; callers layer the
581    /// path-matched operations on top.
582    pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
583        let mut changed = false;
584        for row in &mut self.rows {
585            if mutate(row) {
586                changed = true;
587            }
588        }
589        if changed {
590            let keep = self.selection_to_carry();
591            self.recompute_display();
592            self.reselect(keep);
593        }
594        changed
595    }
596
597    /// Select the visible row at `pos` (clamped to the visible range); clears
598    /// the selection when the list is empty. The index-based counterpart to
599    /// [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev),
600    /// for surfaces that point the cursor at a specific row (the Sources view's
601    /// citation jump).
602    pub fn select(&mut self, pos: usize) {
603        let n = self.visible_len();
604        self.selected = if n == 0 { None } else { Some(pos.min(n - 1)) };
605        // Unconditional: aiming at a row IS the choice, even when it happens
606        // to be the row the seed already sat on. Only the blind nudges below
607        // have to prove they moved.
608        self.selection_pinned = self.selected.is_some();
609    }
610
611    pub fn select_next(&mut self) {
612        let n = self.visible_len();
613        if n == 0 {
614            return;
615        }
616        self.move_selection(Some(self.selected.map_or(0, |i| (i + 1).min(n - 1))));
617    }
618
619    pub fn select_prev(&mut self) {
620        if self.visible_len() == 0 {
621            return;
622        }
623        self.move_selection(Some(self.selected.map_or(0, |i| i.saturating_sub(1))));
624    }
625
626    /// Move the selection, marking it the user's own choice only when it
627    /// actually lands somewhere new. A nudge against either end of the list
628    /// changes nothing, so it must not turn a seed into a choice — one stray
629    /// `Up` on the first row of a streaming listing would otherwise freeze the
630    /// highlight at position 0 and let rows arriving above it walk underneath.
631    fn move_selection(&mut self, next: Option<usize>) {
632        if next != self.selected {
633            self.selected = next;
634            self.selection_pinned = next.is_some();
635        }
636    }
637
638    /// Largest useful viewport offset: the first visible position from which
639    /// the rows through the end still fill the recorded list rect. Scrolling
640    /// past it would leave blank space below the last row, so
641    /// [`scroll_down`](Self::scroll_down) clamps to it.
642    fn max_scroll_offset(&self) -> usize {
643        let viewport = self.list_rect.height as usize;
644        let n = self.visible_len();
645        if viewport == 0 || n == 0 {
646            return 0;
647        }
648        let mut budget = viewport;
649        let mut first = n;
650        while first > 0 {
651            let h = self
652                .visible_row(first - 1)
653                .map(|r| r.visual_height() as usize)
654                .unwrap_or(1);
655            if h > budget {
656                break;
657            }
658            budget -= h;
659            first -= 1;
660        }
661        first.min(n - 1)
662    }
663
664    /// Scroll the viewport one row down, carrying the selection along so the
665    /// selected row keeps its on-screen position. No-op once the last row is
666    /// in view — the shared mouse-wheel behavior for every list surface.
667    pub fn scroll_down(&mut self) {
668        let n = self.visible_len();
669        if n == 0 || self.offset >= self.max_scroll_offset() {
670            return;
671        }
672        self.offset += 1;
673        self.move_selection(self.selected.map(|i| (i + 1).min(n - 1)));
674    }
675
676    /// Scroll the viewport one row up, carrying the selection along so the
677    /// selected row keeps its on-screen position. No-op at the top.
678    pub fn scroll_up(&mut self) {
679        if self.offset == 0 {
680            return;
681        }
682        self.offset -= 1;
683        self.move_selection(self.selected.map(|i| i.saturating_sub(1)));
684    }
685
686    /// The current viewport offset. Test-only: lets scroll tests assert the
687    /// viewport moved while the selection kept its screen position.
688    #[cfg(test)]
689    pub(crate) fn scroll_offset(&self) -> usize {
690        self.offset
691    }
692
693    /// Whether `key` is one of this list's yank chords. For surfaces that do
694    /// NOT route every key into the engine (`ListPanelSpec::HAS_FILTER = false`,
695    /// e.g. the LINKS drawer, where plain letters are the host's sub-view keys):
696    /// they forward only what they recognise, and without this the yank chord
697    /// would be the one thing their rows declare but can never deliver.
698    pub fn is_yank_chord(&self, key: &KeyEvent) -> bool {
699        crate::keys::key_event_to_combo(key).is_some_and(|c| self.yank_combos.contains(&c))
700    }
701
702    /// Write an accepted suggestion into the query — one path for a key
703    /// accept and a click accept.
704    fn apply_accept(&mut self, action: crate::components::autocomplete::AcceptAction) {
705        self.input.replace_range_bytes(
706            action.range.clone(),
707            &action.new_text,
708            action.new_cursor_byte,
709        );
710        // Stash any accepted SavedSearch name for the host's breadcrumb
711        // (`None` for every other kind). The host reads it on this same
712        // event, so a plain assign never clobbers an unread value.
713        self.accepted_saved_search = action.saved_search_name;
714        self.sync_query_from_input();
715    }
716
717    pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
718        use ratatui::crossterm::event::{KeyCode, KeyModifiers};
719
720        // Caller-registered intercepts get first crack — before autocomplete or
721        // any built-in binding.
722        if let Some(combo) = crate::keys::key_event_to_combo(key)
723            && self.intercept.contains(&combo)
724        {
725            return KeyReaction::Intercepted(combo);
726        }
727
728        // Autocomplete popup gets first crack when open. Build snapshot before
729        // taking &mut self.autocomplete to avoid borrow-checker conflict
730        // (snapshot only reads self.input).
731        if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
732            let snap = self.autocomplete_snapshot();
733            if let Some(ac) = &mut self.autocomplete {
734                match ac.handle_key(*key, &snap) {
735                    HandleKeyOutcome::Accepted(action) => {
736                        self.apply_accept(action);
737                        return KeyReaction::Consumed;
738                    }
739                    HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
740                        return KeyReaction::Consumed;
741                    }
742                    HandleKeyOutcome::NotHandled => {}
743                }
744            }
745        }
746
747        // Arrows navigate and Enter submits in BOTH foci (arrows as today).
748        match key.code {
749            KeyCode::Up => {
750                self.select_prev();
751                return KeyReaction::Consumed;
752            }
753            KeyCode::Down => {
754                self.select_next();
755                return KeyReaction::Consumed;
756            }
757            KeyCode::Enter => return KeyReaction::Submit,
758            _ => {}
759        }
760        // Esc: with the list-focus machine active, the first Esc moves Input →
761        // List focus (Consumed); from List focus (and for surfaces that never
762        // opted in) Esc is Cancel, so their keystroke behavior is unchanged.
763        if key.code == KeyCode::Esc {
764            if self.focus_enabled && self.focus == Focus::Input {
765                self.focus = Focus::List;
766                self.close_autocomplete();
767                return KeyReaction::Consumed;
768            }
769            return KeyReaction::Cancel;
770        }
771        // The yank chord, claimed above the Ctrl/Alt drop below (which would
772        // otherwise swallow it). Every surface built on SearchList gets this
773        // without wiring a key — the note browser lacked one for exactly that
774        // reason. What gets copied is the ROW's business; performing
775        // the copy is the CALLER's, since SearchList holds no `AppTx`.
776        if let Some(combo) = crate::keys::key_event_to_combo(key)
777            && self.yank_combos.contains(&combo)
778        {
779            return KeyReaction::Yank(self.selected_row().and_then(|r| r.yank_target()));
780        }
781        // Drop Ctrl/Alt-modified chars so combos don't leak as text (both foci;
782        // registered intercepts already claimed theirs above).
783        if let KeyCode::Char(_) = key.code {
784            let non_shift = key.modifiers - KeyModifiers::SHIFT;
785            if !non_shift.is_empty() {
786                return KeyReaction::Unhandled;
787            }
788        }
789        // List focus: plain letters are verbs, never query text.
790        if self.focus == Focus::List {
791            if let KeyCode::Char(c) = key.code {
792                return match c {
793                    // `i` / `/` return to the input (cursor there, typing filters).
794                    'i' | '/' => {
795                        self.focus = Focus::Input;
796                        KeyReaction::Consumed
797                    }
798                    'j' => {
799                        self.select_next();
800                        KeyReaction::Consumed
801                    }
802                    'k' => {
803                        self.select_prev();
804                        KeyReaction::Consumed
805                    }
806                    _ if self.list_verbs.contains(&c) => KeyReaction::ListVerb(c),
807                    // Unregistered letters do NOTHING — never type into the query.
808                    _ => KeyReaction::Consumed,
809                };
810            }
811            // Other keys (Tab, function keys, …) are the surface's to handle.
812            return KeyReaction::Unhandled;
813        }
814        let outcome = self.input.handle_key(key);
815        // Sync/refresh/close the autocomplete popup based on the input outcome.
816        // Build snapshot before taking &mut self.autocomplete (same borrow trick).
817        let snap = self.autocomplete_snapshot();
818        match outcome {
819            InputOutcome::Changed => {
820                if let Some(ac) = &mut self.autocomplete {
821                    ac.sync(&snap);
822                }
823            }
824            InputOutcome::Consumed => {
825                if let Some(ac) = &mut self.autocomplete {
826                    ac.refresh_if_open(&snap);
827                }
828            }
829            InputOutcome::Cancel | InputOutcome::Submit => {
830                if let Some(ac) = &mut self.autocomplete {
831                    ac.close();
832                }
833            }
834            InputOutcome::NotConsumed => {}
835        }
836        match outcome {
837            InputOutcome::Changed => {
838                self.sync_query_from_input();
839                KeyReaction::Consumed
840            }
841            InputOutcome::Consumed => KeyReaction::Consumed,
842            InputOutcome::Submit => KeyReaction::Submit,
843            InputOutcome::Cancel => KeyReaction::Cancel,
844            InputOutcome::NotConsumed => KeyReaction::Unhandled,
845        }
846    }
847
848    pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
849        // The query input signals focus only when the panel is focused AND the
850        // input half owns the keyboard; in list focus it renders unfocused
851        // (dimmed, cursor hidden). For surfaces that never opt into list focus
852        // `self.focus` is always `Input`, so this is byte-identical to today.
853        self.input_rect = area;
854        let focused = focused && self.focus == Focus::Input;
855        let base = Style::default()
856            .fg(theme.fg.to_ratatui())
857            .bg(theme.bg_panel.to_ratatui());
858        if self.highlight_query {
859            let line =
860                crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
861            self.input.render_line(f, area, line, base, 0, focused);
862        } else {
863            self.input.render(f, area, base, 0, focused);
864        }
865    }
866
867    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
868        self.poll();
869        let sel = self.selected;
870        let items: Vec<ListItem> = (0..self.visible_len())
871            .filter_map(|pos| {
872                self.visible_row(pos)
873                    .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
874            })
875            .collect();
876        let mut state = ListState::default().with_offset(self.offset);
877        state.select(self.selected);
878        let list =
879            List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
880        f.render_stateful_widget(list, area, &mut state);
881        // Read the offset back: ratatui clamps it and keeps the selection in
882        // view (keyboard moves included), so the stored offset always matches
883        // what is actually on screen.
884        self.offset = state.offset();
885        self.list_rect = area;
886        let _ = focused;
887    }
888
889    /// Override the rect used for mouse hit-testing. The recorded rect must be
890    /// the area where list ITEMS actually render — row 0 is the first item, NOT
891    /// a block border. Hosts that draw the list inside a bordered block pass the
892    /// block's INNER rect; borderless hosts pass the list area directly. The
893    /// recorded rect and the rendered-items rect MUST be identical, so
894    /// [`handle_mouse`] maps a click at `row` to visual offset `row - rect.y`.
895    ///
896    /// [`handle_mouse`]: Self::handle_mouse
897    pub fn set_list_rect(&mut self, rect: Rect) {
898        self.list_rect = rect;
899    }
900
901    /// Record the host panel's full bounds so the wheel scrolls the list from
902    /// anywhere within the panel — header, query box, preview — not just over
903    /// the list items. Hosts call this each render with the same rect they
904    /// were drawn into. Never set = wheel hit-tests `list_rect` only.
905    pub fn set_panel_rect(&mut self, rect: Rect) {
906        self.panel_rect = rect;
907    }
908
909    /// Record a host-owned scrollable sub-region (e.g. an expanded preview):
910    /// wheel events inside it are routed back to the host as
911    /// [`SearchMouse::ContentScrollUp`]/[`ContentScrollDown`] instead of
912    /// scrolling the list. Hosts re-record it every render (empty when the
913    /// sub-region is not drawn) so the hit-test never sees a stale rect.
914    ///
915    /// [`ContentScrollDown`]: SearchMouse::ContentScrollDown
916    pub fn set_content_rect(&mut self, rect: Rect) {
917        self.content_rect = rect;
918    }
919
920    /// Test-only: the recorded content sub-region (empty when none is on
921    /// screen), so host tests can hit-test against where the preview was
922    /// drawn.
923    #[cfg(test)]
924    pub(crate) fn content_rect(&self) -> Rect {
925        self.content_rect
926    }
927
928    pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
929        if let Some(ac) = &mut self.autocomplete {
930            ac.poll_results();
931            let caret = self.input.last_caret_pos();
932            if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
933                state.anchor = anchor;
934            }
935            if let Some(state) = ac.state_mut() {
936                crate::components::autocomplete::render(f, state, clamp, theme);
937            }
938        }
939    }
940
941    /// Close an open autocomplete popup. [`handle_mouse`] does this for every
942    /// event it sees ("any mouse interaction dismisses the popup"); hosts that
943    /// consume a mouse event WITHOUT routing it through the engine call this
944    /// to keep that rule intact.
945    ///
946    /// [`handle_mouse`]: Self::handle_mouse
947    pub fn close_autocomplete(&mut self) {
948        if let Some(ac) = &mut self.autocomplete {
949            ac.close();
950        }
951    }
952
953    /// Test-only: true when the autocomplete popup is open, so host tests
954    /// can assert the any-mouse-interaction-dismisses rule.
955    #[cfg(test)]
956    pub(crate) fn autocomplete_is_open(&self) -> bool {
957        self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
958    }
959
960    /// Whether (col,row) is on the query input drawn by the last
961    /// [`Self::render_query`].
962    pub fn input_contains(&self, col: u16, row: u16) -> bool {
963        self.input_rect
964            .contains(ratatui::layout::Position::new(col, row))
965    }
966
967    /// Whether (col,row) is on a row of the open autocomplete popup, as last
968    /// drawn — a host that filters mouse events must let these through.
969    pub fn popup_contains(&self, col: u16, row: u16) -> bool {
970        self.autocomplete
971            .as_ref()
972            .and_then(|ac| ac.state())
973            .is_some_and(|st| {
974                st.rows_rect
975                    .contains(ratatui::layout::Position::new(col, row))
976            })
977    }
978
979    pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
980        self.handle_mouse_at(m, std::time::Instant::now())
981    }
982
983    /// [`Self::handle_mouse`] with the clock passed in, so a test describes a
984    /// gap instead of sleeping through one.
985    pub(crate) fn handle_mouse_at(
986        &mut self,
987        m: &ratatui::crossterm::event::MouseEvent,
988        now: std::time::Instant,
989    ) -> SearchMouse {
990        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
991        use ratatui::layout::Position;
992        // An open popup gets first crack: a click on a suggestion accepts
993        // it, the wheel over it moves the highlight.
994        if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
995            let snap = self.autocomplete_snapshot();
996            if let Some(ac) = &mut self.autocomplete {
997                match ac.handle_mouse(m, &snap) {
998                    HandleKeyOutcome::Accepted(action) => {
999                        self.apply_accept(action);
1000                        return SearchMouse::Autocomplete { edited: true };
1001                    }
1002                    HandleKeyOutcome::Consumed | HandleKeyOutcome::Dismissed => {
1003                        return SearchMouse::Autocomplete { edited: false };
1004                    }
1005                    HandleKeyOutcome::NotHandled => {}
1006                }
1007            }
1008        }
1009        // A press or a scroll anywhere else dismisses an open popup (a click
1010        // on the preview/border closes a stale popup). Pointer motion does
1011        // not: the terminal reports every move, and closing on those would
1012        // drop the popup before the pointer could reach it.
1013        if matches!(
1014            m.kind,
1015            MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1016        ) {
1017            self.close_autocomplete();
1018        }
1019        let pos = Position {
1020            x: m.column,
1021            y: m.row,
1022        };
1023        // The wheel is hit-tested against the host's panel bounds (when
1024        // recorded), so scrolling works from anywhere within the panel;
1025        // clicks below keep hit-testing the list rect only.
1026        if matches!(
1027            m.kind,
1028            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1029        ) {
1030            // The host's content sub-region wins over the panel bounds: a
1031            // wheel inside it is the host's to handle (it scrolls its own
1032            // view), so route it back instead of moving the list.
1033            if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
1034                return if m.kind == MouseEventKind::ScrollUp {
1035                    SearchMouse::ContentScrollUp
1036                } else {
1037                    SearchMouse::ContentScrollDown
1038                };
1039            }
1040            let bounds = if self.panel_rect.is_empty() {
1041                self.list_rect
1042            } else {
1043                self.panel_rect
1044            };
1045            if !bounds.contains(pos) {
1046                return SearchMouse::None;
1047            }
1048            if m.kind == MouseEventKind::ScrollUp {
1049                self.scroll_up();
1050            } else {
1051                self.scroll_down();
1052            }
1053            return SearchMouse::Scrolled;
1054        }
1055        if matches!(m.kind, MouseEventKind::Down(MouseButton::Left))
1056            && self.input_rect.contains(pos)
1057        {
1058            // The mouse's `i` / `/`: the input takes the keyboard back.
1059            self.focus = Focus::Input;
1060            return SearchMouse::InputFocused;
1061        }
1062        let r = self.list_rect;
1063        if !r.contains(pos) {
1064            return SearchMouse::None;
1065        }
1066        match m.kind {
1067            MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
1068                let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
1069                let target_visual = m.row - r.y; // 0-based visual offset; row 0 = first item
1070                let mut acc: u16 = 0;
1071                let mut hit: Option<usize> = None;
1072                // Walk the VISIBLE sequence (leading row at position 0, then the
1073                // display rows) starting at the viewport offset — screen row 0
1074                // is the item at `offset`, not visible position 0 — so visual
1075                // offsets map to the positions actually on screen.
1076                for pos in self.offset..self.visible_len() {
1077                    let h = self
1078                        .visible_row(pos)
1079                        .map(|r| r.visual_height())
1080                        .unwrap_or(1);
1081                    if target_visual < acc + h {
1082                        hit = Some(pos);
1083                        break;
1084                    }
1085                    acc += h;
1086                }
1087                if let Some(pos) = hit {
1088                    let prev = self.selected;
1089                    let prev_click = self.last_click_pos.replace(pos);
1090                    let prev_at = if right_click {
1091                        self.last_click_at.take()
1092                    } else {
1093                        self.last_click_at.replace(now)
1094                    };
1095                    self.selected = Some(pos);
1096                    self.selection_pinned = true;
1097                    let fast = prev_at
1098                        .is_some_and(|at| now.duration_since(at) < crate::components::DOUBLE_CLICK);
1099                    let was_activate = std::mem::take(&mut self.last_click_activated);
1100                    return if right_click {
1101                        SearchMouse::Context(pos)
1102                    } else if prev == Some(pos) && prev_click == Some(pos) && fast {
1103                        // A burst: a third press must not chain into a
1104                        // second double-click.
1105                        self.last_click_at = None;
1106                        SearchMouse::DoubleClicked {
1107                            pos,
1108                            repeat: was_activate,
1109                        }
1110                    } else if prev == Some(pos) && prev_click == Some(pos) {
1111                        // Activate only on click-click: the row was already
1112                        // selected BY A CLICK, not by auto-select or keys.
1113                        self.last_click_activated = true;
1114                        SearchMouse::Activated(pos)
1115                    } else {
1116                        SearchMouse::Selected(pos)
1117                    };
1118                }
1119                SearchMouse::None
1120            }
1121            _ => SearchMouse::None,
1122        }
1123    }
1124
1125    fn recompute_display(&mut self) {
1126        let q = self.query.trim();
1127        // The leading row is query-fresh: rebuilt on every poll AND on every
1128        // local-filter `set_query`, so it never goes stale.
1129        self.leading = self.source.leading_row(q);
1130        // Source order, or `order_by` order when one is set.
1131        let mut base: Vec<usize> = (0..self.rows.len()).collect();
1132        if let Some(cmp) = &self.order {
1133            let rows = &self.rows;
1134            base.sort_by(|&a, &b| cmp(&rows[a], &rows[b]));
1135        }
1136        let mut idx: Vec<usize> = match &self.filter {
1137            Filter::SourceOrder => base,
1138            Filter::Fuzzy if q.is_empty() => base,
1139            Filter::Fuzzy => fuzzy_indices(&self.rows, &base, q),
1140            Filter::Rank(_) if q.is_empty() => base,
1141            Filter::Rank(f) => {
1142                let f = f.clone();
1143                f(&self.rows, &base, q)
1144            }
1145        };
1146        // Filter-exempt rows (match_text() == None: Up / Create / virtual pinned)
1147        // are always present; prepend any that the filter dropped.
1148        for i in 0..self.rows.len() {
1149            if self.rows[i].match_text().is_none() && !idx.contains(&i) {
1150                idx.insert(0, i);
1151            }
1152        }
1153        self.display = idx;
1154        self.clamp_selection();
1155    }
1156
1157    #[cfg(test)]
1158    pub(crate) async fn poll_until_idle(&mut self) {
1159        // In-memory sources settle on the first poll (no sleep paid). Vault-backed
1160        // sources run their read on a worker/blocking thread, which can starve
1161        // under the full parallel suite — so once still loading, sleep a little
1162        // between polls and use a generous ceiling. Early-breaks the instant the
1163        // load lands, keeping the common (in-memory) path fast.
1164        for _ in 0..600 {
1165            tokio::task::yield_now().await;
1166            self.poll();
1167            if !self.is_loading() {
1168                break;
1169            }
1170            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1171        }
1172        self.poll();
1173    }
1174}
1175
1176impl<R: SearchRow> SearchListBuilder<R> {
1177    pub fn initial_query(mut self, q: impl Into<String>) -> Self {
1178        self.initial_query = q.into();
1179        self
1180    }
1181    pub fn filter(mut self, f: Filter<R>) -> Self {
1182        self.filter = f;
1183        self
1184    }
1185    /// Keep the rows in this order regardless of arrival order. Composes with
1186    /// any [`Filter`]: with an empty query the order is the display order;
1187    /// under `Fuzzy` it decides ties between equally-scored matches. Change it
1188    /// later with [`SearchList::set_order`].
1189    pub fn order_by(mut self, cmp: OrderFn<R>) -> Self {
1190        self.order = Some(cmp);
1191        self
1192    }
1193    pub fn autocomplete(
1194        mut self,
1195        suggestions: Arc<dyn SuggestionSource>,
1196        mode: AutocompleteMode,
1197    ) -> Self {
1198        self.autocomplete = Some((suggestions, mode));
1199        self
1200    }
1201    /// Bind the yank chord to whatever the user has bound
1202    /// [`ActionShortcuts::YankRow`](crate::keys::action_shortcuts::ActionShortcuts::YankRow) to.
1203    /// Surfaces that hold `KeyBindings` should always call this — the builder
1204    /// default is only for those that do not (and for tests).
1205    pub fn yank_combos_from(self, bindings: &crate::keys::KeyBindings) -> Self {
1206        self.yank_combos(
1207            bindings.combos_for(&crate::keys::action_shortcuts::ActionShortcuts::YankRow),
1208        )
1209    }
1210
1211    /// Override the yank chords directly (see [`Self::yank_combos_from`]).
1212    /// An empty list disables the chord for this surface.
1213    pub fn yank_combos(mut self, combos: Vec<KeyCombo>) -> Self {
1214        self.yank_combos = combos;
1215        self
1216    }
1217
1218    pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
1219        self.intercept = v;
1220        self
1221    }
1222    /// Render the query input with syntax highlighting.
1223    pub fn highlight_query(mut self) -> Self {
1224        self.highlight_query = true;
1225        self
1226    }
1227    pub fn icons(mut self, icons: Icons) -> Self {
1228        self.icons = icons;
1229        self
1230    }
1231    /// The focus the surface opens on (default [`Focus::Input`]). Opening on
1232    /// [`Focus::List`] also activates the list-focus state machine (so `Esc`
1233    /// cancels from the list rather than flipping into it).
1234    pub fn opening_focus(mut self, focus: Focus) -> Self {
1235        self.opening_focus = focus;
1236        self
1237    }
1238    /// Register a plain letter as a list-focus verb. In [`Focus::List`],
1239    /// pressing it returns [`KeyReaction::ListVerb`] with the char; the engine
1240    /// attaches no meaning — the caller decides the action. Registering any
1241    /// verb activates the list-focus state machine. `j`/`k`/`i`/`/` are
1242    /// reserved (navigation and focus switching) and win over a same-letter
1243    /// verb.
1244    pub fn list_verb(mut self, c: char) -> Self {
1245        self.list_verbs.push(c);
1246        self
1247    }
1248    /// Override the autocomplete controller's debounce. Tests use
1249    /// `Duration::ZERO` to get suggestions without waiting on the debounce timer.
1250    pub fn debounce(mut self, d: std::time::Duration) -> Self {
1251        self.debounce = Some(d);
1252        self
1253    }
1254    pub fn build(self) -> SearchList<R> {
1255        SearchList::new(self)
1256    }
1257
1258    /// Build synchronously over a known, in-memory row set: the rows are
1259    /// applied and the initial selection seeded before this returns — no async
1260    /// load, no channel, no redraw round-trip. For static sources
1261    /// (`reload_on_query() == false`); the source's `load` is never called, so
1262    /// most static consumers pair this with [`StaticRowSource`]. The redraw
1263    /// callback passed to [`builder`](SearchList::builder) is never fired on
1264    /// this path.
1265    ///
1266    /// [`StaticRowSource`]: crate::components::search_list::StaticRowSource
1267    pub fn build_with_rows(self, rows: Vec<R>) -> SearchList<R> {
1268        SearchList::with_rows(self, rows)
1269    }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::adapters::{
1275        ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
1276        VecSource, VecSourceWithLead,
1277    };
1278    use super::*;
1279    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1280
1281    fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
1282        std::sync::Arc::new(|| {})
1283    }
1284
1285    fn key(c: KeyCode) -> KeyEvent {
1286        KeyEvent::new(c, KeyModifiers::NONE)
1287    }
1288
1289    // ── The yank chord ────────────────────────────────────────────
1290    //
1291    // The bug these pin: yanking the selected row was hand-rolled per surface,
1292    // so the note browser — the most-reached list — silently had none. Claiming
1293    // the chord here means every SearchList surface gets it.
1294
1295    fn yank_list(rows: &[&str]) -> SearchList<TestRow> {
1296        SearchList::builder(
1297            VecSource {
1298                rows: vec![],
1299                reload: false,
1300            },
1301            noop_redraw(),
1302        )
1303        .build_with_rows(rows.iter().map(|n| TestRow::new(n)).collect())
1304    }
1305
1306    fn ctrl(c: char) -> KeyEvent {
1307        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1308    }
1309
1310    #[test]
1311    fn yank_chord_reports_the_selected_rows_target() {
1312        let mut list = yank_list(&["alpha", "beta"]);
1313        match list.handle_key(&ctrl('y')) {
1314            KeyReaction::Yank(Some(t)) => {
1315                assert_eq!(t.text, "alpha");
1316                assert_eq!(t.noun, "path");
1317            }
1318            r => panic!("got {r:?}"),
1319        }
1320    }
1321
1322    #[test]
1323    fn yank_chord_reports_none_for_a_row_with_nothing_to_copy() {
1324        // Distinguishable from "the clipboard failed" only because the row
1325        // says so — the caller flashes "nothing to copy".
1326        let mut list = yank_list(&["quiet"]);
1327        list.select_next();
1328        assert!(matches!(
1329            list.handle_key(&ctrl('y')),
1330            KeyReaction::Yank(None)
1331        ));
1332    }
1333
1334    #[test]
1335    fn yank_chord_reports_none_when_nothing_is_selected() {
1336        let mut list = yank_list(&[]);
1337        assert!(matches!(
1338            list.handle_key(&ctrl('y')),
1339            KeyReaction::Yank(None)
1340        ));
1341    }
1342
1343    #[test]
1344    fn yank_chord_is_claimed_before_ctrl_chars_are_dropped() {
1345        // The regression guard: the Ctrl/Alt drop below the chord check
1346        // returns `Unhandled`, which is what swallowed a per-panel yank that
1347        // was registered too late in the ladder.
1348        let mut list = yank_list(&["alpha"]);
1349        list.select_next();
1350        assert!(
1351            !matches!(list.handle_key(&ctrl('y')), KeyReaction::Unhandled),
1352            "the yank chord must not fall through to the Ctrl-char drop"
1353        );
1354    }
1355
1356    #[test]
1357    fn a_rebound_yank_combo_replaces_the_default() {
1358        let mut list = SearchList::builder(
1359            VecSource {
1360                rows: vec![],
1361                reload: false,
1362            },
1363            noop_redraw(),
1364        )
1365        .yank_combos(vec![crate::keys::key_event_to_combo(&ctrl('k')).unwrap()])
1366        .build_with_rows(vec![TestRow::new("alpha")]);
1367        list.select_next();
1368        assert!(matches!(
1369            list.handle_key(&ctrl('k')),
1370            KeyReaction::Yank(Some(_))
1371        ));
1372        assert!(
1373            !matches!(list.handle_key(&ctrl('y')), KeyReaction::Yank(_)),
1374            "the default chord must stop yanking once overridden"
1375        );
1376    }
1377
1378    fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
1379        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1380        MouseEvent {
1381            kind: MouseEventKind::Down(MouseButton::Left),
1382            column: col,
1383            row,
1384            modifiers: KeyModifiers::NONE,
1385        }
1386    }
1387
1388    #[derive(Clone, Debug, PartialEq)]
1389    struct TallRow {
1390        name: String,
1391        height: u16,
1392    }
1393    impl SearchRow for TallRow {
1394        fn to_list_item(
1395            &self,
1396            _t: &crate::settings::themes::Theme,
1397            _i: &crate::settings::icons::Icons,
1398            _s: bool,
1399        ) -> ratatui::widgets::ListItem<'static> {
1400            ratatui::widgets::ListItem::new(self.name.clone())
1401        }
1402        fn visual_height(&self) -> u16 {
1403            self.height
1404        }
1405        fn match_text(&self) -> Option<&str> {
1406            Some(&self.name)
1407        }
1408    }
1409    struct TallSource(Vec<TallRow>);
1410    #[async_trait::async_trait]
1411    impl RowSource<TallRow> for TallSource {
1412        async fn load(&self, _q: &str, emit: Emit<TallRow>) {
1413            emit.replace(self.0.clone());
1414        }
1415    }
1416
1417    /// The wheel is routed to the host (ContentScroll*) inside the recorded
1418    /// content sub-region — which wins over the panel bounds — and scrolls
1419    /// the list everywhere else within the panel.
1420    #[tokio::test]
1421    async fn wheel_in_content_rect_routes_to_host() {
1422        use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
1423        let rows: Vec<TallRow> = (0..10)
1424            .map(|i| TallRow {
1425                name: format!("r{}", i),
1426                height: 1,
1427            })
1428            .collect();
1429        let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
1430        list.poll_until_idle().await;
1431        let rect = |y: u16, h: u16| ratatui::layout::Rect {
1432            x: 0,
1433            y,
1434            width: 20,
1435            height: h,
1436        };
1437        // Panel covers rows 0..10; list draws in 0..4; content region 5..10.
1438        list.set_panel_rect(rect(0, 10));
1439        list.set_list_rect(rect(0, 4));
1440        list.set_content_rect(rect(5, 5));
1441        let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
1442            kind,
1443            column: 2,
1444            row,
1445            modifiers: KeyModifiers::NONE,
1446        };
1447
1448        // Inside the content region: routed to the host, list untouched.
1449        let m = wheel(MouseEventKind::ScrollDown, 6);
1450        assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
1451        assert_eq!(list.offset, 0, "list viewport must not move");
1452        let m = wheel(MouseEventKind::ScrollUp, 6);
1453        assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
1454
1455        // Over the list (panel bounds, outside content): the list scrolls.
1456        let m = wheel(MouseEventKind::ScrollDown, 2);
1457        assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1458
1459        // Cleared sub-region: the wheel falls back to the panel-wide scroll.
1460        list.set_content_rect(ratatui::layout::Rect::default());
1461        let m = wheel(MouseEventKind::ScrollDown, 6);
1462        assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1463    }
1464
1465    #[tokio::test]
1466    async fn mouse_maps_visual_row_to_display_index_by_height() {
1467        // Row 0 occupies 3 visual rows, row 1 occupies 1. The recorded list rect
1468        // is the rendered-items area: row 0 == the FIRST item (no border row).
1469        let src = TallSource(vec![
1470            TallRow {
1471                name: "a".into(),
1472                height: 3,
1473            },
1474            TallRow {
1475                name: "b".into(),
1476                height: 1,
1477            },
1478        ]);
1479        let mut list = SearchList::builder(src, noop_redraw()).build();
1480        list.poll_until_idle().await;
1481        // Force the recorded list rect (render not run in test): items start at y=0.
1482        list.set_list_rect(ratatui::layout::Rect {
1483            x: 0,
1484            y: 0,
1485            width: 20,
1486            height: 10,
1487        });
1488        // "a" occupies rows 0..=2; row 3 is the FIRST row of "b".
1489        let m = mouse_down_at(2, 3);
1490        assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
1491        assert_eq!(list.selected_row().unwrap().name, "b");
1492        // A click at row 1 = within "a" (rows 0..=2) -> display index 0.
1493        let m = mouse_down_at(2, 1);
1494        list.handle_mouse(&m);
1495        assert_eq!(list.selected_row().unwrap().name, "a");
1496    }
1497
1498    // Mouse-wheel scrolling moves the VIEWPORT, carrying the selection along
1499    // so the selected row keeps its on-screen position (selected - offset is
1500    // invariant) — unlike keyboard navigation, which moves the selection.
1501    #[tokio::test]
1502    async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
1503        let src = VecSource {
1504            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1505            reload: true,
1506        };
1507        let mut list = SearchList::builder(src, noop_redraw()).build();
1508        list.poll_until_idle().await;
1509        // Viewport shows 4 of the 10 rows.
1510        list.set_list_rect(ratatui::layout::Rect {
1511            x: 0,
1512            y: 0,
1513            width: 20,
1514            height: 4,
1515        });
1516        // Move the selection to screen row 2 first.
1517        list.select_next();
1518        list.select_next();
1519        assert_eq!(list.selected_row().unwrap().name, "row2");
1520
1521        let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1522            kind,
1523            column: 1,
1524            row: 1,
1525            modifiers: KeyModifiers::NONE,
1526        };
1527        use ratatui::crossterm::event::MouseEventKind;
1528
1529        // Scroll down: viewport and selection move together.
1530        assert_eq!(
1531            list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1532            SearchMouse::Scrolled
1533        );
1534        assert_eq!(list.scroll_offset(), 1);
1535        assert_eq!(list.selected_row().unwrap().name, "row3");
1536
1537        // Scroll back up: both return.
1538        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1539        assert_eq!(list.scroll_offset(), 0);
1540        assert_eq!(list.selected_row().unwrap().name, "row2");
1541
1542        // At the top, scrolling up is a no-op (selection does NOT move).
1543        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1544        assert_eq!(list.scroll_offset(), 0);
1545        assert_eq!(list.selected_row().unwrap().name, "row2");
1546
1547        // Scrolling down clamps once the last row is in view: 10 rows in a
1548        // 4-row viewport → max offset 6.
1549        for _ in 0..20 {
1550            list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1551        }
1552        assert_eq!(list.scroll_offset(), 6);
1553        assert_eq!(list.selected_row().unwrap().name, "row8");
1554        // The selection kept its screen row through the clamped scroll.
1555        // (row2 at offset 0 → screen row 2; row8 at offset 6 → screen row 2.)
1556    }
1557
1558    // The wheel hit-tests the recorded PANEL rect: scrolling over the host's
1559    // header/query box (outside the list rect) still scrolls the list. Without
1560    // a panel rect it falls back to the list rect only.
1561    #[tokio::test]
1562    async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1563        let src = VecSource {
1564            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1565            reload: true,
1566        };
1567        let mut list = SearchList::builder(src, noop_redraw()).build();
1568        list.poll_until_idle().await;
1569        // List items render at y 5..9; the panel spans y 0..20.
1570        list.set_list_rect(ratatui::layout::Rect {
1571            x: 0,
1572            y: 5,
1573            width: 20,
1574            height: 4,
1575        });
1576        let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1577            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1578            column: 1,
1579            row,
1580            modifiers: KeyModifiers::NONE,
1581        };
1582        // No panel rect: a scroll over the header (y=1) misses.
1583        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1584        assert_eq!(list.scroll_offset(), 0);
1585        list.set_panel_rect(ratatui::layout::Rect {
1586            x: 0,
1587            y: 0,
1588            width: 20,
1589            height: 20,
1590        });
1591        // With the panel rect, the same scroll-over-header scrolls the list.
1592        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1593        assert_eq!(list.scroll_offset(), 1);
1594        // Clicks still hit-test the LIST rect only: a click on the header
1595        // (inside the panel, outside the list) selects nothing.
1596        let before = list.selected_row().unwrap().name.clone();
1597        assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1598        assert_eq!(list.selected_row().unwrap().name, before);
1599    }
1600
1601    // Regression: the click hit-test must account for the viewport offset —
1602    // after wheel scrolling, screen row 0 is the item at `offset`, not
1603    // visible position 0.
1604    #[tokio::test]
1605    async fn click_after_scroll_selects_the_clicked_row() {
1606        let src = VecSource {
1607            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1608            reload: true,
1609        };
1610        let mut list = SearchList::builder(src, noop_redraw()).build();
1611        list.poll_until_idle().await;
1612        list.set_list_rect(ratatui::layout::Rect {
1613            x: 0,
1614            y: 0,
1615            width: 20,
1616            height: 4,
1617        });
1618        let scroll_down = ratatui::crossterm::event::MouseEvent {
1619            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1620            column: 1,
1621            row: 1,
1622            modifiers: KeyModifiers::NONE,
1623        };
1624        for _ in 0..3 {
1625            list.handle_mouse(&scroll_down);
1626        }
1627        assert_eq!(list.scroll_offset(), 3);
1628        // Screen row 2 shows visible position offset + 2 = 5.
1629        assert!(matches!(
1630            list.handle_mouse(&mouse_down_at(2, 2)),
1631            SearchMouse::Selected(5)
1632        ));
1633        assert_eq!(list.selected_row().unwrap().name, "row5");
1634        // Screen row 0 shows the item at the offset itself.
1635        list.handle_mouse(&mouse_down_at(2, 0));
1636        assert_eq!(list.selected_row().unwrap().name, "row3");
1637    }
1638
1639    /// Two presses on a row inside the double-click window are a
1640    /// double-click; a slower second press is the old click-click
1641    /// activation. A third fast press does not chain into another double.
1642    #[tokio::test]
1643    async fn double_click_needs_a_fast_second_press() {
1644        let src = VecSource {
1645            rows: (0..3).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1646            reload: true,
1647        };
1648        let mut list = SearchList::builder(src, noop_redraw()).build();
1649        list.poll_until_idle().await;
1650        list.set_list_rect(ratatui::layout::Rect::new(0, 0, 20, 3));
1651        let t0 = std::time::Instant::now();
1652        let ms = |n: u64| t0 + std::time::Duration::from_millis(n);
1653        let press = mouse_down_at(2, 1);
1654
1655        assert_eq!(
1656            list.handle_mouse_at(&press, ms(0)),
1657            SearchMouse::Selected(1)
1658        );
1659        assert_eq!(
1660            list.handle_mouse_at(&press, ms(150)),
1661            SearchMouse::DoubleClicked {
1662                pos: 1,
1663                repeat: false
1664            }
1665        );
1666        assert_eq!(
1667            list.handle_mouse_at(&press, ms(250)),
1668            SearchMouse::Activated(1),
1669            "a third press is not a second double-click"
1670        );
1671        let slow = ms(250) + crate::components::DOUBLE_CLICK;
1672        assert_eq!(
1673            list.handle_mouse_at(&press, slow),
1674            SearchMouse::Activated(1),
1675            "a slow click-click still activates"
1676        );
1677    }
1678
1679    /// A double-click on a row an earlier click already selected: the first
1680    /// press activates (slow click-click), the second completes the double
1681    /// — flagged `repeat`, so hosts where both mean "open" act once.
1682    #[tokio::test]
1683    async fn a_double_click_after_an_activation_is_a_repeat() {
1684        let src = VecSource {
1685            rows: (0..3).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1686            reload: true,
1687        };
1688        let mut list = SearchList::builder(src, noop_redraw()).build();
1689        list.poll_until_idle().await;
1690        list.set_list_rect(ratatui::layout::Rect::new(0, 0, 20, 3));
1691        let t0 = std::time::Instant::now();
1692        let ms = |n: u64| t0 + std::time::Duration::from_millis(n);
1693        let press = mouse_down_at(2, 1);
1694
1695        assert_eq!(
1696            list.handle_mouse_at(&press, ms(0)),
1697            SearchMouse::Selected(1)
1698        );
1699        assert_eq!(
1700            list.handle_mouse_at(&press, ms(1000)),
1701            SearchMouse::Activated(1)
1702        );
1703        assert_eq!(
1704            list.handle_mouse_at(&press, ms(1150)),
1705            SearchMouse::DoubleClicked {
1706                pos: 1,
1707                repeat: true
1708            }
1709        );
1710    }
1711
1712    // The synchronous build seam: `build_with_rows` applies the rows and seeds
1713    // the selection in the same call — no poll, no spawn, `is_loading()` false
1714    // immediately. This is the static-source path (StaticRowSource); the row
1715    // set is readable on the very next line.
1716    #[tokio::test]
1717    async fn build_with_rows_applies_synchronously_without_a_poll() {
1718        let list = SearchList::builder(StaticRowSource, noop_redraw())
1719            .filter(Filter::Fuzzy)
1720            .build_with_rows(vec![TestRow::new("alpha"), TestRow::new("beta")]);
1721        // No poll, no settle: the rows and the seeded selection are live now.
1722        assert!(!list.is_loading(), "static build is not loading");
1723        assert_eq!(list.rows().len(), 2);
1724        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1725    }
1726
1727    #[tokio::test]
1728    async fn initial_load_populates_rows() {
1729        let src = VecSource {
1730            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1731            reload: true,
1732        };
1733        let mut list = SearchList::builder(src, noop_redraw()).build();
1734        list.poll_until_idle().await;
1735        assert_eq!(list.rows().len(), 2);
1736        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1737    }
1738
1739    #[tokio::test]
1740    async fn requery_supersedes_and_reloads() {
1741        let src = VecSource {
1742            rows: vec![
1743                TestRow::new("alpha"),
1744                TestRow::new("alps"),
1745                TestRow::new("beta"),
1746            ],
1747            reload: true,
1748        };
1749        let mut list = SearchList::builder(src, noop_redraw()).build();
1750        list.poll_until_idle().await;
1751        assert_eq!(list.rows().len(), 3);
1752        list.set_query("alp");
1753        list.poll_until_idle().await;
1754        assert_eq!(list.rows().len(), 2); // alpha, alps
1755        assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1756    }
1757
1758    #[tokio::test]
1759    async fn arrows_navigate_and_enter_submits() {
1760        let src = VecSource {
1761            rows: vec![TestRow::new("a"), TestRow::new("b")],
1762            reload: true,
1763        };
1764        let mut list = SearchList::builder(src, noop_redraw()).build();
1765        list.poll_until_idle().await;
1766        assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1767        assert_eq!(list.selected_row().unwrap().name, "b");
1768        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1769        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1770    }
1771
1772    #[tokio::test]
1773    async fn typing_a_char_changes_query() {
1774        let src = VecSource {
1775            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1776            reload: true,
1777        };
1778        let mut list = SearchList::builder(src, noop_redraw()).build();
1779        list.poll_until_idle().await;
1780        assert_eq!(
1781            list.handle_key(&key(KeyCode::Char('a'))),
1782            KeyReaction::Consumed
1783        );
1784        list.poll_until_idle().await;
1785        assert_eq!(list.query(), "a");
1786    }
1787
1788    #[tokio::test]
1789    async fn rank_filter_orders_by_closure() {
1790        let src = VecSource {
1791            rows: vec![
1792                TestRow::new("todo"),
1793                TestRow::new("today"),
1794                TestRow::new("misc"),
1795            ],
1796            reload: false,
1797        };
1798        let rank = std::sync::Arc::new(|rows: &[TestRow], base: &[usize], q: &str| -> Vec<usize> {
1799            let mut idx: Vec<usize> = base
1800                .iter()
1801                .copied()
1802                .filter(|&i| rows[i].name.contains(q))
1803                .collect();
1804            idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1805            idx
1806        });
1807        let mut list = SearchList::builder(src, noop_redraw())
1808            .filter(Filter::Rank(rank))
1809            .build();
1810        list.poll_until_idle().await;
1811        list.set_query("today");
1812        list.poll();
1813        assert_eq!(list.selected_row().unwrap().name, "today");
1814    }
1815
1816    #[tokio::test]
1817    async fn fuzzy_filter_narrows_local_set() {
1818        let src = VecSource {
1819            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
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        list.set_query("alp");
1827        list.poll();
1828        assert_eq!(list.visible_rows().len(), 1);
1829        assert_eq!(list.selected_row().unwrap().name, "alpha");
1830    }
1831
1832    #[tokio::test]
1833    async fn streamed_rows_arrive_then_done_and_filter_locally() {
1834        let src = ScriptedStreamSource {
1835            batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1836        };
1837        let mut list = SearchList::builder(src, noop_redraw())
1838            .filter(Filter::Fuzzy)
1839            .build();
1840        list.poll_until_idle().await;
1841        assert_eq!(list.rows().len(), 2);
1842        assert!(!list.is_loading());
1843        list.set_query("alp");
1844        list.poll();
1845        assert_eq!(list.visible_rows().len(), 1);
1846    }
1847
1848    #[tokio::test]
1849    async fn source_order_unfiltered_passthrough() {
1850        let src = VecSource {
1851            rows: vec![TestRow::new("a"), TestRow::new("b")],
1852            reload: true,
1853        };
1854        let mut list = SearchList::builder(src, noop_redraw()).build(); // default Filter::SourceOrder
1855        list.poll_until_idle().await;
1856        assert_eq!(list.visible_rows().len(), 2);
1857        assert_eq!(list.selected_row().unwrap().name, "a");
1858    }
1859
1860    #[tokio::test]
1861    async fn intercepted_combo_returns_intercepted_without_acting() {
1862        let src = VecSource {
1863            rows: vec![TestRow::new("a")],
1864            reload: true,
1865        };
1866        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1867        let mut list = SearchList::builder(src, noop_redraw())
1868            .intercept(vec![combo])
1869            .build();
1870        list.poll_until_idle().await;
1871        // Enter is intercepted: engine returns Intercepted, does NOT submit/act.
1872        assert_eq!(
1873            list.handle_key(&key(KeyCode::Enter)),
1874            KeyReaction::Intercepted(combo)
1875        );
1876    }
1877
1878    #[tokio::test]
1879    async fn autocomplete_accept_rewrites_query_without_vault() {
1880        struct Mem;
1881        #[async_trait::async_trait]
1882        impl crate::components::search_list::SuggestionSource for Mem {
1883            async fn notes_by_prefix(
1884                &self,
1885                _p: &str,
1886                _n: usize,
1887            ) -> Vec<crate::components::search_list::SuggestionItem> {
1888                vec![]
1889            }
1890            async fn tags_by_prefix(
1891                &self,
1892                p: &str,
1893                _n: usize,
1894            ) -> Vec<crate::components::search_list::SuggestionItem> {
1895                if "projects".starts_with(p) {
1896                    vec![crate::components::search_list::SuggestionItem::plain(
1897                        "projects",
1898                    )]
1899                } else {
1900                    vec![]
1901                }
1902            }
1903        }
1904        let src = VecSource {
1905            rows: vec![],
1906            reload: true,
1907        };
1908        let mut list = SearchList::builder(src, noop_redraw())
1909            .autocomplete(
1910                std::sync::Arc::new(Mem),
1911                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1912            )
1913            .debounce(std::time::Duration::ZERO)
1914            .build();
1915        for c in ['#', 'p', 'r', 'o'] {
1916            let _ = list.handle_key(&key(KeyCode::Char(c)));
1917        }
1918        for _ in 0..50 {
1919            tokio::task::yield_now().await;
1920            list.poll();
1921        }
1922        let _ = list.handle_key(&key(KeyCode::Tab));
1923        assert_eq!(list.query(), "#projects");
1924    }
1925
1926    /// Pointer motion must not close the popup — the terminal reports every
1927    /// move, so the popup would vanish before the pointer reached it.
1928    #[tokio::test]
1929    async fn moving_the_pointer_keeps_the_popup_open() {
1930        struct Mem;
1931        #[async_trait::async_trait]
1932        impl crate::components::search_list::SuggestionSource for Mem {
1933            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1934                vec![]
1935            }
1936            async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1937                vec![SuggestionItem::plain("projects")]
1938            }
1939        }
1940        let src = VecSource {
1941            rows: vec![],
1942            reload: true,
1943        };
1944        let mut list = SearchList::builder(src, noop_redraw())
1945            .autocomplete(
1946                std::sync::Arc::new(Mem),
1947                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1948            )
1949            .debounce(std::time::Duration::ZERO)
1950            .build();
1951        for c in ['#', 'p'] {
1952            let _ = list.handle_key(&key(KeyCode::Char(c)));
1953        }
1954        for _ in 0..50 {
1955            tokio::task::yield_now().await;
1956            list.poll();
1957        }
1958        assert!(list.autocomplete_is_open());
1959        let moved = ratatui::crossterm::event::MouseEvent {
1960            kind: ratatui::crossterm::event::MouseEventKind::Moved,
1961            column: 30,
1962            row: 30,
1963            modifiers: KeyModifiers::NONE,
1964        };
1965        list.handle_mouse(&moved);
1966        assert!(list.autocomplete_is_open(), "motion keeps it");
1967        list.handle_mouse(&mouse_down_at(30, 30));
1968        assert!(!list.autocomplete_is_open(), "a press elsewhere closes it");
1969    }
1970
1971    /// A click on a suggestion accepts it — the same rewrite Tab makes —
1972    /// and reports the edit so hosts run their query-edit path.
1973    #[tokio::test]
1974    async fn clicking_a_suggestion_accepts_it() {
1975        use ratatui::{Terminal, backend::TestBackend};
1976        struct Mem;
1977        #[async_trait::async_trait]
1978        impl crate::components::search_list::SuggestionSource for Mem {
1979            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1980                vec![]
1981            }
1982            async fn tags_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1983                if "projects".starts_with(p) {
1984                    vec![SuggestionItem::plain("projects")]
1985                } else {
1986                    vec![]
1987                }
1988            }
1989        }
1990        let src = VecSource {
1991            rows: vec![],
1992            reload: true,
1993        };
1994        let mut list = SearchList::builder(src, noop_redraw())
1995            .autocomplete(
1996                std::sync::Arc::new(Mem),
1997                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1998            )
1999            .debounce(std::time::Duration::ZERO)
2000            .build();
2001        for c in ['#', 'p', 'r', 'o'] {
2002            let _ = list.handle_key(&key(KeyCode::Char(c)));
2003        }
2004        for _ in 0..50 {
2005            tokio::task::yield_now().await;
2006            list.poll();
2007        }
2008        let theme = Theme::gruvbox_dark();
2009        let mut t = Terminal::new(TestBackend::new(40, 12)).unwrap();
2010        t.draw(|f| {
2011            let input = ratatui::layout::Rect::new(0, 0, 40, 1);
2012            list.render_query(f, input, &theme, true);
2013            list.render_autocomplete(f, f.area(), &theme);
2014        })
2015        .unwrap();
2016        let rows = list
2017            .autocomplete
2018            .as_ref()
2019            .and_then(|ac| ac.state())
2020            .map(|st| st.rows_rect)
2021            .expect("popup open");
2022        assert!(
2023            list.popup_contains(rows.x + 1, rows.y),
2024            "hosts that filter clicks can see the popup"
2025        );
2026        let click = match crate::test_support::mouse_down_at(rows.x + 1, rows.y) {
2027            crate::components::events::InputEvent::Mouse(m) => m,
2028            _ => unreachable!(),
2029        };
2030        assert_eq!(
2031            list.handle_mouse(&click),
2032            SearchMouse::Autocomplete { edited: true }
2033        );
2034        assert_eq!(list.query(), "#projects");
2035    }
2036
2037    // Accepting a SavedSearch suggestion expands the whole field to the
2038    // stored query AND exposes the accepted name (for the breadcrumb) via
2039    // `take_accepted_saved_search`.
2040    #[tokio::test]
2041    async fn accepting_saved_search_expands_query_and_exposes_name() {
2042        struct Mem;
2043        #[async_trait::async_trait]
2044        impl crate::components::search_list::SuggestionSource for Mem {
2045            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
2046                vec![]
2047            }
2048            async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
2049                vec![]
2050            }
2051            async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
2052                if "todo-week".starts_with(p) {
2053                    vec![SuggestionItem {
2054                        display: "todo-week".into(),
2055                        secondary: Some("#todo ^modified".into()),
2056                    }]
2057                } else {
2058                    vec![]
2059                }
2060            }
2061        }
2062        let src = VecSource {
2063            rows: vec![],
2064            reload: true,
2065        };
2066        let mut list = SearchList::builder(src, noop_redraw())
2067            .autocomplete(
2068                std::sync::Arc::new(Mem),
2069                crate::components::autocomplete::AutocompleteMode::SearchQuery,
2070            )
2071            .debounce(std::time::Duration::ZERO)
2072            .build();
2073        for c in ['?', 't', 'o'] {
2074            let _ = list.handle_key(&key(KeyCode::Char(c)));
2075        }
2076        for _ in 0..50 {
2077            tokio::task::yield_now().await;
2078            list.poll();
2079        }
2080        let _ = list.handle_key(&key(KeyCode::Tab));
2081        // Whole field expanded to the stored query.
2082        assert_eq!(list.query(), "#todo ^modified");
2083        // The accepted name is exposed once, then cleared.
2084        assert_eq!(
2085            list.take_accepted_saved_search().as_deref(),
2086            Some("todo-week")
2087        );
2088        assert_eq!(list.take_accepted_saved_search(), None);
2089    }
2090
2091    // Regression: Enter (not just Tab) must accept an open autocomplete popup,
2092    // and the engine must report Consumed — NOT Submit — so a host does not
2093    // mistake the accept for a list submit. (A QueryPanel Enter pre-check used
2094    // to swallow this, breaking accept-on-Enter in the right sidebar.)
2095    #[tokio::test]
2096    async fn enter_accepts_open_popup_and_reports_consumed() {
2097        struct Mem;
2098        #[async_trait::async_trait]
2099        impl crate::components::search_list::SuggestionSource for Mem {
2100            async fn notes_by_prefix(
2101                &self,
2102                _p: &str,
2103                _n: usize,
2104            ) -> Vec<crate::components::search_list::SuggestionItem> {
2105                vec![]
2106            }
2107            async fn tags_by_prefix(
2108                &self,
2109                p: &str,
2110                _n: usize,
2111            ) -> Vec<crate::components::search_list::SuggestionItem> {
2112                if "projects".starts_with(p) {
2113                    vec![crate::components::search_list::SuggestionItem::plain(
2114                        "projects",
2115                    )]
2116                } else {
2117                    vec![]
2118                }
2119            }
2120        }
2121        let src = VecSource {
2122            rows: vec![],
2123            reload: true,
2124        };
2125        let mut list = SearchList::builder(src, noop_redraw())
2126            .autocomplete(
2127                std::sync::Arc::new(Mem),
2128                crate::components::autocomplete::AutocompleteMode::SearchQuery,
2129            )
2130            .debounce(std::time::Duration::ZERO)
2131            .build();
2132        for c in ['#', 'p', 'r', 'o'] {
2133            let _ = list.handle_key(&key(KeyCode::Char(c)));
2134        }
2135        for _ in 0..50 {
2136            tokio::task::yield_now().await;
2137            list.poll();
2138        }
2139        // Popup is open: Enter accepts the suggestion and reports Consumed.
2140        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
2141        assert_eq!(list.query(), "#projects");
2142        // Popup now closed: a second Enter falls through to Submit.
2143        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
2144    }
2145
2146    // Regression (P0): a STREAMED source (sidebar shape) supplies a query-fresh
2147    // leading row. It must appear at visible position 0 even though rows arrive
2148    // via Push (never Replace), be present when the query matches no streamed
2149    // row, and refresh when the query changes (reload_on_query() == false).
2150    #[tokio::test]
2151    async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
2152        let src = ScriptedStreamLeadSource {
2153            items: vec!["alpha".into(), "beta".into()],
2154        };
2155        let mut list = SearchList::builder(src, noop_redraw())
2156            .filter(Filter::Fuzzy)
2157            .initial_query("zz")
2158            .build();
2159        list.poll_until_idle().await;
2160        // Leading present even though "zz" matches no streamed Item.
2161        let vis = list.visible_rows();
2162        assert_eq!(vis[0], &StreamRow::Create("zz".into()));
2163        assert_eq!(list.visible_len(), 1); // just the leading; no Item matches
2164        // Query-fresh: changing the query rebuilds the leading and re-filters.
2165        list.set_query("alp");
2166        list.poll();
2167        let vis = list.visible_rows();
2168        assert_eq!(vis[0], &StreamRow::Create("alp".into()));
2169        assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
2170        assert_eq!(list.visible_len(), 2);
2171        // Empty query: leading disappears, both Items show.
2172        list.set_query("");
2173        list.poll();
2174        assert!(
2175            list.visible_rows()
2176                .iter()
2177                .all(|r| matches!(r, StreamRow::Item(_)))
2178        );
2179        assert_eq!(list.visible_len(), 2);
2180    }
2181
2182    // Regression guard for the saved-searches virtual entry: a one-shot
2183    // (Replace) source with a leading row still pins it at position 0.
2184    #[tokio::test]
2185    async fn oneshot_source_leading_row_still_works() {
2186        let src = VecSourceWithLead {
2187            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2188        };
2189        let mut list = SearchList::builder(src, noop_redraw())
2190            .filter(Filter::Fuzzy)
2191            .initial_query("alp")
2192            .build();
2193        list.poll_until_idle().await;
2194        let vis = list.visible_rows();
2195        assert_eq!(vis[0].name, "create:alp");
2196        assert_eq!(vis[1].name, "alpha");
2197        assert_eq!(list.visible_len(), 2);
2198    }
2199
2200    // Selection walks the VISIBLE sequence: position 0 is the leading row, and
2201    // select_next steps from the leading to the first real row.
2202    #[tokio::test]
2203    async fn selection_includes_leading_at_position_zero() {
2204        let src = VecSourceWithLead {
2205            rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
2206        };
2207        let mut list = SearchList::builder(src, noop_redraw())
2208            .filter(Filter::Fuzzy)
2209            .initial_query("alp")
2210            .build();
2211        list.poll_until_idle().await;
2212        // Auto-selected position 0 -> the leading.
2213        assert_eq!(list.selected_row().unwrap().name, "create:alp");
2214        list.handle_key(&key(KeyCode::Down));
2215        assert_eq!(list.selected_row().unwrap().name, "alpha");
2216    }
2217
2218    // A source with NO leading row has no off-by-one: visible_len == display.
2219    #[tokio::test]
2220    async fn no_leading_row_visible_len_matches_display() {
2221        let src = VecSource {
2222            rows: vec![TestRow::new("a"), TestRow::new("b")],
2223            reload: true,
2224        };
2225        let mut list = SearchList::builder(src, noop_redraw()).build();
2226        list.poll_until_idle().await;
2227        assert_eq!(list.visible_len(), 2);
2228        assert_eq!(list.visible_rows().len(), 2);
2229        assert_eq!(list.selected_row().unwrap().name, "a");
2230    }
2231
2232    // update_rows re-runs the active fuzzy filter after the mutation so rows
2233    // that no longer match the query drop out of the visible view.
2234    #[tokio::test]
2235    async fn update_rows_refilters_visible_view() {
2236        let source = VecSource {
2237            rows: vec![
2238                TestRow::new("alpha"),
2239                TestRow::new("beta"),
2240                TestRow::new("gamma"),
2241            ],
2242            reload: false,
2243        };
2244        let mut list = SearchList::builder(source, noop_redraw())
2245            .filter(Filter::Fuzzy)
2246            .build();
2247        list.poll_until_idle().await;
2248
2249        // With query "alp", only "alpha" should be visible.
2250        list.set_query("alp");
2251        list.poll();
2252        assert_eq!(
2253            list.visible_rows()
2254                .iter()
2255                .map(|r| r.name.as_str())
2256                .collect::<Vec<_>>(),
2257            vec!["alpha"],
2258            "before update: only 'alpha' matches 'alp'"
2259        );
2260
2261        // Rename "alpha" to something that no longer contains "alp".
2262        let changed = list.update_rows(|r| {
2263            if r.name == "alpha" {
2264                r.name = "renamed".to_string();
2265                true
2266            } else {
2267                false
2268            }
2269        });
2270        assert!(changed);
2271
2272        // The visible view must now be empty: "renamed" does not match "alp".
2273        assert_eq!(
2274            list.visible_rows().len(),
2275            0,
2276            "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
2277        );
2278    }
2279
2280    #[tokio::test]
2281    async fn update_rows_mutates_in_place_and_recomputes() {
2282        let source = VecSource {
2283            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2284            reload: false,
2285        };
2286        let mut list = SearchList::builder(source, noop_redraw()).build();
2287        list.poll_until_idle().await;
2288
2289        // Mutate the row named "alpha".
2290        let changed = list.update_rows(|r| {
2291            if r.name == "alpha" {
2292                r.name = "renamed".to_string();
2293                true
2294            } else {
2295                false
2296            }
2297        });
2298        assert!(changed, "a row was changed");
2299        assert!(
2300            list.rows().iter().any(|r| r.name == "renamed"),
2301            "the mutation is visible in rows()"
2302        );
2303
2304        // A no-op mutation reports no change and does not panic.
2305        let changed_again = list.update_rows(|_| false);
2306        assert!(!changed_again, "no row changed");
2307    }
2308
2309    // Regression guard (Fix A): for reload_on_query == true sources that also
2310    // expose a leading row, set_query must rebuild the leading row synchronously
2311    // in the same frame — before any poll/drain. The old code skipped
2312    // recompute_and_seed() for reload sources, so the leading row lagged until
2313    // the async load landed. This test must FAIL without the fix (the leading
2314    // row still shows the old query immediately after set_query).
2315    #[tokio::test]
2316    async fn reload_source_leading_row_updates_synchronously_on_set_query() {
2317        let src = ReloadWithLeadSource {
2318            rows: vec![
2319                TestRow::new("alpha"),
2320                TestRow::new("beta"),
2321                TestRow::new("gamma"),
2322            ],
2323        };
2324        let mut list = SearchList::builder(src, noop_redraw()).build();
2325        list.poll_until_idle().await;
2326        // Sanity: no leading row for empty query.
2327        assert!(list.leading.is_none(), "no leading row for empty query");
2328
2329        // Change query — do NOT poll/drain after this.
2330        list.set_query("alp");
2331
2332        // The leading row must reflect the NEW query immediately (synchronously).
2333        let vis = list.visible_rows();
2334        assert!(
2335            !vis.is_empty(),
2336            "visible_rows must not be empty right after set_query"
2337        );
2338        assert_eq!(
2339            vis[0].name, "create:alp",
2340            "leading row must show new query synchronously, before any poll/drain"
2341        );
2342
2343        // The async load will also arrive, but the leading row must already be
2344        // correct without waiting for it.
2345        list.poll_until_idle().await;
2346        let vis = list.visible_rows();
2347        assert_eq!(
2348            vis[0].name, "create:alp",
2349            "leading row correct after drain too"
2350        );
2351        // Only "alpha" matches "alp" from the server-side filter.
2352        assert_eq!(vis.len(), 2, "leading + alpha");
2353        assert_eq!(vis[1].name, "alpha");
2354    }
2355
2356    // Regression guard: local-filter sources (reload_on_query == false) must
2357    // reseed the selection back to row 0 when a filter change repopulates the
2358    // list after having emptied it.
2359    //
2360    // The gate in `poll()` (only recompute when drain is non-empty) must NOT
2361    // suppress the reseed for local filters, because they go through
2362    // `requery()` → `recompute_and_seed()` directly — no loader drain.
2363    #[tokio::test]
2364    async fn local_filter_reseed_after_empty_then_repopulate() {
2365        let src = VecSource {
2366            rows: vec![
2367                TestRow::new("alpha"),
2368                TestRow::new("beta"),
2369                TestRow::new("gamma"),
2370            ],
2371            reload: false,
2372        };
2373        let mut list = SearchList::builder(src, noop_redraw())
2374            .filter(Filter::Fuzzy)
2375            .build();
2376        list.poll_until_idle().await;
2377
2378        // Sanity: initial load selected the first row.
2379        assert!(
2380            list.selected_row().is_some(),
2381            "should have a selection after initial load"
2382        );
2383
2384        // Apply a filter that matches nothing → visible list is empty → selection cleared.
2385        list.set_query("zzznomatch");
2386        assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
2387        assert!(
2388            list.selected_row().is_none(),
2389            "selection must be None when list is empty"
2390        );
2391
2392        // Widen the filter so rows come back (no drain will happen — local filter).
2393        list.set_query("alp");
2394        assert!(
2395            list.visible_len() > 0,
2396            "at least 'alpha' should match 'alp'"
2397        );
2398        // The selection MUST be reseeded to Some(0) — the subtlety the gating
2399        // would regress if recompute_and_seed() weren't called from requery().
2400        assert!(
2401            list.selected_row().is_some(),
2402            "selection must be reseeded to first visible row after repopulation"
2403        );
2404        assert_eq!(
2405            list.selected_row().unwrap().name,
2406            "alpha",
2407            "first visible row must be selected after reseeding"
2408        );
2409    }
2410
2411    // ── List focus ──────────────────────────────────────────────────────
2412
2413    async fn focus_list(verbs: &[char]) -> SearchList<TestRow> {
2414        let src = VecSource {
2415            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2416            reload: false,
2417        };
2418        let mut b = SearchList::builder(src, noop_redraw()).filter(Filter::Fuzzy);
2419        for &c in verbs {
2420            b = b.list_verb(c);
2421        }
2422        let mut list = b.build();
2423        list.poll_until_idle().await;
2424        list
2425    }
2426
2427    // Registering a verb activates the machine: the first Esc flips Input→List
2428    // (Consumed, not Cancel); a second Esc (now in List focus) Cancels.
2429    #[tokio::test]
2430    async fn esc_enters_list_focus_then_cancels() {
2431        let mut list = focus_list(&['l']).await;
2432        assert_eq!(list.focus(), Focus::Input);
2433        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Consumed);
2434        assert_eq!(list.focus(), Focus::List);
2435        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2436        assert_eq!(list.focus(), Focus::List, "Cancel does not change focus");
2437    }
2438
2439    // Surfaces that never opt in keep byte-identical Esc→Cancel and stay Input.
2440    #[tokio::test]
2441    async fn esc_cancels_immediately_when_focus_disabled() {
2442        let mut list = focus_list(&[]).await;
2443        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2444        assert_eq!(list.focus(), Focus::Input);
2445    }
2446
2447    // `i` and `/` return from List focus to Input focus.
2448    #[tokio::test]
2449    async fn i_and_slash_return_to_input_focus() {
2450        for ret in ['i', '/'] {
2451            let mut list = focus_list(&['l']).await;
2452            list.handle_key(&key(KeyCode::Esc)); // → List
2453            assert_eq!(list.focus(), Focus::List);
2454            assert_eq!(
2455                list.handle_key(&key(KeyCode::Char(ret))),
2456                KeyReaction::Consumed
2457            );
2458            assert_eq!(list.focus(), Focus::Input);
2459            assert_eq!(list.query(), "", "switching focus must not type a char");
2460        }
2461    }
2462
2463    // In List focus `j`/`k` navigate (arrows keep working too).
2464    #[tokio::test]
2465    async fn list_focus_j_k_navigate() {
2466        let mut list = focus_list(&['l']).await;
2467        list.handle_key(&key(KeyCode::Esc)); // → List
2468        assert_eq!(list.selected_row().unwrap().name, "alpha");
2469        assert_eq!(
2470            list.handle_key(&key(KeyCode::Char('j'))),
2471            KeyReaction::Consumed
2472        );
2473        assert_eq!(list.selected_row().unwrap().name, "beta");
2474        assert_eq!(
2475            list.handle_key(&key(KeyCode::Char('k'))),
2476            KeyReaction::Consumed
2477        );
2478        assert_eq!(list.selected_row().unwrap().name, "alpha");
2479    }
2480
2481    // A registered verb fires as ListVerb; an unregistered letter does NOTHING
2482    // (Consumed, query untouched) — it never types into the query.
2483    #[tokio::test]
2484    async fn registered_verb_fires_unregistered_letter_does_nothing() {
2485        let mut list = focus_list(&['l', 'o']).await;
2486        list.handle_key(&key(KeyCode::Esc)); // → List
2487        assert_eq!(
2488            list.handle_key(&key(KeyCode::Char('l'))),
2489            KeyReaction::ListVerb('l')
2490        );
2491        assert_eq!(
2492            list.handle_key(&key(KeyCode::Char('o'))),
2493            KeyReaction::ListVerb('o')
2494        );
2495        // 'z' is not registered: swallowed, query stays empty.
2496        assert_eq!(
2497            list.handle_key(&key(KeyCode::Char('z'))),
2498            KeyReaction::Consumed
2499        );
2500        assert_eq!(list.query(), "");
2501    }
2502
2503    // In Input focus, verb letters type into the query exactly as before —
2504    // the verb is inert until the user Esc-es into the list.
2505    #[tokio::test]
2506    async fn verbs_are_inert_in_input_focus() {
2507        let mut list = focus_list(&['l', 'o']).await;
2508        assert_eq!(list.focus(), Focus::Input);
2509        assert_eq!(
2510            list.handle_key(&key(KeyCode::Char('l'))),
2511            KeyReaction::Consumed
2512        );
2513        list.poll_until_idle().await;
2514        assert_eq!(list.query(), "l", "verb letters still type in Input focus");
2515    }
2516
2517    /// Clicking the query input is the mouse's `i`: list focus goes back to
2518    /// the input, so typing filters again.
2519    #[tokio::test]
2520    async fn clicking_the_input_gives_it_the_keyboard() {
2521        use ratatui::{Terminal, backend::TestBackend};
2522        let src = VecSource {
2523            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2524            reload: false,
2525        };
2526        let mut list = SearchList::builder(src, noop_redraw())
2527            .filter(Filter::Fuzzy)
2528            .opening_focus(Focus::List)
2529            .build();
2530        list.poll_until_idle().await;
2531        let theme = Theme::gruvbox_dark();
2532        let mut t = Terminal::new(TestBackend::new(30, 1)).unwrap();
2533        t.draw(|f| list.render_query(f, f.area(), &theme, true))
2534            .unwrap();
2535        let m = match crate::test_support::mouse_down_at(3, 0) {
2536            crate::components::events::InputEvent::Mouse(m) => m,
2537            _ => unreachable!(),
2538        };
2539        assert_eq!(list.handle_mouse(&m), SearchMouse::InputFocused);
2540        assert_eq!(list.focus(), Focus::Input);
2541        list.handle_key(&key(KeyCode::Char('a')));
2542        list.poll_until_idle().await;
2543        assert_eq!(list.query(), "a");
2544    }
2545
2546    // Opening on the list starts in List focus; a plain letter with no verb
2547    // registered does nothing (never types).
2548    #[tokio::test]
2549    async fn opening_focus_list_starts_in_list() {
2550        let src = VecSource {
2551            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2552            reload: false,
2553        };
2554        let mut list = SearchList::builder(src, noop_redraw())
2555            .filter(Filter::Fuzzy)
2556            .opening_focus(Focus::List)
2557            .build();
2558        list.poll_until_idle().await;
2559        assert_eq!(list.focus(), Focus::List);
2560        assert_eq!(
2561            list.handle_key(&key(KeyCode::Char('a'))),
2562            KeyReaction::Consumed
2563        );
2564        assert_eq!(list.query(), "");
2565        // `i` drops to the input where typing filters again.
2566        list.handle_key(&key(KeyCode::Char('i')));
2567        assert_eq!(list.focus(), Focus::Input);
2568        list.handle_key(&key(KeyCode::Char('a')));
2569        list.poll_until_idle().await;
2570        assert_eq!(list.query(), "a");
2571    }
2572
2573    // Registered intercepts fire in BOTH foci.
2574    #[tokio::test]
2575    async fn intercept_fires_in_both_foci() {
2576        let src = VecSource {
2577            rows: vec![TestRow::new("a")],
2578            reload: false,
2579        };
2580        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
2581        let mut list = SearchList::builder(src, noop_redraw())
2582            .intercept(vec![combo])
2583            .list_verb('l')
2584            .build();
2585        list.poll_until_idle().await;
2586        // Input focus: intercepted.
2587        assert_eq!(
2588            list.handle_key(&key(KeyCode::Enter)),
2589            KeyReaction::Intercepted(combo)
2590        );
2591        // Flip to List focus, intercept still fires.
2592        list.handle_key(&key(KeyCode::Esc));
2593        assert_eq!(list.focus(), Focus::List);
2594        assert_eq!(
2595            list.handle_key(&key(KeyCode::Enter)),
2596            KeyReaction::Intercepted(combo)
2597        );
2598    }
2599}
2600
2601#[cfg(test)]
2602mod order_tests {
2603    use super::adapters::{HeldEmitSource, ScriptedStreamSource, TestRow};
2604    use super::seams::RankFn;
2605    use super::*;
2606    use std::sync::{Arc, Mutex};
2607
2608    fn noop_redraw() -> Arc<dyn Fn() + Send + Sync> {
2609        Arc::new(|| {})
2610    }
2611
2612    fn by_name_asc() -> OrderFn<TestRow> {
2613        Arc::new(|a: &TestRow, b: &TestRow| a.name.cmp(&b.name))
2614    }
2615
2616    fn by_name_desc() -> OrderFn<TestRow> {
2617        Arc::new(|a: &TestRow, b: &TestRow| b.name.cmp(&a.name))
2618    }
2619
2620    fn names(list: &SearchList<TestRow>) -> Vec<String> {
2621        list.visible_rows().iter().map(|r| r.name.clone()).collect()
2622    }
2623
2624    /// Rows pushed out of order land in `order_by` order, so a streamed
2625    /// listing is sorted at every frame, not only once the stream ends.
2626    #[tokio::test]
2627    async fn order_by_sorts_pushed_rows_as_they_arrive() {
2628        let source = ScriptedStreamSource {
2629            batches: vec![
2630                vec![TestRow::new("charlie")],
2631                vec![TestRow::new("alpha")],
2632                vec![TestRow::new("bravo")],
2633            ],
2634        };
2635        let mut list = SearchList::builder(source, noop_redraw())
2636            .filter(Filter::Fuzzy)
2637            .order_by(by_name_asc())
2638            .build();
2639        list.poll_until_idle().await;
2640        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2641    }
2642
2643    /// Changing the order re-sorts the rows already in memory; no reload, no
2644    /// second trip to the source.
2645    #[tokio::test]
2646    async fn set_order_reorders_in_place_without_reload() {
2647        let source = ScriptedStreamSource {
2648            batches: vec![vec![
2649                TestRow::new("bravo"),
2650                TestRow::new("alpha"),
2651                TestRow::new("charlie"),
2652            ]],
2653        };
2654        let mut list = SearchList::builder(source, noop_redraw())
2655            .order_by(by_name_asc())
2656            .build();
2657        list.poll_until_idle().await;
2658        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2659
2660        list.set_order(Some(by_name_desc()));
2661
2662        assert!(!list.is_loading(), "reordering must not start a load");
2663        assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2664    }
2665
2666    /// Changing the sort must respect the same seeded/chosen split as a
2667    /// streaming poll: an untouched selection keeps the TOP slot of the new
2668    /// order, and stays a seed, so later-arriving rows still take it.
2669    ///
2670    /// Regression: `set_order` carried the selection by row index
2671    /// unconditionally, so a sort change pushed an untouched seed off the top
2672    /// row AND froze it at that numeric position — the highlight then walked
2673    /// across a different row on every subsequent poll.
2674    #[tokio::test]
2675    async fn changing_the_order_keeps_a_seeded_selection_on_the_top_row() {
2676        let slot = Arc::new(Mutex::new(None));
2677        let source = HeldEmitSource { slot: slot.clone() };
2678        let mut list = SearchList::builder(source, noop_redraw())
2679            .order_by(by_name_asc())
2680            .build();
2681        let emit = loop {
2682            if let Some(e) = slot.lock().unwrap().clone() {
2683                break e;
2684            }
2685            tokio::task::yield_now().await;
2686        };
2687
2688        emit.push(TestRow::new("bravo"));
2689        emit.push(TestRow::new("charlie"));
2690        list.poll();
2691        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2692
2693        list.set_order(Some(by_name_desc()));
2694        assert_eq!(names(&list), ["charlie", "bravo"]);
2695        assert_eq!(
2696            list.selected_row().map(|r| r.name.as_str()),
2697            Some("charlie"),
2698            "an untouched selection keeps the top slot of the new order"
2699        );
2700
2701        // Still a seed, not a choice: the next row to sort above it takes it.
2702        emit.push(TestRow::new("delta"));
2703        list.poll();
2704        assert_eq!(names(&list), ["delta", "charlie", "bravo"]);
2705        assert_eq!(
2706            list.selected_row().map(|r| r.name.as_str()),
2707            Some("delta"),
2708            "a sort change must not silently turn a seed into a choice"
2709        );
2710        emit.done();
2711    }
2712
2713    /// The chosen half of that split: a row the user picked stays picked
2714    /// across a sort change.
2715    #[tokio::test]
2716    async fn changing_the_order_keeps_a_chosen_selection_on_its_row() {
2717        let source = ScriptedStreamSource {
2718            batches: vec![vec![
2719                TestRow::new("bravo"),
2720                TestRow::new("alpha"),
2721                TestRow::new("charlie"),
2722            ]],
2723        };
2724        let mut list = SearchList::builder(source, noop_redraw())
2725            .order_by(by_name_asc())
2726            .build();
2727        list.poll_until_idle().await;
2728        list.select_next(); // "bravo", by the user's own keys
2729        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2730
2731        list.set_order(Some(by_name_desc()));
2732        assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2733        assert_eq!(
2734            list.selected_row().map(|r| r.name.as_str()),
2735            Some("bravo"),
2736            "a chosen selection tracks its row through a re-sort"
2737        );
2738    }
2739
2740    /// A nudge that hits the end of the list moves nothing, so it must not
2741    /// turn a seed into a choice — otherwise one stray `Up` on the first row
2742    /// of a streaming listing freezes the highlight and lets it drift.
2743    #[tokio::test]
2744    async fn a_nudge_that_moves_nothing_leaves_the_seed_a_seed() {
2745        let slot = Arc::new(Mutex::new(None));
2746        let source = HeldEmitSource { slot: slot.clone() };
2747        let mut list = SearchList::builder(source, noop_redraw())
2748            .order_by(by_name_asc())
2749            .build();
2750        let emit = loop {
2751            if let Some(e) = slot.lock().unwrap().clone() {
2752                break e;
2753            }
2754            tokio::task::yield_now().await;
2755        };
2756
2757        emit.push(TestRow::new("charlie"));
2758        list.poll();
2759        list.select_prev(); // already on the only (top) row: nothing moves
2760        list.select_next(); // already on the only (last) row: nothing moves
2761        assert_eq!(
2762            list.selected_row().map(|r| r.name.as_str()),
2763            Some("charlie")
2764        );
2765
2766        emit.push(TestRow::new("alpha"));
2767        list.poll();
2768        assert_eq!(names(&list), ["alpha", "charlie"]);
2769        assert_eq!(
2770            list.selected_row().map(|r| r.name.as_str()),
2771            Some("alpha"),
2772            "a keypress that changed nothing must leave the selection seeded"
2773        );
2774        emit.done();
2775    }
2776
2777    /// A row the user selected while rows were still streaming stays selected
2778    /// when a later row sorts in above it.
2779    #[tokio::test]
2780    async fn selection_follows_its_row_while_rows_stream_in() {
2781        let slot = Arc::new(Mutex::new(None));
2782        let source = HeldEmitSource { slot: slot.clone() };
2783        let mut list = SearchList::builder(source, noop_redraw())
2784            .order_by(by_name_asc())
2785            .build();
2786        let emit = loop {
2787            if let Some(e) = slot.lock().unwrap().clone() {
2788                break e;
2789            }
2790            tokio::task::yield_now().await;
2791        };
2792
2793        emit.push(TestRow::new("bravo"));
2794        emit.push(TestRow::new("charlie"));
2795        list.poll();
2796        list.select_next();
2797        assert_eq!(
2798            list.selected_row().map(|r| r.name.as_str()),
2799            Some("charlie")
2800        );
2801
2802        emit.push(TestRow::new("alpha"));
2803        list.poll();
2804
2805        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2806        assert_eq!(
2807            list.selected_row().map(|r| r.name.as_str()),
2808            Some("charlie"),
2809            "selection tracks the row, not its old position"
2810        );
2811        emit.done();
2812    }
2813
2814    /// `order_by` composes with a [`Filter::Rank`] surface: the ranker is
2815    /// handed the candidate indices in `order_by` order, so rows it scores
2816    /// equally keep the list's order.
2817    ///
2818    /// Regression: the `Rank` arm ranked `self.rows` directly and threw `base`
2819    /// away, so pairing a rank filter with an order was a silent no-op.
2820    #[tokio::test]
2821    async fn order_by_composes_with_a_rank_filter() {
2822        // Keeps every row containing the query, in the order it was handed.
2823        let rank: RankFn<TestRow> = Arc::new(|rows: &[TestRow], base: &[usize], q: &str| {
2824            base.iter()
2825                .copied()
2826                .filter(|&i| rows[i].name.contains(q))
2827                .collect()
2828        });
2829        let source = ScriptedStreamSource {
2830            batches: vec![vec![
2831                TestRow::new("charlie"),
2832                TestRow::new("alpha"),
2833                TestRow::new("bravo"),
2834            ]],
2835        };
2836        let mut list = SearchList::builder(source, noop_redraw())
2837            .filter(Filter::Rank(rank))
2838            .order_by(by_name_asc())
2839            .build();
2840        list.poll_until_idle().await;
2841        list.set_query("a"); // every row matches, so only the order decides
2842        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2843    }
2844
2845    /// The other half of that contract: a selection the user has NEVER moved
2846    /// is not a choice, it is a seed. It must stay on the top of the
2847    /// `order_by` order as rows stream in.
2848    ///
2849    /// Regression: `poll` carried the seeded selection by row index like a
2850    /// user-chosen one, so on a slow (streamed) directory the initial
2851    /// highlight stuck to whichever entry the walker happened to emit first
2852    /// and drifted down the list as better-sorting rows arrived above it.
2853    #[tokio::test]
2854    async fn a_seeded_selection_stays_on_the_top_row_as_rows_stream_in() {
2855        let slot = Arc::new(Mutex::new(None));
2856        let source = HeldEmitSource { slot: slot.clone() };
2857        let mut list = SearchList::builder(source, noop_redraw())
2858            .order_by(by_name_asc())
2859            .build();
2860        let emit = loop {
2861            if let Some(e) = slot.lock().unwrap().clone() {
2862                break e;
2863            }
2864            tokio::task::yield_now().await;
2865        };
2866
2867        // First row to arrive seeds the selection.
2868        emit.push(TestRow::new("charlie"));
2869        list.poll();
2870        assert_eq!(
2871            list.selected_row().map(|r| r.name.as_str()),
2872            Some("charlie")
2873        );
2874
2875        // A row that sorts above it must take the highlight with the top slot.
2876        emit.push(TestRow::new("alpha"));
2877        list.poll();
2878        assert_eq!(names(&list), ["alpha", "charlie"]);
2879        assert_eq!(
2880            list.selected_row().map(|r| r.name.as_str()),
2881            Some("alpha"),
2882            "an untouched selection seeds to the top of the order, not to the first-arrived row"
2883        );
2884        emit.done();
2885    }
2886
2887    /// Moving the selection and landing back on the top row still counts as a
2888    /// choice: from then on it tracks that row, exactly as any other
2889    /// user-chosen selection does.
2890    #[tokio::test]
2891    async fn a_selection_moved_back_to_the_top_row_is_still_pinned_to_it() {
2892        let slot = Arc::new(Mutex::new(None));
2893        let source = HeldEmitSource { slot: slot.clone() };
2894        let mut list = SearchList::builder(source, noop_redraw())
2895            .order_by(by_name_asc())
2896            .build();
2897        let emit = loop {
2898            if let Some(e) = slot.lock().unwrap().clone() {
2899                break e;
2900            }
2901            tokio::task::yield_now().await;
2902        };
2903
2904        emit.push(TestRow::new("bravo"));
2905        emit.push(TestRow::new("charlie"));
2906        list.poll();
2907        list.select_next();
2908        list.select_prev(); // back on "bravo", but by the user's own keys
2909        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2910
2911        emit.push(TestRow::new("alpha"));
2912        list.poll();
2913        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2914        assert_eq!(
2915            list.selected_row().map(|r| r.name.as_str()),
2916            Some("bravo"),
2917            "a selection the user placed tracks its row even when it sits at the top"
2918        );
2919        emit.done();
2920    }
2921}