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 seams;
9
10pub use seams::{
11    Emit, Filter, Loaded, RowSource, SearchRow, SuggestionItem, SuggestionSource, VaultSuggestions,
12};
13
14use crate::components::autocomplete::{
15    AutocompleteController, AutocompleteMode, HandleKeyOutcome, TriggerOptions,
16};
17use crate::components::single_line_input::{InputOutcome, SingleLineInput};
18use crate::keys::key_combo::KeyCombo;
19use crate::settings::icons::Icons;
20use crate::settings::themes::Theme;
21use load::LoadEngine;
22use ratatui::crossterm::event::KeyEvent;
23use ratatui::{
24    Frame,
25    layout::Rect,
26    style::Style,
27    widgets::{List, ListItem, ListState},
28};
29use seams::Loaded as LoadedInner;
30use std::sync::Arc;
31
32fn fuzzy_indices<R: SearchRow>(rows: &[R], query: &str) -> Vec<usize> {
33    use nucleo::pattern::{CaseMatching, Normalization, Pattern};
34    use nucleo::{Matcher, Utf32Str};
35    let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
36    let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
37    let mut scored: Vec<(usize, u32)> = rows
38        .iter()
39        .enumerate()
40        .filter_map(|(i, r)| {
41            let hay = r.match_text()?;
42            let mut buf = Vec::new();
43            let h = Utf32Str::new(hay, &mut buf);
44            pat.score(h, &mut matcher).map(|s| (i, s))
45        })
46        .collect();
47    scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
48    scored.into_iter().map(|(i, _)| i).collect()
49}
50
51/// Verdict returned by [`SearchList::handle_key`].
52#[derive(Debug, PartialEq, Eq)]
53pub enum KeyReaction {
54    Consumed,
55    Submit,
56    Cancel,
57    Intercepted(crate::keys::key_combo::KeyCombo),
58    Unhandled,
59}
60
61pub struct SearchList<R: SearchRow> {
62    source: Arc<dyn RowSource<R>>,
63    rows: Vec<R>,
64    /// Indices into `rows` in display order (after filtering/ranking).
65    display: Vec<usize>,
66    /// A synthetic, query-fresh, filter-exempt row pinned at visible position 0
67    /// (the "Create: <q>" affordance / saved-searches virtual entry). Held
68    /// separately from `rows` so it works regardless of delivery (one-shot
69    /// `Replace` or streamed `Push`) and refreshes on every query change. See
70    /// [`RowSource::leading_row`].
71    leading: Option<R>,
72    /// Index into the VISIBLE sequence `[leading?] ++ display` of the selected
73    /// item.
74    selected: Option<usize>,
75    /// Viewport offset: visible position of the first row on screen. Owned
76    /// here (not by a per-frame `ListState`) so mouse-wheel scrolling can move
77    /// the viewport directly; `render` writes it back after ratatui clamps it
78    /// to keep the selection visible.
79    offset: usize,
80    filter: Filter<R>,
81    query: String,
82    loader: LoadEngine<R>,
83    input: SingleLineInput,
84    autocomplete: Option<AutocompleteController>,
85    /// Key combos the caller wants to intercept before the engine acts.
86    intercept: Vec<KeyCombo>,
87    icons: Icons,
88    list_rect: Rect,
89    /// The host panel's full bounds, for wheel hit-testing: scroll events
90    /// anywhere within it scroll the list — header, query box, preview —
91    /// while clicks still hit-test against `list_rect` only. Empty (the
92    /// default) falls back to `list_rect`, so hosts that never record it
93    /// keep scroll-over-the-list-only behavior.
94    panel_rect: Rect,
95    /// Load generation whose rows are currently held. When a newer generation
96    /// (a requery / reload) delivers its first event, `poll` clears the stale
97    /// rows before applying it — required for streamed (`Push`) sources, which
98    /// would otherwise append onto a superseded load's rows.
99    applied_generation: u64,
100    /// Set when a SavedSearch suggestion was just accepted: the search's name,
101    /// for the host to pin as the saved-search breadcrumb. Read once via
102    /// [`take_accepted_saved_search`](Self::take_accepted_saved_search).
103    accepted_saved_search: Option<String>,
104}
105
106/// Mouse interaction result from [`SearchList::handle_mouse`].
107#[derive(Debug, PartialEq, Eq)]
108pub enum SearchMouse {
109    Selected(usize),
110    Activated(usize),
111    Scrolled,
112    None,
113}
114
115pub struct SearchListBuilder<R: SearchRow> {
116    source: Arc<dyn RowSource<R>>,
117    redraw: Arc<dyn Fn() + Send + Sync>,
118    initial_query: String,
119    filter: Filter<R>,
120    autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
121    intercept: Vec<KeyCombo>,
122    icons: Icons,
123    debounce: Option<std::time::Duration>,
124}
125
126impl<R: SearchRow> SearchList<R> {
127    pub fn builder(
128        source: impl RowSource<R>,
129        redraw: Arc<dyn Fn() + Send + Sync>,
130    ) -> SearchListBuilder<R> {
131        SearchListBuilder {
132            source: Arc::new(source),
133            redraw,
134            initial_query: String::new(),
135            filter: Filter::SourceOrder,
136            autocomplete: None,
137            intercept: Vec::new(),
138            icons: Icons::new(false),
139            debounce: None,
140        }
141    }
142
143    fn new(b: SearchListBuilder<R>) -> Self {
144        let mut loader = LoadEngine::new(b.redraw.clone());
145        loader.start(b.source.clone(), b.initial_query.clone());
146        let input = SingleLineInput::with_value(&b.initial_query);
147        let debounce = b.debounce;
148        let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
149            let mut ac =
150                AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
151                    disambiguate_header: false,
152                    apply_exclusion_zone: false,
153                    // The controller derives `allow_saved_search` from its mode
154                    // at detect time, so this seed value is not load-bearing.
155                    ..TriggerOptions::default()
156                });
157            if let Some(d) = debounce {
158                ac = ac.with_debounce(d);
159            }
160            ac.set_redraw_callback(b.redraw.clone());
161            ac
162        });
163        Self {
164            source: b.source,
165            rows: Vec::new(),
166            display: Vec::new(),
167            leading: None,
168            selected: None,
169            offset: 0,
170            filter: b.filter,
171            query: b.initial_query,
172            loader,
173            input,
174            autocomplete,
175            intercept: b.intercept,
176            icons: b.icons,
177            list_rect: Rect::default(),
178            panel_rect: Rect::default(),
179            applied_generation: 0,
180            accepted_saved_search: None,
181        }
182    }
183
184    pub fn poll(&mut self) {
185        let drained = self.loader.drain();
186        if !drained.is_empty() {
187            // A newer load delivered its first event(s): drop the prior load's
188            // rows so a streamed source starts from a clean slate (one-shot
189            // `Replace` overwrites anyway, but `Push` would otherwise append).
190            let current_gen = self.loader.generation();
191            if current_gen != self.applied_generation {
192                self.rows.clear();
193                self.selected = None;
194                self.offset = 0;
195                self.applied_generation = current_gen;
196            }
197        }
198        for ev in drained {
199            match ev {
200                LoadedInner::Replace(rows) => {
201                    self.rows = rows;
202                }
203                LoadedInner::Push(row) => {
204                    self.rows.push(row);
205                }
206                LoadedInner::Done => {}
207            }
208        }
209        self.recompute_display();
210        if self.selected.is_none() && self.visible_len() > 0 {
211            self.selected = Some(0);
212        }
213        if let Some(ac) = &mut self.autocomplete {
214            ac.poll_results();
215        }
216    }
217
218    /// Build a host snapshot from the current input state.
219    /// Only reads `self.input` so the result can be stored in a local
220    /// before taking `&mut self.autocomplete`, resolving the borrow conflict.
221    fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
222        let value = self.input.value().to_string();
223        let cursor_byte = self.input.cursor_byte();
224        let col = value[..cursor_byte.min(value.len())].chars().count();
225        host::SearchBoxHostSnapshot {
226            lines: vec![value],
227            cursor: (0, col),
228            caret_pos: self.input.last_caret_pos(),
229        }
230    }
231
232    fn clamp_selection(&mut self) {
233        let len = self.visible_len();
234        self.selected = if len == 0 {
235            None
236        } else {
237            Some(self.selected.unwrap_or(0).min(len - 1))
238        };
239    }
240
241    /// `1` when a leading row is pinned at visible position 0, else `0`.
242    fn leading_offset(&self) -> usize {
243        self.leading.is_some() as usize
244    }
245
246    /// Length of the visible sequence `[leading?] ++ display`.
247    pub fn visible_len(&self) -> usize {
248        self.leading_offset() + self.display.len()
249    }
250
251    /// Row at visible position `pos` in `[leading?] ++ display`.
252    fn visible_row(&self, pos: usize) -> Option<&R> {
253        if self.leading.is_some() && pos == 0 {
254            self.leading.as_ref()
255        } else {
256            self.rows
257                .get(*self.display.get(pos - self.leading_offset())?)
258        }
259    }
260
261    /// The source-delivered rows only (NOT the leading row). Prefer
262    /// [`visible_len`](Self::visible_len)/[`visible_rows`](Self::visible_rows)
263    /// for visible counts.
264    pub fn rows(&self) -> &[R] {
265        &self.rows
266    }
267
268    pub fn selected_row(&self) -> Option<&R> {
269        self.selected.and_then(|p| self.visible_row(p))
270    }
271
272    pub fn visible_rows(&self) -> Vec<&R> {
273        (0..self.visible_len())
274            .filter_map(|p| self.visible_row(p))
275            .collect()
276    }
277
278    pub fn query(&self) -> &str {
279        &self.query
280    }
281
282    /// Take the name of a just-accepted saved search, if any. The host calls
283    /// this after a `Consumed` key to learn whether to pin (or refresh) the
284    /// saved-search breadcrumb. Returns `None` once read.
285    pub fn take_accepted_saved_search(&mut self) -> Option<String> {
286        self.accepted_saved_search.take()
287    }
288
289    /// The visible text in the query input widget. Test-only: lets callers
290    /// assert the input bar reflects a programmatic query change.
291    #[cfg(test)]
292    pub(crate) fn input_value(&self) -> &str {
293        self.input.value()
294    }
295    pub fn is_loading(&self) -> bool {
296        self.loader.loading
297    }
298
299    /// Set the query programmatically: updates the visible input widget (cursor
300    /// to end) AND the query string, then starts a load (for `reload_on_query`
301    /// sources) or recomputes the display. This is the setter every external
302    /// caller wants — a saved search applied, a sort directive rewritten — so
303    /// the input bar always reflects the query. The interactive keystroke path
304    /// uses [`sync_query_from_input`](Self::sync_query_from_input) instead,
305    /// because the input widget already holds the typed text (and its cursor
306    /// must not jump back to the end on every keystroke).
307    pub fn set_query(&mut self, q: impl Into<String>) {
308        let q = q.into();
309        self.input.set_value(q.clone());
310        self.query = q;
311        self.requery();
312    }
313
314    /// Pull the query string FROM the input widget without touching the widget
315    /// (so the cursor stays put), then reload/recompute. The keystroke and
316    /// autocomplete-accept paths use this after they have already mutated the
317    /// input in place.
318    fn sync_query_from_input(&mut self) {
319        self.query = self.input.value().to_string();
320        self.requery();
321    }
322
323    /// Start a fresh load for `reload_on_query` sources, else recompute the
324    /// local display. The generation guard in `LoadEngine` drops stale results.
325    fn requery(&mut self) {
326        if self.source.reload_on_query() {
327            self.loader.start(self.source.clone(), self.query.clone());
328        } else {
329            self.recompute_display();
330        }
331    }
332
333    /// Re-run the source load for the current query (e.g. after a mutation).
334    pub fn reload(&mut self) {
335        self.loader.start(self.source.clone(), self.query.clone());
336    }
337
338    pub fn select_next(&mut self) {
339        let n = self.visible_len();
340        if n == 0 {
341            return;
342        }
343        self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
344    }
345
346    pub fn select_prev(&mut self) {
347        if self.visible_len() == 0 {
348            return;
349        }
350        self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
351    }
352
353    /// Largest useful viewport offset: the first visible position from which
354    /// the rows through the end still fill the recorded list rect. Scrolling
355    /// past it would leave blank space below the last row, so
356    /// [`scroll_down`](Self::scroll_down) clamps to it.
357    fn max_scroll_offset(&self) -> usize {
358        let viewport = self.list_rect.height as usize;
359        let n = self.visible_len();
360        if viewport == 0 || n == 0 {
361            return 0;
362        }
363        let mut budget = viewport;
364        let mut first = n;
365        while first > 0 {
366            let h = self
367                .visible_row(first - 1)
368                .map(|r| r.visual_height() as usize)
369                .unwrap_or(1);
370            if h > budget {
371                break;
372            }
373            budget -= h;
374            first -= 1;
375        }
376        first.min(n - 1)
377    }
378
379    /// Scroll the viewport one row down, carrying the selection along so the
380    /// selected row keeps its on-screen position. No-op once the last row is
381    /// in view — the shared mouse-wheel behavior for every list surface.
382    pub fn scroll_down(&mut self) {
383        let n = self.visible_len();
384        if n == 0 || self.offset >= self.max_scroll_offset() {
385            return;
386        }
387        self.offset += 1;
388        self.selected = self.selected.map(|i| (i + 1).min(n - 1));
389    }
390
391    /// Scroll the viewport one row up, carrying the selection along so the
392    /// selected row keeps its on-screen position. No-op at the top.
393    pub fn scroll_up(&mut self) {
394        if self.offset == 0 {
395            return;
396        }
397        self.offset -= 1;
398        self.selected = self.selected.map(|i| i.saturating_sub(1));
399    }
400
401    /// The current viewport offset. Test-only: lets scroll tests assert the
402    /// viewport moved while the selection kept its screen position.
403    #[cfg(test)]
404    pub(crate) fn scroll_offset(&self) -> usize {
405        self.offset
406    }
407
408    pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
409        use ratatui::crossterm::event::{KeyCode, KeyModifiers};
410
411        // Caller-registered intercepts get first crack — before autocomplete or
412        // any built-in binding.
413        if let Some(combo) = crate::keys::key_event_to_combo(key)
414            && self.intercept.contains(&combo)
415        {
416            return KeyReaction::Intercepted(combo);
417        }
418
419        // Autocomplete popup gets first crack when open. Build snapshot before
420        // taking &mut self.autocomplete to avoid borrow-checker conflict
421        // (snapshot only reads self.input).
422        if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
423            let snap = self.autocomplete_snapshot();
424            if let Some(ac) = &mut self.autocomplete {
425                match ac.handle_key(*key, &snap) {
426                    HandleKeyOutcome::Accepted(action) => {
427                        self.input.replace_range_bytes(
428                            action.range.clone(),
429                            &action.new_text,
430                            action.new_cursor_byte,
431                        );
432                        // Stash any accepted SavedSearch name for the host's
433                        // breadcrumb (`None` for every other kind). The host
434                        // reads it on this same `Consumed`, so a plain assign
435                        // never clobbers an unread value.
436                        self.accepted_saved_search = action.saved_search_name;
437                        self.sync_query_from_input();
438                        return KeyReaction::Consumed;
439                    }
440                    HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
441                        return KeyReaction::Consumed;
442                    }
443                    HandleKeyOutcome::NotHandled => {}
444                }
445            }
446        }
447
448        match key.code {
449            KeyCode::Up => {
450                self.select_prev();
451                return KeyReaction::Consumed;
452            }
453            KeyCode::Down => {
454                self.select_next();
455                return KeyReaction::Consumed;
456            }
457            KeyCode::Enter => return KeyReaction::Submit,
458            KeyCode::Esc => return KeyReaction::Cancel,
459            _ => {}
460        }
461        // Drop Ctrl/Alt-modified chars so combos don't leak as text.
462        if let KeyCode::Char(_) = key.code {
463            let non_shift = key.modifiers - KeyModifiers::SHIFT;
464            if !non_shift.is_empty() {
465                return KeyReaction::Unhandled;
466            }
467        }
468        let outcome = self.input.handle_key(key);
469        // Sync/refresh/close the autocomplete popup based on the input outcome.
470        // Build snapshot before taking &mut self.autocomplete (same borrow trick).
471        let snap = self.autocomplete_snapshot();
472        match outcome {
473            InputOutcome::Changed => {
474                if let Some(ac) = &mut self.autocomplete {
475                    ac.sync(&snap);
476                }
477            }
478            InputOutcome::Consumed => {
479                if let Some(ac) = &mut self.autocomplete {
480                    ac.refresh_if_open(&snap);
481                }
482            }
483            InputOutcome::Cancel | InputOutcome::Submit => {
484                if let Some(ac) = &mut self.autocomplete {
485                    ac.close();
486                }
487            }
488            InputOutcome::NotConsumed => {}
489        }
490        match outcome {
491            InputOutcome::Changed => {
492                self.sync_query_from_input();
493                KeyReaction::Consumed
494            }
495            InputOutcome::Consumed => KeyReaction::Consumed,
496            InputOutcome::Submit => KeyReaction::Submit,
497            InputOutcome::Cancel => KeyReaction::Cancel,
498            InputOutcome::NotConsumed => KeyReaction::Unhandled,
499        }
500    }
501
502    pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
503        self.input.render(
504            f,
505            area,
506            Style::default()
507                .fg(theme.fg.to_ratatui())
508                .bg(theme.bg_panel.to_ratatui()),
509            0,
510            focused,
511        );
512    }
513
514    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
515        self.poll();
516        let sel = self.selected;
517        let items: Vec<ListItem> = (0..self.visible_len())
518            .filter_map(|pos| {
519                self.visible_row(pos)
520                    .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
521            })
522            .collect();
523        let mut state = ListState::default().with_offset(self.offset);
524        state.select(self.selected);
525        let list =
526            List::new(items).highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
527        f.render_stateful_widget(list, area, &mut state);
528        // Read the offset back: ratatui clamps it and keeps the selection in
529        // view (keyboard moves included), so the stored offset always matches
530        // what is actually on screen.
531        self.offset = state.offset();
532        self.list_rect = area;
533        let _ = focused;
534    }
535
536    /// Override the rect used for mouse hit-testing. The recorded rect must be
537    /// the area where list ITEMS actually render — row 0 is the first item, NOT
538    /// a block border. Hosts that draw the list inside a bordered block pass the
539    /// block's INNER rect; borderless hosts pass the list area directly. The
540    /// recorded rect and the rendered-items rect MUST be identical, so
541    /// [`handle_mouse`] maps a click at `row` to visual offset `row - rect.y`.
542    ///
543    /// [`handle_mouse`]: Self::handle_mouse
544    pub fn set_list_rect(&mut self, rect: Rect) {
545        self.list_rect = rect;
546    }
547
548    /// Record the host panel's full bounds so the wheel scrolls the list from
549    /// anywhere within the panel — header, query box, preview — not just over
550    /// the list items. Hosts call this each render with the same rect they
551    /// were drawn into. Never set = wheel hit-tests `list_rect` only.
552    pub fn set_panel_rect(&mut self, rect: Rect) {
553        self.panel_rect = rect;
554    }
555
556    pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
557        if let Some(ac) = &mut self.autocomplete {
558            ac.poll_results();
559            let caret = self.input.last_caret_pos();
560            if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
561                state.anchor = anchor;
562            }
563            if let Some(state) = ac.state() {
564                crate::components::autocomplete::render(f, state, clamp, theme);
565            }
566        }
567    }
568
569    pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
570        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
571        use ratatui::layout::Position;
572        // Any mouse interaction dismisses an open autocomplete popup (matches
573        // the old modal: a click on the preview/border closes a stale popup).
574        if let Some(ac) = &mut self.autocomplete {
575            ac.close();
576        }
577        let pos = Position {
578            x: m.column,
579            y: m.row,
580        };
581        // The wheel is hit-tested against the host's panel bounds (when
582        // recorded), so scrolling works from anywhere within the panel;
583        // clicks below keep hit-testing the list rect only.
584        if matches!(
585            m.kind,
586            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
587        ) {
588            let bounds = if self.panel_rect.is_empty() {
589                self.list_rect
590            } else {
591                self.panel_rect
592            };
593            if !bounds.contains(pos) {
594                return SearchMouse::None;
595            }
596            if m.kind == MouseEventKind::ScrollUp {
597                self.scroll_up();
598            } else {
599                self.scroll_down();
600            }
601            return SearchMouse::Scrolled;
602        }
603        let r = self.list_rect;
604        if !r.contains(pos) {
605            return SearchMouse::None;
606        }
607        match m.kind {
608            MouseEventKind::Down(MouseButton::Left) if m.row >= r.y => {
609                let target_visual = m.row - r.y; // 0-based visual offset; row 0 = first item
610                let mut acc: u16 = 0;
611                let mut hit: Option<usize> = None;
612                // Walk the VISIBLE sequence (leading row at position 0, then the
613                // display rows) starting at the viewport offset — screen row 0
614                // is the item at `offset`, not visible position 0 — so visual
615                // offsets map to the positions actually on screen.
616                for pos in self.offset..self.visible_len() {
617                    let h = self
618                        .visible_row(pos)
619                        .map(|r| r.visual_height())
620                        .unwrap_or(1);
621                    if target_visual < acc + h {
622                        hit = Some(pos);
623                        break;
624                    }
625                    acc += h;
626                }
627                if let Some(pos) = hit {
628                    let prev = self.selected;
629                    self.selected = Some(pos);
630                    return if prev == Some(pos) {
631                        SearchMouse::Activated(pos)
632                    } else {
633                        SearchMouse::Selected(pos)
634                    };
635                }
636                SearchMouse::None
637            }
638            _ => SearchMouse::None,
639        }
640    }
641
642    fn recompute_display(&mut self) {
643        let q = self.query.trim();
644        // The leading row is query-fresh: rebuilt on every poll AND on every
645        // local-filter `set_query`, so it never goes stale.
646        self.leading = self.source.leading_row(q);
647        let mut idx: Vec<usize> = match &self.filter {
648            Filter::SourceOrder => (0..self.rows.len()).collect(),
649            Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
650            Filter::Fuzzy => fuzzy_indices(&self.rows, q),
651            Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
652            Filter::Rank(f) => {
653                let f = f.clone();
654                f(&self.rows, q)
655            }
656        };
657        // Filter-exempt rows (match_text() == None: Up / Create / virtual pinned)
658        // are always present; prepend any that the filter dropped.
659        for i in 0..self.rows.len() {
660            if self.rows[i].match_text().is_none() && !idx.contains(&i) {
661                idx.insert(0, i);
662            }
663        }
664        self.display = idx;
665        self.clamp_selection();
666    }
667
668    #[cfg(test)]
669    pub(crate) async fn poll_until_idle(&mut self) {
670        // In-memory sources settle on the first poll (no sleep paid). Vault-backed
671        // sources run their read on a worker/blocking thread, which can starve
672        // under the full parallel suite — so once still loading, sleep a little
673        // between polls and use a generous ceiling. Early-breaks the instant the
674        // load lands, keeping the common (in-memory) path fast.
675        for _ in 0..600 {
676            tokio::task::yield_now().await;
677            self.poll();
678            if !self.is_loading() {
679                break;
680            }
681            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
682        }
683        self.poll();
684    }
685}
686
687impl<R: SearchRow> SearchListBuilder<R> {
688    pub fn initial_query(mut self, q: impl Into<String>) -> Self {
689        self.initial_query = q.into();
690        self
691    }
692    pub fn filter(mut self, f: Filter<R>) -> Self {
693        self.filter = f;
694        self
695    }
696    pub fn autocomplete(
697        mut self,
698        suggestions: Arc<dyn SuggestionSource>,
699        mode: AutocompleteMode,
700    ) -> Self {
701        self.autocomplete = Some((suggestions, mode));
702        self
703    }
704    pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
705        self.intercept = v;
706        self
707    }
708    pub fn icons(mut self, icons: Icons) -> Self {
709        self.icons = icons;
710        self
711    }
712    /// Override the autocomplete controller's debounce. Tests use
713    /// `Duration::ZERO` to get suggestions without waiting on the debounce timer.
714    pub fn debounce(mut self, d: std::time::Duration) -> Self {
715        self.debounce = Some(d);
716        self
717    }
718    pub fn build(self) -> SearchList<R> {
719        SearchList::new(self)
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::adapters::{
726        ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow, VecSource,
727        VecSourceWithLead,
728    };
729    use super::*;
730    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
731
732    fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
733        std::sync::Arc::new(|| {})
734    }
735
736    fn key(c: KeyCode) -> KeyEvent {
737        KeyEvent::new(c, KeyModifiers::NONE)
738    }
739
740    fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
741        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
742        MouseEvent {
743            kind: MouseEventKind::Down(MouseButton::Left),
744            column: col,
745            row,
746            modifiers: KeyModifiers::NONE,
747        }
748    }
749
750    #[derive(Clone, Debug, PartialEq)]
751    struct TallRow {
752        name: String,
753        height: u16,
754    }
755    impl SearchRow for TallRow {
756        fn to_list_item(
757            &self,
758            _t: &crate::settings::themes::Theme,
759            _i: &crate::settings::icons::Icons,
760            _s: bool,
761        ) -> ratatui::widgets::ListItem<'static> {
762            ratatui::widgets::ListItem::new(self.name.clone())
763        }
764        fn visual_height(&self) -> u16 {
765            self.height
766        }
767        fn match_text(&self) -> Option<&str> {
768            Some(&self.name)
769        }
770    }
771    struct TallSource(Vec<TallRow>);
772    #[async_trait::async_trait]
773    impl RowSource<TallRow> for TallSource {
774        async fn load(&self, _q: &str, emit: Emit<TallRow>) {
775            emit.replace(self.0.clone());
776        }
777    }
778
779    #[tokio::test]
780    async fn mouse_maps_visual_row_to_display_index_by_height() {
781        // Row 0 occupies 3 visual rows, row 1 occupies 1. The recorded list rect
782        // is the rendered-items area: row 0 == the FIRST item (no border row).
783        let src = TallSource(vec![
784            TallRow {
785                name: "a".into(),
786                height: 3,
787            },
788            TallRow {
789                name: "b".into(),
790                height: 1,
791            },
792        ]);
793        let mut list = SearchList::builder(src, noop_redraw()).build();
794        list.poll_until_idle().await;
795        // Force the recorded list rect (render not run in test): items start at y=0.
796        list.set_list_rect(ratatui::layout::Rect {
797            x: 0,
798            y: 0,
799            width: 20,
800            height: 10,
801        });
802        // "a" occupies rows 0..=2; row 3 is the FIRST row of "b".
803        let m = mouse_down_at(2, 3);
804        assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
805        assert_eq!(list.selected_row().unwrap().name, "b");
806        // A click at row 1 = within "a" (rows 0..=2) -> display index 0.
807        let m = mouse_down_at(2, 1);
808        list.handle_mouse(&m);
809        assert_eq!(list.selected_row().unwrap().name, "a");
810    }
811
812    // Mouse-wheel scrolling moves the VIEWPORT, carrying the selection along
813    // so the selected row keeps its on-screen position (selected - offset is
814    // invariant) — unlike keyboard navigation, which moves the selection.
815    #[tokio::test]
816    async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
817        let src = VecSource {
818            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
819            reload: true,
820        };
821        let mut list = SearchList::builder(src, noop_redraw()).build();
822        list.poll_until_idle().await;
823        // Viewport shows 4 of the 10 rows.
824        list.set_list_rect(ratatui::layout::Rect {
825            x: 0,
826            y: 0,
827            width: 20,
828            height: 4,
829        });
830        // Move the selection to screen row 2 first.
831        list.select_next();
832        list.select_next();
833        assert_eq!(list.selected_row().unwrap().name, "row2");
834
835        let scroll = |kind| ratatui::crossterm::event::MouseEvent {
836            kind,
837            column: 1,
838            row: 1,
839            modifiers: KeyModifiers::NONE,
840        };
841        use ratatui::crossterm::event::MouseEventKind;
842
843        // Scroll down: viewport and selection move together.
844        assert_eq!(
845            list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
846            SearchMouse::Scrolled
847        );
848        assert_eq!(list.scroll_offset(), 1);
849        assert_eq!(list.selected_row().unwrap().name, "row3");
850
851        // Scroll back up: both return.
852        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
853        assert_eq!(list.scroll_offset(), 0);
854        assert_eq!(list.selected_row().unwrap().name, "row2");
855
856        // At the top, scrolling up is a no-op (selection does NOT move).
857        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
858        assert_eq!(list.scroll_offset(), 0);
859        assert_eq!(list.selected_row().unwrap().name, "row2");
860
861        // Scrolling down clamps once the last row is in view: 10 rows in a
862        // 4-row viewport → max offset 6.
863        for _ in 0..20 {
864            list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
865        }
866        assert_eq!(list.scroll_offset(), 6);
867        assert_eq!(list.selected_row().unwrap().name, "row8");
868        // The selection kept its screen row through the clamped scroll.
869        // (row2 at offset 0 → screen row 2; row8 at offset 6 → screen row 2.)
870    }
871
872    // The wheel hit-tests the recorded PANEL rect: scrolling over the host's
873    // header/query box (outside the list rect) still scrolls the list. Without
874    // a panel rect it falls back to the list rect only.
875    #[tokio::test]
876    async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
877        let src = VecSource {
878            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
879            reload: true,
880        };
881        let mut list = SearchList::builder(src, noop_redraw()).build();
882        list.poll_until_idle().await;
883        // List items render at y 5..9; the panel spans y 0..20.
884        list.set_list_rect(ratatui::layout::Rect {
885            x: 0,
886            y: 5,
887            width: 20,
888            height: 4,
889        });
890        let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
891            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
892            column: 1,
893            row,
894            modifiers: KeyModifiers::NONE,
895        };
896        // No panel rect: a scroll over the header (y=1) misses.
897        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
898        assert_eq!(list.scroll_offset(), 0);
899        list.set_panel_rect(ratatui::layout::Rect {
900            x: 0,
901            y: 0,
902            width: 20,
903            height: 20,
904        });
905        // With the panel rect, the same scroll-over-header scrolls the list.
906        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
907        assert_eq!(list.scroll_offset(), 1);
908        // Clicks still hit-test the LIST rect only: a click on the header
909        // (inside the panel, outside the list) selects nothing.
910        let before = list.selected_row().unwrap().name.clone();
911        assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
912        assert_eq!(list.selected_row().unwrap().name, before);
913    }
914
915    // Regression: the click hit-test must account for the viewport offset —
916    // after wheel scrolling, screen row 0 is the item at `offset`, not
917    // visible position 0.
918    #[tokio::test]
919    async fn click_after_scroll_selects_the_clicked_row() {
920        let src = VecSource {
921            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
922            reload: true,
923        };
924        let mut list = SearchList::builder(src, noop_redraw()).build();
925        list.poll_until_idle().await;
926        list.set_list_rect(ratatui::layout::Rect {
927            x: 0,
928            y: 0,
929            width: 20,
930            height: 4,
931        });
932        let scroll_down = ratatui::crossterm::event::MouseEvent {
933            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
934            column: 1,
935            row: 1,
936            modifiers: KeyModifiers::NONE,
937        };
938        for _ in 0..3 {
939            list.handle_mouse(&scroll_down);
940        }
941        assert_eq!(list.scroll_offset(), 3);
942        // Screen row 2 shows visible position offset + 2 = 5.
943        assert!(matches!(
944            list.handle_mouse(&mouse_down_at(2, 2)),
945            SearchMouse::Selected(5)
946        ));
947        assert_eq!(list.selected_row().unwrap().name, "row5");
948        // Screen row 0 shows the item at the offset itself.
949        list.handle_mouse(&mouse_down_at(2, 0));
950        assert_eq!(list.selected_row().unwrap().name, "row3");
951    }
952
953    #[tokio::test]
954    async fn initial_load_populates_rows() {
955        let src = VecSource {
956            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
957            reload: true,
958        };
959        let mut list = SearchList::builder(src, noop_redraw()).build();
960        list.poll_until_idle().await;
961        assert_eq!(list.rows().len(), 2);
962        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
963    }
964
965    #[tokio::test]
966    async fn requery_supersedes_and_reloads() {
967        let src = VecSource {
968            rows: vec![
969                TestRow::new("alpha"),
970                TestRow::new("alps"),
971                TestRow::new("beta"),
972            ],
973            reload: true,
974        };
975        let mut list = SearchList::builder(src, noop_redraw()).build();
976        list.poll_until_idle().await;
977        assert_eq!(list.rows().len(), 3);
978        list.set_query("alp");
979        list.poll_until_idle().await;
980        assert_eq!(list.rows().len(), 2); // alpha, alps
981        assert!(list.rows().iter().all(|r| r.name.contains("alp")));
982    }
983
984    #[tokio::test]
985    async fn arrows_navigate_and_enter_submits() {
986        let src = VecSource {
987            rows: vec![TestRow::new("a"), TestRow::new("b")],
988            reload: true,
989        };
990        let mut list = SearchList::builder(src, noop_redraw()).build();
991        list.poll_until_idle().await;
992        assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
993        assert_eq!(list.selected_row().unwrap().name, "b");
994        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
995        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
996    }
997
998    #[tokio::test]
999    async fn typing_a_char_changes_query() {
1000        let src = VecSource {
1001            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1002            reload: true,
1003        };
1004        let mut list = SearchList::builder(src, noop_redraw()).build();
1005        list.poll_until_idle().await;
1006        assert_eq!(
1007            list.handle_key(&key(KeyCode::Char('a'))),
1008            KeyReaction::Consumed
1009        );
1010        list.poll_until_idle().await;
1011        assert_eq!(list.query(), "a");
1012    }
1013
1014    #[tokio::test]
1015    async fn rank_filter_orders_by_closure() {
1016        let src = VecSource {
1017            rows: vec![
1018                TestRow::new("todo"),
1019                TestRow::new("today"),
1020                TestRow::new("misc"),
1021            ],
1022            reload: false,
1023        };
1024        let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1025            let mut idx: Vec<usize> = (0..rows.len())
1026                .filter(|&i| rows[i].name.contains(q))
1027                .collect();
1028            idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1029            idx
1030        });
1031        let mut list = SearchList::builder(src, noop_redraw())
1032            .filter(Filter::Rank(rank))
1033            .build();
1034        list.poll_until_idle().await;
1035        list.set_query("today");
1036        list.poll();
1037        assert_eq!(list.selected_row().unwrap().name, "today");
1038    }
1039
1040    #[tokio::test]
1041    async fn fuzzy_filter_narrows_local_set() {
1042        let src = VecSource {
1043            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1044            reload: false,
1045        };
1046        let mut list = SearchList::builder(src, noop_redraw())
1047            .filter(Filter::Fuzzy)
1048            .build();
1049        list.poll_until_idle().await;
1050        list.set_query("alp");
1051        list.poll();
1052        assert_eq!(list.visible_rows().len(), 1);
1053        assert_eq!(list.selected_row().unwrap().name, "alpha");
1054    }
1055
1056    #[tokio::test]
1057    async fn streamed_rows_arrive_then_done_and_filter_locally() {
1058        let src = ScriptedStreamSource {
1059            batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1060        };
1061        let mut list = SearchList::builder(src, noop_redraw())
1062            .filter(Filter::Fuzzy)
1063            .build();
1064        list.poll_until_idle().await;
1065        assert_eq!(list.rows().len(), 2);
1066        assert!(!list.is_loading());
1067        list.set_query("alp");
1068        list.poll();
1069        assert_eq!(list.visible_rows().len(), 1);
1070    }
1071
1072    #[tokio::test]
1073    async fn source_order_unfiltered_passthrough() {
1074        let src = VecSource {
1075            rows: vec![TestRow::new("a"), TestRow::new("b")],
1076            reload: true,
1077        };
1078        let mut list = SearchList::builder(src, noop_redraw()).build(); // default Filter::SourceOrder
1079        list.poll_until_idle().await;
1080        assert_eq!(list.visible_rows().len(), 2);
1081        assert_eq!(list.selected_row().unwrap().name, "a");
1082    }
1083
1084    #[tokio::test]
1085    async fn intercepted_combo_returns_intercepted_without_acting() {
1086        let src = VecSource {
1087            rows: vec![TestRow::new("a")],
1088            reload: true,
1089        };
1090        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1091        let mut list = SearchList::builder(src, noop_redraw())
1092            .intercept(vec![combo])
1093            .build();
1094        list.poll_until_idle().await;
1095        // Enter is intercepted: engine returns Intercepted, does NOT submit/act.
1096        assert_eq!(
1097            list.handle_key(&key(KeyCode::Enter)),
1098            KeyReaction::Intercepted(combo)
1099        );
1100    }
1101
1102    #[tokio::test]
1103    async fn autocomplete_accept_rewrites_query_without_vault() {
1104        struct Mem;
1105        #[async_trait::async_trait]
1106        impl crate::components::search_list::SuggestionSource for Mem {
1107            async fn notes_by_prefix(
1108                &self,
1109                _p: &str,
1110                _n: usize,
1111            ) -> Vec<crate::components::search_list::SuggestionItem> {
1112                vec![]
1113            }
1114            async fn tags_by_prefix(
1115                &self,
1116                p: &str,
1117                _n: usize,
1118            ) -> Vec<crate::components::search_list::SuggestionItem> {
1119                if "projects".starts_with(p) {
1120                    vec![crate::components::search_list::SuggestionItem::plain(
1121                        "projects",
1122                    )]
1123                } else {
1124                    vec![]
1125                }
1126            }
1127        }
1128        let src = VecSource {
1129            rows: vec![],
1130            reload: true,
1131        };
1132        let mut list = SearchList::builder(src, noop_redraw())
1133            .autocomplete(
1134                std::sync::Arc::new(Mem),
1135                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1136            )
1137            .debounce(std::time::Duration::ZERO)
1138            .build();
1139        for c in ['#', 'p', 'r', 'o'] {
1140            let _ = list.handle_key(&key(KeyCode::Char(c)));
1141        }
1142        for _ in 0..50 {
1143            tokio::task::yield_now().await;
1144            list.poll();
1145        }
1146        let _ = list.handle_key(&key(KeyCode::Tab));
1147        assert_eq!(list.query(), "#projects");
1148    }
1149
1150    // Accepting a SavedSearch suggestion expands the whole field to the
1151    // stored query AND exposes the accepted name (for the breadcrumb) via
1152    // `take_accepted_saved_search`.
1153    #[tokio::test]
1154    async fn accepting_saved_search_expands_query_and_exposes_name() {
1155        struct Mem;
1156        #[async_trait::async_trait]
1157        impl crate::components::search_list::SuggestionSource for Mem {
1158            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1159                vec![]
1160            }
1161            async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1162                vec![]
1163            }
1164            async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1165                if "todo-week".starts_with(p) {
1166                    vec![SuggestionItem {
1167                        display: "todo-week".into(),
1168                        secondary: Some("#todo ^modified".into()),
1169                    }]
1170                } else {
1171                    vec![]
1172                }
1173            }
1174        }
1175        let src = VecSource {
1176            rows: vec![],
1177            reload: true,
1178        };
1179        let mut list = SearchList::builder(src, noop_redraw())
1180            .autocomplete(
1181                std::sync::Arc::new(Mem),
1182                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1183            )
1184            .debounce(std::time::Duration::ZERO)
1185            .build();
1186        for c in ['?', 't', 'o'] {
1187            let _ = list.handle_key(&key(KeyCode::Char(c)));
1188        }
1189        for _ in 0..50 {
1190            tokio::task::yield_now().await;
1191            list.poll();
1192        }
1193        let _ = list.handle_key(&key(KeyCode::Tab));
1194        // Whole field expanded to the stored query.
1195        assert_eq!(list.query(), "#todo ^modified");
1196        // The accepted name is exposed once, then cleared.
1197        assert_eq!(
1198            list.take_accepted_saved_search().as_deref(),
1199            Some("todo-week")
1200        );
1201        assert_eq!(list.take_accepted_saved_search(), None);
1202    }
1203
1204    // Regression: Enter (not just Tab) must accept an open autocomplete popup,
1205    // and the engine must report Consumed — NOT Submit — so a host does not
1206    // mistake the accept for a list submit. (A QueryPanel Enter pre-check used
1207    // to swallow this, breaking accept-on-Enter in the right sidebar.)
1208    #[tokio::test]
1209    async fn enter_accepts_open_popup_and_reports_consumed() {
1210        struct Mem;
1211        #[async_trait::async_trait]
1212        impl crate::components::search_list::SuggestionSource for Mem {
1213            async fn notes_by_prefix(
1214                &self,
1215                _p: &str,
1216                _n: usize,
1217            ) -> Vec<crate::components::search_list::SuggestionItem> {
1218                vec![]
1219            }
1220            async fn tags_by_prefix(
1221                &self,
1222                p: &str,
1223                _n: usize,
1224            ) -> Vec<crate::components::search_list::SuggestionItem> {
1225                if "projects".starts_with(p) {
1226                    vec![crate::components::search_list::SuggestionItem::plain(
1227                        "projects",
1228                    )]
1229                } else {
1230                    vec![]
1231                }
1232            }
1233        }
1234        let src = VecSource {
1235            rows: vec![],
1236            reload: true,
1237        };
1238        let mut list = SearchList::builder(src, noop_redraw())
1239            .autocomplete(
1240                std::sync::Arc::new(Mem),
1241                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1242            )
1243            .debounce(std::time::Duration::ZERO)
1244            .build();
1245        for c in ['#', 'p', 'r', 'o'] {
1246            let _ = list.handle_key(&key(KeyCode::Char(c)));
1247        }
1248        for _ in 0..50 {
1249            tokio::task::yield_now().await;
1250            list.poll();
1251        }
1252        // Popup is open: Enter accepts the suggestion and reports Consumed.
1253        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1254        assert_eq!(list.query(), "#projects");
1255        // Popup now closed: a second Enter falls through to Submit.
1256        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1257    }
1258
1259    // Regression (P0): a STREAMED source (sidebar shape) supplies a query-fresh
1260    // leading row. It must appear at visible position 0 even though rows arrive
1261    // via Push (never Replace), be present when the query matches no streamed
1262    // row, and refresh when the query changes (reload_on_query() == false).
1263    #[tokio::test]
1264    async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1265        let src = ScriptedStreamLeadSource {
1266            items: vec!["alpha".into(), "beta".into()],
1267        };
1268        let mut list = SearchList::builder(src, noop_redraw())
1269            .filter(Filter::Fuzzy)
1270            .initial_query("zz")
1271            .build();
1272        list.poll_until_idle().await;
1273        // Leading present even though "zz" matches no streamed Item.
1274        let vis = list.visible_rows();
1275        assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1276        assert_eq!(list.visible_len(), 1); // just the leading; no Item matches
1277        // Query-fresh: changing the query rebuilds the leading and re-filters.
1278        list.set_query("alp");
1279        list.poll();
1280        let vis = list.visible_rows();
1281        assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1282        assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1283        assert_eq!(list.visible_len(), 2);
1284        // Empty query: leading disappears, both Items show.
1285        list.set_query("");
1286        list.poll();
1287        assert!(
1288            list.visible_rows()
1289                .iter()
1290                .all(|r| matches!(r, StreamRow::Item(_)))
1291        );
1292        assert_eq!(list.visible_len(), 2);
1293    }
1294
1295    // Regression guard for the saved-searches virtual entry: a one-shot
1296    // (Replace) source with a leading row still pins it at position 0.
1297    #[tokio::test]
1298    async fn oneshot_source_leading_row_still_works() {
1299        let src = VecSourceWithLead {
1300            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1301        };
1302        let mut list = SearchList::builder(src, noop_redraw())
1303            .filter(Filter::Fuzzy)
1304            .initial_query("alp")
1305            .build();
1306        list.poll_until_idle().await;
1307        let vis = list.visible_rows();
1308        assert_eq!(vis[0].name, "create:alp");
1309        assert_eq!(vis[1].name, "alpha");
1310        assert_eq!(list.visible_len(), 2);
1311    }
1312
1313    // Selection walks the VISIBLE sequence: position 0 is the leading row, and
1314    // select_next steps from the leading to the first real row.
1315    #[tokio::test]
1316    async fn selection_includes_leading_at_position_zero() {
1317        let src = VecSourceWithLead {
1318            rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1319        };
1320        let mut list = SearchList::builder(src, noop_redraw())
1321            .filter(Filter::Fuzzy)
1322            .initial_query("alp")
1323            .build();
1324        list.poll_until_idle().await;
1325        // Auto-selected position 0 -> the leading.
1326        assert_eq!(list.selected_row().unwrap().name, "create:alp");
1327        list.handle_key(&key(KeyCode::Down));
1328        assert_eq!(list.selected_row().unwrap().name, "alpha");
1329    }
1330
1331    // A source with NO leading row has no off-by-one: visible_len == display.
1332    #[tokio::test]
1333    async fn no_leading_row_visible_len_matches_display() {
1334        let src = VecSource {
1335            rows: vec![TestRow::new("a"), TestRow::new("b")],
1336            reload: true,
1337        };
1338        let mut list = SearchList::builder(src, noop_redraw()).build();
1339        list.poll_until_idle().await;
1340        assert_eq!(list.visible_len(), 2);
1341        assert_eq!(list.visible_rows().len(), 2);
1342        assert_eq!(list.selected_row().unwrap().name, "a");
1343    }
1344}