Skip to main content

kimun_notes/components/search_list/
mod.rs

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