Skip to main content

kimun_notes/components/search_list/
mod.rs

1//! `SearchList`: the one module behind every query-input-over-an-async-loaded
2//! list surface in the TUI. See CONTEXT.md.
3
4#[cfg(test)]
5mod adapters;
6mod host;
7mod load;
8mod resolving;
9mod seams;
10
11pub use resolving::{ResolvingRowSource, Unresolvable};
12pub use seams::{
13    Emit, Filter, Loaded, OrderFn, RowSource, SearchRow, StaticRowSource, SuggestionItem,
14    SuggestionSource, VaultSuggestions, YankTarget,
15};
16
17use crate::components::autocomplete::{
18    AutocompleteController, AutocompleteMode, HandleKeyOutcome, TriggerOptions,
19};
20use crate::components::single_line_input::{InputOutcome, SingleLineInput};
21use crate::keys::key_combo::KeyCombo;
22use crate::settings::icons::Icons;
23use crate::settings::themes::Theme;
24use load::LoadEngine;
25use ratatui::crossterm::event::KeyEvent;
26use ratatui::{
27    Frame,
28    layout::Rect,
29    style::Style,
30    widgets::{List, ListItem, ListState},
31};
32use seams::Loaded as LoadedInner;
33use std::sync::Arc;
34
35/// Fuzzy-ranks the rows named by `base` (indices into `rows`, in the order
36/// ties should keep) against `query`; absent = no match.
37fn fuzzy_indices<R: SearchRow>(rows: &[R], base: &[usize], query: &str) -> Vec<usize> {
38    use nucleo::pattern::{CaseMatching, Normalization, Pattern};
39    use nucleo::{Matcher, Utf32Str};
40    let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
41    let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
42    let mut scored: Vec<(usize, u32)> = base
43        .iter()
44        .filter_map(|&i| {
45            let hay = rows[i].match_text()?;
46            let mut buf = Vec::new();
47            let h = Utf32Str::new(hay, &mut buf);
48            pat.score(h, &mut matcher).map(|s| (i, s))
49        })
50        .collect();
51    // Stable: equal scores keep `base` order, so an `order_by` still decides
52    // among ties.
53    scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
54    scored.into_iter().map(|(i, _)| i).collect()
55}
56
57/// Which half of a [`SearchList`] owns the keyboard. See CONTEXT.md
58/// (**List focus**). In [`Focus::Input`] typing filters the list; in
59/// [`Focus::List`] plain letters are verbs (`j`/`k` navigate, surface-registered
60/// letters act on the selected row) and never type into the query.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Focus {
63    Input,
64    List,
65}
66
67/// Verdict returned by [`SearchList::handle_key`].
68#[derive(Debug, PartialEq, Eq)]
69pub enum KeyReaction {
70    Consumed,
71    Submit,
72    Cancel,
73    Intercepted(crate::keys::key_combo::KeyCombo),
74    /// A surface-registered list-focus verb fired on the selected row. The
75    /// engine attaches NO meaning to the char — the caller maps it to an
76    /// action (see [`SearchListBuilder::list_verb`]).
77    ListVerb(char),
78    /// The yank chord fired: the selected row's [`YankTarget`], or `None` when
79    /// nothing is selected or the row has nothing worth copying.
80    ///
81    /// SearchList decides *that* the chord is a yank and *what* it would copy;
82    /// it does not touch the clipboard, because it holds no `AppTx` and emits
83    /// nothing on its own. Callers hand this straight to
84    /// [`crate::components::yank_row`].
85    Yank(Option<YankTarget>),
86    Unhandled,
87}
88
89pub struct SearchList<R: SearchRow> {
90    source: Arc<dyn RowSource<R>>,
91    rows: Vec<R>,
92    /// Indices into `rows` in display order (after filtering/ranking).
93    display: Vec<usize>,
94    /// A synthetic, query-fresh, filter-exempt row pinned at visible position 0
95    /// (the `Create: <q>` affordance / saved-searches virtual entry). Held
96    /// separately from `rows` so it works regardless of delivery (one-shot
97    /// `Replace` or streamed `Push`) and refreshes on every query change. See
98    /// [`RowSource::leading_row`].
99    leading: Option<R>,
100    /// Index into the VISIBLE sequence `[leading?] ++ display` of the selected
101    /// item.
102    selected: Option<usize>,
103    /// Whether `selected` is the user's own choice rather than the seed
104    /// `recompute_and_seed` puts on the first row. A chosen selection tracks
105    /// its row across a re-sort; a seeded one keeps the top slot, so a streamed
106    /// listing highlights the top of the `order_by` order at every frame
107    /// instead of whichever row the source happened to emit first.
108    selection_pinned: bool,
109    /// Viewport offset: visible position of the first row on screen. Owned
110    /// here (not by a per-frame `ListState`) so mouse-wheel scrolling can move
111    /// the viewport directly; `render` writes it back after ratatui clamps it
112    /// to keep the selection visible.
113    offset: usize,
114    filter: Filter<R>,
115    /// Optional total order applied before the local filter; see
116    /// [`SearchListBuilder::order_by`].
117    order: Option<OrderFn<R>>,
118    query: String,
119    loader: LoadEngine<R>,
120    input: SingleLineInput,
121    autocomplete: Option<AutocompleteController>,
122    /// Key combos the caller wants to intercept before the engine acts.
123    intercept: Vec<KeyCombo>,
124    /// The chords that yank the selected row's [`YankTarget`]. Defaults to
125    /// [`crate::keys::default_yank_combo`]; surfaces that hold the user's
126    /// [`KeyBindings`](crate::keys::KeyBindings) pass the resolved combos, so a
127    /// rebinding reaches the list. Empty = the user unbound it.
128    yank_combos: Vec<KeyCombo>,
129    icons: Icons,
130    list_rect: Rect,
131    /// The host panel's full bounds, for wheel hit-testing: scroll events
132    /// anywhere within it scroll the list — header, query box, preview —
133    /// while clicks still hit-test against `list_rect` only. Empty (the
134    /// default) falls back to `list_rect`, so hosts that never record it
135    /// keep scroll-over-the-list-only behavior.
136    panel_rect: Rect,
137    /// A host-owned scrollable sub-region within the panel (e.g. an expanded
138    /// note preview). Wheel events inside it are routed back to the host as
139    /// [`SearchMouse::ContentScrollUp`]/[`ContentScrollDown`] instead of
140    /// scrolling the list — the sub-region wins over `panel_rect`. Empty (the
141    /// default) means no sub-region; hosts re-record it every render so it is
142    /// never stale.
143    ///
144    /// [`ContentScrollDown`]: SearchMouse::ContentScrollDown
145    content_rect: Rect,
146    /// Load generation whose rows are currently held. When a newer generation
147    /// (a requery / reload) delivers its first event, `poll` clears the stale
148    /// rows before applying it — required for streamed (`Push`) sources, which
149    /// would otherwise append onto a superseded load's rows.
150    applied_generation: u64,
151    /// Set when a SavedSearch suggestion was just accepted: the search's name,
152    /// for the host to pin as the saved-search breadcrumb. Read once via
153    /// [`take_accepted_saved_search`](Self::take_accepted_saved_search).
154    accepted_saved_search: Option<String>,
155    /// Visible position of the last left-click, so "click the selected row
156    /// again activates" only fires on a true click-click — never on a click
157    /// landing on an auto- or keyboard-made selection.
158    last_click_pos: Option<usize>,
159    /// Render the query input with §9 syntax highlighting (the FIND drawer
160    /// and the telescope modal; plain inputs like the sidebar filter skip it).
161    highlight_query: bool,
162    /// Which half owns the keyboard. See [`Focus`] and CONTEXT.md.
163    focus: Focus,
164    /// Whether the list-focus state machine is active for this surface. `true`
165    /// iff the surface opens on the list OR registers at least one verb;
166    /// otherwise `Esc` cancels immediately, byte-identical to a plain input
167    /// list, so surfaces that never opt in keep their exact keystroke behavior.
168    focus_enabled: bool,
169    /// Surface-registered list-focus verb chars. When one is pressed in
170    /// [`Focus::List`], `handle_key` returns [`KeyReaction::ListVerb`]; the
171    /// engine attaches no meaning to them.
172    list_verbs: Vec<char>,
173}
174
175/// Mouse interaction result from [`SearchList::handle_mouse`].
176#[derive(Debug, PartialEq, Eq)]
177pub enum SearchMouse {
178    Selected(usize),
179    Activated(usize),
180    /// Right-click on a row: selected, and the host should open its context
181    /// menu for it.
182    Context(usize),
183    Scrolled,
184    /// The wheel landed inside the host's content sub-region (see
185    /// [`SearchList::set_content_rect`]); the host owns that view's scroll,
186    /// so the engine routed the event instead of moving the list.
187    ContentScrollUp,
188    ContentScrollDown,
189    None,
190}
191
192pub struct SearchListBuilder<R: SearchRow> {
193    source: Arc<dyn RowSource<R>>,
194    redraw: Arc<dyn Fn() + Send + Sync>,
195    initial_query: String,
196    filter: Filter<R>,
197    order: Option<OrderFn<R>>,
198    autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
199    intercept: Vec<KeyCombo>,
200    yank_combos: Vec<KeyCombo>,
201    icons: Icons,
202    debounce: Option<std::time::Duration>,
203    highlight_query: bool,
204    opening_focus: Focus,
205    list_verbs: Vec<char>,
206}
207
208impl<R: SearchRow> SearchList<R> {
209    pub fn builder(
210        source: impl RowSource<R>,
211        redraw: Arc<dyn Fn() + Send + Sync>,
212    ) -> SearchListBuilder<R> {
213        SearchListBuilder {
214            source: Arc::new(source),
215            redraw,
216            initial_query: String::new(),
217            filter: Filter::SourceOrder,
218            order: None,
219            autocomplete: None,
220            intercept: Vec::new(),
221            yank_combos: vec![crate::keys::default_yank_combo()],
222            icons: Icons::new(false),
223            debounce: None,
224            highlight_query: false,
225            opening_focus: Focus::Input,
226            list_verbs: Vec::new(),
227        }
228    }
229
230    /// The async path: kick off the initial load; rows land on a later `poll`.
231    fn new(b: SearchListBuilder<R>) -> Self {
232        let mut list = Self::assemble(b);
233        list.loader.start(list.source.clone(), list.query.clone());
234        list
235    }
236
237    /// The synchronous path: apply an in-memory row set and seed the initial
238    /// selection here and now — no async load, no channel, no redraw
239    /// round-trip. The [`LoadEngine`] stays idle (`is_loading()` is false
240    /// immediately), and the source's `load` is never invoked, so a caller can
241    /// read `selected_row()`/`rows()` on the very next line. For static sources
242    /// (`reload_on_query() == false`), where the query is a local filter over
243    /// the built rows. See [`StaticRowSource`].
244    ///
245    /// [`StaticRowSource`]: crate::components::search_list::StaticRowSource
246    fn with_rows(b: SearchListBuilder<R>, rows: Vec<R>) -> Self {
247        let mut list = Self::assemble(b);
248        list.rows = rows;
249        list.recompute_and_seed();
250        list
251    }
252
253    /// Build the struct with an idle loader (no load started). The two entry
254    /// points ([`new`](Self::new)/[`with_rows`](Self::with_rows)) diverge on
255    /// what they do next: spawn an async load, or seed rows synchronously.
256    fn assemble(b: SearchListBuilder<R>) -> Self {
257        let loader = LoadEngine::new(b.redraw.clone());
258        let input = SingleLineInput::with_value(&b.initial_query);
259        let debounce = b.debounce;
260        let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
261            let mut ac =
262                AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
263                    disambiguate_header: false,
264                    apply_exclusion_zone: false,
265                    // The controller derives `allow_saved_search` from its mode
266                    // at detect time, so this seed value is not load-bearing.
267                    ..TriggerOptions::default()
268                });
269            if let Some(d) = debounce {
270                ac = ac.with_debounce(d);
271            }
272            ac.set_redraw_callback(b.redraw.clone());
273            ac
274        });
275        Self {
276            source: b.source,
277            rows: Vec::new(),
278            display: Vec::new(),
279            leading: None,
280            selected: None,
281            selection_pinned: false,
282            offset: 0,
283            filter: b.filter,
284            order: b.order,
285            query: b.initial_query,
286            loader,
287            input,
288            highlight_query: b.highlight_query,
289            last_click_pos: None,
290            autocomplete,
291            intercept: b.intercept,
292            yank_combos: b.yank_combos,
293            icons: b.icons,
294            list_rect: Rect::default(),
295            panel_rect: Rect::default(),
296            content_rect: Rect::default(),
297            applied_generation: 0,
298            accepted_saved_search: None,
299            focus: b.opening_focus,
300            // List focus is active when the surface opens on the list or
301            // registers verbs; otherwise the surface keeps plain Esc→Cancel.
302            focus_enabled: b.opening_focus == Focus::List || !b.list_verbs.is_empty(),
303            list_verbs: b.list_verbs,
304        }
305    }
306
307    /// Which half currently owns the keyboard. See [`Focus`].
308    pub fn focus(&self) -> Focus {
309        self.focus
310    }
311
312    pub fn poll(&mut self) {
313        let drained = self.loader.drain();
314        if !drained.is_empty() {
315            // A newer load delivered its first event(s): drop the prior load's
316            // rows so a streamed source starts from a clean slate (one-shot
317            // `Replace` overwrites anyway, but `Push` would otherwise append).
318            let current_gen = self.loader.generation();
319            if current_gen != self.applied_generation {
320                self.rows.clear();
321                self.selected = None;
322                self.selection_pinned = false;
323                self.offset = 0;
324                self.applied_generation = current_gen;
325            }
326            // Pushes only append, so row indices stay valid across the drain
327            // and the selection can follow its row wherever `order` puts it.
328            // A `Replace` swaps the whole set; indices mean nothing after it.
329            let keep = if drained
330                .iter()
331                .any(|ev| matches!(ev, LoadedInner::Replace(_)))
332            {
333                None
334            } else {
335                self.selection_to_carry()
336            };
337            for ev in drained {
338                match ev {
339                    LoadedInner::Replace(rows) => {
340                        self.rows = rows;
341                    }
342                    LoadedInner::Push(row) => {
343                        self.rows.push(row);
344                    }
345                    LoadedInner::Done => {}
346                }
347            }
348            self.recompute_and_seed();
349            self.reselect(keep);
350        }
351        if let Some(ac) = &mut self.autocomplete {
352            ac.poll_results();
353        }
354    }
355
356    /// Recompute the display order, then seed the selection to the first row
357    /// when nothing is selected yet (e.g. after the first load or a filter that
358    /// repopulated the list). The single place display + initial selection are
359    /// brought in sync.
360    fn recompute_and_seed(&mut self) {
361        self.recompute_display();
362        if self.selected.is_none() && self.visible_len() > 0 {
363            self.selected = Some(0);
364        }
365    }
366
367    /// Build a host snapshot from the current input state.
368    /// Only reads `self.input` so the result can be stored in a local
369    /// before taking `&mut self.autocomplete`, resolving the borrow conflict.
370    fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
371        let value = self.input.value().to_string();
372        let cursor_byte = self.input.cursor_byte();
373        let col = value[..cursor_byte.min(value.len())].chars().count();
374        host::SearchBoxHostSnapshot {
375            lines: vec![value],
376            cursor: (0, col),
377            caret_pos: self.input.last_caret_pos(),
378        }
379    }
380
381    fn clamp_selection(&mut self) {
382        let len = self.visible_len();
383        self.selected = if len == 0 {
384            None
385        } else {
386            Some(self.selected.unwrap_or(0).min(len - 1))
387        };
388    }
389
390    /// `1` when a leading row is pinned at visible position 0, else `0`.
391    fn leading_offset(&self) -> usize {
392        self.leading.is_some() as usize
393    }
394
395    /// Length of the visible sequence `[leading?] ++ display`.
396    pub fn visible_len(&self) -> usize {
397        self.leading_offset() + self.display.len()
398    }
399
400    /// Number of real matches — the visible rows minus the synthetic leading
401    /// affordance ("Create: …"), for result-count displays.
402    pub fn match_count(&self) -> usize {
403        self.display.len()
404    }
405
406    /// Row at visible position `pos` in `[leading?] ++ display`.
407    fn visible_row(&self, pos: usize) -> Option<&R> {
408        if self.leading.is_some() && pos == 0 {
409            self.leading.as_ref()
410        } else {
411            self.rows
412                .get(*self.display.get(pos - self.leading_offset())?)
413        }
414    }
415
416    /// The source-delivered rows only (NOT the leading row). Prefer
417    /// [`visible_len`](Self::visible_len)/[`visible_rows`](Self::visible_rows)
418    /// for visible counts.
419    pub fn rows(&self) -> &[R] {
420        &self.rows
421    }
422
423    pub fn selected_row(&self) -> Option<&R> {
424        self.selected.and_then(|p| self.visible_row(p))
425    }
426
427    pub fn visible_rows(&self) -> Vec<&R> {
428        (0..self.visible_len())
429            .filter_map(|p| self.visible_row(p))
430            .collect()
431    }
432
433    pub fn query(&self) -> &str {
434        &self.query
435    }
436
437    /// Take the name of a just-accepted saved search, if any. The host calls
438    /// this after a `Consumed` key to learn whether to pin (or refresh) the
439    /// saved-search breadcrumb. Returns `None` once read.
440    pub fn take_accepted_saved_search(&mut self) -> Option<String> {
441        self.accepted_saved_search.take()
442    }
443
444    /// The visible text in the query input widget. Test-only: lets callers
445    /// assert the input bar reflects a programmatic query change.
446    #[cfg(test)]
447    pub(crate) fn input_value(&self) -> &str {
448        self.input.value()
449    }
450    pub fn is_loading(&self) -> bool {
451        self.loader.loading
452    }
453
454    /// Set the query programmatically: updates the visible input widget (cursor
455    /// to end) AND the query string, then starts a load (for `reload_on_query`
456    /// sources) or recomputes the display. This is the setter every external
457    /// caller wants — a saved search applied, a sort directive rewritten — so
458    /// the input bar always reflects the query. The interactive keystroke path
459    /// uses `sync_query_from_input` instead,
460    /// because the input widget already holds the typed text (and its cursor
461    /// must not jump back to the end on every keystroke).
462    pub fn set_query(&mut self, q: impl Into<String>) {
463        let q = q.into();
464        self.input.set_value(q.clone());
465        self.query = q;
466        self.requery();
467    }
468
469    /// Pull the query string FROM the input widget without touching the widget
470    /// (so the cursor stays put), then reload/recompute. The keystroke and
471    /// autocomplete-accept paths use this after they have already mutated the
472    /// input in place.
473    fn sync_query_from_input(&mut self) {
474        self.query = self.input.value().to_string();
475        self.requery();
476    }
477
478    /// Start a fresh load for `reload_on_query` sources, else recompute the
479    /// local display. The generation guard in `LoadEngine` drops stale results.
480    fn requery(&mut self) {
481        if self.source.reload_on_query() {
482            self.loader.start(self.source.clone(), self.query.clone());
483        }
484        // Recompute now so the query-fresh leading row (and local filter, for
485        // non-reload sources) reflect the new query in this frame. Reload
486        // sources refresh again when their load drains in poll().
487        self.recompute_and_seed();
488    }
489
490    /// Re-run the source load for the current query (e.g. after a mutation).
491    pub fn reload(&mut self) {
492        self.loader.start(self.source.clone(), self.query.clone());
493    }
494
495    /// Replace (or clear) the row order and re-sort the rows already loaded.
496    /// A recompute, not a reload: the source is not consulted. A selection the
497    /// user chose stays on the row it was on; a seeded one keeps the top slot
498    /// of the NEW order — and stays a seed, so a still-streaming listing goes
499    /// on re-seeding it (the `selection_pinned` split, as in `poll`).
500    pub fn set_order(&mut self, cmp: Option<OrderFn<R>>) {
501        let keep = self.selection_to_carry();
502        self.order = cmp;
503        self.recompute_display();
504        self.reselect(keep);
505    }
506
507    /// The row a recompute has to put the selection back on: the row the user
508    /// chose, or `None` for a seed — a seed belongs to the top of the order,
509    /// wherever the recompute puts it. The one rule behind `poll`, `set_order`
510    /// and `update_rows`.
511    fn selection_to_carry(&self) -> Option<usize> {
512        self.selection_pinned
513            .then(|| self.selected_row_index())
514            .flatten()
515    }
516
517    /// Index into `rows` of the selected row, if the selection is on a real
518    /// (non-leading) row.
519    fn selected_row_index(&self) -> Option<usize> {
520        let pos = self.selected?.checked_sub(self.leading_offset())?;
521        self.display.get(pos).copied()
522    }
523
524    /// Move the selection back onto the row at `rows[row]` after the display
525    /// was recomputed, if that row is still visible; otherwise leave it where
526    /// the recompute clamped it.
527    fn reselect(&mut self, row: Option<usize>) {
528        if let Some(row) = row
529            && let Some(pos) = self.display.iter().position(|&i| i == row)
530        {
531            self.selected = Some(pos + self.leading_offset());
532        }
533    }
534
535    /// Mutate rows in place. `mutate` is called for each row and returns `true`
536    /// for each row it changed; if any did, the display is recomputed — which
537    /// re-filters AND re-sorts, so an edit to a field the `order_by` reads
538    /// moves its row. Returns whether anything changed.
539    ///
540    /// The selection is carried the same way every other recompute carries it:
541    /// a chosen row stays chosen wherever it lands, a seed keeps the top slot.
542    ///
543    /// This is the one seam that touches rows outside the [`RowSource`]; every
544    /// other change rebuilds from the source. Structural changes (add/remove)
545    /// must still reload; a reorder is [`set_order`](Self::set_order).
546    /// `SearchList` stays ignorant of the row type; callers layer the
547    /// path-matched operations on top.
548    pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
549        let mut changed = false;
550        for row in &mut self.rows {
551            if mutate(row) {
552                changed = true;
553            }
554        }
555        if changed {
556            let keep = self.selection_to_carry();
557            self.recompute_display();
558            self.reselect(keep);
559        }
560        changed
561    }
562
563    /// Select the visible row at `pos` (clamped to the visible range); clears
564    /// the selection when the list is empty. The index-based counterpart to
565    /// [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev),
566    /// for surfaces that point the cursor at a specific row (the Sources view's
567    /// citation jump).
568    pub fn select(&mut self, pos: usize) {
569        let n = self.visible_len();
570        self.selected = if n == 0 { None } else { Some(pos.min(n - 1)) };
571        // Unconditional: aiming at a row IS the choice, even when it happens
572        // to be the row the seed already sat on. Only the blind nudges below
573        // have to prove they moved.
574        self.selection_pinned = self.selected.is_some();
575    }
576
577    pub fn select_next(&mut self) {
578        let n = self.visible_len();
579        if n == 0 {
580            return;
581        }
582        self.move_selection(Some(self.selected.map_or(0, |i| (i + 1).min(n - 1))));
583    }
584
585    pub fn select_prev(&mut self) {
586        if self.visible_len() == 0 {
587            return;
588        }
589        self.move_selection(Some(self.selected.map_or(0, |i| i.saturating_sub(1))));
590    }
591
592    /// Move the selection, marking it the user's own choice only when it
593    /// actually lands somewhere new. A nudge against either end of the list
594    /// changes nothing, so it must not turn a seed into a choice — one stray
595    /// `Up` on the first row of a streaming listing would otherwise freeze the
596    /// highlight at position 0 and let rows arriving above it walk underneath.
597    fn move_selection(&mut self, next: Option<usize>) {
598        if next != self.selected {
599            self.selected = next;
600            self.selection_pinned = next.is_some();
601        }
602    }
603
604    /// Largest useful viewport offset: the first visible position from which
605    /// the rows through the end still fill the recorded list rect. Scrolling
606    /// past it would leave blank space below the last row, so
607    /// [`scroll_down`](Self::scroll_down) clamps to it.
608    fn max_scroll_offset(&self) -> usize {
609        let viewport = self.list_rect.height as usize;
610        let n = self.visible_len();
611        if viewport == 0 || n == 0 {
612            return 0;
613        }
614        let mut budget = viewport;
615        let mut first = n;
616        while first > 0 {
617            let h = self
618                .visible_row(first - 1)
619                .map(|r| r.visual_height() as usize)
620                .unwrap_or(1);
621            if h > budget {
622                break;
623            }
624            budget -= h;
625            first -= 1;
626        }
627        first.min(n - 1)
628    }
629
630    /// Scroll the viewport one row down, carrying the selection along so the
631    /// selected row keeps its on-screen position. No-op once the last row is
632    /// in view — the shared mouse-wheel behavior for every list surface.
633    pub fn scroll_down(&mut self) {
634        let n = self.visible_len();
635        if n == 0 || self.offset >= self.max_scroll_offset() {
636            return;
637        }
638        self.offset += 1;
639        self.move_selection(self.selected.map(|i| (i + 1).min(n - 1)));
640    }
641
642    /// Scroll the viewport one row up, carrying the selection along so the
643    /// selected row keeps its on-screen position. No-op at the top.
644    pub fn scroll_up(&mut self) {
645        if self.offset == 0 {
646            return;
647        }
648        self.offset -= 1;
649        self.move_selection(self.selected.map(|i| i.saturating_sub(1)));
650    }
651
652    /// The current viewport offset. Test-only: lets scroll tests assert the
653    /// viewport moved while the selection kept its screen position.
654    #[cfg(test)]
655    pub(crate) fn scroll_offset(&self) -> usize {
656        self.offset
657    }
658
659    /// Whether `key` is one of this list's yank chords. For surfaces that do
660    /// NOT route every key into the engine (`ListPanelSpec::HAS_FILTER = false`,
661    /// e.g. the LINKS drawer, where plain letters are the host's sub-view keys):
662    /// they forward only what they recognise, and without this the yank chord
663    /// would be the one thing their rows declare but can never deliver.
664    pub fn is_yank_chord(&self, key: &KeyEvent) -> bool {
665        crate::keys::key_event_to_combo(key).is_some_and(|c| self.yank_combos.contains(&c))
666    }
667
668    pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
669        use ratatui::crossterm::event::{KeyCode, KeyModifiers};
670
671        // Caller-registered intercepts get first crack — before autocomplete or
672        // any built-in binding.
673        if let Some(combo) = crate::keys::key_event_to_combo(key)
674            && self.intercept.contains(&combo)
675        {
676            return KeyReaction::Intercepted(combo);
677        }
678
679        // Autocomplete popup gets first crack when open. Build snapshot before
680        // taking &mut self.autocomplete to avoid borrow-checker conflict
681        // (snapshot only reads self.input).
682        if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
683            let snap = self.autocomplete_snapshot();
684            if let Some(ac) = &mut self.autocomplete {
685                match ac.handle_key(*key, &snap) {
686                    HandleKeyOutcome::Accepted(action) => {
687                        self.input.replace_range_bytes(
688                            action.range.clone(),
689                            &action.new_text,
690                            action.new_cursor_byte,
691                        );
692                        // Stash any accepted SavedSearch name for the host's
693                        // breadcrumb (`None` for every other kind). The host
694                        // reads it on this same `Consumed`, so a plain assign
695                        // never clobbers an unread value.
696                        self.accepted_saved_search = action.saved_search_name;
697                        self.sync_query_from_input();
698                        return KeyReaction::Consumed;
699                    }
700                    HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
701                        return KeyReaction::Consumed;
702                    }
703                    HandleKeyOutcome::NotHandled => {}
704                }
705            }
706        }
707
708        // Arrows navigate and Enter submits in BOTH foci (arrows as today).
709        match key.code {
710            KeyCode::Up => {
711                self.select_prev();
712                return KeyReaction::Consumed;
713            }
714            KeyCode::Down => {
715                self.select_next();
716                return KeyReaction::Consumed;
717            }
718            KeyCode::Enter => return KeyReaction::Submit,
719            _ => {}
720        }
721        // Esc: with the list-focus machine active, the first Esc moves Input →
722        // List focus (Consumed); from List focus (and for surfaces that never
723        // opted in) Esc is Cancel, so their keystroke behavior is unchanged.
724        if key.code == KeyCode::Esc {
725            if self.focus_enabled && self.focus == Focus::Input {
726                self.focus = Focus::List;
727                self.close_autocomplete();
728                return KeyReaction::Consumed;
729            }
730            return KeyReaction::Cancel;
731        }
732        // The yank chord, claimed above the Ctrl/Alt drop below (which would
733        // otherwise swallow it). Every surface built on SearchList gets this
734        // without wiring a key — the note browser lacked one for exactly that
735        // reason. What gets copied is the ROW's business; performing
736        // the copy is the CALLER's, since SearchList holds no `AppTx`.
737        if let Some(combo) = crate::keys::key_event_to_combo(key)
738            && self.yank_combos.contains(&combo)
739        {
740            return KeyReaction::Yank(self.selected_row().and_then(|r| r.yank_target()));
741        }
742        // Drop Ctrl/Alt-modified chars so combos don't leak as text (both foci;
743        // registered intercepts already claimed theirs above).
744        if let KeyCode::Char(_) = key.code {
745            let non_shift = key.modifiers - KeyModifiers::SHIFT;
746            if !non_shift.is_empty() {
747                return KeyReaction::Unhandled;
748            }
749        }
750        // List focus: plain letters are verbs, never query text.
751        if self.focus == Focus::List {
752            if let KeyCode::Char(c) = key.code {
753                return match c {
754                    // `i` / `/` return to the input (cursor there, typing filters).
755                    'i' | '/' => {
756                        self.focus = Focus::Input;
757                        KeyReaction::Consumed
758                    }
759                    'j' => {
760                        self.select_next();
761                        KeyReaction::Consumed
762                    }
763                    'k' => {
764                        self.select_prev();
765                        KeyReaction::Consumed
766                    }
767                    _ if self.list_verbs.contains(&c) => KeyReaction::ListVerb(c),
768                    // Unregistered letters do NOTHING — never type into the query.
769                    _ => KeyReaction::Consumed,
770                };
771            }
772            // Other keys (Tab, function keys, …) are the surface's to handle.
773            return KeyReaction::Unhandled;
774        }
775        let outcome = self.input.handle_key(key);
776        // Sync/refresh/close the autocomplete popup based on the input outcome.
777        // Build snapshot before taking &mut self.autocomplete (same borrow trick).
778        let snap = self.autocomplete_snapshot();
779        match outcome {
780            InputOutcome::Changed => {
781                if let Some(ac) = &mut self.autocomplete {
782                    ac.sync(&snap);
783                }
784            }
785            InputOutcome::Consumed => {
786                if let Some(ac) = &mut self.autocomplete {
787                    ac.refresh_if_open(&snap);
788                }
789            }
790            InputOutcome::Cancel | InputOutcome::Submit => {
791                if let Some(ac) = &mut self.autocomplete {
792                    ac.close();
793                }
794            }
795            InputOutcome::NotConsumed => {}
796        }
797        match outcome {
798            InputOutcome::Changed => {
799                self.sync_query_from_input();
800                KeyReaction::Consumed
801            }
802            InputOutcome::Consumed => KeyReaction::Consumed,
803            InputOutcome::Submit => KeyReaction::Submit,
804            InputOutcome::Cancel => KeyReaction::Cancel,
805            InputOutcome::NotConsumed => KeyReaction::Unhandled,
806        }
807    }
808
809    pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
810        // The query input signals focus only when the panel is focused AND the
811        // input half owns the keyboard; in list focus it renders unfocused
812        // (dimmed, cursor hidden). For surfaces that never opt into list focus
813        // `self.focus` is always `Input`, so this is byte-identical to today.
814        let focused = focused && self.focus == Focus::Input;
815        let base = Style::default()
816            .fg(theme.fg.to_ratatui())
817            .bg(theme.bg_panel.to_ratatui());
818        if self.highlight_query {
819            let line =
820                crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
821            self.input.render_line(f, area, line, base, 0, focused);
822        } else {
823            self.input.render(f, area, base, 0, focused);
824        }
825    }
826
827    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
828        self.poll();
829        let sel = self.selected;
830        let items: Vec<ListItem> = (0..self.visible_len())
831            .filter_map(|pos| {
832                self.visible_row(pos)
833                    .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
834            })
835            .collect();
836        let mut state = ListState::default().with_offset(self.offset);
837        state.select(self.selected);
838        let list =
839            List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
840        f.render_stateful_widget(list, area, &mut state);
841        // Read the offset back: ratatui clamps it and keeps the selection in
842        // view (keyboard moves included), so the stored offset always matches
843        // what is actually on screen.
844        self.offset = state.offset();
845        self.list_rect = area;
846        let _ = focused;
847    }
848
849    /// Override the rect used for mouse hit-testing. The recorded rect must be
850    /// the area where list ITEMS actually render — row 0 is the first item, NOT
851    /// a block border. Hosts that draw the list inside a bordered block pass the
852    /// block's INNER rect; borderless hosts pass the list area directly. The
853    /// recorded rect and the rendered-items rect MUST be identical, so
854    /// [`handle_mouse`] maps a click at `row` to visual offset `row - rect.y`.
855    ///
856    /// [`handle_mouse`]: Self::handle_mouse
857    pub fn set_list_rect(&mut self, rect: Rect) {
858        self.list_rect = rect;
859    }
860
861    /// Record the host panel's full bounds so the wheel scrolls the list from
862    /// anywhere within the panel — header, query box, preview — not just over
863    /// the list items. Hosts call this each render with the same rect they
864    /// were drawn into. Never set = wheel hit-tests `list_rect` only.
865    pub fn set_panel_rect(&mut self, rect: Rect) {
866        self.panel_rect = rect;
867    }
868
869    /// Record a host-owned scrollable sub-region (e.g. an expanded preview):
870    /// wheel events inside it are routed back to the host as
871    /// [`SearchMouse::ContentScrollUp`]/[`ContentScrollDown`] instead of
872    /// scrolling the list. Hosts re-record it every render (empty when the
873    /// sub-region is not drawn) so the hit-test never sees a stale rect.
874    ///
875    /// [`ContentScrollDown`]: SearchMouse::ContentScrollDown
876    pub fn set_content_rect(&mut self, rect: Rect) {
877        self.content_rect = rect;
878    }
879
880    /// Test-only: the recorded content sub-region (empty when none is on
881    /// screen), so host tests can hit-test against where the preview was
882    /// drawn.
883    #[cfg(test)]
884    pub(crate) fn content_rect(&self) -> Rect {
885        self.content_rect
886    }
887
888    pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
889        if let Some(ac) = &mut self.autocomplete {
890            ac.poll_results();
891            let caret = self.input.last_caret_pos();
892            if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
893                state.anchor = anchor;
894            }
895            if let Some(state) = ac.state() {
896                crate::components::autocomplete::render(f, state, clamp, theme);
897            }
898        }
899    }
900
901    /// Close an open autocomplete popup. [`handle_mouse`] does this for every
902    /// event it sees ("any mouse interaction dismisses the popup"); hosts that
903    /// consume a mouse event WITHOUT routing it through the engine call this
904    /// to keep that rule intact.
905    ///
906    /// [`handle_mouse`]: Self::handle_mouse
907    pub fn close_autocomplete(&mut self) {
908        if let Some(ac) = &mut self.autocomplete {
909            ac.close();
910        }
911    }
912
913    /// Test-only: true when the autocomplete popup is open, so host tests
914    /// can assert the any-mouse-interaction-dismisses rule.
915    #[cfg(test)]
916    pub(crate) fn autocomplete_is_open(&self) -> bool {
917        self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
918    }
919
920    pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
921        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
922        use ratatui::layout::Position;
923        // Any mouse interaction dismisses an open autocomplete popup (matches
924        // the old modal: a click on the preview/border closes a stale popup).
925        self.close_autocomplete();
926        let pos = Position {
927            x: m.column,
928            y: m.row,
929        };
930        // The wheel is hit-tested against the host's panel bounds (when
931        // recorded), so scrolling works from anywhere within the panel;
932        // clicks below keep hit-testing the list rect only.
933        if matches!(
934            m.kind,
935            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
936        ) {
937            // The host's content sub-region wins over the panel bounds: a
938            // wheel inside it is the host's to handle (it scrolls its own
939            // view), so route it back instead of moving the list.
940            if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
941                return if m.kind == MouseEventKind::ScrollUp {
942                    SearchMouse::ContentScrollUp
943                } else {
944                    SearchMouse::ContentScrollDown
945                };
946            }
947            let bounds = if self.panel_rect.is_empty() {
948                self.list_rect
949            } else {
950                self.panel_rect
951            };
952            if !bounds.contains(pos) {
953                return SearchMouse::None;
954            }
955            if m.kind == MouseEventKind::ScrollUp {
956                self.scroll_up();
957            } else {
958                self.scroll_down();
959            }
960            return SearchMouse::Scrolled;
961        }
962        let r = self.list_rect;
963        if !r.contains(pos) {
964            return SearchMouse::None;
965        }
966        match m.kind {
967            MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
968                let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
969                let target_visual = m.row - r.y; // 0-based visual offset; row 0 = first item
970                let mut acc: u16 = 0;
971                let mut hit: Option<usize> = None;
972                // Walk the VISIBLE sequence (leading row at position 0, then the
973                // display rows) starting at the viewport offset — screen row 0
974                // is the item at `offset`, not visible position 0 — so visual
975                // offsets map to the positions actually on screen.
976                for pos in self.offset..self.visible_len() {
977                    let h = self
978                        .visible_row(pos)
979                        .map(|r| r.visual_height())
980                        .unwrap_or(1);
981                    if target_visual < acc + h {
982                        hit = Some(pos);
983                        break;
984                    }
985                    acc += h;
986                }
987                if let Some(pos) = hit {
988                    let prev = self.selected;
989                    let prev_click = self.last_click_pos.replace(pos);
990                    self.selected = Some(pos);
991                    self.selection_pinned = true;
992                    return if right_click {
993                        SearchMouse::Context(pos)
994                    } else if prev == Some(pos) && prev_click == Some(pos) {
995                        // Activate only on click-click: the row was already
996                        // selected BY A CLICK, not by auto-select or keys.
997                        SearchMouse::Activated(pos)
998                    } else {
999                        SearchMouse::Selected(pos)
1000                    };
1001                }
1002                SearchMouse::None
1003            }
1004            _ => SearchMouse::None,
1005        }
1006    }
1007
1008    fn recompute_display(&mut self) {
1009        let q = self.query.trim();
1010        // The leading row is query-fresh: rebuilt on every poll AND on every
1011        // local-filter `set_query`, so it never goes stale.
1012        self.leading = self.source.leading_row(q);
1013        // Source order, or `order_by` order when one is set.
1014        let mut base: Vec<usize> = (0..self.rows.len()).collect();
1015        if let Some(cmp) = &self.order {
1016            let rows = &self.rows;
1017            base.sort_by(|&a, &b| cmp(&rows[a], &rows[b]));
1018        }
1019        let mut idx: Vec<usize> = match &self.filter {
1020            Filter::SourceOrder => base,
1021            Filter::Fuzzy if q.is_empty() => base,
1022            Filter::Fuzzy => fuzzy_indices(&self.rows, &base, q),
1023            Filter::Rank(_) if q.is_empty() => base,
1024            Filter::Rank(f) => {
1025                let f = f.clone();
1026                f(&self.rows, &base, q)
1027            }
1028        };
1029        // Filter-exempt rows (match_text() == None: Up / Create / virtual pinned)
1030        // are always present; prepend any that the filter dropped.
1031        for i in 0..self.rows.len() {
1032            if self.rows[i].match_text().is_none() && !idx.contains(&i) {
1033                idx.insert(0, i);
1034            }
1035        }
1036        self.display = idx;
1037        self.clamp_selection();
1038    }
1039
1040    #[cfg(test)]
1041    pub(crate) async fn poll_until_idle(&mut self) {
1042        // In-memory sources settle on the first poll (no sleep paid). Vault-backed
1043        // sources run their read on a worker/blocking thread, which can starve
1044        // under the full parallel suite — so once still loading, sleep a little
1045        // between polls and use a generous ceiling. Early-breaks the instant the
1046        // load lands, keeping the common (in-memory) path fast.
1047        for _ in 0..600 {
1048            tokio::task::yield_now().await;
1049            self.poll();
1050            if !self.is_loading() {
1051                break;
1052            }
1053            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1054        }
1055        self.poll();
1056    }
1057}
1058
1059impl<R: SearchRow> SearchListBuilder<R> {
1060    pub fn initial_query(mut self, q: impl Into<String>) -> Self {
1061        self.initial_query = q.into();
1062        self
1063    }
1064    pub fn filter(mut self, f: Filter<R>) -> Self {
1065        self.filter = f;
1066        self
1067    }
1068    /// Keep the rows in this order regardless of arrival order. Composes with
1069    /// any [`Filter`]: with an empty query the order is the display order;
1070    /// under `Fuzzy` it decides ties between equally-scored matches. Change it
1071    /// later with [`SearchList::set_order`].
1072    pub fn order_by(mut self, cmp: OrderFn<R>) -> Self {
1073        self.order = Some(cmp);
1074        self
1075    }
1076    pub fn autocomplete(
1077        mut self,
1078        suggestions: Arc<dyn SuggestionSource>,
1079        mode: AutocompleteMode,
1080    ) -> Self {
1081        self.autocomplete = Some((suggestions, mode));
1082        self
1083    }
1084    /// Bind the yank chord to whatever the user has bound
1085    /// [`ActionShortcuts::YankRow`](crate::keys::action_shortcuts::ActionShortcuts::YankRow) to.
1086    /// Surfaces that hold `KeyBindings` should always call this — the builder
1087    /// default is only for those that do not (and for tests).
1088    pub fn yank_combos_from(self, bindings: &crate::keys::KeyBindings) -> Self {
1089        self.yank_combos(
1090            bindings.combos_for(&crate::keys::action_shortcuts::ActionShortcuts::YankRow),
1091        )
1092    }
1093
1094    /// Override the yank chords directly (see [`Self::yank_combos_from`]).
1095    /// An empty list disables the chord for this surface.
1096    pub fn yank_combos(mut self, combos: Vec<KeyCombo>) -> Self {
1097        self.yank_combos = combos;
1098        self
1099    }
1100
1101    pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
1102        self.intercept = v;
1103        self
1104    }
1105    /// Render the query input with §9 syntax highlighting.
1106    pub fn highlight_query(mut self) -> Self {
1107        self.highlight_query = true;
1108        self
1109    }
1110    pub fn icons(mut self, icons: Icons) -> Self {
1111        self.icons = icons;
1112        self
1113    }
1114    /// The focus the surface opens on (default [`Focus::Input`]). Opening on
1115    /// [`Focus::List`] also activates the list-focus state machine (so `Esc`
1116    /// cancels from the list rather than flipping into it).
1117    pub fn opening_focus(mut self, focus: Focus) -> Self {
1118        self.opening_focus = focus;
1119        self
1120    }
1121    /// Register a plain letter as a list-focus verb. In [`Focus::List`],
1122    /// pressing it returns [`KeyReaction::ListVerb`] with the char; the engine
1123    /// attaches no meaning — the caller decides the action. Registering any
1124    /// verb activates the list-focus state machine. `j`/`k`/`i`/`/` are
1125    /// reserved (navigation and focus switching) and win over a same-letter
1126    /// verb.
1127    pub fn list_verb(mut self, c: char) -> Self {
1128        self.list_verbs.push(c);
1129        self
1130    }
1131    /// Override the autocomplete controller's debounce. Tests use
1132    /// `Duration::ZERO` to get suggestions without waiting on the debounce timer.
1133    pub fn debounce(mut self, d: std::time::Duration) -> Self {
1134        self.debounce = Some(d);
1135        self
1136    }
1137    pub fn build(self) -> SearchList<R> {
1138        SearchList::new(self)
1139    }
1140
1141    /// Build synchronously over a known, in-memory row set: the rows are
1142    /// applied and the initial selection seeded before this returns — no async
1143    /// load, no channel, no redraw round-trip. For static sources
1144    /// (`reload_on_query() == false`); the source's `load` is never called, so
1145    /// most static consumers pair this with [`StaticRowSource`]. The redraw
1146    /// callback passed to [`builder`](SearchList::builder) is never fired on
1147    /// this path.
1148    ///
1149    /// [`StaticRowSource`]: crate::components::search_list::StaticRowSource
1150    pub fn build_with_rows(self, rows: Vec<R>) -> SearchList<R> {
1151        SearchList::with_rows(self, rows)
1152    }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::adapters::{
1158        ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
1159        VecSource, VecSourceWithLead,
1160    };
1161    use super::*;
1162    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1163
1164    fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
1165        std::sync::Arc::new(|| {})
1166    }
1167
1168    fn key(c: KeyCode) -> KeyEvent {
1169        KeyEvent::new(c, KeyModifiers::NONE)
1170    }
1171
1172    // ── The yank chord ────────────────────────────────────────────
1173    //
1174    // The bug these pin: yanking the selected row was hand-rolled per surface,
1175    // so the note browser — the most-reached list — silently had none. Claiming
1176    // the chord here means every SearchList surface gets it.
1177
1178    fn yank_list(rows: &[&str]) -> SearchList<TestRow> {
1179        SearchList::builder(
1180            VecSource {
1181                rows: vec![],
1182                reload: false,
1183            },
1184            noop_redraw(),
1185        )
1186        .build_with_rows(rows.iter().map(|n| TestRow::new(n)).collect())
1187    }
1188
1189    fn ctrl(c: char) -> KeyEvent {
1190        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1191    }
1192
1193    #[test]
1194    fn yank_chord_reports_the_selected_rows_target() {
1195        let mut list = yank_list(&["alpha", "beta"]);
1196        match list.handle_key(&ctrl('y')) {
1197            KeyReaction::Yank(Some(t)) => {
1198                assert_eq!(t.text, "alpha");
1199                assert_eq!(t.noun, "path");
1200            }
1201            r => panic!("got {r:?}"),
1202        }
1203    }
1204
1205    #[test]
1206    fn yank_chord_reports_none_for_a_row_with_nothing_to_copy() {
1207        // Distinguishable from "the clipboard failed" only because the row
1208        // says so — the caller flashes "nothing to copy".
1209        let mut list = yank_list(&["quiet"]);
1210        list.select_next();
1211        assert!(matches!(
1212            list.handle_key(&ctrl('y')),
1213            KeyReaction::Yank(None)
1214        ));
1215    }
1216
1217    #[test]
1218    fn yank_chord_reports_none_when_nothing_is_selected() {
1219        let mut list = yank_list(&[]);
1220        assert!(matches!(
1221            list.handle_key(&ctrl('y')),
1222            KeyReaction::Yank(None)
1223        ));
1224    }
1225
1226    #[test]
1227    fn yank_chord_is_claimed_before_ctrl_chars_are_dropped() {
1228        // The regression guard: the Ctrl/Alt drop below the chord check
1229        // returns `Unhandled`, which is what swallowed a per-panel yank that
1230        // was registered too late in the ladder.
1231        let mut list = yank_list(&["alpha"]);
1232        list.select_next();
1233        assert!(
1234            !matches!(list.handle_key(&ctrl('y')), KeyReaction::Unhandled),
1235            "the yank chord must not fall through to the Ctrl-char drop"
1236        );
1237    }
1238
1239    #[test]
1240    fn a_rebound_yank_combo_replaces_the_default() {
1241        let mut list = SearchList::builder(
1242            VecSource {
1243                rows: vec![],
1244                reload: false,
1245            },
1246            noop_redraw(),
1247        )
1248        .yank_combos(vec![crate::keys::key_event_to_combo(&ctrl('k')).unwrap()])
1249        .build_with_rows(vec![TestRow::new("alpha")]);
1250        list.select_next();
1251        assert!(matches!(
1252            list.handle_key(&ctrl('k')),
1253            KeyReaction::Yank(Some(_))
1254        ));
1255        assert!(
1256            !matches!(list.handle_key(&ctrl('y')), KeyReaction::Yank(_)),
1257            "the default chord must stop yanking once overridden"
1258        );
1259    }
1260
1261    fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
1262        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1263        MouseEvent {
1264            kind: MouseEventKind::Down(MouseButton::Left),
1265            column: col,
1266            row,
1267            modifiers: KeyModifiers::NONE,
1268        }
1269    }
1270
1271    #[derive(Clone, Debug, PartialEq)]
1272    struct TallRow {
1273        name: String,
1274        height: u16,
1275    }
1276    impl SearchRow for TallRow {
1277        fn to_list_item(
1278            &self,
1279            _t: &crate::settings::themes::Theme,
1280            _i: &crate::settings::icons::Icons,
1281            _s: bool,
1282        ) -> ratatui::widgets::ListItem<'static> {
1283            ratatui::widgets::ListItem::new(self.name.clone())
1284        }
1285        fn visual_height(&self) -> u16 {
1286            self.height
1287        }
1288        fn match_text(&self) -> Option<&str> {
1289            Some(&self.name)
1290        }
1291    }
1292    struct TallSource(Vec<TallRow>);
1293    #[async_trait::async_trait]
1294    impl RowSource<TallRow> for TallSource {
1295        async fn load(&self, _q: &str, emit: Emit<TallRow>) {
1296            emit.replace(self.0.clone());
1297        }
1298    }
1299
1300    /// The wheel is routed to the host (ContentScroll*) inside the recorded
1301    /// content sub-region — which wins over the panel bounds — and scrolls
1302    /// the list everywhere else within the panel.
1303    #[tokio::test]
1304    async fn wheel_in_content_rect_routes_to_host() {
1305        use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
1306        let rows: Vec<TallRow> = (0..10)
1307            .map(|i| TallRow {
1308                name: format!("r{}", i),
1309                height: 1,
1310            })
1311            .collect();
1312        let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
1313        list.poll_until_idle().await;
1314        let rect = |y: u16, h: u16| ratatui::layout::Rect {
1315            x: 0,
1316            y,
1317            width: 20,
1318            height: h,
1319        };
1320        // Panel covers rows 0..10; list draws in 0..4; content region 5..10.
1321        list.set_panel_rect(rect(0, 10));
1322        list.set_list_rect(rect(0, 4));
1323        list.set_content_rect(rect(5, 5));
1324        let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
1325            kind,
1326            column: 2,
1327            row,
1328            modifiers: KeyModifiers::NONE,
1329        };
1330
1331        // Inside the content region: routed to the host, list untouched.
1332        let m = wheel(MouseEventKind::ScrollDown, 6);
1333        assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
1334        assert_eq!(list.offset, 0, "list viewport must not move");
1335        let m = wheel(MouseEventKind::ScrollUp, 6);
1336        assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
1337
1338        // Over the list (panel bounds, outside content): the list scrolls.
1339        let m = wheel(MouseEventKind::ScrollDown, 2);
1340        assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1341
1342        // Cleared sub-region: the wheel falls back to the panel-wide scroll.
1343        list.set_content_rect(ratatui::layout::Rect::default());
1344        let m = wheel(MouseEventKind::ScrollDown, 6);
1345        assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1346    }
1347
1348    #[tokio::test]
1349    async fn mouse_maps_visual_row_to_display_index_by_height() {
1350        // Row 0 occupies 3 visual rows, row 1 occupies 1. The recorded list rect
1351        // is the rendered-items area: row 0 == the FIRST item (no border row).
1352        let src = TallSource(vec![
1353            TallRow {
1354                name: "a".into(),
1355                height: 3,
1356            },
1357            TallRow {
1358                name: "b".into(),
1359                height: 1,
1360            },
1361        ]);
1362        let mut list = SearchList::builder(src, noop_redraw()).build();
1363        list.poll_until_idle().await;
1364        // Force the recorded list rect (render not run in test): items start at y=0.
1365        list.set_list_rect(ratatui::layout::Rect {
1366            x: 0,
1367            y: 0,
1368            width: 20,
1369            height: 10,
1370        });
1371        // "a" occupies rows 0..=2; row 3 is the FIRST row of "b".
1372        let m = mouse_down_at(2, 3);
1373        assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
1374        assert_eq!(list.selected_row().unwrap().name, "b");
1375        // A click at row 1 = within "a" (rows 0..=2) -> display index 0.
1376        let m = mouse_down_at(2, 1);
1377        list.handle_mouse(&m);
1378        assert_eq!(list.selected_row().unwrap().name, "a");
1379    }
1380
1381    // Mouse-wheel scrolling moves the VIEWPORT, carrying the selection along
1382    // so the selected row keeps its on-screen position (selected - offset is
1383    // invariant) — unlike keyboard navigation, which moves the selection.
1384    #[tokio::test]
1385    async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
1386        let src = VecSource {
1387            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1388            reload: true,
1389        };
1390        let mut list = SearchList::builder(src, noop_redraw()).build();
1391        list.poll_until_idle().await;
1392        // Viewport shows 4 of the 10 rows.
1393        list.set_list_rect(ratatui::layout::Rect {
1394            x: 0,
1395            y: 0,
1396            width: 20,
1397            height: 4,
1398        });
1399        // Move the selection to screen row 2 first.
1400        list.select_next();
1401        list.select_next();
1402        assert_eq!(list.selected_row().unwrap().name, "row2");
1403
1404        let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1405            kind,
1406            column: 1,
1407            row: 1,
1408            modifiers: KeyModifiers::NONE,
1409        };
1410        use ratatui::crossterm::event::MouseEventKind;
1411
1412        // Scroll down: viewport and selection move together.
1413        assert_eq!(
1414            list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1415            SearchMouse::Scrolled
1416        );
1417        assert_eq!(list.scroll_offset(), 1);
1418        assert_eq!(list.selected_row().unwrap().name, "row3");
1419
1420        // Scroll back up: both return.
1421        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1422        assert_eq!(list.scroll_offset(), 0);
1423        assert_eq!(list.selected_row().unwrap().name, "row2");
1424
1425        // At the top, scrolling up is a no-op (selection does NOT move).
1426        list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1427        assert_eq!(list.scroll_offset(), 0);
1428        assert_eq!(list.selected_row().unwrap().name, "row2");
1429
1430        // Scrolling down clamps once the last row is in view: 10 rows in a
1431        // 4-row viewport → max offset 6.
1432        for _ in 0..20 {
1433            list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1434        }
1435        assert_eq!(list.scroll_offset(), 6);
1436        assert_eq!(list.selected_row().unwrap().name, "row8");
1437        // The selection kept its screen row through the clamped scroll.
1438        // (row2 at offset 0 → screen row 2; row8 at offset 6 → screen row 2.)
1439    }
1440
1441    // The wheel hit-tests the recorded PANEL rect: scrolling over the host's
1442    // header/query box (outside the list rect) still scrolls the list. Without
1443    // a panel rect it falls back to the list rect only.
1444    #[tokio::test]
1445    async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1446        let src = VecSource {
1447            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1448            reload: true,
1449        };
1450        let mut list = SearchList::builder(src, noop_redraw()).build();
1451        list.poll_until_idle().await;
1452        // List items render at y 5..9; the panel spans y 0..20.
1453        list.set_list_rect(ratatui::layout::Rect {
1454            x: 0,
1455            y: 5,
1456            width: 20,
1457            height: 4,
1458        });
1459        let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1460            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1461            column: 1,
1462            row,
1463            modifiers: KeyModifiers::NONE,
1464        };
1465        // No panel rect: a scroll over the header (y=1) misses.
1466        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1467        assert_eq!(list.scroll_offset(), 0);
1468        list.set_panel_rect(ratatui::layout::Rect {
1469            x: 0,
1470            y: 0,
1471            width: 20,
1472            height: 20,
1473        });
1474        // With the panel rect, the same scroll-over-header scrolls the list.
1475        assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1476        assert_eq!(list.scroll_offset(), 1);
1477        // Clicks still hit-test the LIST rect only: a click on the header
1478        // (inside the panel, outside the list) selects nothing.
1479        let before = list.selected_row().unwrap().name.clone();
1480        assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1481        assert_eq!(list.selected_row().unwrap().name, before);
1482    }
1483
1484    // Regression: the click hit-test must account for the viewport offset —
1485    // after wheel scrolling, screen row 0 is the item at `offset`, not
1486    // visible position 0.
1487    #[tokio::test]
1488    async fn click_after_scroll_selects_the_clicked_row() {
1489        let src = VecSource {
1490            rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1491            reload: true,
1492        };
1493        let mut list = SearchList::builder(src, noop_redraw()).build();
1494        list.poll_until_idle().await;
1495        list.set_list_rect(ratatui::layout::Rect {
1496            x: 0,
1497            y: 0,
1498            width: 20,
1499            height: 4,
1500        });
1501        let scroll_down = ratatui::crossterm::event::MouseEvent {
1502            kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1503            column: 1,
1504            row: 1,
1505            modifiers: KeyModifiers::NONE,
1506        };
1507        for _ in 0..3 {
1508            list.handle_mouse(&scroll_down);
1509        }
1510        assert_eq!(list.scroll_offset(), 3);
1511        // Screen row 2 shows visible position offset + 2 = 5.
1512        assert!(matches!(
1513            list.handle_mouse(&mouse_down_at(2, 2)),
1514            SearchMouse::Selected(5)
1515        ));
1516        assert_eq!(list.selected_row().unwrap().name, "row5");
1517        // Screen row 0 shows the item at the offset itself.
1518        list.handle_mouse(&mouse_down_at(2, 0));
1519        assert_eq!(list.selected_row().unwrap().name, "row3");
1520    }
1521
1522    // The synchronous build seam: `build_with_rows` applies the rows and seeds
1523    // the selection in the same call — no poll, no spawn, `is_loading()` false
1524    // immediately. This is the static-source path (StaticRowSource); the row
1525    // set is readable on the very next line.
1526    #[tokio::test]
1527    async fn build_with_rows_applies_synchronously_without_a_poll() {
1528        let list = SearchList::builder(StaticRowSource, noop_redraw())
1529            .filter(Filter::Fuzzy)
1530            .build_with_rows(vec![TestRow::new("alpha"), TestRow::new("beta")]);
1531        // No poll, no settle: the rows and the seeded selection are live now.
1532        assert!(!list.is_loading(), "static build is not loading");
1533        assert_eq!(list.rows().len(), 2);
1534        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1535    }
1536
1537    #[tokio::test]
1538    async fn initial_load_populates_rows() {
1539        let src = VecSource {
1540            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1541            reload: true,
1542        };
1543        let mut list = SearchList::builder(src, noop_redraw()).build();
1544        list.poll_until_idle().await;
1545        assert_eq!(list.rows().len(), 2);
1546        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1547    }
1548
1549    #[tokio::test]
1550    async fn requery_supersedes_and_reloads() {
1551        let src = VecSource {
1552            rows: vec![
1553                TestRow::new("alpha"),
1554                TestRow::new("alps"),
1555                TestRow::new("beta"),
1556            ],
1557            reload: true,
1558        };
1559        let mut list = SearchList::builder(src, noop_redraw()).build();
1560        list.poll_until_idle().await;
1561        assert_eq!(list.rows().len(), 3);
1562        list.set_query("alp");
1563        list.poll_until_idle().await;
1564        assert_eq!(list.rows().len(), 2); // alpha, alps
1565        assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1566    }
1567
1568    #[tokio::test]
1569    async fn arrows_navigate_and_enter_submits() {
1570        let src = VecSource {
1571            rows: vec![TestRow::new("a"), TestRow::new("b")],
1572            reload: true,
1573        };
1574        let mut list = SearchList::builder(src, noop_redraw()).build();
1575        list.poll_until_idle().await;
1576        assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1577        assert_eq!(list.selected_row().unwrap().name, "b");
1578        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1579        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1580    }
1581
1582    #[tokio::test]
1583    async fn typing_a_char_changes_query() {
1584        let src = VecSource {
1585            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1586            reload: true,
1587        };
1588        let mut list = SearchList::builder(src, noop_redraw()).build();
1589        list.poll_until_idle().await;
1590        assert_eq!(
1591            list.handle_key(&key(KeyCode::Char('a'))),
1592            KeyReaction::Consumed
1593        );
1594        list.poll_until_idle().await;
1595        assert_eq!(list.query(), "a");
1596    }
1597
1598    #[tokio::test]
1599    async fn rank_filter_orders_by_closure() {
1600        let src = VecSource {
1601            rows: vec![
1602                TestRow::new("todo"),
1603                TestRow::new("today"),
1604                TestRow::new("misc"),
1605            ],
1606            reload: false,
1607        };
1608        let rank = std::sync::Arc::new(|rows: &[TestRow], base: &[usize], q: &str| -> Vec<usize> {
1609            let mut idx: Vec<usize> = base
1610                .iter()
1611                .copied()
1612                .filter(|&i| rows[i].name.contains(q))
1613                .collect();
1614            idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1615            idx
1616        });
1617        let mut list = SearchList::builder(src, noop_redraw())
1618            .filter(Filter::Rank(rank))
1619            .build();
1620        list.poll_until_idle().await;
1621        list.set_query("today");
1622        list.poll();
1623        assert_eq!(list.selected_row().unwrap().name, "today");
1624    }
1625
1626    #[tokio::test]
1627    async fn fuzzy_filter_narrows_local_set() {
1628        let src = VecSource {
1629            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1630            reload: false,
1631        };
1632        let mut list = SearchList::builder(src, noop_redraw())
1633            .filter(Filter::Fuzzy)
1634            .build();
1635        list.poll_until_idle().await;
1636        list.set_query("alp");
1637        list.poll();
1638        assert_eq!(list.visible_rows().len(), 1);
1639        assert_eq!(list.selected_row().unwrap().name, "alpha");
1640    }
1641
1642    #[tokio::test]
1643    async fn streamed_rows_arrive_then_done_and_filter_locally() {
1644        let src = ScriptedStreamSource {
1645            batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1646        };
1647        let mut list = SearchList::builder(src, noop_redraw())
1648            .filter(Filter::Fuzzy)
1649            .build();
1650        list.poll_until_idle().await;
1651        assert_eq!(list.rows().len(), 2);
1652        assert!(!list.is_loading());
1653        list.set_query("alp");
1654        list.poll();
1655        assert_eq!(list.visible_rows().len(), 1);
1656    }
1657
1658    #[tokio::test]
1659    async fn source_order_unfiltered_passthrough() {
1660        let src = VecSource {
1661            rows: vec![TestRow::new("a"), TestRow::new("b")],
1662            reload: true,
1663        };
1664        let mut list = SearchList::builder(src, noop_redraw()).build(); // default Filter::SourceOrder
1665        list.poll_until_idle().await;
1666        assert_eq!(list.visible_rows().len(), 2);
1667        assert_eq!(list.selected_row().unwrap().name, "a");
1668    }
1669
1670    #[tokio::test]
1671    async fn intercepted_combo_returns_intercepted_without_acting() {
1672        let src = VecSource {
1673            rows: vec![TestRow::new("a")],
1674            reload: true,
1675        };
1676        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1677        let mut list = SearchList::builder(src, noop_redraw())
1678            .intercept(vec![combo])
1679            .build();
1680        list.poll_until_idle().await;
1681        // Enter is intercepted: engine returns Intercepted, does NOT submit/act.
1682        assert_eq!(
1683            list.handle_key(&key(KeyCode::Enter)),
1684            KeyReaction::Intercepted(combo)
1685        );
1686    }
1687
1688    #[tokio::test]
1689    async fn autocomplete_accept_rewrites_query_without_vault() {
1690        struct Mem;
1691        #[async_trait::async_trait]
1692        impl crate::components::search_list::SuggestionSource for Mem {
1693            async fn notes_by_prefix(
1694                &self,
1695                _p: &str,
1696                _n: usize,
1697            ) -> Vec<crate::components::search_list::SuggestionItem> {
1698                vec![]
1699            }
1700            async fn tags_by_prefix(
1701                &self,
1702                p: &str,
1703                _n: usize,
1704            ) -> Vec<crate::components::search_list::SuggestionItem> {
1705                if "projects".starts_with(p) {
1706                    vec![crate::components::search_list::SuggestionItem::plain(
1707                        "projects",
1708                    )]
1709                } else {
1710                    vec![]
1711                }
1712            }
1713        }
1714        let src = VecSource {
1715            rows: vec![],
1716            reload: true,
1717        };
1718        let mut list = SearchList::builder(src, noop_redraw())
1719            .autocomplete(
1720                std::sync::Arc::new(Mem),
1721                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1722            )
1723            .debounce(std::time::Duration::ZERO)
1724            .build();
1725        for c in ['#', 'p', 'r', 'o'] {
1726            let _ = list.handle_key(&key(KeyCode::Char(c)));
1727        }
1728        for _ in 0..50 {
1729            tokio::task::yield_now().await;
1730            list.poll();
1731        }
1732        let _ = list.handle_key(&key(KeyCode::Tab));
1733        assert_eq!(list.query(), "#projects");
1734    }
1735
1736    // Accepting a SavedSearch suggestion expands the whole field to the
1737    // stored query AND exposes the accepted name (for the breadcrumb) via
1738    // `take_accepted_saved_search`.
1739    #[tokio::test]
1740    async fn accepting_saved_search_expands_query_and_exposes_name() {
1741        struct Mem;
1742        #[async_trait::async_trait]
1743        impl crate::components::search_list::SuggestionSource for Mem {
1744            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1745                vec![]
1746            }
1747            async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1748                vec![]
1749            }
1750            async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1751                if "todo-week".starts_with(p) {
1752                    vec![SuggestionItem {
1753                        display: "todo-week".into(),
1754                        secondary: Some("#todo ^modified".into()),
1755                    }]
1756                } else {
1757                    vec![]
1758                }
1759            }
1760        }
1761        let src = VecSource {
1762            rows: vec![],
1763            reload: true,
1764        };
1765        let mut list = SearchList::builder(src, noop_redraw())
1766            .autocomplete(
1767                std::sync::Arc::new(Mem),
1768                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1769            )
1770            .debounce(std::time::Duration::ZERO)
1771            .build();
1772        for c in ['?', 't', 'o'] {
1773            let _ = list.handle_key(&key(KeyCode::Char(c)));
1774        }
1775        for _ in 0..50 {
1776            tokio::task::yield_now().await;
1777            list.poll();
1778        }
1779        let _ = list.handle_key(&key(KeyCode::Tab));
1780        // Whole field expanded to the stored query.
1781        assert_eq!(list.query(), "#todo ^modified");
1782        // The accepted name is exposed once, then cleared.
1783        assert_eq!(
1784            list.take_accepted_saved_search().as_deref(),
1785            Some("todo-week")
1786        );
1787        assert_eq!(list.take_accepted_saved_search(), None);
1788    }
1789
1790    // Regression: Enter (not just Tab) must accept an open autocomplete popup,
1791    // and the engine must report Consumed — NOT Submit — so a host does not
1792    // mistake the accept for a list submit. (A QueryPanel Enter pre-check used
1793    // to swallow this, breaking accept-on-Enter in the right sidebar.)
1794    #[tokio::test]
1795    async fn enter_accepts_open_popup_and_reports_consumed() {
1796        struct Mem;
1797        #[async_trait::async_trait]
1798        impl crate::components::search_list::SuggestionSource for Mem {
1799            async fn notes_by_prefix(
1800                &self,
1801                _p: &str,
1802                _n: usize,
1803            ) -> Vec<crate::components::search_list::SuggestionItem> {
1804                vec![]
1805            }
1806            async fn tags_by_prefix(
1807                &self,
1808                p: &str,
1809                _n: usize,
1810            ) -> Vec<crate::components::search_list::SuggestionItem> {
1811                if "projects".starts_with(p) {
1812                    vec![crate::components::search_list::SuggestionItem::plain(
1813                        "projects",
1814                    )]
1815                } else {
1816                    vec![]
1817                }
1818            }
1819        }
1820        let src = VecSource {
1821            rows: vec![],
1822            reload: true,
1823        };
1824        let mut list = SearchList::builder(src, noop_redraw())
1825            .autocomplete(
1826                std::sync::Arc::new(Mem),
1827                crate::components::autocomplete::AutocompleteMode::SearchQuery,
1828            )
1829            .debounce(std::time::Duration::ZERO)
1830            .build();
1831        for c in ['#', 'p', 'r', 'o'] {
1832            let _ = list.handle_key(&key(KeyCode::Char(c)));
1833        }
1834        for _ in 0..50 {
1835            tokio::task::yield_now().await;
1836            list.poll();
1837        }
1838        // Popup is open: Enter accepts the suggestion and reports Consumed.
1839        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1840        assert_eq!(list.query(), "#projects");
1841        // Popup now closed: a second Enter falls through to Submit.
1842        assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1843    }
1844
1845    // Regression (P0): a STREAMED source (sidebar shape) supplies a query-fresh
1846    // leading row. It must appear at visible position 0 even though rows arrive
1847    // via Push (never Replace), be present when the query matches no streamed
1848    // row, and refresh when the query changes (reload_on_query() == false).
1849    #[tokio::test]
1850    async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1851        let src = ScriptedStreamLeadSource {
1852            items: vec!["alpha".into(), "beta".into()],
1853        };
1854        let mut list = SearchList::builder(src, noop_redraw())
1855            .filter(Filter::Fuzzy)
1856            .initial_query("zz")
1857            .build();
1858        list.poll_until_idle().await;
1859        // Leading present even though "zz" matches no streamed Item.
1860        let vis = list.visible_rows();
1861        assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1862        assert_eq!(list.visible_len(), 1); // just the leading; no Item matches
1863        // Query-fresh: changing the query rebuilds the leading and re-filters.
1864        list.set_query("alp");
1865        list.poll();
1866        let vis = list.visible_rows();
1867        assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1868        assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1869        assert_eq!(list.visible_len(), 2);
1870        // Empty query: leading disappears, both Items show.
1871        list.set_query("");
1872        list.poll();
1873        assert!(
1874            list.visible_rows()
1875                .iter()
1876                .all(|r| matches!(r, StreamRow::Item(_)))
1877        );
1878        assert_eq!(list.visible_len(), 2);
1879    }
1880
1881    // Regression guard for the saved-searches virtual entry: a one-shot
1882    // (Replace) source with a leading row still pins it at position 0.
1883    #[tokio::test]
1884    async fn oneshot_source_leading_row_still_works() {
1885        let src = VecSourceWithLead {
1886            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1887        };
1888        let mut list = SearchList::builder(src, noop_redraw())
1889            .filter(Filter::Fuzzy)
1890            .initial_query("alp")
1891            .build();
1892        list.poll_until_idle().await;
1893        let vis = list.visible_rows();
1894        assert_eq!(vis[0].name, "create:alp");
1895        assert_eq!(vis[1].name, "alpha");
1896        assert_eq!(list.visible_len(), 2);
1897    }
1898
1899    // Selection walks the VISIBLE sequence: position 0 is the leading row, and
1900    // select_next steps from the leading to the first real row.
1901    #[tokio::test]
1902    async fn selection_includes_leading_at_position_zero() {
1903        let src = VecSourceWithLead {
1904            rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1905        };
1906        let mut list = SearchList::builder(src, noop_redraw())
1907            .filter(Filter::Fuzzy)
1908            .initial_query("alp")
1909            .build();
1910        list.poll_until_idle().await;
1911        // Auto-selected position 0 -> the leading.
1912        assert_eq!(list.selected_row().unwrap().name, "create:alp");
1913        list.handle_key(&key(KeyCode::Down));
1914        assert_eq!(list.selected_row().unwrap().name, "alpha");
1915    }
1916
1917    // A source with NO leading row has no off-by-one: visible_len == display.
1918    #[tokio::test]
1919    async fn no_leading_row_visible_len_matches_display() {
1920        let src = VecSource {
1921            rows: vec![TestRow::new("a"), TestRow::new("b")],
1922            reload: true,
1923        };
1924        let mut list = SearchList::builder(src, noop_redraw()).build();
1925        list.poll_until_idle().await;
1926        assert_eq!(list.visible_len(), 2);
1927        assert_eq!(list.visible_rows().len(), 2);
1928        assert_eq!(list.selected_row().unwrap().name, "a");
1929    }
1930
1931    // update_rows re-runs the active fuzzy filter after the mutation so rows
1932    // that no longer match the query drop out of the visible view.
1933    #[tokio::test]
1934    async fn update_rows_refilters_visible_view() {
1935        let source = VecSource {
1936            rows: vec![
1937                TestRow::new("alpha"),
1938                TestRow::new("beta"),
1939                TestRow::new("gamma"),
1940            ],
1941            reload: false,
1942        };
1943        let mut list = SearchList::builder(source, noop_redraw())
1944            .filter(Filter::Fuzzy)
1945            .build();
1946        list.poll_until_idle().await;
1947
1948        // With query "alp", only "alpha" should be visible.
1949        list.set_query("alp");
1950        list.poll();
1951        assert_eq!(
1952            list.visible_rows()
1953                .iter()
1954                .map(|r| r.name.as_str())
1955                .collect::<Vec<_>>(),
1956            vec!["alpha"],
1957            "before update: only 'alpha' matches 'alp'"
1958        );
1959
1960        // Rename "alpha" to something that no longer contains "alp".
1961        let changed = list.update_rows(|r| {
1962            if r.name == "alpha" {
1963                r.name = "renamed".to_string();
1964                true
1965            } else {
1966                false
1967            }
1968        });
1969        assert!(changed);
1970
1971        // The visible view must now be empty: "renamed" does not match "alp".
1972        assert_eq!(
1973            list.visible_rows().len(),
1974            0,
1975            "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
1976        );
1977    }
1978
1979    #[tokio::test]
1980    async fn update_rows_mutates_in_place_and_recomputes() {
1981        let source = VecSource {
1982            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1983            reload: false,
1984        };
1985        let mut list = SearchList::builder(source, noop_redraw()).build();
1986        list.poll_until_idle().await;
1987
1988        // Mutate the row named "alpha".
1989        let changed = list.update_rows(|r| {
1990            if r.name == "alpha" {
1991                r.name = "renamed".to_string();
1992                true
1993            } else {
1994                false
1995            }
1996        });
1997        assert!(changed, "a row was changed");
1998        assert!(
1999            list.rows().iter().any(|r| r.name == "renamed"),
2000            "the mutation is visible in rows()"
2001        );
2002
2003        // A no-op mutation reports no change and does not panic.
2004        let changed_again = list.update_rows(|_| false);
2005        assert!(!changed_again, "no row changed");
2006    }
2007
2008    // Regression guard (Fix A): for reload_on_query == true sources that also
2009    // expose a leading row, set_query must rebuild the leading row synchronously
2010    // in the same frame — before any poll/drain. The old code skipped
2011    // recompute_and_seed() for reload sources, so the leading row lagged until
2012    // the async load landed. This test must FAIL without the fix (the leading
2013    // row still shows the old query immediately after set_query).
2014    #[tokio::test]
2015    async fn reload_source_leading_row_updates_synchronously_on_set_query() {
2016        let src = ReloadWithLeadSource {
2017            rows: vec![
2018                TestRow::new("alpha"),
2019                TestRow::new("beta"),
2020                TestRow::new("gamma"),
2021            ],
2022        };
2023        let mut list = SearchList::builder(src, noop_redraw()).build();
2024        list.poll_until_idle().await;
2025        // Sanity: no leading row for empty query.
2026        assert!(list.leading.is_none(), "no leading row for empty query");
2027
2028        // Change query — do NOT poll/drain after this.
2029        list.set_query("alp");
2030
2031        // The leading row must reflect the NEW query immediately (synchronously).
2032        let vis = list.visible_rows();
2033        assert!(
2034            !vis.is_empty(),
2035            "visible_rows must not be empty right after set_query"
2036        );
2037        assert_eq!(
2038            vis[0].name, "create:alp",
2039            "leading row must show new query synchronously, before any poll/drain"
2040        );
2041
2042        // The async load will also arrive, but the leading row must already be
2043        // correct without waiting for it.
2044        list.poll_until_idle().await;
2045        let vis = list.visible_rows();
2046        assert_eq!(
2047            vis[0].name, "create:alp",
2048            "leading row correct after drain too"
2049        );
2050        // Only "alpha" matches "alp" from the server-side filter.
2051        assert_eq!(vis.len(), 2, "leading + alpha");
2052        assert_eq!(vis[1].name, "alpha");
2053    }
2054
2055    // Regression guard: local-filter sources (reload_on_query == false) must
2056    // reseed the selection back to row 0 when a filter change repopulates the
2057    // list after having emptied it.
2058    //
2059    // The gate in `poll()` (only recompute when drain is non-empty) must NOT
2060    // suppress the reseed for local filters, because they go through
2061    // `requery()` → `recompute_and_seed()` directly — no loader drain.
2062    #[tokio::test]
2063    async fn local_filter_reseed_after_empty_then_repopulate() {
2064        let src = VecSource {
2065            rows: vec![
2066                TestRow::new("alpha"),
2067                TestRow::new("beta"),
2068                TestRow::new("gamma"),
2069            ],
2070            reload: false,
2071        };
2072        let mut list = SearchList::builder(src, noop_redraw())
2073            .filter(Filter::Fuzzy)
2074            .build();
2075        list.poll_until_idle().await;
2076
2077        // Sanity: initial load selected the first row.
2078        assert!(
2079            list.selected_row().is_some(),
2080            "should have a selection after initial load"
2081        );
2082
2083        // Apply a filter that matches nothing → visible list is empty → selection cleared.
2084        list.set_query("zzznomatch");
2085        assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
2086        assert!(
2087            list.selected_row().is_none(),
2088            "selection must be None when list is empty"
2089        );
2090
2091        // Widen the filter so rows come back (no drain will happen — local filter).
2092        list.set_query("alp");
2093        assert!(
2094            list.visible_len() > 0,
2095            "at least 'alpha' should match 'alp'"
2096        );
2097        // The selection MUST be reseeded to Some(0) — the subtlety the gating
2098        // would regress if recompute_and_seed() weren't called from requery().
2099        assert!(
2100            list.selected_row().is_some(),
2101            "selection must be reseeded to first visible row after repopulation"
2102        );
2103        assert_eq!(
2104            list.selected_row().unwrap().name,
2105            "alpha",
2106            "first visible row must be selected after reseeding"
2107        );
2108    }
2109
2110    // ── List focus ──────────────────────────────────────────────────────
2111
2112    async fn focus_list(verbs: &[char]) -> SearchList<TestRow> {
2113        let src = VecSource {
2114            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2115            reload: false,
2116        };
2117        let mut b = SearchList::builder(src, noop_redraw()).filter(Filter::Fuzzy);
2118        for &c in verbs {
2119            b = b.list_verb(c);
2120        }
2121        let mut list = b.build();
2122        list.poll_until_idle().await;
2123        list
2124    }
2125
2126    // Registering a verb activates the machine: the first Esc flips Input→List
2127    // (Consumed, not Cancel); a second Esc (now in List focus) Cancels.
2128    #[tokio::test]
2129    async fn esc_enters_list_focus_then_cancels() {
2130        let mut list = focus_list(&['l']).await;
2131        assert_eq!(list.focus(), Focus::Input);
2132        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Consumed);
2133        assert_eq!(list.focus(), Focus::List);
2134        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2135        assert_eq!(list.focus(), Focus::List, "Cancel does not change focus");
2136    }
2137
2138    // Surfaces that never opt in keep byte-identical Esc→Cancel and stay Input.
2139    #[tokio::test]
2140    async fn esc_cancels_immediately_when_focus_disabled() {
2141        let mut list = focus_list(&[]).await;
2142        assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2143        assert_eq!(list.focus(), Focus::Input);
2144    }
2145
2146    // `i` and `/` return from List focus to Input focus.
2147    #[tokio::test]
2148    async fn i_and_slash_return_to_input_focus() {
2149        for ret in ['i', '/'] {
2150            let mut list = focus_list(&['l']).await;
2151            list.handle_key(&key(KeyCode::Esc)); // → List
2152            assert_eq!(list.focus(), Focus::List);
2153            assert_eq!(
2154                list.handle_key(&key(KeyCode::Char(ret))),
2155                KeyReaction::Consumed
2156            );
2157            assert_eq!(list.focus(), Focus::Input);
2158            assert_eq!(list.query(), "", "switching focus must not type a char");
2159        }
2160    }
2161
2162    // In List focus `j`/`k` navigate (arrows keep working too).
2163    #[tokio::test]
2164    async fn list_focus_j_k_navigate() {
2165        let mut list = focus_list(&['l']).await;
2166        list.handle_key(&key(KeyCode::Esc)); // → List
2167        assert_eq!(list.selected_row().unwrap().name, "alpha");
2168        assert_eq!(
2169            list.handle_key(&key(KeyCode::Char('j'))),
2170            KeyReaction::Consumed
2171        );
2172        assert_eq!(list.selected_row().unwrap().name, "beta");
2173        assert_eq!(
2174            list.handle_key(&key(KeyCode::Char('k'))),
2175            KeyReaction::Consumed
2176        );
2177        assert_eq!(list.selected_row().unwrap().name, "alpha");
2178    }
2179
2180    // A registered verb fires as ListVerb; an unregistered letter does NOTHING
2181    // (Consumed, query untouched) — it never types into the query.
2182    #[tokio::test]
2183    async fn registered_verb_fires_unregistered_letter_does_nothing() {
2184        let mut list = focus_list(&['l', 'o']).await;
2185        list.handle_key(&key(KeyCode::Esc)); // → List
2186        assert_eq!(
2187            list.handle_key(&key(KeyCode::Char('l'))),
2188            KeyReaction::ListVerb('l')
2189        );
2190        assert_eq!(
2191            list.handle_key(&key(KeyCode::Char('o'))),
2192            KeyReaction::ListVerb('o')
2193        );
2194        // 'z' is not registered: swallowed, query stays empty.
2195        assert_eq!(
2196            list.handle_key(&key(KeyCode::Char('z'))),
2197            KeyReaction::Consumed
2198        );
2199        assert_eq!(list.query(), "");
2200    }
2201
2202    // In Input focus, verb letters type into the query exactly as before —
2203    // the verb is inert until the user Esc-es into the list.
2204    #[tokio::test]
2205    async fn verbs_are_inert_in_input_focus() {
2206        let mut list = focus_list(&['l', 'o']).await;
2207        assert_eq!(list.focus(), Focus::Input);
2208        assert_eq!(
2209            list.handle_key(&key(KeyCode::Char('l'))),
2210            KeyReaction::Consumed
2211        );
2212        list.poll_until_idle().await;
2213        assert_eq!(list.query(), "l", "verb letters still type in Input focus");
2214    }
2215
2216    // Opening on the list starts in List focus; a plain letter with no verb
2217    // registered does nothing (never types).
2218    #[tokio::test]
2219    async fn opening_focus_list_starts_in_list() {
2220        let src = VecSource {
2221            rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2222            reload: false,
2223        };
2224        let mut list = SearchList::builder(src, noop_redraw())
2225            .filter(Filter::Fuzzy)
2226            .opening_focus(Focus::List)
2227            .build();
2228        list.poll_until_idle().await;
2229        assert_eq!(list.focus(), Focus::List);
2230        assert_eq!(
2231            list.handle_key(&key(KeyCode::Char('a'))),
2232            KeyReaction::Consumed
2233        );
2234        assert_eq!(list.query(), "");
2235        // `i` drops to the input where typing filters again.
2236        list.handle_key(&key(KeyCode::Char('i')));
2237        assert_eq!(list.focus(), Focus::Input);
2238        list.handle_key(&key(KeyCode::Char('a')));
2239        list.poll_until_idle().await;
2240        assert_eq!(list.query(), "a");
2241    }
2242
2243    // Registered intercepts fire in BOTH foci.
2244    #[tokio::test]
2245    async fn intercept_fires_in_both_foci() {
2246        let src = VecSource {
2247            rows: vec![TestRow::new("a")],
2248            reload: false,
2249        };
2250        let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
2251        let mut list = SearchList::builder(src, noop_redraw())
2252            .intercept(vec![combo])
2253            .list_verb('l')
2254            .build();
2255        list.poll_until_idle().await;
2256        // Input focus: intercepted.
2257        assert_eq!(
2258            list.handle_key(&key(KeyCode::Enter)),
2259            KeyReaction::Intercepted(combo)
2260        );
2261        // Flip to List focus, intercept still fires.
2262        list.handle_key(&key(KeyCode::Esc));
2263        assert_eq!(list.focus(), Focus::List);
2264        assert_eq!(
2265            list.handle_key(&key(KeyCode::Enter)),
2266            KeyReaction::Intercepted(combo)
2267        );
2268    }
2269}
2270
2271#[cfg(test)]
2272mod order_tests {
2273    use super::adapters::{HeldEmitSource, ScriptedStreamSource, TestRow};
2274    use super::seams::RankFn;
2275    use super::*;
2276    use std::sync::{Arc, Mutex};
2277
2278    fn noop_redraw() -> Arc<dyn Fn() + Send + Sync> {
2279        Arc::new(|| {})
2280    }
2281
2282    fn by_name_asc() -> OrderFn<TestRow> {
2283        Arc::new(|a: &TestRow, b: &TestRow| a.name.cmp(&b.name))
2284    }
2285
2286    fn by_name_desc() -> OrderFn<TestRow> {
2287        Arc::new(|a: &TestRow, b: &TestRow| b.name.cmp(&a.name))
2288    }
2289
2290    fn names(list: &SearchList<TestRow>) -> Vec<String> {
2291        list.visible_rows().iter().map(|r| r.name.clone()).collect()
2292    }
2293
2294    /// Rows pushed out of order land in `order_by` order, so a streamed
2295    /// listing is sorted at every frame, not only once the stream ends.
2296    #[tokio::test]
2297    async fn order_by_sorts_pushed_rows_as_they_arrive() {
2298        let source = ScriptedStreamSource {
2299            batches: vec![
2300                vec![TestRow::new("charlie")],
2301                vec![TestRow::new("alpha")],
2302                vec![TestRow::new("bravo")],
2303            ],
2304        };
2305        let mut list = SearchList::builder(source, noop_redraw())
2306            .filter(Filter::Fuzzy)
2307            .order_by(by_name_asc())
2308            .build();
2309        list.poll_until_idle().await;
2310        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2311    }
2312
2313    /// Changing the order re-sorts the rows already in memory; no reload, no
2314    /// second trip to the source.
2315    #[tokio::test]
2316    async fn set_order_reorders_in_place_without_reload() {
2317        let source = ScriptedStreamSource {
2318            batches: vec![vec![
2319                TestRow::new("bravo"),
2320                TestRow::new("alpha"),
2321                TestRow::new("charlie"),
2322            ]],
2323        };
2324        let mut list = SearchList::builder(source, noop_redraw())
2325            .order_by(by_name_asc())
2326            .build();
2327        list.poll_until_idle().await;
2328        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2329
2330        list.set_order(Some(by_name_desc()));
2331
2332        assert!(!list.is_loading(), "reordering must not start a load");
2333        assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2334    }
2335
2336    /// Changing the sort must respect the same seeded/chosen split as a
2337    /// streaming poll: an untouched selection keeps the TOP slot of the new
2338    /// order, and stays a seed, so later-arriving rows still take it.
2339    ///
2340    /// Regression: `set_order` carried the selection by row index
2341    /// unconditionally, so a sort change pushed an untouched seed off the top
2342    /// row AND froze it at that numeric position — the highlight then walked
2343    /// across a different row on every subsequent poll.
2344    #[tokio::test]
2345    async fn changing_the_order_keeps_a_seeded_selection_on_the_top_row() {
2346        let slot = Arc::new(Mutex::new(None));
2347        let source = HeldEmitSource { slot: slot.clone() };
2348        let mut list = SearchList::builder(source, noop_redraw())
2349            .order_by(by_name_asc())
2350            .build();
2351        let emit = loop {
2352            if let Some(e) = slot.lock().unwrap().clone() {
2353                break e;
2354            }
2355            tokio::task::yield_now().await;
2356        };
2357
2358        emit.push(TestRow::new("bravo"));
2359        emit.push(TestRow::new("charlie"));
2360        list.poll();
2361        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2362
2363        list.set_order(Some(by_name_desc()));
2364        assert_eq!(names(&list), ["charlie", "bravo"]);
2365        assert_eq!(
2366            list.selected_row().map(|r| r.name.as_str()),
2367            Some("charlie"),
2368            "an untouched selection keeps the top slot of the new order"
2369        );
2370
2371        // Still a seed, not a choice: the next row to sort above it takes it.
2372        emit.push(TestRow::new("delta"));
2373        list.poll();
2374        assert_eq!(names(&list), ["delta", "charlie", "bravo"]);
2375        assert_eq!(
2376            list.selected_row().map(|r| r.name.as_str()),
2377            Some("delta"),
2378            "a sort change must not silently turn a seed into a choice"
2379        );
2380        emit.done();
2381    }
2382
2383    /// The chosen half of that split: a row the user picked stays picked
2384    /// across a sort change.
2385    #[tokio::test]
2386    async fn changing_the_order_keeps_a_chosen_selection_on_its_row() {
2387        let source = ScriptedStreamSource {
2388            batches: vec![vec![
2389                TestRow::new("bravo"),
2390                TestRow::new("alpha"),
2391                TestRow::new("charlie"),
2392            ]],
2393        };
2394        let mut list = SearchList::builder(source, noop_redraw())
2395            .order_by(by_name_asc())
2396            .build();
2397        list.poll_until_idle().await;
2398        list.select_next(); // "bravo", by the user's own keys
2399        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2400
2401        list.set_order(Some(by_name_desc()));
2402        assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2403        assert_eq!(
2404            list.selected_row().map(|r| r.name.as_str()),
2405            Some("bravo"),
2406            "a chosen selection tracks its row through a re-sort"
2407        );
2408    }
2409
2410    /// A nudge that hits the end of the list moves nothing, so it must not
2411    /// turn a seed into a choice — otherwise one stray `Up` on the first row
2412    /// of a streaming listing freezes the highlight and lets it drift.
2413    #[tokio::test]
2414    async fn a_nudge_that_moves_nothing_leaves_the_seed_a_seed() {
2415        let slot = Arc::new(Mutex::new(None));
2416        let source = HeldEmitSource { slot: slot.clone() };
2417        let mut list = SearchList::builder(source, noop_redraw())
2418            .order_by(by_name_asc())
2419            .build();
2420        let emit = loop {
2421            if let Some(e) = slot.lock().unwrap().clone() {
2422                break e;
2423            }
2424            tokio::task::yield_now().await;
2425        };
2426
2427        emit.push(TestRow::new("charlie"));
2428        list.poll();
2429        list.select_prev(); // already on the only (top) row: nothing moves
2430        list.select_next(); // already on the only (last) row: nothing moves
2431        assert_eq!(
2432            list.selected_row().map(|r| r.name.as_str()),
2433            Some("charlie")
2434        );
2435
2436        emit.push(TestRow::new("alpha"));
2437        list.poll();
2438        assert_eq!(names(&list), ["alpha", "charlie"]);
2439        assert_eq!(
2440            list.selected_row().map(|r| r.name.as_str()),
2441            Some("alpha"),
2442            "a keypress that changed nothing must leave the selection seeded"
2443        );
2444        emit.done();
2445    }
2446
2447    /// A row the user selected while rows were still streaming stays selected
2448    /// when a later row sorts in above it.
2449    #[tokio::test]
2450    async fn selection_follows_its_row_while_rows_stream_in() {
2451        let slot = Arc::new(Mutex::new(None));
2452        let source = HeldEmitSource { slot: slot.clone() };
2453        let mut list = SearchList::builder(source, noop_redraw())
2454            .order_by(by_name_asc())
2455            .build();
2456        let emit = loop {
2457            if let Some(e) = slot.lock().unwrap().clone() {
2458                break e;
2459            }
2460            tokio::task::yield_now().await;
2461        };
2462
2463        emit.push(TestRow::new("bravo"));
2464        emit.push(TestRow::new("charlie"));
2465        list.poll();
2466        list.select_next();
2467        assert_eq!(
2468            list.selected_row().map(|r| r.name.as_str()),
2469            Some("charlie")
2470        );
2471
2472        emit.push(TestRow::new("alpha"));
2473        list.poll();
2474
2475        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2476        assert_eq!(
2477            list.selected_row().map(|r| r.name.as_str()),
2478            Some("charlie"),
2479            "selection tracks the row, not its old position"
2480        );
2481        emit.done();
2482    }
2483
2484    /// `order_by` composes with a [`Filter::Rank`] surface: the ranker is
2485    /// handed the candidate indices in `order_by` order, so rows it scores
2486    /// equally keep the list's order.
2487    ///
2488    /// Regression: the `Rank` arm ranked `self.rows` directly and threw `base`
2489    /// away, so pairing a rank filter with an order was a silent no-op.
2490    #[tokio::test]
2491    async fn order_by_composes_with_a_rank_filter() {
2492        // Keeps every row containing the query, in the order it was handed.
2493        let rank: RankFn<TestRow> = Arc::new(|rows: &[TestRow], base: &[usize], q: &str| {
2494            base.iter()
2495                .copied()
2496                .filter(|&i| rows[i].name.contains(q))
2497                .collect()
2498        });
2499        let source = ScriptedStreamSource {
2500            batches: vec![vec![
2501                TestRow::new("charlie"),
2502                TestRow::new("alpha"),
2503                TestRow::new("bravo"),
2504            ]],
2505        };
2506        let mut list = SearchList::builder(source, noop_redraw())
2507            .filter(Filter::Rank(rank))
2508            .order_by(by_name_asc())
2509            .build();
2510        list.poll_until_idle().await;
2511        list.set_query("a"); // every row matches, so only the order decides
2512        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2513    }
2514
2515    /// The other half of that contract: a selection the user has NEVER moved
2516    /// is not a choice, it is a seed. It must stay on the top of the
2517    /// `order_by` order as rows stream in.
2518    ///
2519    /// Regression: `poll` carried the seeded selection by row index like a
2520    /// user-chosen one, so on a slow (streamed) directory the initial
2521    /// highlight stuck to whichever entry the walker happened to emit first
2522    /// and drifted down the list as better-sorting rows arrived above it.
2523    #[tokio::test]
2524    async fn a_seeded_selection_stays_on_the_top_row_as_rows_stream_in() {
2525        let slot = Arc::new(Mutex::new(None));
2526        let source = HeldEmitSource { slot: slot.clone() };
2527        let mut list = SearchList::builder(source, noop_redraw())
2528            .order_by(by_name_asc())
2529            .build();
2530        let emit = loop {
2531            if let Some(e) = slot.lock().unwrap().clone() {
2532                break e;
2533            }
2534            tokio::task::yield_now().await;
2535        };
2536
2537        // First row to arrive seeds the selection.
2538        emit.push(TestRow::new("charlie"));
2539        list.poll();
2540        assert_eq!(
2541            list.selected_row().map(|r| r.name.as_str()),
2542            Some("charlie")
2543        );
2544
2545        // A row that sorts above it must take the highlight with the top slot.
2546        emit.push(TestRow::new("alpha"));
2547        list.poll();
2548        assert_eq!(names(&list), ["alpha", "charlie"]);
2549        assert_eq!(
2550            list.selected_row().map(|r| r.name.as_str()),
2551            Some("alpha"),
2552            "an untouched selection seeds to the top of the order, not to the first-arrived row"
2553        );
2554        emit.done();
2555    }
2556
2557    /// Moving the selection and landing back on the top row still counts as a
2558    /// choice: from then on it tracks that row, exactly as any other
2559    /// user-chosen selection does.
2560    #[tokio::test]
2561    async fn a_selection_moved_back_to_the_top_row_is_still_pinned_to_it() {
2562        let slot = Arc::new(Mutex::new(None));
2563        let source = HeldEmitSource { slot: slot.clone() };
2564        let mut list = SearchList::builder(source, noop_redraw())
2565            .order_by(by_name_asc())
2566            .build();
2567        let emit = loop {
2568            if let Some(e) = slot.lock().unwrap().clone() {
2569                break e;
2570            }
2571            tokio::task::yield_now().await;
2572        };
2573
2574        emit.push(TestRow::new("bravo"));
2575        emit.push(TestRow::new("charlie"));
2576        list.poll();
2577        list.select_next();
2578        list.select_prev(); // back on "bravo", but by the user's own keys
2579        assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2580
2581        emit.push(TestRow::new("alpha"));
2582        list.poll();
2583        assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2584        assert_eq!(
2585            list.selected_row().map(|r| r.name.as_str()),
2586            Some("bravo"),
2587            "a selection the user placed tracks its row even when it sits at the top"
2588        );
2589        emit.done();
2590    }
2591}