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