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    filter: Filter<R>,
76    query: String,
77    loader: LoadEngine<R>,
78    input: SingleLineInput,
79    autocomplete: Option<AutocompleteController>,
80    /// Key combos the caller wants to intercept before the engine acts.
81    intercept: Vec<KeyCombo>,
82    icons: Icons,
83    list_rect: Rect,
84    /// Load generation whose rows are currently held. When a newer generation
85    /// (a requery / reload) delivers its first event, `poll` clears the stale
86    /// rows before applying it — required for streamed (`Push`) sources, which
87    /// would otherwise append onto a superseded load's rows.
88    applied_generation: u64,
89    /// Set when a SavedSearch suggestion was just accepted: the search's name,
90    /// for the host to pin as the saved-search breadcrumb. Read once via
91    /// [`take_accepted_saved_search`](Self::take_accepted_saved_search). See
92    /// `adr/0006`.
93    accepted_saved_search: Option<String>,
94}
95
96/// Mouse interaction result from [`SearchList::handle_mouse`].
97#[derive(Debug, PartialEq, Eq)]
98pub enum SearchMouse {
99    Selected(usize),
100    Activated(usize),
101    Scrolled,
102    None,
103}
104
105pub struct SearchListBuilder<R: SearchRow> {
106    source: Arc<dyn RowSource<R>>,
107    redraw: Arc<dyn Fn() + Send + Sync>,
108    initial_query: String,
109    filter: Filter<R>,
110    autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
111    intercept: Vec<KeyCombo>,
112    icons: Icons,
113    debounce: Option<std::time::Duration>,
114}
115
116impl<R: SearchRow> SearchList<R> {
117    pub fn builder(
118        source: impl RowSource<R>,
119        redraw: Arc<dyn Fn() + Send + Sync>,
120    ) -> SearchListBuilder<R> {
121        SearchListBuilder {
122            source: Arc::new(source),
123            redraw,
124            initial_query: String::new(),
125            filter: Filter::SourceOrder,
126            autocomplete: None,
127            intercept: Vec::new(),
128            icons: Icons::new(false),
129            debounce: None,
130        }
131    }
132
133    fn new(b: SearchListBuilder<R>) -> Self {
134        let mut loader = LoadEngine::new(b.redraw.clone());
135        loader.start(b.source.clone(), b.initial_query.clone());
136        let input = SingleLineInput::with_value(&b.initial_query);
137        let debounce = b.debounce;
138        let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
139            let mut ac =
140                AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
141                    disambiguate_header: false,
142                    apply_exclusion_zone: false,
143                    // The controller derives `allow_saved_search` from its mode
144                    // at detect time, so this seed value is not load-bearing.
145                    ..TriggerOptions::default()
146                });
147            if let Some(d) = debounce {
148                ac = ac.with_debounce(d);
149            }
150            ac.set_redraw_callback(b.redraw.clone());
151            ac
152        });
153        Self {
154            source: b.source,
155            rows: Vec::new(),
156            display: Vec::new(),
157            leading: None,
158            selected: None,
159            filter: b.filter,
160            query: b.initial_query,
161            loader,
162            input,
163            autocomplete,
164            intercept: b.intercept,
165            icons: b.icons,
166            list_rect: Rect::default(),
167            applied_generation: 0,
168            accepted_saved_search: None,
169        }
170    }
171
172    pub fn poll(&mut self) {
173        let drained = self.loader.drain();
174        if !drained.is_empty() {
175            // A newer load delivered its first event(s): drop the prior load's
176            // rows so a streamed source starts from a clean slate (one-shot
177            // `Replace` overwrites anyway, but `Push` would otherwise append).
178            let current_gen = self.loader.generation();
179            if current_gen != self.applied_generation {
180                self.rows.clear();
181                self.selected = None;
182                self.applied_generation = current_gen;
183            }
184        }
185        for ev in drained {
186            match ev {
187                LoadedInner::Replace(rows) => {
188                    self.rows = rows;
189                }
190                LoadedInner::Push(row) => {
191                    self.rows.push(row);
192                }
193                LoadedInner::Done => {}
194            }
195        }
196        self.recompute_display();
197        if self.selected.is_none() && self.visible_len() > 0 {
198            self.selected = Some(0);
199        }
200        if let Some(ac) = &mut self.autocomplete {
201            ac.poll_results();
202        }
203    }
204
205    /// Build a host snapshot from the current input state.
206    /// Only reads `self.input` so the result can be stored in a local
207    /// before taking `&mut self.autocomplete`, resolving the borrow conflict.
208    fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
209        let value = self.input.value().to_string();
210        let cursor_byte = self.input.cursor_byte();
211        let col = value[..cursor_byte.min(value.len())].chars().count();
212        host::SearchBoxHostSnapshot {
213            lines: vec![value],
214            cursor: (0, col),
215            caret_pos: self.input.last_caret_pos(),
216        }
217    }
218
219    fn clamp_selection(&mut self) {
220        let len = self.visible_len();
221        self.selected = if len == 0 {
222            None
223        } else {
224            Some(self.selected.unwrap_or(0).min(len - 1))
225        };
226    }
227
228    /// `1` when a leading row is pinned at visible position 0, else `0`.
229    fn leading_offset(&self) -> usize {
230        self.leading.is_some() as usize
231    }
232
233    /// Length of the visible sequence `[leading?] ++ display`.
234    pub fn visible_len(&self) -> usize {
235        self.leading_offset() + self.display.len()
236    }
237
238    /// Row at visible position `pos` in `[leading?] ++ display`.
239    fn visible_row(&self, pos: usize) -> Option<&R> {
240        if self.leading.is_some() && pos == 0 {
241            self.leading.as_ref()
242        } else {
243            self.rows
244                .get(*self.display.get(pos - self.leading_offset())?)
245        }
246    }
247
248    /// The source-delivered rows only (NOT the leading row). Prefer
249    /// [`visible_len`](Self::visible_len)/[`visible_rows`](Self::visible_rows)
250    /// for visible counts.
251    pub fn rows(&self) -> &[R] {
252        &self.rows
253    }
254
255    pub fn selected_row(&self) -> Option<&R> {
256        self.selected.and_then(|p| self.visible_row(p))
257    }
258
259    pub fn visible_rows(&self) -> Vec<&R> {
260        (0..self.visible_len())
261            .filter_map(|p| self.visible_row(p))
262            .collect()
263    }
264
265    pub fn query(&self) -> &str {
266        &self.query
267    }
268
269    /// Take the name of a just-accepted saved search, if any. The host calls
270    /// this after a `Consumed` key to learn whether to pin (or refresh) the
271    /// saved-search breadcrumb. Returns `None` once read. See `adr/0006`.
272    pub fn take_accepted_saved_search(&mut self) -> Option<String> {
273        self.accepted_saved_search.take()
274    }
275
276    /// The visible text in the query input widget. Test-only: lets callers
277    /// assert the input bar reflects a programmatic query change.
278    #[cfg(test)]
279    pub(crate) fn input_value(&self) -> &str {
280        self.input.value()
281    }
282    pub fn is_loading(&self) -> bool {
283        self.loader.loading
284    }
285
286    /// Set the query programmatically: updates the visible input widget (cursor
287    /// to end) AND the query string, then starts a load (for `reload_on_query`
288    /// sources) or recomputes the display. This is the setter every external
289    /// caller wants — a saved search applied, a sort directive rewritten — so
290    /// the input bar always reflects the query. The interactive keystroke path
291    /// uses [`sync_query_from_input`](Self::sync_query_from_input) instead,
292    /// because the input widget already holds the typed text (and its cursor
293    /// must not jump back to the end on every keystroke).
294    pub fn set_query(&mut self, q: impl Into<String>) {
295        let q = q.into();
296        self.input.set_value(q.clone());
297        self.query = q;
298        self.requery();
299    }
300
301    /// Pull the query string FROM the input widget without touching the widget
302    /// (so the cursor stays put), then reload/recompute. The keystroke and
303    /// autocomplete-accept paths use this after they have already mutated the
304    /// input in place.
305    fn sync_query_from_input(&mut self) {
306        self.query = self.input.value().to_string();
307        self.requery();
308    }
309
310    /// Start a fresh load for `reload_on_query` sources, else recompute the
311    /// local display. The generation guard in `LoadEngine` drops stale results.
312    fn requery(&mut self) {
313        if self.source.reload_on_query() {
314            self.loader.start(self.source.clone(), self.query.clone());
315        } else {
316            self.recompute_display();
317        }
318    }
319
320    /// Re-run the source load for the current query (e.g. after a mutation).
321    pub fn reload(&mut self) {
322        self.loader.start(self.source.clone(), self.query.clone());
323    }
324
325    pub fn select_next(&mut self) {
326        let n = self.visible_len();
327        if n == 0 {
328            return;
329        }
330        self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
331    }
332
333    pub fn select_prev(&mut self) {
334        if self.visible_len() == 0 {
335            return;
336        }
337        self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
338    }
339
340    pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
341        use ratatui::crossterm::event::{KeyCode, KeyModifiers};
342
343        // Caller-registered intercepts get first crack — before autocomplete or
344        // any built-in binding.
345        if let Some(combo) = crate::keys::key_event_to_combo(key)
346            && self.intercept.contains(&combo)
347        {
348            return KeyReaction::Intercepted(combo);
349        }
350
351        // Autocomplete popup gets first crack when open. Build snapshot before
352        // taking &mut self.autocomplete to avoid borrow-checker conflict
353        // (snapshot only reads self.input).
354        if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
355            let snap = self.autocomplete_snapshot();
356            if let Some(ac) = &mut self.autocomplete {
357                match ac.handle_key(*key, &snap) {
358                    HandleKeyOutcome::Accepted(action) => {
359                        self.input.replace_range_bytes(
360                            action.range.clone(),
361                            &action.new_text,
362                            action.new_cursor_byte,
363                        );
364                        // Stash any accepted SavedSearch name for the host's
365                        // breadcrumb (`None` for every other kind). The host
366                        // reads it on this same `Consumed`, so a plain assign
367                        // never clobbers an unread value. See `adr/0006`.
368                        self.accepted_saved_search = action.saved_search_name;
369                        self.sync_query_from_input();
370                        return KeyReaction::Consumed;
371                    }
372                    HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
373                        return KeyReaction::Consumed;
374                    }
375                    HandleKeyOutcome::NotHandled => {}
376                }
377            }
378        }
379
380        match key.code {
381            KeyCode::Up => {
382                self.select_prev();
383                return KeyReaction::Consumed;
384            }
385            KeyCode::Down => {
386                self.select_next();
387                return KeyReaction::Consumed;
388            }
389            KeyCode::Enter => return KeyReaction::Submit,
390            KeyCode::Esc => return KeyReaction::Cancel,
391            _ => {}
392        }
393        // Drop Ctrl/Alt-modified chars so combos don't leak as text.
394        if let KeyCode::Char(_) = key.code {
395            let non_shift = key.modifiers - KeyModifiers::SHIFT;
396            if !non_shift.is_empty() {
397                return KeyReaction::Unhandled;
398            }
399        }
400        let outcome = self.input.handle_key(key);
401        // Sync/refresh/close the autocomplete popup based on the input outcome.
402        // Build snapshot before taking &mut self.autocomplete (same borrow trick).
403        let snap = self.autocomplete_snapshot();
404        match outcome {
405            InputOutcome::Changed => {
406                if let Some(ac) = &mut self.autocomplete {
407                    ac.sync(&snap);
408                }
409            }
410            InputOutcome::Consumed => {
411                if let Some(ac) = &mut self.autocomplete {
412                    ac.refresh_if_open(&snap);
413                }
414            }
415            InputOutcome::Cancel | InputOutcome::Submit => {
416                if let Some(ac) = &mut self.autocomplete {
417                    ac.close();
418                }
419            }
420            InputOutcome::NotConsumed => {}
421        }
422        match outcome {
423            InputOutcome::Changed => {
424                self.sync_query_from_input();
425                KeyReaction::Consumed
426            }
427            InputOutcome::Consumed => KeyReaction::Consumed,
428            InputOutcome::Submit => KeyReaction::Submit,
429            InputOutcome::Cancel => KeyReaction::Cancel,
430            InputOutcome::NotConsumed => KeyReaction::Unhandled,
431        }
432    }
433
434    pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
435        self.input.render(
436            f,
437            area,
438            Style::default()
439                .fg(theme.fg.to_ratatui())
440                .bg(theme.bg_panel.to_ratatui()),
441            0,
442            focused,
443        );
444    }
445
446    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
447        self.poll();
448        let sel = self.selected;
449        let items: Vec<ListItem> = (0..self.visible_len())
450            .filter_map(|pos| {
451                self.visible_row(pos)
452                    .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
453            })
454            .collect();
455        let mut state = ListState::default();
456        state.select(self.selected);
457        let list =
458            List::new(items).highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
459        f.render_stateful_widget(list, area, &mut state);
460        self.list_rect = area;
461        let _ = focused;
462    }
463
464    /// Override the rect used for mouse hit-testing. The recorded rect must be
465    /// the area where list ITEMS actually render — row 0 is the first item, NOT
466    /// a block border. Hosts that draw the list inside a bordered block pass the
467    /// block's INNER rect; borderless hosts pass the list area directly. The
468    /// recorded rect and the rendered-items rect MUST be identical, so
469    /// [`handle_mouse`] maps a click at `row` to visual offset `row - rect.y`.
470    ///
471    /// [`handle_mouse`]: Self::handle_mouse
472    pub fn set_list_rect(&mut self, rect: Rect) {
473        self.list_rect = rect;
474    }
475
476    pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
477        if let Some(ac) = &mut self.autocomplete {
478            ac.poll_results();
479            let caret = self.input.last_caret_pos();
480            if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
481                state.anchor = anchor;
482            }
483            if let Some(state) = ac.state() {
484                crate::components::autocomplete::render(f, state, clamp, theme);
485            }
486        }
487    }
488
489    pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
490        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
491        use ratatui::layout::Position;
492        // Any mouse interaction dismisses an open autocomplete popup (matches
493        // the old modal: a click on the preview/border closes a stale popup).
494        if let Some(ac) = &mut self.autocomplete {
495            ac.close();
496        }
497        let r = self.list_rect;
498        if !r.contains(Position {
499            x: m.column,
500            y: m.row,
501        }) {
502            return SearchMouse::None;
503        }
504        match m.kind {
505            MouseEventKind::Down(MouseButton::Left) if m.row >= r.y => {
506                let target_visual = m.row - r.y; // 0-based visual offset; row 0 = first item
507                let mut acc: u16 = 0;
508                let mut hit: Option<usize> = None;
509                // Walk the VISIBLE sequence (leading row at position 0, then the
510                // display rows) so visual offsets map to visible positions.
511                for pos in 0..self.visible_len() {
512                    let h = self
513                        .visible_row(pos)
514                        .map(|r| r.visual_height())
515                        .unwrap_or(1);
516                    if target_visual < acc + h {
517                        hit = Some(pos);
518                        break;
519                    }
520                    acc += h;
521                }
522                if let Some(pos) = hit {
523                    let prev = self.selected;
524                    self.selected = Some(pos);
525                    return if prev == Some(pos) {
526                        SearchMouse::Activated(pos)
527                    } else {
528                        SearchMouse::Selected(pos)
529                    };
530                }
531                SearchMouse::None
532            }
533            MouseEventKind::ScrollUp => {
534                self.select_prev();
535                SearchMouse::Scrolled
536            }
537            MouseEventKind::ScrollDown => {
538                self.select_next();
539                SearchMouse::Scrolled
540            }
541            _ => SearchMouse::None,
542        }
543    }
544
545    fn recompute_display(&mut self) {
546        let q = self.query.trim();
547        // The leading row is query-fresh: rebuilt on every poll AND on every
548        // local-filter `set_query`, so it never goes stale.
549        self.leading = self.source.leading_row(q);
550        let mut idx: Vec<usize> = match &self.filter {
551            Filter::SourceOrder => (0..self.rows.len()).collect(),
552            Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
553            Filter::Fuzzy => fuzzy_indices(&self.rows, q),
554            Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
555            Filter::Rank(f) => {
556                let f = f.clone();
557                f(&self.rows, q)
558            }
559        };
560        // Filter-exempt rows (match_text() == None: Up / Create / virtual pinned)
561        // are always present; prepend any that the filter dropped.
562        for i in 0..self.rows.len() {
563            if self.rows[i].match_text().is_none() && !idx.contains(&i) {
564                idx.insert(0, i);
565            }
566        }
567        self.display = idx;
568        self.clamp_selection();
569    }
570
571    #[cfg(test)]
572    pub(crate) async fn poll_until_idle(&mut self) {
573        // In-memory sources settle on the first poll (no sleep paid). Vault-backed
574        // sources run their read on a worker/blocking thread, which can starve
575        // under the full parallel suite — so once still loading, sleep a little
576        // between polls and use a generous ceiling. Early-breaks the instant the
577        // load lands, keeping the common (in-memory) path fast.
578        for _ in 0..600 {
579            tokio::task::yield_now().await;
580            self.poll();
581            if !self.is_loading() {
582                break;
583            }
584            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
585        }
586        self.poll();
587    }
588}
589
590impl<R: SearchRow> SearchListBuilder<R> {
591    pub fn initial_query(mut self, q: impl Into<String>) -> Self {
592        self.initial_query = q.into();
593        self
594    }
595    pub fn filter(mut self, f: Filter<R>) -> Self {
596        self.filter = f;
597        self
598    }
599    pub fn autocomplete(
600        mut self,
601        suggestions: Arc<dyn SuggestionSource>,
602        mode: AutocompleteMode,
603    ) -> Self {
604        self.autocomplete = Some((suggestions, mode));
605        self
606    }
607    pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
608        self.intercept = v;
609        self
610    }
611    pub fn icons(mut self, icons: Icons) -> Self {
612        self.icons = icons;
613        self
614    }
615    /// Override the autocomplete controller's debounce. Tests use
616    /// `Duration::ZERO` to get suggestions without waiting on the debounce timer.
617    pub fn debounce(mut self, d: std::time::Duration) -> Self {
618        self.debounce = Some(d);
619        self
620    }
621    pub fn build(self) -> SearchList<R> {
622        SearchList::new(self)
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::adapters::{
629        ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow, VecSource,
630        VecSourceWithLead,
631    };
632    use super::*;
633    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
634
635    fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
636        std::sync::Arc::new(|| {})
637    }
638
639    fn key(c: KeyCode) -> KeyEvent {
640        KeyEvent::new(c, KeyModifiers::NONE)
641    }
642
643    fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
644        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
645        MouseEvent {
646            kind: MouseEventKind::Down(MouseButton::Left),
647            column: col,
648            row,
649            modifiers: KeyModifiers::NONE,
650        }
651    }
652
653    #[derive(Clone, Debug, PartialEq)]
654    struct TallRow {
655        name: String,
656        height: u16,
657    }
658    impl SearchRow for TallRow {
659        fn to_list_item(
660            &self,
661            _t: &crate::settings::themes::Theme,
662            _i: &crate::settings::icons::Icons,
663            _s: bool,
664        ) -> ratatui::widgets::ListItem<'static> {
665            ratatui::widgets::ListItem::new(self.name.clone())
666        }
667        fn visual_height(&self) -> u16 {
668            self.height
669        }
670        fn match_text(&self) -> Option<&str> {
671            Some(&self.name)
672        }
673    }
674    struct TallSource(Vec<TallRow>);
675    #[async_trait::async_trait]
676    impl RowSource<TallRow> for TallSource {
677        async fn load(&self, _q: &str, emit: Emit<TallRow>) {
678            emit.replace(self.0.clone());
679        }
680    }
681
682    #[tokio::test]
683    async fn mouse_maps_visual_row_to_display_index_by_height() {
684        // Row 0 occupies 3 visual rows, row 1 occupies 1. The recorded list rect
685        // is the rendered-items area: row 0 == the FIRST item (no border row).
686        let src = TallSource(vec![
687            TallRow {
688                name: "a".into(),
689                height: 3,
690            },
691            TallRow {
692                name: "b".into(),
693                height: 1,
694            },
695        ]);
696        let mut list = SearchList::builder(src, noop_redraw()).build();
697        list.poll_until_idle().await;
698        // Force the recorded list rect (render not run in test): items start at y=0.
699        list.set_list_rect(ratatui::layout::Rect {
700            x: 0,
701            y: 0,
702            width: 20,
703            height: 10,
704        });
705        // "a" occupies rows 0..=2; row 3 is the FIRST row of "b".
706        let m = mouse_down_at(2, 3);
707        assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
708        assert_eq!(list.selected_row().unwrap().name, "b");
709        // A click at row 1 = within "a" (rows 0..=2) -> display index 0.
710        let m = mouse_down_at(2, 1);
711        list.handle_mouse(&m);
712        assert_eq!(list.selected_row().unwrap().name, "a");
713    }
714
715    #[tokio::test]
716    async fn initial_load_populates_rows() {
717        let src = VecSource {
718            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
719            reload: true,
720        };
721        let mut list = SearchList::builder(src, noop_redraw()).build();
722        list.poll_until_idle().await;
723        assert_eq!(list.rows().len(), 2);
724        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
725    }
726
727    #[tokio::test]
728    async fn requery_supersedes_and_reloads() {
729        let src = VecSource {
730            rows: vec![
731                TestRow::new("alpha"),
732                TestRow::new("alps"),
733                TestRow::new("beta"),
734            ],
735            reload: true,
736        };
737        let mut list = SearchList::builder(src, noop_redraw()).build();
738        list.poll_until_idle().await;
739        assert_eq!(list.rows().len(), 3);
740        list.set_query("alp");
741        list.poll_until_idle().await;
742        assert_eq!(list.rows().len(), 2); // alpha, alps
743        assert!(list.rows().iter().all(|r| r.name.contains("alp")));
744    }
745
746    #[tokio::test]
747    async fn arrows_navigate_and_enter_submits() {
748        let src = VecSource {
749            rows: vec![TestRow::new("a"), TestRow::new("b")],
750            reload: true,
751        };
752        let mut list = SearchList::builder(src, noop_redraw()).build();
753        list.poll_until_idle().await;
754        assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
755        assert_eq!(list.selected_row().unwrap().name, "b");
756        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
757        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
758    }
759
760    #[tokio::test]
761    async fn typing_a_char_changes_query() {
762        let src = VecSource {
763            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
764            reload: true,
765        };
766        let mut list = SearchList::builder(src, noop_redraw()).build();
767        list.poll_until_idle().await;
768        assert_eq!(
769            list.handle_key(&key(KeyCode::Char('a'))),
770            KeyReaction::Consumed
771        );
772        list.poll_until_idle().await;
773        assert_eq!(list.query(), "a");
774    }
775
776    #[tokio::test]
777    async fn rank_filter_orders_by_closure() {
778        let src = VecSource {
779            rows: vec![
780                TestRow::new("todo"),
781                TestRow::new("today"),
782                TestRow::new("misc"),
783            ],
784            reload: false,
785        };
786        let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
787            let mut idx: Vec<usize> = (0..rows.len())
788                .filter(|&i| rows[i].name.contains(q))
789                .collect();
790            idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
791            idx
792        });
793        let mut list = SearchList::builder(src, noop_redraw())
794            .filter(Filter::Rank(rank))
795            .build();
796        list.poll_until_idle().await;
797        list.set_query("today");
798        list.poll();
799        assert_eq!(list.selected_row().unwrap().name, "today");
800    }
801
802    #[tokio::test]
803    async fn fuzzy_filter_narrows_local_set() {
804        let src = VecSource {
805            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
806            reload: false,
807        };
808        let mut list = SearchList::builder(src, noop_redraw())
809            .filter(Filter::Fuzzy)
810            .build();
811        list.poll_until_idle().await;
812        list.set_query("alp");
813        list.poll();
814        assert_eq!(list.visible_rows().len(), 1);
815        assert_eq!(list.selected_row().unwrap().name, "alpha");
816    }
817
818    #[tokio::test]
819    async fn streamed_rows_arrive_then_done_and_filter_locally() {
820        let src = ScriptedStreamSource {
821            batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
822        };
823        let mut list = SearchList::builder(src, noop_redraw())
824            .filter(Filter::Fuzzy)
825            .build();
826        list.poll_until_idle().await;
827        assert_eq!(list.rows().len(), 2);
828        assert!(!list.is_loading());
829        list.set_query("alp");
830        list.poll();
831        assert_eq!(list.visible_rows().len(), 1);
832    }
833
834    #[tokio::test]
835    async fn source_order_unfiltered_passthrough() {
836        let src = VecSource {
837            rows: vec![TestRow::new("a"), TestRow::new("b")],
838            reload: true,
839        };
840        let mut list = SearchList::builder(src, noop_redraw()).build(); // default Filter::SourceOrder
841        list.poll_until_idle().await;
842        assert_eq!(list.visible_rows().len(), 2);
843        assert_eq!(list.selected_row().unwrap().name, "a");
844    }
845
846    #[tokio::test]
847    async fn intercepted_combo_returns_intercepted_without_acting() {
848        let src = VecSource {
849            rows: vec![TestRow::new("a")],
850            reload: true,
851        };
852        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
853        let mut list = SearchList::builder(src, noop_redraw())
854            .intercept(vec![combo])
855            .build();
856        list.poll_until_idle().await;
857        // Enter is intercepted: engine returns Intercepted, does NOT submit/act.
858        assert_eq!(
859            list.handle_key(&key(KeyCode::Enter)),
860            KeyReaction::Intercepted(combo)
861        );
862    }
863
864    #[tokio::test]
865    async fn autocomplete_accept_rewrites_query_without_vault() {
866        struct Mem;
867        #[async_trait::async_trait]
868        impl crate::components::search_list::SuggestionSource for Mem {
869            async fn notes_by_prefix(
870                &self,
871                _p: &str,
872                _n: usize,
873            ) -> Vec<crate::components::search_list::SuggestionItem> {
874                vec![]
875            }
876            async fn tags_by_prefix(
877                &self,
878                p: &str,
879                _n: usize,
880            ) -> Vec<crate::components::search_list::SuggestionItem> {
881                if "projects".starts_with(p) {
882                    vec![crate::components::search_list::SuggestionItem::plain(
883                        "projects",
884                    )]
885                } else {
886                    vec![]
887                }
888            }
889        }
890        let src = VecSource {
891            rows: vec![],
892            reload: true,
893        };
894        let mut list = SearchList::builder(src, noop_redraw())
895            .autocomplete(
896                std::sync::Arc::new(Mem),
897                crate::components::autocomplete::AutocompleteMode::SearchQuery,
898            )
899            .debounce(std::time::Duration::ZERO)
900            .build();
901        for c in ['#', 'p', 'r', 'o'] {
902            let _ = list.handle_key(&key(KeyCode::Char(c)));
903        }
904        for _ in 0..50 {
905            tokio::task::yield_now().await;
906            list.poll();
907        }
908        let _ = list.handle_key(&key(KeyCode::Tab));
909        assert_eq!(list.query(), "#projects");
910    }
911
912    // Accepting a SavedSearch suggestion expands the whole field to the
913    // stored query AND exposes the accepted name (for the breadcrumb) via
914    // `take_accepted_saved_search`. See `adr/0006`.
915    #[tokio::test]
916    async fn accepting_saved_search_expands_query_and_exposes_name() {
917        struct Mem;
918        #[async_trait::async_trait]
919        impl crate::components::search_list::SuggestionSource for Mem {
920            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
921                vec![]
922            }
923            async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
924                vec![]
925            }
926            async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
927                if "todo-week".starts_with(p) {
928                    vec![SuggestionItem {
929                        display: "todo-week".into(),
930                        secondary: Some("#todo ^modified".into()),
931                    }]
932                } else {
933                    vec![]
934                }
935            }
936        }
937        let src = VecSource {
938            rows: vec![],
939            reload: true,
940        };
941        let mut list = SearchList::builder(src, noop_redraw())
942            .autocomplete(
943                std::sync::Arc::new(Mem),
944                crate::components::autocomplete::AutocompleteMode::SearchQuery,
945            )
946            .debounce(std::time::Duration::ZERO)
947            .build();
948        for c in ['?', 't', 'o'] {
949            let _ = list.handle_key(&key(KeyCode::Char(c)));
950        }
951        for _ in 0..50 {
952            tokio::task::yield_now().await;
953            list.poll();
954        }
955        let _ = list.handle_key(&key(KeyCode::Tab));
956        // Whole field expanded to the stored query.
957        assert_eq!(list.query(), "#todo ^modified");
958        // The accepted name is exposed once, then cleared.
959        assert_eq!(
960            list.take_accepted_saved_search().as_deref(),
961            Some("todo-week")
962        );
963        assert_eq!(list.take_accepted_saved_search(), None);
964    }
965
966    // Regression: Enter (not just Tab) must accept an open autocomplete popup,
967    // and the engine must report Consumed — NOT Submit — so a host does not
968    // mistake the accept for a list submit. (A QueryPanel Enter pre-check used
969    // to swallow this, breaking accept-on-Enter in the right sidebar.)
970    #[tokio::test]
971    async fn enter_accepts_open_popup_and_reports_consumed() {
972        struct Mem;
973        #[async_trait::async_trait]
974        impl crate::components::search_list::SuggestionSource for Mem {
975            async fn notes_by_prefix(
976                &self,
977                _p: &str,
978                _n: usize,
979            ) -> Vec<crate::components::search_list::SuggestionItem> {
980                vec![]
981            }
982            async fn tags_by_prefix(
983                &self,
984                p: &str,
985                _n: usize,
986            ) -> Vec<crate::components::search_list::SuggestionItem> {
987                if "projects".starts_with(p) {
988                    vec![crate::components::search_list::SuggestionItem::plain(
989                        "projects",
990                    )]
991                } else {
992                    vec![]
993                }
994            }
995        }
996        let src = VecSource {
997            rows: vec![],
998            reload: true,
999        };
1000        let mut list = SearchList::builder(src, noop_redraw())
1001            .autocomplete(
1002                std::sync::Arc::new(Mem),
1003                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1004            )
1005            .debounce(std::time::Duration::ZERO)
1006            .build();
1007        for c in ['#', 'p', 'r', 'o'] {
1008            let _ = list.handle_key(&key(KeyCode::Char(c)));
1009        }
1010        for _ in 0..50 {
1011            tokio::task::yield_now().await;
1012            list.poll();
1013        }
1014        // Popup is open: Enter accepts the suggestion and reports Consumed.
1015        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1016        assert_eq!(list.query(), "#projects");
1017        // Popup now closed: a second Enter falls through to Submit.
1018        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1019    }
1020
1021    // Regression (P0): a STREAMED source (sidebar shape) supplies a query-fresh
1022    // leading row. It must appear at visible position 0 even though rows arrive
1023    // via Push (never Replace), be present when the query matches no streamed
1024    // row, and refresh when the query changes (reload_on_query() == false).
1025    #[tokio::test]
1026    async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1027        let src = ScriptedStreamLeadSource {
1028            items: vec!["alpha".into(), "beta".into()],
1029        };
1030        let mut list = SearchList::builder(src, noop_redraw())
1031            .filter(Filter::Fuzzy)
1032            .initial_query("zz")
1033            .build();
1034        list.poll_until_idle().await;
1035        // Leading present even though "zz" matches no streamed Item.
1036        let vis = list.visible_rows();
1037        assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1038        assert_eq!(list.visible_len(), 1); // just the leading; no Item matches
1039        // Query-fresh: changing the query rebuilds the leading and re-filters.
1040        list.set_query("alp");
1041        list.poll();
1042        let vis = list.visible_rows();
1043        assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1044        assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1045        assert_eq!(list.visible_len(), 2);
1046        // Empty query: leading disappears, both Items show.
1047        list.set_query("");
1048        list.poll();
1049        assert!(
1050            list.visible_rows()
1051                .iter()
1052                .all(|r| matches!(r, StreamRow::Item(_)))
1053        );
1054        assert_eq!(list.visible_len(), 2);
1055    }
1056
1057    // Regression guard for the saved-searches virtual entry: a one-shot
1058    // (Replace) source with a leading row still pins it at position 0.
1059    #[tokio::test]
1060    async fn oneshot_source_leading_row_still_works() {
1061        let src = VecSourceWithLead {
1062            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1063        };
1064        let mut list = SearchList::builder(src, noop_redraw())
1065            .filter(Filter::Fuzzy)
1066            .initial_query("alp")
1067            .build();
1068        list.poll_until_idle().await;
1069        let vis = list.visible_rows();
1070        assert_eq!(vis[0].name, "create:alp");
1071        assert_eq!(vis[1].name, "alpha");
1072        assert_eq!(list.visible_len(), 2);
1073    }
1074
1075    // Selection walks the VISIBLE sequence: position 0 is the leading row, and
1076    // select_next steps from the leading to the first real row.
1077    #[tokio::test]
1078    async fn selection_includes_leading_at_position_zero() {
1079        let src = VecSourceWithLead {
1080            rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1081        };
1082        let mut list = SearchList::builder(src, noop_redraw())
1083            .filter(Filter::Fuzzy)
1084            .initial_query("alp")
1085            .build();
1086        list.poll_until_idle().await;
1087        // Auto-selected position 0 -> the leading.
1088        assert_eq!(list.selected_row().unwrap().name, "create:alp");
1089        list.handle_key(&key(KeyCode::Down));
1090        assert_eq!(list.selected_row().unwrap().name, "alpha");
1091    }
1092
1093    // A source with NO leading row has no off-by-one: visible_len == display.
1094    #[tokio::test]
1095    async fn no_leading_row_visible_len_matches_display() {
1096        let src = VecSource {
1097            rows: vec![TestRow::new("a"), TestRow::new("b")],
1098            reload: true,
1099        };
1100        let mut list = SearchList::builder(src, noop_redraw()).build();
1101        list.poll_until_idle().await;
1102        assert_eq!(list.visible_len(), 2);
1103        assert_eq!(list.visible_rows().len(), 2);
1104        assert_eq!(list.selected_row().unwrap().name, "a");
1105    }
1106}