1#[cfg(test)]
5mod adapters;
6mod host;
7mod load;
8mod resolving;
9mod seams;
10
11pub use resolving::{ResolvingRowSource, Unresolvable};
12pub use seams::{
13 Emit, Filter, Loaded, RowSource, SearchRow, StaticRowSource, SuggestionItem, SuggestionSource,
14 VaultSuggestions,
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
35fn fuzzy_indices<R: SearchRow>(rows: &[R], query: &str) -> Vec<usize> {
36 use nucleo::pattern::{CaseMatching, Normalization, Pattern};
37 use nucleo::{Matcher, Utf32Str};
38 let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
39 let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
40 let mut scored: Vec<(usize, u32)> = rows
41 .iter()
42 .enumerate()
43 .filter_map(|(i, r)| {
44 let hay = r.match_text()?;
45 let mut buf = Vec::new();
46 let h = Utf32Str::new(hay, &mut buf);
47 pat.score(h, &mut matcher).map(|s| (i, s))
48 })
49 .collect();
50 scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
51 scored.into_iter().map(|(i, _)| i).collect()
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Focus {
60 Input,
61 List,
62}
63
64#[derive(Debug, PartialEq, Eq)]
66pub enum KeyReaction {
67 Consumed,
68 Submit,
69 Cancel,
70 Intercepted(crate::keys::key_combo::KeyCombo),
71 ListVerb(char),
75 Unhandled,
76}
77
78pub struct SearchList<R: SearchRow> {
79 source: Arc<dyn RowSource<R>>,
80 rows: Vec<R>,
81 display: Vec<usize>,
83 leading: Option<R>,
89 selected: Option<usize>,
92 offset: usize,
97 filter: Filter<R>,
98 query: String,
99 loader: LoadEngine<R>,
100 input: SingleLineInput,
101 autocomplete: Option<AutocompleteController>,
102 intercept: Vec<KeyCombo>,
104 icons: Icons,
105 list_rect: Rect,
106 panel_rect: Rect,
112 content_rect: Rect,
121 applied_generation: u64,
126 accepted_saved_search: Option<String>,
130 last_click_pos: Option<usize>,
134 highlight_query: bool,
137 focus: Focus,
139 focus_enabled: bool,
144 list_verbs: Vec<char>,
148}
149
150#[derive(Debug, PartialEq, Eq)]
152pub enum SearchMouse {
153 Selected(usize),
154 Activated(usize),
155 Context(usize),
158 Scrolled,
159 ContentScrollUp,
163 ContentScrollDown,
164 None,
165}
166
167pub struct SearchListBuilder<R: SearchRow> {
168 source: Arc<dyn RowSource<R>>,
169 redraw: Arc<dyn Fn() + Send + Sync>,
170 initial_query: String,
171 filter: Filter<R>,
172 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
173 intercept: Vec<KeyCombo>,
174 icons: Icons,
175 debounce: Option<std::time::Duration>,
176 highlight_query: bool,
177 opening_focus: Focus,
178 list_verbs: Vec<char>,
179}
180
181impl<R: SearchRow> SearchList<R> {
182 pub fn builder(
183 source: impl RowSource<R>,
184 redraw: Arc<dyn Fn() + Send + Sync>,
185 ) -> SearchListBuilder<R> {
186 SearchListBuilder {
187 source: Arc::new(source),
188 redraw,
189 initial_query: String::new(),
190 filter: Filter::SourceOrder,
191 autocomplete: None,
192 intercept: Vec::new(),
193 icons: Icons::new(false),
194 debounce: None,
195 highlight_query: false,
196 opening_focus: Focus::Input,
197 list_verbs: Vec::new(),
198 }
199 }
200
201 fn new(b: SearchListBuilder<R>) -> Self {
203 let mut list = Self::assemble(b);
204 list.loader.start(list.source.clone(), list.query.clone());
205 list
206 }
207
208 fn with_rows(b: SearchListBuilder<R>, rows: Vec<R>) -> Self {
218 let mut list = Self::assemble(b);
219 list.rows = rows;
220 list.recompute_and_seed();
221 list
222 }
223
224 fn assemble(b: SearchListBuilder<R>) -> Self {
228 let loader = LoadEngine::new(b.redraw.clone());
229 let input = SingleLineInput::with_value(&b.initial_query);
230 let debounce = b.debounce;
231 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
232 let mut ac =
233 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
234 disambiguate_header: false,
235 apply_exclusion_zone: false,
236 ..TriggerOptions::default()
239 });
240 if let Some(d) = debounce {
241 ac = ac.with_debounce(d);
242 }
243 ac.set_redraw_callback(b.redraw.clone());
244 ac
245 });
246 Self {
247 source: b.source,
248 rows: Vec::new(),
249 display: Vec::new(),
250 leading: None,
251 selected: None,
252 offset: 0,
253 filter: b.filter,
254 query: b.initial_query,
255 loader,
256 input,
257 highlight_query: b.highlight_query,
258 last_click_pos: None,
259 autocomplete,
260 intercept: b.intercept,
261 icons: b.icons,
262 list_rect: Rect::default(),
263 panel_rect: Rect::default(),
264 content_rect: Rect::default(),
265 applied_generation: 0,
266 accepted_saved_search: None,
267 focus: b.opening_focus,
268 focus_enabled: b.opening_focus == Focus::List || !b.list_verbs.is_empty(),
271 list_verbs: b.list_verbs,
272 }
273 }
274
275 pub fn focus(&self) -> Focus {
277 self.focus
278 }
279
280 pub fn poll(&mut self) {
281 let drained = self.loader.drain();
282 if !drained.is_empty() {
283 let current_gen = self.loader.generation();
287 if current_gen != self.applied_generation {
288 self.rows.clear();
289 self.selected = None;
290 self.offset = 0;
291 self.applied_generation = current_gen;
292 }
293 for ev in drained {
294 match ev {
295 LoadedInner::Replace(rows) => {
296 self.rows = rows;
297 }
298 LoadedInner::Push(row) => {
299 self.rows.push(row);
300 }
301 LoadedInner::Done => {}
302 }
303 }
304 self.recompute_and_seed();
305 }
306 if let Some(ac) = &mut self.autocomplete {
307 ac.poll_results();
308 }
309 }
310
311 fn recompute_and_seed(&mut self) {
316 self.recompute_display();
317 if self.selected.is_none() && self.visible_len() > 0 {
318 self.selected = Some(0);
319 }
320 }
321
322 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
326 let value = self.input.value().to_string();
327 let cursor_byte = self.input.cursor_byte();
328 let col = value[..cursor_byte.min(value.len())].chars().count();
329 host::SearchBoxHostSnapshot {
330 lines: vec![value],
331 cursor: (0, col),
332 caret_pos: self.input.last_caret_pos(),
333 }
334 }
335
336 fn clamp_selection(&mut self) {
337 let len = self.visible_len();
338 self.selected = if len == 0 {
339 None
340 } else {
341 Some(self.selected.unwrap_or(0).min(len - 1))
342 };
343 }
344
345 fn leading_offset(&self) -> usize {
347 self.leading.is_some() as usize
348 }
349
350 pub fn visible_len(&self) -> usize {
352 self.leading_offset() + self.display.len()
353 }
354
355 pub fn match_count(&self) -> usize {
358 self.display.len()
359 }
360
361 fn visible_row(&self, pos: usize) -> Option<&R> {
363 if self.leading.is_some() && pos == 0 {
364 self.leading.as_ref()
365 } else {
366 self.rows
367 .get(*self.display.get(pos - self.leading_offset())?)
368 }
369 }
370
371 pub fn rows(&self) -> &[R] {
375 &self.rows
376 }
377
378 pub fn selected_row(&self) -> Option<&R> {
379 self.selected.and_then(|p| self.visible_row(p))
380 }
381
382 pub fn visible_rows(&self) -> Vec<&R> {
383 (0..self.visible_len())
384 .filter_map(|p| self.visible_row(p))
385 .collect()
386 }
387
388 pub fn query(&self) -> &str {
389 &self.query
390 }
391
392 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
396 self.accepted_saved_search.take()
397 }
398
399 #[cfg(test)]
402 pub(crate) fn input_value(&self) -> &str {
403 self.input.value()
404 }
405 pub fn is_loading(&self) -> bool {
406 self.loader.loading
407 }
408
409 pub fn set_query(&mut self, q: impl Into<String>) {
418 let q = q.into();
419 self.input.set_value(q.clone());
420 self.query = q;
421 self.requery();
422 }
423
424 fn sync_query_from_input(&mut self) {
429 self.query = self.input.value().to_string();
430 self.requery();
431 }
432
433 fn requery(&mut self) {
436 if self.source.reload_on_query() {
437 self.loader.start(self.source.clone(), self.query.clone());
438 }
439 self.recompute_and_seed();
443 }
444
445 pub fn reload(&mut self) {
447 self.loader.start(self.source.clone(), self.query.clone());
448 }
449
450 pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
459 let mut changed = false;
460 for row in &mut self.rows {
461 if mutate(row) {
462 changed = true;
463 }
464 }
465 if changed {
466 self.recompute_display();
467 }
468 changed
469 }
470
471 pub fn select(&mut self, pos: usize) {
477 let n = self.visible_len();
478 self.selected = if n == 0 { None } else { Some(pos.min(n - 1)) };
479 }
480
481 pub fn select_next(&mut self) {
482 let n = self.visible_len();
483 if n == 0 {
484 return;
485 }
486 self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
487 }
488
489 pub fn select_prev(&mut self) {
490 if self.visible_len() == 0 {
491 return;
492 }
493 self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
494 }
495
496 fn max_scroll_offset(&self) -> usize {
501 let viewport = self.list_rect.height as usize;
502 let n = self.visible_len();
503 if viewport == 0 || n == 0 {
504 return 0;
505 }
506 let mut budget = viewport;
507 let mut first = n;
508 while first > 0 {
509 let h = self
510 .visible_row(first - 1)
511 .map(|r| r.visual_height() as usize)
512 .unwrap_or(1);
513 if h > budget {
514 break;
515 }
516 budget -= h;
517 first -= 1;
518 }
519 first.min(n - 1)
520 }
521
522 pub fn scroll_down(&mut self) {
526 let n = self.visible_len();
527 if n == 0 || self.offset >= self.max_scroll_offset() {
528 return;
529 }
530 self.offset += 1;
531 self.selected = self.selected.map(|i| (i + 1).min(n - 1));
532 }
533
534 pub fn scroll_up(&mut self) {
537 if self.offset == 0 {
538 return;
539 }
540 self.offset -= 1;
541 self.selected = self.selected.map(|i| i.saturating_sub(1));
542 }
543
544 #[cfg(test)]
547 pub(crate) fn scroll_offset(&self) -> usize {
548 self.offset
549 }
550
551 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
552 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
553
554 if let Some(combo) = crate::keys::key_event_to_combo(key)
557 && self.intercept.contains(&combo)
558 {
559 return KeyReaction::Intercepted(combo);
560 }
561
562 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
566 let snap = self.autocomplete_snapshot();
567 if let Some(ac) = &mut self.autocomplete {
568 match ac.handle_key(*key, &snap) {
569 HandleKeyOutcome::Accepted(action) => {
570 self.input.replace_range_bytes(
571 action.range.clone(),
572 &action.new_text,
573 action.new_cursor_byte,
574 );
575 self.accepted_saved_search = action.saved_search_name;
580 self.sync_query_from_input();
581 return KeyReaction::Consumed;
582 }
583 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
584 return KeyReaction::Consumed;
585 }
586 HandleKeyOutcome::NotHandled => {}
587 }
588 }
589 }
590
591 match key.code {
593 KeyCode::Up => {
594 self.select_prev();
595 return KeyReaction::Consumed;
596 }
597 KeyCode::Down => {
598 self.select_next();
599 return KeyReaction::Consumed;
600 }
601 KeyCode::Enter => return KeyReaction::Submit,
602 _ => {}
603 }
604 if key.code == KeyCode::Esc {
608 if self.focus_enabled && self.focus == Focus::Input {
609 self.focus = Focus::List;
610 self.close_autocomplete();
611 return KeyReaction::Consumed;
612 }
613 return KeyReaction::Cancel;
614 }
615 if let KeyCode::Char(_) = key.code {
618 let non_shift = key.modifiers - KeyModifiers::SHIFT;
619 if !non_shift.is_empty() {
620 return KeyReaction::Unhandled;
621 }
622 }
623 if self.focus == Focus::List {
625 if let KeyCode::Char(c) = key.code {
626 return match c {
627 'i' | '/' => {
629 self.focus = Focus::Input;
630 KeyReaction::Consumed
631 }
632 'j' => {
633 self.select_next();
634 KeyReaction::Consumed
635 }
636 'k' => {
637 self.select_prev();
638 KeyReaction::Consumed
639 }
640 _ if self.list_verbs.contains(&c) => KeyReaction::ListVerb(c),
641 _ => KeyReaction::Consumed,
643 };
644 }
645 return KeyReaction::Unhandled;
647 }
648 let outcome = self.input.handle_key(key);
649 let snap = self.autocomplete_snapshot();
652 match outcome {
653 InputOutcome::Changed => {
654 if let Some(ac) = &mut self.autocomplete {
655 ac.sync(&snap);
656 }
657 }
658 InputOutcome::Consumed => {
659 if let Some(ac) = &mut self.autocomplete {
660 ac.refresh_if_open(&snap);
661 }
662 }
663 InputOutcome::Cancel | InputOutcome::Submit => {
664 if let Some(ac) = &mut self.autocomplete {
665 ac.close();
666 }
667 }
668 InputOutcome::NotConsumed => {}
669 }
670 match outcome {
671 InputOutcome::Changed => {
672 self.sync_query_from_input();
673 KeyReaction::Consumed
674 }
675 InputOutcome::Consumed => KeyReaction::Consumed,
676 InputOutcome::Submit => KeyReaction::Submit,
677 InputOutcome::Cancel => KeyReaction::Cancel,
678 InputOutcome::NotConsumed => KeyReaction::Unhandled,
679 }
680 }
681
682 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
683 let focused = focused && self.focus == Focus::Input;
688 let base = Style::default()
689 .fg(theme.fg.to_ratatui())
690 .bg(theme.bg_panel.to_ratatui());
691 if self.highlight_query {
692 let line =
693 crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
694 self.input.render_line(f, area, line, base, 0, focused);
695 } else {
696 self.input.render(f, area, base, 0, focused);
697 }
698 }
699
700 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
701 self.poll();
702 let sel = self.selected;
703 let items: Vec<ListItem> = (0..self.visible_len())
704 .filter_map(|pos| {
705 self.visible_row(pos)
706 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
707 })
708 .collect();
709 let mut state = ListState::default().with_offset(self.offset);
710 state.select(self.selected);
711 let list =
712 List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
713 f.render_stateful_widget(list, area, &mut state);
714 self.offset = state.offset();
718 self.list_rect = area;
719 let _ = focused;
720 }
721
722 pub fn set_list_rect(&mut self, rect: Rect) {
731 self.list_rect = rect;
732 }
733
734 pub fn set_panel_rect(&mut self, rect: Rect) {
739 self.panel_rect = rect;
740 }
741
742 pub fn set_content_rect(&mut self, rect: Rect) {
750 self.content_rect = rect;
751 }
752
753 #[cfg(test)]
757 pub(crate) fn content_rect(&self) -> Rect {
758 self.content_rect
759 }
760
761 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
762 if let Some(ac) = &mut self.autocomplete {
763 ac.poll_results();
764 let caret = self.input.last_caret_pos();
765 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
766 state.anchor = anchor;
767 }
768 if let Some(state) = ac.state() {
769 crate::components::autocomplete::render(f, state, clamp, theme);
770 }
771 }
772 }
773
774 pub fn close_autocomplete(&mut self) {
781 if let Some(ac) = &mut self.autocomplete {
782 ac.close();
783 }
784 }
785
786 #[cfg(test)]
789 pub(crate) fn autocomplete_is_open(&self) -> bool {
790 self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
791 }
792
793 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
794 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
795 use ratatui::layout::Position;
796 self.close_autocomplete();
799 let pos = Position {
800 x: m.column,
801 y: m.row,
802 };
803 if matches!(
807 m.kind,
808 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
809 ) {
810 if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
814 return if m.kind == MouseEventKind::ScrollUp {
815 SearchMouse::ContentScrollUp
816 } else {
817 SearchMouse::ContentScrollDown
818 };
819 }
820 let bounds = if self.panel_rect.is_empty() {
821 self.list_rect
822 } else {
823 self.panel_rect
824 };
825 if !bounds.contains(pos) {
826 return SearchMouse::None;
827 }
828 if m.kind == MouseEventKind::ScrollUp {
829 self.scroll_up();
830 } else {
831 self.scroll_down();
832 }
833 return SearchMouse::Scrolled;
834 }
835 let r = self.list_rect;
836 if !r.contains(pos) {
837 return SearchMouse::None;
838 }
839 match m.kind {
840 MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
841 let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
842 let target_visual = m.row - r.y; let mut acc: u16 = 0;
844 let mut hit: Option<usize> = None;
845 for pos in self.offset..self.visible_len() {
850 let h = self
851 .visible_row(pos)
852 .map(|r| r.visual_height())
853 .unwrap_or(1);
854 if target_visual < acc + h {
855 hit = Some(pos);
856 break;
857 }
858 acc += h;
859 }
860 if let Some(pos) = hit {
861 let prev = self.selected;
862 let prev_click = self.last_click_pos.replace(pos);
863 self.selected = Some(pos);
864 return if right_click {
865 SearchMouse::Context(pos)
866 } else if prev == Some(pos) && prev_click == Some(pos) {
867 SearchMouse::Activated(pos)
870 } else {
871 SearchMouse::Selected(pos)
872 };
873 }
874 SearchMouse::None
875 }
876 _ => SearchMouse::None,
877 }
878 }
879
880 fn recompute_display(&mut self) {
881 let q = self.query.trim();
882 self.leading = self.source.leading_row(q);
885 let mut idx: Vec<usize> = match &self.filter {
886 Filter::SourceOrder => (0..self.rows.len()).collect(),
887 Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
888 Filter::Fuzzy => fuzzy_indices(&self.rows, q),
889 Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
890 Filter::Rank(f) => {
891 let f = f.clone();
892 f(&self.rows, q)
893 }
894 };
895 for i in 0..self.rows.len() {
898 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
899 idx.insert(0, i);
900 }
901 }
902 self.display = idx;
903 self.clamp_selection();
904 }
905
906 #[cfg(test)]
907 pub(crate) async fn poll_until_idle(&mut self) {
908 for _ in 0..600 {
914 tokio::task::yield_now().await;
915 self.poll();
916 if !self.is_loading() {
917 break;
918 }
919 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
920 }
921 self.poll();
922 }
923}
924
925impl<R: SearchRow> SearchListBuilder<R> {
926 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
927 self.initial_query = q.into();
928 self
929 }
930 pub fn filter(mut self, f: Filter<R>) -> Self {
931 self.filter = f;
932 self
933 }
934 pub fn autocomplete(
935 mut self,
936 suggestions: Arc<dyn SuggestionSource>,
937 mode: AutocompleteMode,
938 ) -> Self {
939 self.autocomplete = Some((suggestions, mode));
940 self
941 }
942 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
943 self.intercept = v;
944 self
945 }
946 pub fn highlight_query(mut self) -> Self {
948 self.highlight_query = true;
949 self
950 }
951 pub fn icons(mut self, icons: Icons) -> Self {
952 self.icons = icons;
953 self
954 }
955 pub fn opening_focus(mut self, focus: Focus) -> Self {
959 self.opening_focus = focus;
960 self
961 }
962 pub fn list_verb(mut self, c: char) -> Self {
969 self.list_verbs.push(c);
970 self
971 }
972 pub fn debounce(mut self, d: std::time::Duration) -> Self {
975 self.debounce = Some(d);
976 self
977 }
978 pub fn build(self) -> SearchList<R> {
979 SearchList::new(self)
980 }
981
982 pub fn build_with_rows(self, rows: Vec<R>) -> SearchList<R> {
992 SearchList::with_rows(self, rows)
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::adapters::{
999 ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
1000 VecSource, VecSourceWithLead,
1001 };
1002 use super::*;
1003 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1004
1005 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
1006 std::sync::Arc::new(|| {})
1007 }
1008
1009 fn key(c: KeyCode) -> KeyEvent {
1010 KeyEvent::new(c, KeyModifiers::NONE)
1011 }
1012
1013 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
1014 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1015 MouseEvent {
1016 kind: MouseEventKind::Down(MouseButton::Left),
1017 column: col,
1018 row,
1019 modifiers: KeyModifiers::NONE,
1020 }
1021 }
1022
1023 #[derive(Clone, Debug, PartialEq)]
1024 struct TallRow {
1025 name: String,
1026 height: u16,
1027 }
1028 impl SearchRow for TallRow {
1029 fn to_list_item(
1030 &self,
1031 _t: &crate::settings::themes::Theme,
1032 _i: &crate::settings::icons::Icons,
1033 _s: bool,
1034 ) -> ratatui::widgets::ListItem<'static> {
1035 ratatui::widgets::ListItem::new(self.name.clone())
1036 }
1037 fn visual_height(&self) -> u16 {
1038 self.height
1039 }
1040 fn match_text(&self) -> Option<&str> {
1041 Some(&self.name)
1042 }
1043 }
1044 struct TallSource(Vec<TallRow>);
1045 #[async_trait::async_trait]
1046 impl RowSource<TallRow> for TallSource {
1047 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
1048 emit.replace(self.0.clone());
1049 }
1050 }
1051
1052 #[tokio::test]
1056 async fn wheel_in_content_rect_routes_to_host() {
1057 use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
1058 let rows: Vec<TallRow> = (0..10)
1059 .map(|i| TallRow {
1060 name: format!("r{}", i),
1061 height: 1,
1062 })
1063 .collect();
1064 let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
1065 list.poll_until_idle().await;
1066 let rect = |y: u16, h: u16| ratatui::layout::Rect {
1067 x: 0,
1068 y,
1069 width: 20,
1070 height: h,
1071 };
1072 list.set_panel_rect(rect(0, 10));
1074 list.set_list_rect(rect(0, 4));
1075 list.set_content_rect(rect(5, 5));
1076 let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
1077 kind,
1078 column: 2,
1079 row,
1080 modifiers: KeyModifiers::NONE,
1081 };
1082
1083 let m = wheel(MouseEventKind::ScrollDown, 6);
1085 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
1086 assert_eq!(list.offset, 0, "list viewport must not move");
1087 let m = wheel(MouseEventKind::ScrollUp, 6);
1088 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
1089
1090 let m = wheel(MouseEventKind::ScrollDown, 2);
1092 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1093
1094 list.set_content_rect(ratatui::layout::Rect::default());
1096 let m = wheel(MouseEventKind::ScrollDown, 6);
1097 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1098 }
1099
1100 #[tokio::test]
1101 async fn mouse_maps_visual_row_to_display_index_by_height() {
1102 let src = TallSource(vec![
1105 TallRow {
1106 name: "a".into(),
1107 height: 3,
1108 },
1109 TallRow {
1110 name: "b".into(),
1111 height: 1,
1112 },
1113 ]);
1114 let mut list = SearchList::builder(src, noop_redraw()).build();
1115 list.poll_until_idle().await;
1116 list.set_list_rect(ratatui::layout::Rect {
1118 x: 0,
1119 y: 0,
1120 width: 20,
1121 height: 10,
1122 });
1123 let m = mouse_down_at(2, 3);
1125 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
1126 assert_eq!(list.selected_row().unwrap().name, "b");
1127 let m = mouse_down_at(2, 1);
1129 list.handle_mouse(&m);
1130 assert_eq!(list.selected_row().unwrap().name, "a");
1131 }
1132
1133 #[tokio::test]
1137 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
1138 let src = VecSource {
1139 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1140 reload: true,
1141 };
1142 let mut list = SearchList::builder(src, noop_redraw()).build();
1143 list.poll_until_idle().await;
1144 list.set_list_rect(ratatui::layout::Rect {
1146 x: 0,
1147 y: 0,
1148 width: 20,
1149 height: 4,
1150 });
1151 list.select_next();
1153 list.select_next();
1154 assert_eq!(list.selected_row().unwrap().name, "row2");
1155
1156 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1157 kind,
1158 column: 1,
1159 row: 1,
1160 modifiers: KeyModifiers::NONE,
1161 };
1162 use ratatui::crossterm::event::MouseEventKind;
1163
1164 assert_eq!(
1166 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1167 SearchMouse::Scrolled
1168 );
1169 assert_eq!(list.scroll_offset(), 1);
1170 assert_eq!(list.selected_row().unwrap().name, "row3");
1171
1172 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1174 assert_eq!(list.scroll_offset(), 0);
1175 assert_eq!(list.selected_row().unwrap().name, "row2");
1176
1177 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1179 assert_eq!(list.scroll_offset(), 0);
1180 assert_eq!(list.selected_row().unwrap().name, "row2");
1181
1182 for _ in 0..20 {
1185 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1186 }
1187 assert_eq!(list.scroll_offset(), 6);
1188 assert_eq!(list.selected_row().unwrap().name, "row8");
1189 }
1192
1193 #[tokio::test]
1197 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1198 let src = VecSource {
1199 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1200 reload: true,
1201 };
1202 let mut list = SearchList::builder(src, noop_redraw()).build();
1203 list.poll_until_idle().await;
1204 list.set_list_rect(ratatui::layout::Rect {
1206 x: 0,
1207 y: 5,
1208 width: 20,
1209 height: 4,
1210 });
1211 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1212 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1213 column: 1,
1214 row,
1215 modifiers: KeyModifiers::NONE,
1216 };
1217 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1219 assert_eq!(list.scroll_offset(), 0);
1220 list.set_panel_rect(ratatui::layout::Rect {
1221 x: 0,
1222 y: 0,
1223 width: 20,
1224 height: 20,
1225 });
1226 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1228 assert_eq!(list.scroll_offset(), 1);
1229 let before = list.selected_row().unwrap().name.clone();
1232 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1233 assert_eq!(list.selected_row().unwrap().name, before);
1234 }
1235
1236 #[tokio::test]
1240 async fn click_after_scroll_selects_the_clicked_row() {
1241 let src = VecSource {
1242 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1243 reload: true,
1244 };
1245 let mut list = SearchList::builder(src, noop_redraw()).build();
1246 list.poll_until_idle().await;
1247 list.set_list_rect(ratatui::layout::Rect {
1248 x: 0,
1249 y: 0,
1250 width: 20,
1251 height: 4,
1252 });
1253 let scroll_down = ratatui::crossterm::event::MouseEvent {
1254 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1255 column: 1,
1256 row: 1,
1257 modifiers: KeyModifiers::NONE,
1258 };
1259 for _ in 0..3 {
1260 list.handle_mouse(&scroll_down);
1261 }
1262 assert_eq!(list.scroll_offset(), 3);
1263 assert!(matches!(
1265 list.handle_mouse(&mouse_down_at(2, 2)),
1266 SearchMouse::Selected(5)
1267 ));
1268 assert_eq!(list.selected_row().unwrap().name, "row5");
1269 list.handle_mouse(&mouse_down_at(2, 0));
1271 assert_eq!(list.selected_row().unwrap().name, "row3");
1272 }
1273
1274 #[tokio::test]
1279 async fn build_with_rows_applies_synchronously_without_a_poll() {
1280 let list = SearchList::builder(StaticRowSource, noop_redraw())
1281 .filter(Filter::Fuzzy)
1282 .build_with_rows(vec![TestRow::new("alpha"), TestRow::new("beta")]);
1283 assert!(!list.is_loading(), "static build is not loading");
1285 assert_eq!(list.rows().len(), 2);
1286 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1287 }
1288
1289 #[tokio::test]
1290 async fn initial_load_populates_rows() {
1291 let src = VecSource {
1292 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1293 reload: true,
1294 };
1295 let mut list = SearchList::builder(src, noop_redraw()).build();
1296 list.poll_until_idle().await;
1297 assert_eq!(list.rows().len(), 2);
1298 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1299 }
1300
1301 #[tokio::test]
1302 async fn requery_supersedes_and_reloads() {
1303 let src = VecSource {
1304 rows: vec![
1305 TestRow::new("alpha"),
1306 TestRow::new("alps"),
1307 TestRow::new("beta"),
1308 ],
1309 reload: true,
1310 };
1311 let mut list = SearchList::builder(src, noop_redraw()).build();
1312 list.poll_until_idle().await;
1313 assert_eq!(list.rows().len(), 3);
1314 list.set_query("alp");
1315 list.poll_until_idle().await;
1316 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1318 }
1319
1320 #[tokio::test]
1321 async fn arrows_navigate_and_enter_submits() {
1322 let src = VecSource {
1323 rows: vec![TestRow::new("a"), TestRow::new("b")],
1324 reload: true,
1325 };
1326 let mut list = SearchList::builder(src, noop_redraw()).build();
1327 list.poll_until_idle().await;
1328 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1329 assert_eq!(list.selected_row().unwrap().name, "b");
1330 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1331 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1332 }
1333
1334 #[tokio::test]
1335 async fn typing_a_char_changes_query() {
1336 let src = VecSource {
1337 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1338 reload: true,
1339 };
1340 let mut list = SearchList::builder(src, noop_redraw()).build();
1341 list.poll_until_idle().await;
1342 assert_eq!(
1343 list.handle_key(&key(KeyCode::Char('a'))),
1344 KeyReaction::Consumed
1345 );
1346 list.poll_until_idle().await;
1347 assert_eq!(list.query(), "a");
1348 }
1349
1350 #[tokio::test]
1351 async fn rank_filter_orders_by_closure() {
1352 let src = VecSource {
1353 rows: vec![
1354 TestRow::new("todo"),
1355 TestRow::new("today"),
1356 TestRow::new("misc"),
1357 ],
1358 reload: false,
1359 };
1360 let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1361 let mut idx: Vec<usize> = (0..rows.len())
1362 .filter(|&i| rows[i].name.contains(q))
1363 .collect();
1364 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1365 idx
1366 });
1367 let mut list = SearchList::builder(src, noop_redraw())
1368 .filter(Filter::Rank(rank))
1369 .build();
1370 list.poll_until_idle().await;
1371 list.set_query("today");
1372 list.poll();
1373 assert_eq!(list.selected_row().unwrap().name, "today");
1374 }
1375
1376 #[tokio::test]
1377 async fn fuzzy_filter_narrows_local_set() {
1378 let src = VecSource {
1379 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1380 reload: false,
1381 };
1382 let mut list = SearchList::builder(src, noop_redraw())
1383 .filter(Filter::Fuzzy)
1384 .build();
1385 list.poll_until_idle().await;
1386 list.set_query("alp");
1387 list.poll();
1388 assert_eq!(list.visible_rows().len(), 1);
1389 assert_eq!(list.selected_row().unwrap().name, "alpha");
1390 }
1391
1392 #[tokio::test]
1393 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1394 let src = ScriptedStreamSource {
1395 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1396 };
1397 let mut list = SearchList::builder(src, noop_redraw())
1398 .filter(Filter::Fuzzy)
1399 .build();
1400 list.poll_until_idle().await;
1401 assert_eq!(list.rows().len(), 2);
1402 assert!(!list.is_loading());
1403 list.set_query("alp");
1404 list.poll();
1405 assert_eq!(list.visible_rows().len(), 1);
1406 }
1407
1408 #[tokio::test]
1409 async fn source_order_unfiltered_passthrough() {
1410 let src = VecSource {
1411 rows: vec![TestRow::new("a"), TestRow::new("b")],
1412 reload: true,
1413 };
1414 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1416 assert_eq!(list.visible_rows().len(), 2);
1417 assert_eq!(list.selected_row().unwrap().name, "a");
1418 }
1419
1420 #[tokio::test]
1421 async fn intercepted_combo_returns_intercepted_without_acting() {
1422 let src = VecSource {
1423 rows: vec![TestRow::new("a")],
1424 reload: true,
1425 };
1426 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1427 let mut list = SearchList::builder(src, noop_redraw())
1428 .intercept(vec![combo])
1429 .build();
1430 list.poll_until_idle().await;
1431 assert_eq!(
1433 list.handle_key(&key(KeyCode::Enter)),
1434 KeyReaction::Intercepted(combo)
1435 );
1436 }
1437
1438 #[tokio::test]
1439 async fn autocomplete_accept_rewrites_query_without_vault() {
1440 struct Mem;
1441 #[async_trait::async_trait]
1442 impl crate::components::search_list::SuggestionSource for Mem {
1443 async fn notes_by_prefix(
1444 &self,
1445 _p: &str,
1446 _n: usize,
1447 ) -> Vec<crate::components::search_list::SuggestionItem> {
1448 vec![]
1449 }
1450 async fn tags_by_prefix(
1451 &self,
1452 p: &str,
1453 _n: usize,
1454 ) -> Vec<crate::components::search_list::SuggestionItem> {
1455 if "projects".starts_with(p) {
1456 vec![crate::components::search_list::SuggestionItem::plain(
1457 "projects",
1458 )]
1459 } else {
1460 vec![]
1461 }
1462 }
1463 }
1464 let src = VecSource {
1465 rows: vec![],
1466 reload: true,
1467 };
1468 let mut list = SearchList::builder(src, noop_redraw())
1469 .autocomplete(
1470 std::sync::Arc::new(Mem),
1471 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1472 )
1473 .debounce(std::time::Duration::ZERO)
1474 .build();
1475 for c in ['#', 'p', 'r', 'o'] {
1476 let _ = list.handle_key(&key(KeyCode::Char(c)));
1477 }
1478 for _ in 0..50 {
1479 tokio::task::yield_now().await;
1480 list.poll();
1481 }
1482 let _ = list.handle_key(&key(KeyCode::Tab));
1483 assert_eq!(list.query(), "#projects");
1484 }
1485
1486 #[tokio::test]
1490 async fn accepting_saved_search_expands_query_and_exposes_name() {
1491 struct Mem;
1492 #[async_trait::async_trait]
1493 impl crate::components::search_list::SuggestionSource for Mem {
1494 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1495 vec![]
1496 }
1497 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1498 vec![]
1499 }
1500 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1501 if "todo-week".starts_with(p) {
1502 vec![SuggestionItem {
1503 display: "todo-week".into(),
1504 secondary: Some("#todo ^modified".into()),
1505 }]
1506 } else {
1507 vec![]
1508 }
1509 }
1510 }
1511 let src = VecSource {
1512 rows: vec![],
1513 reload: true,
1514 };
1515 let mut list = SearchList::builder(src, noop_redraw())
1516 .autocomplete(
1517 std::sync::Arc::new(Mem),
1518 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1519 )
1520 .debounce(std::time::Duration::ZERO)
1521 .build();
1522 for c in ['?', 't', 'o'] {
1523 let _ = list.handle_key(&key(KeyCode::Char(c)));
1524 }
1525 for _ in 0..50 {
1526 tokio::task::yield_now().await;
1527 list.poll();
1528 }
1529 let _ = list.handle_key(&key(KeyCode::Tab));
1530 assert_eq!(list.query(), "#todo ^modified");
1532 assert_eq!(
1534 list.take_accepted_saved_search().as_deref(),
1535 Some("todo-week")
1536 );
1537 assert_eq!(list.take_accepted_saved_search(), None);
1538 }
1539
1540 #[tokio::test]
1545 async fn enter_accepts_open_popup_and_reports_consumed() {
1546 struct Mem;
1547 #[async_trait::async_trait]
1548 impl crate::components::search_list::SuggestionSource for Mem {
1549 async fn notes_by_prefix(
1550 &self,
1551 _p: &str,
1552 _n: usize,
1553 ) -> Vec<crate::components::search_list::SuggestionItem> {
1554 vec![]
1555 }
1556 async fn tags_by_prefix(
1557 &self,
1558 p: &str,
1559 _n: usize,
1560 ) -> Vec<crate::components::search_list::SuggestionItem> {
1561 if "projects".starts_with(p) {
1562 vec![crate::components::search_list::SuggestionItem::plain(
1563 "projects",
1564 )]
1565 } else {
1566 vec![]
1567 }
1568 }
1569 }
1570 let src = VecSource {
1571 rows: vec![],
1572 reload: true,
1573 };
1574 let mut list = SearchList::builder(src, noop_redraw())
1575 .autocomplete(
1576 std::sync::Arc::new(Mem),
1577 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1578 )
1579 .debounce(std::time::Duration::ZERO)
1580 .build();
1581 for c in ['#', 'p', 'r', 'o'] {
1582 let _ = list.handle_key(&key(KeyCode::Char(c)));
1583 }
1584 for _ in 0..50 {
1585 tokio::task::yield_now().await;
1586 list.poll();
1587 }
1588 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1590 assert_eq!(list.query(), "#projects");
1591 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1593 }
1594
1595 #[tokio::test]
1600 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1601 let src = ScriptedStreamLeadSource {
1602 items: vec!["alpha".into(), "beta".into()],
1603 };
1604 let mut list = SearchList::builder(src, noop_redraw())
1605 .filter(Filter::Fuzzy)
1606 .initial_query("zz")
1607 .build();
1608 list.poll_until_idle().await;
1609 let vis = list.visible_rows();
1611 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1612 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1615 list.poll();
1616 let vis = list.visible_rows();
1617 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1618 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1619 assert_eq!(list.visible_len(), 2);
1620 list.set_query("");
1622 list.poll();
1623 assert!(
1624 list.visible_rows()
1625 .iter()
1626 .all(|r| matches!(r, StreamRow::Item(_)))
1627 );
1628 assert_eq!(list.visible_len(), 2);
1629 }
1630
1631 #[tokio::test]
1634 async fn oneshot_source_leading_row_still_works() {
1635 let src = VecSourceWithLead {
1636 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1637 };
1638 let mut list = SearchList::builder(src, noop_redraw())
1639 .filter(Filter::Fuzzy)
1640 .initial_query("alp")
1641 .build();
1642 list.poll_until_idle().await;
1643 let vis = list.visible_rows();
1644 assert_eq!(vis[0].name, "create:alp");
1645 assert_eq!(vis[1].name, "alpha");
1646 assert_eq!(list.visible_len(), 2);
1647 }
1648
1649 #[tokio::test]
1652 async fn selection_includes_leading_at_position_zero() {
1653 let src = VecSourceWithLead {
1654 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1655 };
1656 let mut list = SearchList::builder(src, noop_redraw())
1657 .filter(Filter::Fuzzy)
1658 .initial_query("alp")
1659 .build();
1660 list.poll_until_idle().await;
1661 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1663 list.handle_key(&key(KeyCode::Down));
1664 assert_eq!(list.selected_row().unwrap().name, "alpha");
1665 }
1666
1667 #[tokio::test]
1669 async fn no_leading_row_visible_len_matches_display() {
1670 let src = VecSource {
1671 rows: vec![TestRow::new("a"), TestRow::new("b")],
1672 reload: true,
1673 };
1674 let mut list = SearchList::builder(src, noop_redraw()).build();
1675 list.poll_until_idle().await;
1676 assert_eq!(list.visible_len(), 2);
1677 assert_eq!(list.visible_rows().len(), 2);
1678 assert_eq!(list.selected_row().unwrap().name, "a");
1679 }
1680
1681 #[tokio::test]
1684 async fn update_rows_refilters_visible_view() {
1685 let source = VecSource {
1686 rows: vec![
1687 TestRow::new("alpha"),
1688 TestRow::new("beta"),
1689 TestRow::new("gamma"),
1690 ],
1691 reload: false,
1692 };
1693 let mut list = SearchList::builder(source, noop_redraw())
1694 .filter(Filter::Fuzzy)
1695 .build();
1696 list.poll_until_idle().await;
1697
1698 list.set_query("alp");
1700 list.poll();
1701 assert_eq!(
1702 list.visible_rows()
1703 .iter()
1704 .map(|r| r.name.as_str())
1705 .collect::<Vec<_>>(),
1706 vec!["alpha"],
1707 "before update: only 'alpha' matches 'alp'"
1708 );
1709
1710 let changed = list.update_rows(|r| {
1712 if r.name == "alpha" {
1713 r.name = "renamed".to_string();
1714 true
1715 } else {
1716 false
1717 }
1718 });
1719 assert!(changed);
1720
1721 assert_eq!(
1723 list.visible_rows().len(),
1724 0,
1725 "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
1726 );
1727 }
1728
1729 #[tokio::test]
1730 async fn update_rows_mutates_in_place_and_recomputes() {
1731 let source = VecSource {
1732 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1733 reload: false,
1734 };
1735 let mut list = SearchList::builder(source, noop_redraw()).build();
1736 list.poll_until_idle().await;
1737
1738 let changed = list.update_rows(|r| {
1740 if r.name == "alpha" {
1741 r.name = "renamed".to_string();
1742 true
1743 } else {
1744 false
1745 }
1746 });
1747 assert!(changed, "a row was changed");
1748 assert!(
1749 list.rows().iter().any(|r| r.name == "renamed"),
1750 "the mutation is visible in rows()"
1751 );
1752
1753 let changed_again = list.update_rows(|_| false);
1755 assert!(!changed_again, "no row changed");
1756 }
1757
1758 #[tokio::test]
1765 async fn reload_source_leading_row_updates_synchronously_on_set_query() {
1766 let src = ReloadWithLeadSource {
1767 rows: vec![
1768 TestRow::new("alpha"),
1769 TestRow::new("beta"),
1770 TestRow::new("gamma"),
1771 ],
1772 };
1773 let mut list = SearchList::builder(src, noop_redraw()).build();
1774 list.poll_until_idle().await;
1775 assert!(list.leading.is_none(), "no leading row for empty query");
1777
1778 list.set_query("alp");
1780
1781 let vis = list.visible_rows();
1783 assert!(
1784 !vis.is_empty(),
1785 "visible_rows must not be empty right after set_query"
1786 );
1787 assert_eq!(
1788 vis[0].name, "create:alp",
1789 "leading row must show new query synchronously, before any poll/drain"
1790 );
1791
1792 list.poll_until_idle().await;
1795 let vis = list.visible_rows();
1796 assert_eq!(
1797 vis[0].name, "create:alp",
1798 "leading row correct after drain too"
1799 );
1800 assert_eq!(vis.len(), 2, "leading + alpha");
1802 assert_eq!(vis[1].name, "alpha");
1803 }
1804
1805 #[tokio::test]
1813 async fn local_filter_reseed_after_empty_then_repopulate() {
1814 let src = VecSource {
1815 rows: vec![
1816 TestRow::new("alpha"),
1817 TestRow::new("beta"),
1818 TestRow::new("gamma"),
1819 ],
1820 reload: false,
1821 };
1822 let mut list = SearchList::builder(src, noop_redraw())
1823 .filter(Filter::Fuzzy)
1824 .build();
1825 list.poll_until_idle().await;
1826
1827 assert!(
1829 list.selected_row().is_some(),
1830 "should have a selection after initial load"
1831 );
1832
1833 list.set_query("zzznomatch");
1835 assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
1836 assert!(
1837 list.selected_row().is_none(),
1838 "selection must be None when list is empty"
1839 );
1840
1841 list.set_query("alp");
1843 assert!(
1844 list.visible_len() > 0,
1845 "at least 'alpha' should match 'alp'"
1846 );
1847 assert!(
1850 list.selected_row().is_some(),
1851 "selection must be reseeded to first visible row after repopulation"
1852 );
1853 assert_eq!(
1854 list.selected_row().unwrap().name,
1855 "alpha",
1856 "first visible row must be selected after reseeding"
1857 );
1858 }
1859
1860 async fn focus_list(verbs: &[char]) -> SearchList<TestRow> {
1863 let src = VecSource {
1864 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1865 reload: false,
1866 };
1867 let mut b = SearchList::builder(src, noop_redraw()).filter(Filter::Fuzzy);
1868 for &c in verbs {
1869 b = b.list_verb(c);
1870 }
1871 let mut list = b.build();
1872 list.poll_until_idle().await;
1873 list
1874 }
1875
1876 #[tokio::test]
1879 async fn esc_enters_list_focus_then_cancels() {
1880 let mut list = focus_list(&['l']).await;
1881 assert_eq!(list.focus(), Focus::Input);
1882 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Consumed);
1883 assert_eq!(list.focus(), Focus::List);
1884 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1885 assert_eq!(list.focus(), Focus::List, "Cancel does not change focus");
1886 }
1887
1888 #[tokio::test]
1890 async fn esc_cancels_immediately_when_focus_disabled() {
1891 let mut list = focus_list(&[]).await;
1892 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1893 assert_eq!(list.focus(), Focus::Input);
1894 }
1895
1896 #[tokio::test]
1898 async fn i_and_slash_return_to_input_focus() {
1899 for ret in ['i', '/'] {
1900 let mut list = focus_list(&['l']).await;
1901 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.focus(), Focus::List);
1903 assert_eq!(
1904 list.handle_key(&key(KeyCode::Char(ret))),
1905 KeyReaction::Consumed
1906 );
1907 assert_eq!(list.focus(), Focus::Input);
1908 assert_eq!(list.query(), "", "switching focus must not type a char");
1909 }
1910 }
1911
1912 #[tokio::test]
1914 async fn list_focus_j_k_navigate() {
1915 let mut list = focus_list(&['l']).await;
1916 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.selected_row().unwrap().name, "alpha");
1918 assert_eq!(
1919 list.handle_key(&key(KeyCode::Char('j'))),
1920 KeyReaction::Consumed
1921 );
1922 assert_eq!(list.selected_row().unwrap().name, "beta");
1923 assert_eq!(
1924 list.handle_key(&key(KeyCode::Char('k'))),
1925 KeyReaction::Consumed
1926 );
1927 assert_eq!(list.selected_row().unwrap().name, "alpha");
1928 }
1929
1930 #[tokio::test]
1933 async fn registered_verb_fires_unregistered_letter_does_nothing() {
1934 let mut list = focus_list(&['l', 'o']).await;
1935 list.handle_key(&key(KeyCode::Esc)); assert_eq!(
1937 list.handle_key(&key(KeyCode::Char('l'))),
1938 KeyReaction::ListVerb('l')
1939 );
1940 assert_eq!(
1941 list.handle_key(&key(KeyCode::Char('o'))),
1942 KeyReaction::ListVerb('o')
1943 );
1944 assert_eq!(
1946 list.handle_key(&key(KeyCode::Char('z'))),
1947 KeyReaction::Consumed
1948 );
1949 assert_eq!(list.query(), "");
1950 }
1951
1952 #[tokio::test]
1955 async fn verbs_are_inert_in_input_focus() {
1956 let mut list = focus_list(&['l', 'o']).await;
1957 assert_eq!(list.focus(), Focus::Input);
1958 assert_eq!(
1959 list.handle_key(&key(KeyCode::Char('l'))),
1960 KeyReaction::Consumed
1961 );
1962 list.poll_until_idle().await;
1963 assert_eq!(list.query(), "l", "verb letters still type in Input focus");
1964 }
1965
1966 #[tokio::test]
1969 async fn opening_focus_list_starts_in_list() {
1970 let src = VecSource {
1971 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1972 reload: false,
1973 };
1974 let mut list = SearchList::builder(src, noop_redraw())
1975 .filter(Filter::Fuzzy)
1976 .opening_focus(Focus::List)
1977 .build();
1978 list.poll_until_idle().await;
1979 assert_eq!(list.focus(), Focus::List);
1980 assert_eq!(
1981 list.handle_key(&key(KeyCode::Char('a'))),
1982 KeyReaction::Consumed
1983 );
1984 assert_eq!(list.query(), "");
1985 list.handle_key(&key(KeyCode::Char('i')));
1987 assert_eq!(list.focus(), Focus::Input);
1988 list.handle_key(&key(KeyCode::Char('a')));
1989 list.poll_until_idle().await;
1990 assert_eq!(list.query(), "a");
1991 }
1992
1993 #[tokio::test]
1995 async fn intercept_fires_in_both_foci() {
1996 let src = VecSource {
1997 rows: vec![TestRow::new("a")],
1998 reload: false,
1999 };
2000 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
2001 let mut list = SearchList::builder(src, noop_redraw())
2002 .intercept(vec![combo])
2003 .list_verb('l')
2004 .build();
2005 list.poll_until_idle().await;
2006 assert_eq!(
2008 list.handle_key(&key(KeyCode::Enter)),
2009 KeyReaction::Intercepted(combo)
2010 );
2011 list.handle_key(&key(KeyCode::Esc));
2013 assert_eq!(list.focus(), Focus::List);
2014 assert_eq!(
2015 list.handle_key(&key(KeyCode::Enter)),
2016 KeyReaction::Intercepted(combo)
2017 );
2018 }
2019}