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