Skip to main content

kimun_notes/components/search_list/
mod.rs

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