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