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, 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
35fn 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 scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
54 scored.into_iter().map(|(i, _)| i).collect()
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Focus {
63 Input,
64 List,
65}
66
67#[derive(Debug, PartialEq, Eq)]
69pub enum KeyReaction {
70 Consumed,
71 Submit,
72 Cancel,
73 Intercepted(crate::keys::key_combo::KeyCombo),
74 ListVerb(char),
78 Yank(Option<YankTarget>),
86 Unhandled,
87}
88
89pub struct SearchList<R: SearchRow> {
90 source: Arc<dyn RowSource<R>>,
91 rows: Vec<R>,
92 display: Vec<usize>,
94 leading: Option<R>,
100 selected: Option<usize>,
103 selection_pinned: bool,
109 offset: usize,
114 filter: Filter<R>,
115 order: Option<OrderFn<R>>,
118 query: String,
119 loader: LoadEngine<R>,
120 input: SingleLineInput,
121 autocomplete: Option<AutocompleteController>,
122 intercept: Vec<KeyCombo>,
124 yank_combos: Vec<KeyCombo>,
129 icons: Icons,
130 list_rect: Rect,
131 panel_rect: Rect,
137 content_rect: Rect,
146 input_rect: Rect,
149 applied_generation: u64,
154 accepted_saved_search: Option<String>,
158 last_click_pos: Option<usize>,
162 last_click_at: Option<std::time::Instant>,
165 last_click_activated: bool,
169 highlight_query: bool,
172 focus: Focus,
174 focus_enabled: bool,
179 list_verbs: Vec<char>,
183}
184
185#[derive(Debug, PartialEq, Eq)]
187pub enum SearchMouse {
188 Selected(usize),
189 Activated(usize),
190 DoubleClicked {
199 pos: usize,
200 repeat: bool,
201 },
202 Context(usize),
205 Scrolled,
206 ContentScrollUp,
210 ContentScrollDown,
211 InputFocused,
214 Autocomplete {
218 edited: bool,
219 },
220 None,
221}
222
223pub struct SearchListBuilder<R: SearchRow> {
224 source: Arc<dyn RowSource<R>>,
225 redraw: Arc<dyn Fn() + Send + Sync>,
226 initial_query: String,
227 filter: Filter<R>,
228 order: Option<OrderFn<R>>,
229 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
230 intercept: Vec<KeyCombo>,
231 yank_combos: Vec<KeyCombo>,
232 icons: Icons,
233 debounce: Option<std::time::Duration>,
234 highlight_query: bool,
235 opening_focus: Focus,
236 list_verbs: Vec<char>,
237}
238
239impl<R: SearchRow> SearchList<R> {
240 pub fn builder(
241 source: impl RowSource<R>,
242 redraw: Arc<dyn Fn() + Send + Sync>,
243 ) -> SearchListBuilder<R> {
244 SearchListBuilder {
245 source: Arc::new(source),
246 redraw,
247 initial_query: String::new(),
248 filter: Filter::SourceOrder,
249 order: None,
250 autocomplete: None,
251 intercept: Vec::new(),
252 yank_combos: vec![crate::keys::default_yank_combo()],
253 icons: Icons::new(false),
254 debounce: None,
255 highlight_query: false,
256 opening_focus: Focus::Input,
257 list_verbs: Vec::new(),
258 }
259 }
260
261 fn new(b: SearchListBuilder<R>) -> Self {
263 let mut list = Self::assemble(b);
264 list.loader.start(list.source.clone(), list.query.clone());
265 list
266 }
267
268 fn with_rows(b: SearchListBuilder<R>, rows: Vec<R>) -> Self {
278 let mut list = Self::assemble(b);
279 list.rows = rows;
280 list.recompute_and_seed();
281 list
282 }
283
284 fn assemble(b: SearchListBuilder<R>) -> Self {
288 let loader = LoadEngine::new(b.redraw.clone());
289 let input = SingleLineInput::with_value(&b.initial_query);
290 let debounce = b.debounce;
291 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
292 let mut ac =
293 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
294 disambiguate_header: false,
295 apply_exclusion_zone: false,
296 ..TriggerOptions::default()
299 });
300 if let Some(d) = debounce {
301 ac = ac.with_debounce(d);
302 }
303 ac.set_redraw_callback(b.redraw.clone());
304 ac
305 });
306 Self {
307 source: b.source,
308 rows: Vec::new(),
309 display: Vec::new(),
310 leading: None,
311 selected: None,
312 selection_pinned: false,
313 offset: 0,
314 filter: b.filter,
315 order: b.order,
316 query: b.initial_query,
317 loader,
318 input,
319 highlight_query: b.highlight_query,
320 last_click_pos: None,
321 last_click_at: None,
322 last_click_activated: false,
323 autocomplete,
324 intercept: b.intercept,
325 yank_combos: b.yank_combos,
326 icons: b.icons,
327 list_rect: Rect::default(),
328 panel_rect: Rect::default(),
329 content_rect: Rect::default(),
330 input_rect: Rect::default(),
331 applied_generation: 0,
332 accepted_saved_search: None,
333 focus: b.opening_focus,
334 focus_enabled: b.opening_focus == Focus::List || !b.list_verbs.is_empty(),
337 list_verbs: b.list_verbs,
338 }
339 }
340
341 pub fn focus(&self) -> Focus {
343 self.focus
344 }
345
346 pub fn poll(&mut self) {
347 let drained = self.loader.drain();
348 if !drained.is_empty() {
349 let current_gen = self.loader.generation();
353 if current_gen != self.applied_generation {
354 self.rows.clear();
355 self.selected = None;
356 self.selection_pinned = false;
357 self.offset = 0;
358 self.applied_generation = current_gen;
359 }
360 let keep = if drained
364 .iter()
365 .any(|ev| matches!(ev, LoadedInner::Replace(_)))
366 {
367 None
368 } else {
369 self.selection_to_carry()
370 };
371 for ev in drained {
372 match ev {
373 LoadedInner::Replace(rows) => {
374 self.rows = rows;
375 }
376 LoadedInner::Push(row) => {
377 self.rows.push(row);
378 }
379 LoadedInner::Done => {}
380 }
381 }
382 self.recompute_and_seed();
383 self.reselect(keep);
384 }
385 if let Some(ac) = &mut self.autocomplete {
386 ac.poll_results();
387 }
388 }
389
390 fn recompute_and_seed(&mut self) {
395 self.recompute_display();
396 if self.selected.is_none() && self.visible_len() > 0 {
397 self.selected = Some(0);
398 }
399 }
400
401 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
405 let value = self.input.value().to_string();
406 let cursor_byte = self.input.cursor_byte();
407 let col = value[..cursor_byte.min(value.len())].chars().count();
408 host::SearchBoxHostSnapshot {
409 lines: vec![value],
410 cursor: (0, col),
411 caret_pos: self.input.last_caret_pos(),
412 }
413 }
414
415 fn clamp_selection(&mut self) {
416 let len = self.visible_len();
417 self.selected = if len == 0 {
418 None
419 } else {
420 Some(self.selected.unwrap_or(0).min(len - 1))
421 };
422 }
423
424 fn leading_offset(&self) -> usize {
426 self.leading.is_some() as usize
427 }
428
429 pub fn visible_len(&self) -> usize {
431 self.leading_offset() + self.display.len()
432 }
433
434 pub fn match_count(&self) -> usize {
437 self.display.len()
438 }
439
440 fn visible_row(&self, pos: usize) -> Option<&R> {
442 if self.leading.is_some() && pos == 0 {
443 self.leading.as_ref()
444 } else {
445 self.rows
446 .get(*self.display.get(pos - self.leading_offset())?)
447 }
448 }
449
450 pub fn rows(&self) -> &[R] {
454 &self.rows
455 }
456
457 pub fn selected_row(&self) -> Option<&R> {
458 self.selected.and_then(|p| self.visible_row(p))
459 }
460
461 pub fn visible_rows(&self) -> Vec<&R> {
462 (0..self.visible_len())
463 .filter_map(|p| self.visible_row(p))
464 .collect()
465 }
466
467 pub fn query(&self) -> &str {
468 &self.query
469 }
470
471 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
475 self.accepted_saved_search.take()
476 }
477
478 #[cfg(test)]
481 pub(crate) fn input_value(&self) -> &str {
482 self.input.value()
483 }
484 pub fn is_loading(&self) -> bool {
485 self.loader.loading
486 }
487
488 pub fn set_query(&mut self, q: impl Into<String>) {
497 let q = q.into();
498 self.input.set_value(q.clone());
499 self.query = q;
500 self.requery();
501 }
502
503 fn sync_query_from_input(&mut self) {
508 self.query = self.input.value().to_string();
509 self.requery();
510 }
511
512 fn requery(&mut self) {
515 if self.source.reload_on_query() {
516 self.loader.start(self.source.clone(), self.query.clone());
517 }
518 self.recompute_and_seed();
522 }
523
524 pub fn reload(&mut self) {
526 self.loader.start(self.source.clone(), self.query.clone());
527 }
528
529 pub fn set_order(&mut self, cmp: Option<OrderFn<R>>) {
535 let keep = self.selection_to_carry();
536 self.order = cmp;
537 self.recompute_display();
538 self.reselect(keep);
539 }
540
541 fn selection_to_carry(&self) -> Option<usize> {
546 self.selection_pinned
547 .then(|| self.selected_row_index())
548 .flatten()
549 }
550
551 fn selected_row_index(&self) -> Option<usize> {
554 let pos = self.selected?.checked_sub(self.leading_offset())?;
555 self.display.get(pos).copied()
556 }
557
558 fn reselect(&mut self, row: Option<usize>) {
562 if let Some(row) = row
563 && let Some(pos) = self.display.iter().position(|&i| i == row)
564 {
565 self.selected = Some(pos + self.leading_offset());
566 }
567 }
568
569 pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
583 let mut changed = false;
584 for row in &mut self.rows {
585 if mutate(row) {
586 changed = true;
587 }
588 }
589 if changed {
590 let keep = self.selection_to_carry();
591 self.recompute_display();
592 self.reselect(keep);
593 }
594 changed
595 }
596
597 pub fn select(&mut self, pos: usize) {
603 let n = self.visible_len();
604 self.selected = if n == 0 { None } else { Some(pos.min(n - 1)) };
605 self.selection_pinned = self.selected.is_some();
609 }
610
611 pub fn select_next(&mut self) {
612 let n = self.visible_len();
613 if n == 0 {
614 return;
615 }
616 self.move_selection(Some(self.selected.map_or(0, |i| (i + 1).min(n - 1))));
617 }
618
619 pub fn select_prev(&mut self) {
620 if self.visible_len() == 0 {
621 return;
622 }
623 self.move_selection(Some(self.selected.map_or(0, |i| i.saturating_sub(1))));
624 }
625
626 fn move_selection(&mut self, next: Option<usize>) {
632 if next != self.selected {
633 self.selected = next;
634 self.selection_pinned = next.is_some();
635 }
636 }
637
638 fn max_scroll_offset(&self) -> usize {
643 let viewport = self.list_rect.height as usize;
644 let n = self.visible_len();
645 if viewport == 0 || n == 0 {
646 return 0;
647 }
648 let mut budget = viewport;
649 let mut first = n;
650 while first > 0 {
651 let h = self
652 .visible_row(first - 1)
653 .map(|r| r.visual_height() as usize)
654 .unwrap_or(1);
655 if h > budget {
656 break;
657 }
658 budget -= h;
659 first -= 1;
660 }
661 first.min(n - 1)
662 }
663
664 pub fn scroll_down(&mut self) {
668 let n = self.visible_len();
669 if n == 0 || self.offset >= self.max_scroll_offset() {
670 return;
671 }
672 self.offset += 1;
673 self.move_selection(self.selected.map(|i| (i + 1).min(n - 1)));
674 }
675
676 pub fn scroll_up(&mut self) {
679 if self.offset == 0 {
680 return;
681 }
682 self.offset -= 1;
683 self.move_selection(self.selected.map(|i| i.saturating_sub(1)));
684 }
685
686 #[cfg(test)]
689 pub(crate) fn scroll_offset(&self) -> usize {
690 self.offset
691 }
692
693 pub fn is_yank_chord(&self, key: &KeyEvent) -> bool {
699 crate::keys::key_event_to_combo(key).is_some_and(|c| self.yank_combos.contains(&c))
700 }
701
702 fn apply_accept(&mut self, action: crate::components::autocomplete::AcceptAction) {
705 self.input.replace_range_bytes(
706 action.range.clone(),
707 &action.new_text,
708 action.new_cursor_byte,
709 );
710 self.accepted_saved_search = action.saved_search_name;
714 self.sync_query_from_input();
715 }
716
717 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
718 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
719
720 if let Some(combo) = crate::keys::key_event_to_combo(key)
723 && self.intercept.contains(&combo)
724 {
725 return KeyReaction::Intercepted(combo);
726 }
727
728 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
732 let snap = self.autocomplete_snapshot();
733 if let Some(ac) = &mut self.autocomplete {
734 match ac.handle_key(*key, &snap) {
735 HandleKeyOutcome::Accepted(action) => {
736 self.apply_accept(action);
737 return KeyReaction::Consumed;
738 }
739 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
740 return KeyReaction::Consumed;
741 }
742 HandleKeyOutcome::NotHandled => {}
743 }
744 }
745 }
746
747 match key.code {
749 KeyCode::Up => {
750 self.select_prev();
751 return KeyReaction::Consumed;
752 }
753 KeyCode::Down => {
754 self.select_next();
755 return KeyReaction::Consumed;
756 }
757 KeyCode::Enter => return KeyReaction::Submit,
758 _ => {}
759 }
760 if key.code == KeyCode::Esc {
764 if self.focus_enabled && self.focus == Focus::Input {
765 self.focus = Focus::List;
766 self.close_autocomplete();
767 return KeyReaction::Consumed;
768 }
769 return KeyReaction::Cancel;
770 }
771 if let Some(combo) = crate::keys::key_event_to_combo(key)
777 && self.yank_combos.contains(&combo)
778 {
779 return KeyReaction::Yank(self.selected_row().and_then(|r| r.yank_target()));
780 }
781 if let KeyCode::Char(_) = key.code {
784 let non_shift = key.modifiers - KeyModifiers::SHIFT;
785 if !non_shift.is_empty() {
786 return KeyReaction::Unhandled;
787 }
788 }
789 if self.focus == Focus::List {
791 if let KeyCode::Char(c) = key.code {
792 return match c {
793 'i' | '/' => {
795 self.focus = Focus::Input;
796 KeyReaction::Consumed
797 }
798 'j' => {
799 self.select_next();
800 KeyReaction::Consumed
801 }
802 'k' => {
803 self.select_prev();
804 KeyReaction::Consumed
805 }
806 _ if self.list_verbs.contains(&c) => KeyReaction::ListVerb(c),
807 _ => KeyReaction::Consumed,
809 };
810 }
811 return KeyReaction::Unhandled;
813 }
814 let outcome = self.input.handle_key(key);
815 let snap = self.autocomplete_snapshot();
818 match outcome {
819 InputOutcome::Changed => {
820 if let Some(ac) = &mut self.autocomplete {
821 ac.sync(&snap);
822 }
823 }
824 InputOutcome::Consumed => {
825 if let Some(ac) = &mut self.autocomplete {
826 ac.refresh_if_open(&snap);
827 }
828 }
829 InputOutcome::Cancel | InputOutcome::Submit => {
830 if let Some(ac) = &mut self.autocomplete {
831 ac.close();
832 }
833 }
834 InputOutcome::NotConsumed => {}
835 }
836 match outcome {
837 InputOutcome::Changed => {
838 self.sync_query_from_input();
839 KeyReaction::Consumed
840 }
841 InputOutcome::Consumed => KeyReaction::Consumed,
842 InputOutcome::Submit => KeyReaction::Submit,
843 InputOutcome::Cancel => KeyReaction::Cancel,
844 InputOutcome::NotConsumed => KeyReaction::Unhandled,
845 }
846 }
847
848 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
849 self.input_rect = area;
854 let focused = focused && self.focus == Focus::Input;
855 let base = Style::default()
856 .fg(theme.fg.to_ratatui())
857 .bg(theme.bg_panel.to_ratatui());
858 if self.highlight_query {
859 let line =
860 crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
861 self.input.render_line(f, area, line, base, 0, focused);
862 } else {
863 self.input.render(f, area, base, 0, focused);
864 }
865 }
866
867 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
868 self.poll();
869 let sel = self.selected;
870 let items: Vec<ListItem> = (0..self.visible_len())
871 .filter_map(|pos| {
872 self.visible_row(pos)
873 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
874 })
875 .collect();
876 let mut state = ListState::default().with_offset(self.offset);
877 state.select(self.selected);
878 let list =
879 List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
880 f.render_stateful_widget(list, area, &mut state);
881 self.offset = state.offset();
885 self.list_rect = area;
886 let _ = focused;
887 }
888
889 pub fn set_list_rect(&mut self, rect: Rect) {
898 self.list_rect = rect;
899 }
900
901 pub fn set_panel_rect(&mut self, rect: Rect) {
906 self.panel_rect = rect;
907 }
908
909 pub fn set_content_rect(&mut self, rect: Rect) {
917 self.content_rect = rect;
918 }
919
920 #[cfg(test)]
924 pub(crate) fn content_rect(&self) -> Rect {
925 self.content_rect
926 }
927
928 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
929 if let Some(ac) = &mut self.autocomplete {
930 ac.poll_results();
931 let caret = self.input.last_caret_pos();
932 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
933 state.anchor = anchor;
934 }
935 if let Some(state) = ac.state_mut() {
936 crate::components::autocomplete::render(f, state, clamp, theme);
937 }
938 }
939 }
940
941 pub fn close_autocomplete(&mut self) {
948 if let Some(ac) = &mut self.autocomplete {
949 ac.close();
950 }
951 }
952
953 #[cfg(test)]
956 pub(crate) fn autocomplete_is_open(&self) -> bool {
957 self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
958 }
959
960 pub fn input_contains(&self, col: u16, row: u16) -> bool {
963 self.input_rect
964 .contains(ratatui::layout::Position::new(col, row))
965 }
966
967 pub fn popup_contains(&self, col: u16, row: u16) -> bool {
970 self.autocomplete
971 .as_ref()
972 .and_then(|ac| ac.state())
973 .is_some_and(|st| {
974 st.rows_rect
975 .contains(ratatui::layout::Position::new(col, row))
976 })
977 }
978
979 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
980 self.handle_mouse_at(m, std::time::Instant::now())
981 }
982
983 pub(crate) fn handle_mouse_at(
986 &mut self,
987 m: &ratatui::crossterm::event::MouseEvent,
988 now: std::time::Instant,
989 ) -> SearchMouse {
990 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
991 use ratatui::layout::Position;
992 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
995 let snap = self.autocomplete_snapshot();
996 if let Some(ac) = &mut self.autocomplete {
997 match ac.handle_mouse(m, &snap) {
998 HandleKeyOutcome::Accepted(action) => {
999 self.apply_accept(action);
1000 return SearchMouse::Autocomplete { edited: true };
1001 }
1002 HandleKeyOutcome::Consumed | HandleKeyOutcome::Dismissed => {
1003 return SearchMouse::Autocomplete { edited: false };
1004 }
1005 HandleKeyOutcome::NotHandled => {}
1006 }
1007 }
1008 }
1009 if matches!(
1014 m.kind,
1015 MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1016 ) {
1017 self.close_autocomplete();
1018 }
1019 let pos = Position {
1020 x: m.column,
1021 y: m.row,
1022 };
1023 if matches!(
1027 m.kind,
1028 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1029 ) {
1030 if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
1034 return if m.kind == MouseEventKind::ScrollUp {
1035 SearchMouse::ContentScrollUp
1036 } else {
1037 SearchMouse::ContentScrollDown
1038 };
1039 }
1040 let bounds = if self.panel_rect.is_empty() {
1041 self.list_rect
1042 } else {
1043 self.panel_rect
1044 };
1045 if !bounds.contains(pos) {
1046 return SearchMouse::None;
1047 }
1048 if m.kind == MouseEventKind::ScrollUp {
1049 self.scroll_up();
1050 } else {
1051 self.scroll_down();
1052 }
1053 return SearchMouse::Scrolled;
1054 }
1055 if matches!(m.kind, MouseEventKind::Down(MouseButton::Left))
1056 && self.input_rect.contains(pos)
1057 {
1058 self.focus = Focus::Input;
1060 return SearchMouse::InputFocused;
1061 }
1062 let r = self.list_rect;
1063 if !r.contains(pos) {
1064 return SearchMouse::None;
1065 }
1066 match m.kind {
1067 MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
1068 let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
1069 let target_visual = m.row - r.y; let mut acc: u16 = 0;
1071 let mut hit: Option<usize> = None;
1072 for pos in self.offset..self.visible_len() {
1077 let h = self
1078 .visible_row(pos)
1079 .map(|r| r.visual_height())
1080 .unwrap_or(1);
1081 if target_visual < acc + h {
1082 hit = Some(pos);
1083 break;
1084 }
1085 acc += h;
1086 }
1087 if let Some(pos) = hit {
1088 let prev = self.selected;
1089 let prev_click = self.last_click_pos.replace(pos);
1090 let prev_at = if right_click {
1091 self.last_click_at.take()
1092 } else {
1093 self.last_click_at.replace(now)
1094 };
1095 self.selected = Some(pos);
1096 self.selection_pinned = true;
1097 let fast = prev_at
1098 .is_some_and(|at| now.duration_since(at) < crate::components::DOUBLE_CLICK);
1099 let was_activate = std::mem::take(&mut self.last_click_activated);
1100 return if right_click {
1101 SearchMouse::Context(pos)
1102 } else if prev == Some(pos) && prev_click == Some(pos) && fast {
1103 self.last_click_at = None;
1106 SearchMouse::DoubleClicked {
1107 pos,
1108 repeat: was_activate,
1109 }
1110 } else if prev == Some(pos) && prev_click == Some(pos) {
1111 self.last_click_activated = true;
1114 SearchMouse::Activated(pos)
1115 } else {
1116 SearchMouse::Selected(pos)
1117 };
1118 }
1119 SearchMouse::None
1120 }
1121 _ => SearchMouse::None,
1122 }
1123 }
1124
1125 fn recompute_display(&mut self) {
1126 let q = self.query.trim();
1127 self.leading = self.source.leading_row(q);
1130 let mut base: Vec<usize> = (0..self.rows.len()).collect();
1132 if let Some(cmp) = &self.order {
1133 let rows = &self.rows;
1134 base.sort_by(|&a, &b| cmp(&rows[a], &rows[b]));
1135 }
1136 let mut idx: Vec<usize> = match &self.filter {
1137 Filter::SourceOrder => base,
1138 Filter::Fuzzy if q.is_empty() => base,
1139 Filter::Fuzzy => fuzzy_indices(&self.rows, &base, q),
1140 Filter::Rank(_) if q.is_empty() => base,
1141 Filter::Rank(f) => {
1142 let f = f.clone();
1143 f(&self.rows, &base, q)
1144 }
1145 };
1146 for i in 0..self.rows.len() {
1149 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
1150 idx.insert(0, i);
1151 }
1152 }
1153 self.display = idx;
1154 self.clamp_selection();
1155 }
1156
1157 #[cfg(test)]
1158 pub(crate) async fn poll_until_idle(&mut self) {
1159 for _ in 0..600 {
1165 tokio::task::yield_now().await;
1166 self.poll();
1167 if !self.is_loading() {
1168 break;
1169 }
1170 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1171 }
1172 self.poll();
1173 }
1174}
1175
1176impl<R: SearchRow> SearchListBuilder<R> {
1177 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
1178 self.initial_query = q.into();
1179 self
1180 }
1181 pub fn filter(mut self, f: Filter<R>) -> Self {
1182 self.filter = f;
1183 self
1184 }
1185 pub fn order_by(mut self, cmp: OrderFn<R>) -> Self {
1190 self.order = Some(cmp);
1191 self
1192 }
1193 pub fn autocomplete(
1194 mut self,
1195 suggestions: Arc<dyn SuggestionSource>,
1196 mode: AutocompleteMode,
1197 ) -> Self {
1198 self.autocomplete = Some((suggestions, mode));
1199 self
1200 }
1201 pub fn yank_combos_from(self, bindings: &crate::keys::KeyBindings) -> Self {
1206 self.yank_combos(
1207 bindings.combos_for(&crate::keys::action_shortcuts::ActionShortcuts::YankRow),
1208 )
1209 }
1210
1211 pub fn yank_combos(mut self, combos: Vec<KeyCombo>) -> Self {
1214 self.yank_combos = combos;
1215 self
1216 }
1217
1218 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
1219 self.intercept = v;
1220 self
1221 }
1222 pub fn highlight_query(mut self) -> Self {
1224 self.highlight_query = true;
1225 self
1226 }
1227 pub fn icons(mut self, icons: Icons) -> Self {
1228 self.icons = icons;
1229 self
1230 }
1231 pub fn opening_focus(mut self, focus: Focus) -> Self {
1235 self.opening_focus = focus;
1236 self
1237 }
1238 pub fn list_verb(mut self, c: char) -> Self {
1245 self.list_verbs.push(c);
1246 self
1247 }
1248 pub fn debounce(mut self, d: std::time::Duration) -> Self {
1251 self.debounce = Some(d);
1252 self
1253 }
1254 pub fn build(self) -> SearchList<R> {
1255 SearchList::new(self)
1256 }
1257
1258 pub fn build_with_rows(self, rows: Vec<R>) -> SearchList<R> {
1268 SearchList::with_rows(self, rows)
1269 }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::adapters::{
1275 ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
1276 VecSource, VecSourceWithLead,
1277 };
1278 use super::*;
1279 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1280
1281 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
1282 std::sync::Arc::new(|| {})
1283 }
1284
1285 fn key(c: KeyCode) -> KeyEvent {
1286 KeyEvent::new(c, KeyModifiers::NONE)
1287 }
1288
1289 fn yank_list(rows: &[&str]) -> SearchList<TestRow> {
1296 SearchList::builder(
1297 VecSource {
1298 rows: vec![],
1299 reload: false,
1300 },
1301 noop_redraw(),
1302 )
1303 .build_with_rows(rows.iter().map(|n| TestRow::new(n)).collect())
1304 }
1305
1306 fn ctrl(c: char) -> KeyEvent {
1307 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1308 }
1309
1310 #[test]
1311 fn yank_chord_reports_the_selected_rows_target() {
1312 let mut list = yank_list(&["alpha", "beta"]);
1313 match list.handle_key(&ctrl('y')) {
1314 KeyReaction::Yank(Some(t)) => {
1315 assert_eq!(t.text, "alpha");
1316 assert_eq!(t.noun, "path");
1317 }
1318 r => panic!("got {r:?}"),
1319 }
1320 }
1321
1322 #[test]
1323 fn yank_chord_reports_none_for_a_row_with_nothing_to_copy() {
1324 let mut list = yank_list(&["quiet"]);
1327 list.select_next();
1328 assert!(matches!(
1329 list.handle_key(&ctrl('y')),
1330 KeyReaction::Yank(None)
1331 ));
1332 }
1333
1334 #[test]
1335 fn yank_chord_reports_none_when_nothing_is_selected() {
1336 let mut list = yank_list(&[]);
1337 assert!(matches!(
1338 list.handle_key(&ctrl('y')),
1339 KeyReaction::Yank(None)
1340 ));
1341 }
1342
1343 #[test]
1344 fn yank_chord_is_claimed_before_ctrl_chars_are_dropped() {
1345 let mut list = yank_list(&["alpha"]);
1349 list.select_next();
1350 assert!(
1351 !matches!(list.handle_key(&ctrl('y')), KeyReaction::Unhandled),
1352 "the yank chord must not fall through to the Ctrl-char drop"
1353 );
1354 }
1355
1356 #[test]
1357 fn a_rebound_yank_combo_replaces_the_default() {
1358 let mut list = SearchList::builder(
1359 VecSource {
1360 rows: vec![],
1361 reload: false,
1362 },
1363 noop_redraw(),
1364 )
1365 .yank_combos(vec![crate::keys::key_event_to_combo(&ctrl('k')).unwrap()])
1366 .build_with_rows(vec![TestRow::new("alpha")]);
1367 list.select_next();
1368 assert!(matches!(
1369 list.handle_key(&ctrl('k')),
1370 KeyReaction::Yank(Some(_))
1371 ));
1372 assert!(
1373 !matches!(list.handle_key(&ctrl('y')), KeyReaction::Yank(_)),
1374 "the default chord must stop yanking once overridden"
1375 );
1376 }
1377
1378 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
1379 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1380 MouseEvent {
1381 kind: MouseEventKind::Down(MouseButton::Left),
1382 column: col,
1383 row,
1384 modifiers: KeyModifiers::NONE,
1385 }
1386 }
1387
1388 #[derive(Clone, Debug, PartialEq)]
1389 struct TallRow {
1390 name: String,
1391 height: u16,
1392 }
1393 impl SearchRow for TallRow {
1394 fn to_list_item(
1395 &self,
1396 _t: &crate::settings::themes::Theme,
1397 _i: &crate::settings::icons::Icons,
1398 _s: bool,
1399 ) -> ratatui::widgets::ListItem<'static> {
1400 ratatui::widgets::ListItem::new(self.name.clone())
1401 }
1402 fn visual_height(&self) -> u16 {
1403 self.height
1404 }
1405 fn match_text(&self) -> Option<&str> {
1406 Some(&self.name)
1407 }
1408 }
1409 struct TallSource(Vec<TallRow>);
1410 #[async_trait::async_trait]
1411 impl RowSource<TallRow> for TallSource {
1412 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
1413 emit.replace(self.0.clone());
1414 }
1415 }
1416
1417 #[tokio::test]
1421 async fn wheel_in_content_rect_routes_to_host() {
1422 use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
1423 let rows: Vec<TallRow> = (0..10)
1424 .map(|i| TallRow {
1425 name: format!("r{}", i),
1426 height: 1,
1427 })
1428 .collect();
1429 let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
1430 list.poll_until_idle().await;
1431 let rect = |y: u16, h: u16| ratatui::layout::Rect {
1432 x: 0,
1433 y,
1434 width: 20,
1435 height: h,
1436 };
1437 list.set_panel_rect(rect(0, 10));
1439 list.set_list_rect(rect(0, 4));
1440 list.set_content_rect(rect(5, 5));
1441 let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
1442 kind,
1443 column: 2,
1444 row,
1445 modifiers: KeyModifiers::NONE,
1446 };
1447
1448 let m = wheel(MouseEventKind::ScrollDown, 6);
1450 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
1451 assert_eq!(list.offset, 0, "list viewport must not move");
1452 let m = wheel(MouseEventKind::ScrollUp, 6);
1453 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
1454
1455 let m = wheel(MouseEventKind::ScrollDown, 2);
1457 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1458
1459 list.set_content_rect(ratatui::layout::Rect::default());
1461 let m = wheel(MouseEventKind::ScrollDown, 6);
1462 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1463 }
1464
1465 #[tokio::test]
1466 async fn mouse_maps_visual_row_to_display_index_by_height() {
1467 let src = TallSource(vec![
1470 TallRow {
1471 name: "a".into(),
1472 height: 3,
1473 },
1474 TallRow {
1475 name: "b".into(),
1476 height: 1,
1477 },
1478 ]);
1479 let mut list = SearchList::builder(src, noop_redraw()).build();
1480 list.poll_until_idle().await;
1481 list.set_list_rect(ratatui::layout::Rect {
1483 x: 0,
1484 y: 0,
1485 width: 20,
1486 height: 10,
1487 });
1488 let m = mouse_down_at(2, 3);
1490 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
1491 assert_eq!(list.selected_row().unwrap().name, "b");
1492 let m = mouse_down_at(2, 1);
1494 list.handle_mouse(&m);
1495 assert_eq!(list.selected_row().unwrap().name, "a");
1496 }
1497
1498 #[tokio::test]
1502 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
1503 let src = VecSource {
1504 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1505 reload: true,
1506 };
1507 let mut list = SearchList::builder(src, noop_redraw()).build();
1508 list.poll_until_idle().await;
1509 list.set_list_rect(ratatui::layout::Rect {
1511 x: 0,
1512 y: 0,
1513 width: 20,
1514 height: 4,
1515 });
1516 list.select_next();
1518 list.select_next();
1519 assert_eq!(list.selected_row().unwrap().name, "row2");
1520
1521 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1522 kind,
1523 column: 1,
1524 row: 1,
1525 modifiers: KeyModifiers::NONE,
1526 };
1527 use ratatui::crossterm::event::MouseEventKind;
1528
1529 assert_eq!(
1531 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1532 SearchMouse::Scrolled
1533 );
1534 assert_eq!(list.scroll_offset(), 1);
1535 assert_eq!(list.selected_row().unwrap().name, "row3");
1536
1537 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1539 assert_eq!(list.scroll_offset(), 0);
1540 assert_eq!(list.selected_row().unwrap().name, "row2");
1541
1542 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1544 assert_eq!(list.scroll_offset(), 0);
1545 assert_eq!(list.selected_row().unwrap().name, "row2");
1546
1547 for _ in 0..20 {
1550 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1551 }
1552 assert_eq!(list.scroll_offset(), 6);
1553 assert_eq!(list.selected_row().unwrap().name, "row8");
1554 }
1557
1558 #[tokio::test]
1562 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1563 let src = VecSource {
1564 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1565 reload: true,
1566 };
1567 let mut list = SearchList::builder(src, noop_redraw()).build();
1568 list.poll_until_idle().await;
1569 list.set_list_rect(ratatui::layout::Rect {
1571 x: 0,
1572 y: 5,
1573 width: 20,
1574 height: 4,
1575 });
1576 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1577 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1578 column: 1,
1579 row,
1580 modifiers: KeyModifiers::NONE,
1581 };
1582 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1584 assert_eq!(list.scroll_offset(), 0);
1585 list.set_panel_rect(ratatui::layout::Rect {
1586 x: 0,
1587 y: 0,
1588 width: 20,
1589 height: 20,
1590 });
1591 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1593 assert_eq!(list.scroll_offset(), 1);
1594 let before = list.selected_row().unwrap().name.clone();
1597 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1598 assert_eq!(list.selected_row().unwrap().name, before);
1599 }
1600
1601 #[tokio::test]
1605 async fn click_after_scroll_selects_the_clicked_row() {
1606 let src = VecSource {
1607 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1608 reload: true,
1609 };
1610 let mut list = SearchList::builder(src, noop_redraw()).build();
1611 list.poll_until_idle().await;
1612 list.set_list_rect(ratatui::layout::Rect {
1613 x: 0,
1614 y: 0,
1615 width: 20,
1616 height: 4,
1617 });
1618 let scroll_down = ratatui::crossterm::event::MouseEvent {
1619 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1620 column: 1,
1621 row: 1,
1622 modifiers: KeyModifiers::NONE,
1623 };
1624 for _ in 0..3 {
1625 list.handle_mouse(&scroll_down);
1626 }
1627 assert_eq!(list.scroll_offset(), 3);
1628 assert!(matches!(
1630 list.handle_mouse(&mouse_down_at(2, 2)),
1631 SearchMouse::Selected(5)
1632 ));
1633 assert_eq!(list.selected_row().unwrap().name, "row5");
1634 list.handle_mouse(&mouse_down_at(2, 0));
1636 assert_eq!(list.selected_row().unwrap().name, "row3");
1637 }
1638
1639 #[tokio::test]
1643 async fn double_click_needs_a_fast_second_press() {
1644 let src = VecSource {
1645 rows: (0..3).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1646 reload: true,
1647 };
1648 let mut list = SearchList::builder(src, noop_redraw()).build();
1649 list.poll_until_idle().await;
1650 list.set_list_rect(ratatui::layout::Rect::new(0, 0, 20, 3));
1651 let t0 = std::time::Instant::now();
1652 let ms = |n: u64| t0 + std::time::Duration::from_millis(n);
1653 let press = mouse_down_at(2, 1);
1654
1655 assert_eq!(
1656 list.handle_mouse_at(&press, ms(0)),
1657 SearchMouse::Selected(1)
1658 );
1659 assert_eq!(
1660 list.handle_mouse_at(&press, ms(150)),
1661 SearchMouse::DoubleClicked {
1662 pos: 1,
1663 repeat: false
1664 }
1665 );
1666 assert_eq!(
1667 list.handle_mouse_at(&press, ms(250)),
1668 SearchMouse::Activated(1),
1669 "a third press is not a second double-click"
1670 );
1671 let slow = ms(250) + crate::components::DOUBLE_CLICK;
1672 assert_eq!(
1673 list.handle_mouse_at(&press, slow),
1674 SearchMouse::Activated(1),
1675 "a slow click-click still activates"
1676 );
1677 }
1678
1679 #[tokio::test]
1683 async fn a_double_click_after_an_activation_is_a_repeat() {
1684 let src = VecSource {
1685 rows: (0..3).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1686 reload: true,
1687 };
1688 let mut list = SearchList::builder(src, noop_redraw()).build();
1689 list.poll_until_idle().await;
1690 list.set_list_rect(ratatui::layout::Rect::new(0, 0, 20, 3));
1691 let t0 = std::time::Instant::now();
1692 let ms = |n: u64| t0 + std::time::Duration::from_millis(n);
1693 let press = mouse_down_at(2, 1);
1694
1695 assert_eq!(
1696 list.handle_mouse_at(&press, ms(0)),
1697 SearchMouse::Selected(1)
1698 );
1699 assert_eq!(
1700 list.handle_mouse_at(&press, ms(1000)),
1701 SearchMouse::Activated(1)
1702 );
1703 assert_eq!(
1704 list.handle_mouse_at(&press, ms(1150)),
1705 SearchMouse::DoubleClicked {
1706 pos: 1,
1707 repeat: true
1708 }
1709 );
1710 }
1711
1712 #[tokio::test]
1717 async fn build_with_rows_applies_synchronously_without_a_poll() {
1718 let list = SearchList::builder(StaticRowSource, noop_redraw())
1719 .filter(Filter::Fuzzy)
1720 .build_with_rows(vec![TestRow::new("alpha"), TestRow::new("beta")]);
1721 assert!(!list.is_loading(), "static build is not loading");
1723 assert_eq!(list.rows().len(), 2);
1724 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1725 }
1726
1727 #[tokio::test]
1728 async fn initial_load_populates_rows() {
1729 let src = VecSource {
1730 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1731 reload: true,
1732 };
1733 let mut list = SearchList::builder(src, noop_redraw()).build();
1734 list.poll_until_idle().await;
1735 assert_eq!(list.rows().len(), 2);
1736 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1737 }
1738
1739 #[tokio::test]
1740 async fn requery_supersedes_and_reloads() {
1741 let src = VecSource {
1742 rows: vec![
1743 TestRow::new("alpha"),
1744 TestRow::new("alps"),
1745 TestRow::new("beta"),
1746 ],
1747 reload: true,
1748 };
1749 let mut list = SearchList::builder(src, noop_redraw()).build();
1750 list.poll_until_idle().await;
1751 assert_eq!(list.rows().len(), 3);
1752 list.set_query("alp");
1753 list.poll_until_idle().await;
1754 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1756 }
1757
1758 #[tokio::test]
1759 async fn arrows_navigate_and_enter_submits() {
1760 let src = VecSource {
1761 rows: vec![TestRow::new("a"), TestRow::new("b")],
1762 reload: true,
1763 };
1764 let mut list = SearchList::builder(src, noop_redraw()).build();
1765 list.poll_until_idle().await;
1766 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1767 assert_eq!(list.selected_row().unwrap().name, "b");
1768 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1769 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1770 }
1771
1772 #[tokio::test]
1773 async fn typing_a_char_changes_query() {
1774 let src = VecSource {
1775 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1776 reload: true,
1777 };
1778 let mut list = SearchList::builder(src, noop_redraw()).build();
1779 list.poll_until_idle().await;
1780 assert_eq!(
1781 list.handle_key(&key(KeyCode::Char('a'))),
1782 KeyReaction::Consumed
1783 );
1784 list.poll_until_idle().await;
1785 assert_eq!(list.query(), "a");
1786 }
1787
1788 #[tokio::test]
1789 async fn rank_filter_orders_by_closure() {
1790 let src = VecSource {
1791 rows: vec![
1792 TestRow::new("todo"),
1793 TestRow::new("today"),
1794 TestRow::new("misc"),
1795 ],
1796 reload: false,
1797 };
1798 let rank = std::sync::Arc::new(|rows: &[TestRow], base: &[usize], q: &str| -> Vec<usize> {
1799 let mut idx: Vec<usize> = base
1800 .iter()
1801 .copied()
1802 .filter(|&i| rows[i].name.contains(q))
1803 .collect();
1804 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1805 idx
1806 });
1807 let mut list = SearchList::builder(src, noop_redraw())
1808 .filter(Filter::Rank(rank))
1809 .build();
1810 list.poll_until_idle().await;
1811 list.set_query("today");
1812 list.poll();
1813 assert_eq!(list.selected_row().unwrap().name, "today");
1814 }
1815
1816 #[tokio::test]
1817 async fn fuzzy_filter_narrows_local_set() {
1818 let src = VecSource {
1819 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
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 list.set_query("alp");
1827 list.poll();
1828 assert_eq!(list.visible_rows().len(), 1);
1829 assert_eq!(list.selected_row().unwrap().name, "alpha");
1830 }
1831
1832 #[tokio::test]
1833 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1834 let src = ScriptedStreamSource {
1835 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1836 };
1837 let mut list = SearchList::builder(src, noop_redraw())
1838 .filter(Filter::Fuzzy)
1839 .build();
1840 list.poll_until_idle().await;
1841 assert_eq!(list.rows().len(), 2);
1842 assert!(!list.is_loading());
1843 list.set_query("alp");
1844 list.poll();
1845 assert_eq!(list.visible_rows().len(), 1);
1846 }
1847
1848 #[tokio::test]
1849 async fn source_order_unfiltered_passthrough() {
1850 let src = VecSource {
1851 rows: vec![TestRow::new("a"), TestRow::new("b")],
1852 reload: true,
1853 };
1854 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1856 assert_eq!(list.visible_rows().len(), 2);
1857 assert_eq!(list.selected_row().unwrap().name, "a");
1858 }
1859
1860 #[tokio::test]
1861 async fn intercepted_combo_returns_intercepted_without_acting() {
1862 let src = VecSource {
1863 rows: vec![TestRow::new("a")],
1864 reload: true,
1865 };
1866 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1867 let mut list = SearchList::builder(src, noop_redraw())
1868 .intercept(vec![combo])
1869 .build();
1870 list.poll_until_idle().await;
1871 assert_eq!(
1873 list.handle_key(&key(KeyCode::Enter)),
1874 KeyReaction::Intercepted(combo)
1875 );
1876 }
1877
1878 #[tokio::test]
1879 async fn autocomplete_accept_rewrites_query_without_vault() {
1880 struct Mem;
1881 #[async_trait::async_trait]
1882 impl crate::components::search_list::SuggestionSource for Mem {
1883 async fn notes_by_prefix(
1884 &self,
1885 _p: &str,
1886 _n: usize,
1887 ) -> Vec<crate::components::search_list::SuggestionItem> {
1888 vec![]
1889 }
1890 async fn tags_by_prefix(
1891 &self,
1892 p: &str,
1893 _n: usize,
1894 ) -> Vec<crate::components::search_list::SuggestionItem> {
1895 if "projects".starts_with(p) {
1896 vec![crate::components::search_list::SuggestionItem::plain(
1897 "projects",
1898 )]
1899 } else {
1900 vec![]
1901 }
1902 }
1903 }
1904 let src = VecSource {
1905 rows: vec![],
1906 reload: true,
1907 };
1908 let mut list = SearchList::builder(src, noop_redraw())
1909 .autocomplete(
1910 std::sync::Arc::new(Mem),
1911 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1912 )
1913 .debounce(std::time::Duration::ZERO)
1914 .build();
1915 for c in ['#', 'p', 'r', 'o'] {
1916 let _ = list.handle_key(&key(KeyCode::Char(c)));
1917 }
1918 for _ in 0..50 {
1919 tokio::task::yield_now().await;
1920 list.poll();
1921 }
1922 let _ = list.handle_key(&key(KeyCode::Tab));
1923 assert_eq!(list.query(), "#projects");
1924 }
1925
1926 #[tokio::test]
1929 async fn moving_the_pointer_keeps_the_popup_open() {
1930 struct Mem;
1931 #[async_trait::async_trait]
1932 impl crate::components::search_list::SuggestionSource for Mem {
1933 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1934 vec![]
1935 }
1936 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1937 vec![SuggestionItem::plain("projects")]
1938 }
1939 }
1940 let src = VecSource {
1941 rows: vec![],
1942 reload: true,
1943 };
1944 let mut list = SearchList::builder(src, noop_redraw())
1945 .autocomplete(
1946 std::sync::Arc::new(Mem),
1947 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1948 )
1949 .debounce(std::time::Duration::ZERO)
1950 .build();
1951 for c in ['#', 'p'] {
1952 let _ = list.handle_key(&key(KeyCode::Char(c)));
1953 }
1954 for _ in 0..50 {
1955 tokio::task::yield_now().await;
1956 list.poll();
1957 }
1958 assert!(list.autocomplete_is_open());
1959 let moved = ratatui::crossterm::event::MouseEvent {
1960 kind: ratatui::crossterm::event::MouseEventKind::Moved,
1961 column: 30,
1962 row: 30,
1963 modifiers: KeyModifiers::NONE,
1964 };
1965 list.handle_mouse(&moved);
1966 assert!(list.autocomplete_is_open(), "motion keeps it");
1967 list.handle_mouse(&mouse_down_at(30, 30));
1968 assert!(!list.autocomplete_is_open(), "a press elsewhere closes it");
1969 }
1970
1971 #[tokio::test]
1974 async fn clicking_a_suggestion_accepts_it() {
1975 use ratatui::{Terminal, backend::TestBackend};
1976 struct Mem;
1977 #[async_trait::async_trait]
1978 impl crate::components::search_list::SuggestionSource for Mem {
1979 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1980 vec![]
1981 }
1982 async fn tags_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1983 if "projects".starts_with(p) {
1984 vec![SuggestionItem::plain("projects")]
1985 } else {
1986 vec![]
1987 }
1988 }
1989 }
1990 let src = VecSource {
1991 rows: vec![],
1992 reload: true,
1993 };
1994 let mut list = SearchList::builder(src, noop_redraw())
1995 .autocomplete(
1996 std::sync::Arc::new(Mem),
1997 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1998 )
1999 .debounce(std::time::Duration::ZERO)
2000 .build();
2001 for c in ['#', 'p', 'r', 'o'] {
2002 let _ = list.handle_key(&key(KeyCode::Char(c)));
2003 }
2004 for _ in 0..50 {
2005 tokio::task::yield_now().await;
2006 list.poll();
2007 }
2008 let theme = Theme::gruvbox_dark();
2009 let mut t = Terminal::new(TestBackend::new(40, 12)).unwrap();
2010 t.draw(|f| {
2011 let input = ratatui::layout::Rect::new(0, 0, 40, 1);
2012 list.render_query(f, input, &theme, true);
2013 list.render_autocomplete(f, f.area(), &theme);
2014 })
2015 .unwrap();
2016 let rows = list
2017 .autocomplete
2018 .as_ref()
2019 .and_then(|ac| ac.state())
2020 .map(|st| st.rows_rect)
2021 .expect("popup open");
2022 assert!(
2023 list.popup_contains(rows.x + 1, rows.y),
2024 "hosts that filter clicks can see the popup"
2025 );
2026 let click = match crate::test_support::mouse_down_at(rows.x + 1, rows.y) {
2027 crate::components::events::InputEvent::Mouse(m) => m,
2028 _ => unreachable!(),
2029 };
2030 assert_eq!(
2031 list.handle_mouse(&click),
2032 SearchMouse::Autocomplete { edited: true }
2033 );
2034 assert_eq!(list.query(), "#projects");
2035 }
2036
2037 #[tokio::test]
2041 async fn accepting_saved_search_expands_query_and_exposes_name() {
2042 struct Mem;
2043 #[async_trait::async_trait]
2044 impl crate::components::search_list::SuggestionSource for Mem {
2045 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
2046 vec![]
2047 }
2048 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
2049 vec![]
2050 }
2051 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
2052 if "todo-week".starts_with(p) {
2053 vec![SuggestionItem {
2054 display: "todo-week".into(),
2055 secondary: Some("#todo ^modified".into()),
2056 }]
2057 } else {
2058 vec![]
2059 }
2060 }
2061 }
2062 let src = VecSource {
2063 rows: vec![],
2064 reload: true,
2065 };
2066 let mut list = SearchList::builder(src, noop_redraw())
2067 .autocomplete(
2068 std::sync::Arc::new(Mem),
2069 crate::components::autocomplete::AutocompleteMode::SearchQuery,
2070 )
2071 .debounce(std::time::Duration::ZERO)
2072 .build();
2073 for c in ['?', 't', 'o'] {
2074 let _ = list.handle_key(&key(KeyCode::Char(c)));
2075 }
2076 for _ in 0..50 {
2077 tokio::task::yield_now().await;
2078 list.poll();
2079 }
2080 let _ = list.handle_key(&key(KeyCode::Tab));
2081 assert_eq!(list.query(), "#todo ^modified");
2083 assert_eq!(
2085 list.take_accepted_saved_search().as_deref(),
2086 Some("todo-week")
2087 );
2088 assert_eq!(list.take_accepted_saved_search(), None);
2089 }
2090
2091 #[tokio::test]
2096 async fn enter_accepts_open_popup_and_reports_consumed() {
2097 struct Mem;
2098 #[async_trait::async_trait]
2099 impl crate::components::search_list::SuggestionSource for Mem {
2100 async fn notes_by_prefix(
2101 &self,
2102 _p: &str,
2103 _n: usize,
2104 ) -> Vec<crate::components::search_list::SuggestionItem> {
2105 vec![]
2106 }
2107 async fn tags_by_prefix(
2108 &self,
2109 p: &str,
2110 _n: usize,
2111 ) -> Vec<crate::components::search_list::SuggestionItem> {
2112 if "projects".starts_with(p) {
2113 vec![crate::components::search_list::SuggestionItem::plain(
2114 "projects",
2115 )]
2116 } else {
2117 vec![]
2118 }
2119 }
2120 }
2121 let src = VecSource {
2122 rows: vec![],
2123 reload: true,
2124 };
2125 let mut list = SearchList::builder(src, noop_redraw())
2126 .autocomplete(
2127 std::sync::Arc::new(Mem),
2128 crate::components::autocomplete::AutocompleteMode::SearchQuery,
2129 )
2130 .debounce(std::time::Duration::ZERO)
2131 .build();
2132 for c in ['#', 'p', 'r', 'o'] {
2133 let _ = list.handle_key(&key(KeyCode::Char(c)));
2134 }
2135 for _ in 0..50 {
2136 tokio::task::yield_now().await;
2137 list.poll();
2138 }
2139 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
2141 assert_eq!(list.query(), "#projects");
2142 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
2144 }
2145
2146 #[tokio::test]
2151 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
2152 let src = ScriptedStreamLeadSource {
2153 items: vec!["alpha".into(), "beta".into()],
2154 };
2155 let mut list = SearchList::builder(src, noop_redraw())
2156 .filter(Filter::Fuzzy)
2157 .initial_query("zz")
2158 .build();
2159 list.poll_until_idle().await;
2160 let vis = list.visible_rows();
2162 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
2163 assert_eq!(list.visible_len(), 1); list.set_query("alp");
2166 list.poll();
2167 let vis = list.visible_rows();
2168 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
2169 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
2170 assert_eq!(list.visible_len(), 2);
2171 list.set_query("");
2173 list.poll();
2174 assert!(
2175 list.visible_rows()
2176 .iter()
2177 .all(|r| matches!(r, StreamRow::Item(_)))
2178 );
2179 assert_eq!(list.visible_len(), 2);
2180 }
2181
2182 #[tokio::test]
2185 async fn oneshot_source_leading_row_still_works() {
2186 let src = VecSourceWithLead {
2187 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2188 };
2189 let mut list = SearchList::builder(src, noop_redraw())
2190 .filter(Filter::Fuzzy)
2191 .initial_query("alp")
2192 .build();
2193 list.poll_until_idle().await;
2194 let vis = list.visible_rows();
2195 assert_eq!(vis[0].name, "create:alp");
2196 assert_eq!(vis[1].name, "alpha");
2197 assert_eq!(list.visible_len(), 2);
2198 }
2199
2200 #[tokio::test]
2203 async fn selection_includes_leading_at_position_zero() {
2204 let src = VecSourceWithLead {
2205 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
2206 };
2207 let mut list = SearchList::builder(src, noop_redraw())
2208 .filter(Filter::Fuzzy)
2209 .initial_query("alp")
2210 .build();
2211 list.poll_until_idle().await;
2212 assert_eq!(list.selected_row().unwrap().name, "create:alp");
2214 list.handle_key(&key(KeyCode::Down));
2215 assert_eq!(list.selected_row().unwrap().name, "alpha");
2216 }
2217
2218 #[tokio::test]
2220 async fn no_leading_row_visible_len_matches_display() {
2221 let src = VecSource {
2222 rows: vec![TestRow::new("a"), TestRow::new("b")],
2223 reload: true,
2224 };
2225 let mut list = SearchList::builder(src, noop_redraw()).build();
2226 list.poll_until_idle().await;
2227 assert_eq!(list.visible_len(), 2);
2228 assert_eq!(list.visible_rows().len(), 2);
2229 assert_eq!(list.selected_row().unwrap().name, "a");
2230 }
2231
2232 #[tokio::test]
2235 async fn update_rows_refilters_visible_view() {
2236 let source = VecSource {
2237 rows: vec![
2238 TestRow::new("alpha"),
2239 TestRow::new("beta"),
2240 TestRow::new("gamma"),
2241 ],
2242 reload: false,
2243 };
2244 let mut list = SearchList::builder(source, noop_redraw())
2245 .filter(Filter::Fuzzy)
2246 .build();
2247 list.poll_until_idle().await;
2248
2249 list.set_query("alp");
2251 list.poll();
2252 assert_eq!(
2253 list.visible_rows()
2254 .iter()
2255 .map(|r| r.name.as_str())
2256 .collect::<Vec<_>>(),
2257 vec!["alpha"],
2258 "before update: only 'alpha' matches 'alp'"
2259 );
2260
2261 let changed = list.update_rows(|r| {
2263 if r.name == "alpha" {
2264 r.name = "renamed".to_string();
2265 true
2266 } else {
2267 false
2268 }
2269 });
2270 assert!(changed);
2271
2272 assert_eq!(
2274 list.visible_rows().len(),
2275 0,
2276 "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
2277 );
2278 }
2279
2280 #[tokio::test]
2281 async fn update_rows_mutates_in_place_and_recomputes() {
2282 let source = VecSource {
2283 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2284 reload: false,
2285 };
2286 let mut list = SearchList::builder(source, noop_redraw()).build();
2287 list.poll_until_idle().await;
2288
2289 let changed = list.update_rows(|r| {
2291 if r.name == "alpha" {
2292 r.name = "renamed".to_string();
2293 true
2294 } else {
2295 false
2296 }
2297 });
2298 assert!(changed, "a row was changed");
2299 assert!(
2300 list.rows().iter().any(|r| r.name == "renamed"),
2301 "the mutation is visible in rows()"
2302 );
2303
2304 let changed_again = list.update_rows(|_| false);
2306 assert!(!changed_again, "no row changed");
2307 }
2308
2309 #[tokio::test]
2316 async fn reload_source_leading_row_updates_synchronously_on_set_query() {
2317 let src = ReloadWithLeadSource {
2318 rows: vec![
2319 TestRow::new("alpha"),
2320 TestRow::new("beta"),
2321 TestRow::new("gamma"),
2322 ],
2323 };
2324 let mut list = SearchList::builder(src, noop_redraw()).build();
2325 list.poll_until_idle().await;
2326 assert!(list.leading.is_none(), "no leading row for empty query");
2328
2329 list.set_query("alp");
2331
2332 let vis = list.visible_rows();
2334 assert!(
2335 !vis.is_empty(),
2336 "visible_rows must not be empty right after set_query"
2337 );
2338 assert_eq!(
2339 vis[0].name, "create:alp",
2340 "leading row must show new query synchronously, before any poll/drain"
2341 );
2342
2343 list.poll_until_idle().await;
2346 let vis = list.visible_rows();
2347 assert_eq!(
2348 vis[0].name, "create:alp",
2349 "leading row correct after drain too"
2350 );
2351 assert_eq!(vis.len(), 2, "leading + alpha");
2353 assert_eq!(vis[1].name, "alpha");
2354 }
2355
2356 #[tokio::test]
2364 async fn local_filter_reseed_after_empty_then_repopulate() {
2365 let src = VecSource {
2366 rows: vec![
2367 TestRow::new("alpha"),
2368 TestRow::new("beta"),
2369 TestRow::new("gamma"),
2370 ],
2371 reload: false,
2372 };
2373 let mut list = SearchList::builder(src, noop_redraw())
2374 .filter(Filter::Fuzzy)
2375 .build();
2376 list.poll_until_idle().await;
2377
2378 assert!(
2380 list.selected_row().is_some(),
2381 "should have a selection after initial load"
2382 );
2383
2384 list.set_query("zzznomatch");
2386 assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
2387 assert!(
2388 list.selected_row().is_none(),
2389 "selection must be None when list is empty"
2390 );
2391
2392 list.set_query("alp");
2394 assert!(
2395 list.visible_len() > 0,
2396 "at least 'alpha' should match 'alp'"
2397 );
2398 assert!(
2401 list.selected_row().is_some(),
2402 "selection must be reseeded to first visible row after repopulation"
2403 );
2404 assert_eq!(
2405 list.selected_row().unwrap().name,
2406 "alpha",
2407 "first visible row must be selected after reseeding"
2408 );
2409 }
2410
2411 async fn focus_list(verbs: &[char]) -> SearchList<TestRow> {
2414 let src = VecSource {
2415 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2416 reload: false,
2417 };
2418 let mut b = SearchList::builder(src, noop_redraw()).filter(Filter::Fuzzy);
2419 for &c in verbs {
2420 b = b.list_verb(c);
2421 }
2422 let mut list = b.build();
2423 list.poll_until_idle().await;
2424 list
2425 }
2426
2427 #[tokio::test]
2430 async fn esc_enters_list_focus_then_cancels() {
2431 let mut list = focus_list(&['l']).await;
2432 assert_eq!(list.focus(), Focus::Input);
2433 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Consumed);
2434 assert_eq!(list.focus(), Focus::List);
2435 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2436 assert_eq!(list.focus(), Focus::List, "Cancel does not change focus");
2437 }
2438
2439 #[tokio::test]
2441 async fn esc_cancels_immediately_when_focus_disabled() {
2442 let mut list = focus_list(&[]).await;
2443 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2444 assert_eq!(list.focus(), Focus::Input);
2445 }
2446
2447 #[tokio::test]
2449 async fn i_and_slash_return_to_input_focus() {
2450 for ret in ['i', '/'] {
2451 let mut list = focus_list(&['l']).await;
2452 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.focus(), Focus::List);
2454 assert_eq!(
2455 list.handle_key(&key(KeyCode::Char(ret))),
2456 KeyReaction::Consumed
2457 );
2458 assert_eq!(list.focus(), Focus::Input);
2459 assert_eq!(list.query(), "", "switching focus must not type a char");
2460 }
2461 }
2462
2463 #[tokio::test]
2465 async fn list_focus_j_k_navigate() {
2466 let mut list = focus_list(&['l']).await;
2467 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.selected_row().unwrap().name, "alpha");
2469 assert_eq!(
2470 list.handle_key(&key(KeyCode::Char('j'))),
2471 KeyReaction::Consumed
2472 );
2473 assert_eq!(list.selected_row().unwrap().name, "beta");
2474 assert_eq!(
2475 list.handle_key(&key(KeyCode::Char('k'))),
2476 KeyReaction::Consumed
2477 );
2478 assert_eq!(list.selected_row().unwrap().name, "alpha");
2479 }
2480
2481 #[tokio::test]
2484 async fn registered_verb_fires_unregistered_letter_does_nothing() {
2485 let mut list = focus_list(&['l', 'o']).await;
2486 list.handle_key(&key(KeyCode::Esc)); assert_eq!(
2488 list.handle_key(&key(KeyCode::Char('l'))),
2489 KeyReaction::ListVerb('l')
2490 );
2491 assert_eq!(
2492 list.handle_key(&key(KeyCode::Char('o'))),
2493 KeyReaction::ListVerb('o')
2494 );
2495 assert_eq!(
2497 list.handle_key(&key(KeyCode::Char('z'))),
2498 KeyReaction::Consumed
2499 );
2500 assert_eq!(list.query(), "");
2501 }
2502
2503 #[tokio::test]
2506 async fn verbs_are_inert_in_input_focus() {
2507 let mut list = focus_list(&['l', 'o']).await;
2508 assert_eq!(list.focus(), Focus::Input);
2509 assert_eq!(
2510 list.handle_key(&key(KeyCode::Char('l'))),
2511 KeyReaction::Consumed
2512 );
2513 list.poll_until_idle().await;
2514 assert_eq!(list.query(), "l", "verb letters still type in Input focus");
2515 }
2516
2517 #[tokio::test]
2520 async fn clicking_the_input_gives_it_the_keyboard() {
2521 use ratatui::{Terminal, backend::TestBackend};
2522 let src = VecSource {
2523 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2524 reload: false,
2525 };
2526 let mut list = SearchList::builder(src, noop_redraw())
2527 .filter(Filter::Fuzzy)
2528 .opening_focus(Focus::List)
2529 .build();
2530 list.poll_until_idle().await;
2531 let theme = Theme::gruvbox_dark();
2532 let mut t = Terminal::new(TestBackend::new(30, 1)).unwrap();
2533 t.draw(|f| list.render_query(f, f.area(), &theme, true))
2534 .unwrap();
2535 let m = match crate::test_support::mouse_down_at(3, 0) {
2536 crate::components::events::InputEvent::Mouse(m) => m,
2537 _ => unreachable!(),
2538 };
2539 assert_eq!(list.handle_mouse(&m), SearchMouse::InputFocused);
2540 assert_eq!(list.focus(), Focus::Input);
2541 list.handle_key(&key(KeyCode::Char('a')));
2542 list.poll_until_idle().await;
2543 assert_eq!(list.query(), "a");
2544 }
2545
2546 #[tokio::test]
2549 async fn opening_focus_list_starts_in_list() {
2550 let src = VecSource {
2551 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2552 reload: false,
2553 };
2554 let mut list = SearchList::builder(src, noop_redraw())
2555 .filter(Filter::Fuzzy)
2556 .opening_focus(Focus::List)
2557 .build();
2558 list.poll_until_idle().await;
2559 assert_eq!(list.focus(), Focus::List);
2560 assert_eq!(
2561 list.handle_key(&key(KeyCode::Char('a'))),
2562 KeyReaction::Consumed
2563 );
2564 assert_eq!(list.query(), "");
2565 list.handle_key(&key(KeyCode::Char('i')));
2567 assert_eq!(list.focus(), Focus::Input);
2568 list.handle_key(&key(KeyCode::Char('a')));
2569 list.poll_until_idle().await;
2570 assert_eq!(list.query(), "a");
2571 }
2572
2573 #[tokio::test]
2575 async fn intercept_fires_in_both_foci() {
2576 let src = VecSource {
2577 rows: vec![TestRow::new("a")],
2578 reload: false,
2579 };
2580 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
2581 let mut list = SearchList::builder(src, noop_redraw())
2582 .intercept(vec![combo])
2583 .list_verb('l')
2584 .build();
2585 list.poll_until_idle().await;
2586 assert_eq!(
2588 list.handle_key(&key(KeyCode::Enter)),
2589 KeyReaction::Intercepted(combo)
2590 );
2591 list.handle_key(&key(KeyCode::Esc));
2593 assert_eq!(list.focus(), Focus::List);
2594 assert_eq!(
2595 list.handle_key(&key(KeyCode::Enter)),
2596 KeyReaction::Intercepted(combo)
2597 );
2598 }
2599}
2600
2601#[cfg(test)]
2602mod order_tests {
2603 use super::adapters::{HeldEmitSource, ScriptedStreamSource, TestRow};
2604 use super::seams::RankFn;
2605 use super::*;
2606 use std::sync::{Arc, Mutex};
2607
2608 fn noop_redraw() -> Arc<dyn Fn() + Send + Sync> {
2609 Arc::new(|| {})
2610 }
2611
2612 fn by_name_asc() -> OrderFn<TestRow> {
2613 Arc::new(|a: &TestRow, b: &TestRow| a.name.cmp(&b.name))
2614 }
2615
2616 fn by_name_desc() -> OrderFn<TestRow> {
2617 Arc::new(|a: &TestRow, b: &TestRow| b.name.cmp(&a.name))
2618 }
2619
2620 fn names(list: &SearchList<TestRow>) -> Vec<String> {
2621 list.visible_rows().iter().map(|r| r.name.clone()).collect()
2622 }
2623
2624 #[tokio::test]
2627 async fn order_by_sorts_pushed_rows_as_they_arrive() {
2628 let source = ScriptedStreamSource {
2629 batches: vec![
2630 vec![TestRow::new("charlie")],
2631 vec![TestRow::new("alpha")],
2632 vec![TestRow::new("bravo")],
2633 ],
2634 };
2635 let mut list = SearchList::builder(source, noop_redraw())
2636 .filter(Filter::Fuzzy)
2637 .order_by(by_name_asc())
2638 .build();
2639 list.poll_until_idle().await;
2640 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2641 }
2642
2643 #[tokio::test]
2646 async fn set_order_reorders_in_place_without_reload() {
2647 let source = ScriptedStreamSource {
2648 batches: vec![vec![
2649 TestRow::new("bravo"),
2650 TestRow::new("alpha"),
2651 TestRow::new("charlie"),
2652 ]],
2653 };
2654 let mut list = SearchList::builder(source, noop_redraw())
2655 .order_by(by_name_asc())
2656 .build();
2657 list.poll_until_idle().await;
2658 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2659
2660 list.set_order(Some(by_name_desc()));
2661
2662 assert!(!list.is_loading(), "reordering must not start a load");
2663 assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2664 }
2665
2666 #[tokio::test]
2675 async fn changing_the_order_keeps_a_seeded_selection_on_the_top_row() {
2676 let slot = Arc::new(Mutex::new(None));
2677 let source = HeldEmitSource { slot: slot.clone() };
2678 let mut list = SearchList::builder(source, noop_redraw())
2679 .order_by(by_name_asc())
2680 .build();
2681 let emit = loop {
2682 if let Some(e) = slot.lock().unwrap().clone() {
2683 break e;
2684 }
2685 tokio::task::yield_now().await;
2686 };
2687
2688 emit.push(TestRow::new("bravo"));
2689 emit.push(TestRow::new("charlie"));
2690 list.poll();
2691 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2692
2693 list.set_order(Some(by_name_desc()));
2694 assert_eq!(names(&list), ["charlie", "bravo"]);
2695 assert_eq!(
2696 list.selected_row().map(|r| r.name.as_str()),
2697 Some("charlie"),
2698 "an untouched selection keeps the top slot of the new order"
2699 );
2700
2701 emit.push(TestRow::new("delta"));
2703 list.poll();
2704 assert_eq!(names(&list), ["delta", "charlie", "bravo"]);
2705 assert_eq!(
2706 list.selected_row().map(|r| r.name.as_str()),
2707 Some("delta"),
2708 "a sort change must not silently turn a seed into a choice"
2709 );
2710 emit.done();
2711 }
2712
2713 #[tokio::test]
2716 async fn changing_the_order_keeps_a_chosen_selection_on_its_row() {
2717 let source = ScriptedStreamSource {
2718 batches: vec![vec![
2719 TestRow::new("bravo"),
2720 TestRow::new("alpha"),
2721 TestRow::new("charlie"),
2722 ]],
2723 };
2724 let mut list = SearchList::builder(source, noop_redraw())
2725 .order_by(by_name_asc())
2726 .build();
2727 list.poll_until_idle().await;
2728 list.select_next(); assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2730
2731 list.set_order(Some(by_name_desc()));
2732 assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2733 assert_eq!(
2734 list.selected_row().map(|r| r.name.as_str()),
2735 Some("bravo"),
2736 "a chosen selection tracks its row through a re-sort"
2737 );
2738 }
2739
2740 #[tokio::test]
2744 async fn a_nudge_that_moves_nothing_leaves_the_seed_a_seed() {
2745 let slot = Arc::new(Mutex::new(None));
2746 let source = HeldEmitSource { slot: slot.clone() };
2747 let mut list = SearchList::builder(source, noop_redraw())
2748 .order_by(by_name_asc())
2749 .build();
2750 let emit = loop {
2751 if let Some(e) = slot.lock().unwrap().clone() {
2752 break e;
2753 }
2754 tokio::task::yield_now().await;
2755 };
2756
2757 emit.push(TestRow::new("charlie"));
2758 list.poll();
2759 list.select_prev(); list.select_next(); assert_eq!(
2762 list.selected_row().map(|r| r.name.as_str()),
2763 Some("charlie")
2764 );
2765
2766 emit.push(TestRow::new("alpha"));
2767 list.poll();
2768 assert_eq!(names(&list), ["alpha", "charlie"]);
2769 assert_eq!(
2770 list.selected_row().map(|r| r.name.as_str()),
2771 Some("alpha"),
2772 "a keypress that changed nothing must leave the selection seeded"
2773 );
2774 emit.done();
2775 }
2776
2777 #[tokio::test]
2780 async fn selection_follows_its_row_while_rows_stream_in() {
2781 let slot = Arc::new(Mutex::new(None));
2782 let source = HeldEmitSource { slot: slot.clone() };
2783 let mut list = SearchList::builder(source, noop_redraw())
2784 .order_by(by_name_asc())
2785 .build();
2786 let emit = loop {
2787 if let Some(e) = slot.lock().unwrap().clone() {
2788 break e;
2789 }
2790 tokio::task::yield_now().await;
2791 };
2792
2793 emit.push(TestRow::new("bravo"));
2794 emit.push(TestRow::new("charlie"));
2795 list.poll();
2796 list.select_next();
2797 assert_eq!(
2798 list.selected_row().map(|r| r.name.as_str()),
2799 Some("charlie")
2800 );
2801
2802 emit.push(TestRow::new("alpha"));
2803 list.poll();
2804
2805 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2806 assert_eq!(
2807 list.selected_row().map(|r| r.name.as_str()),
2808 Some("charlie"),
2809 "selection tracks the row, not its old position"
2810 );
2811 emit.done();
2812 }
2813
2814 #[tokio::test]
2821 async fn order_by_composes_with_a_rank_filter() {
2822 let rank: RankFn<TestRow> = Arc::new(|rows: &[TestRow], base: &[usize], q: &str| {
2824 base.iter()
2825 .copied()
2826 .filter(|&i| rows[i].name.contains(q))
2827 .collect()
2828 });
2829 let source = ScriptedStreamSource {
2830 batches: vec![vec![
2831 TestRow::new("charlie"),
2832 TestRow::new("alpha"),
2833 TestRow::new("bravo"),
2834 ]],
2835 };
2836 let mut list = SearchList::builder(source, noop_redraw())
2837 .filter(Filter::Rank(rank))
2838 .order_by(by_name_asc())
2839 .build();
2840 list.poll_until_idle().await;
2841 list.set_query("a"); assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2843 }
2844
2845 #[tokio::test]
2854 async fn a_seeded_selection_stays_on_the_top_row_as_rows_stream_in() {
2855 let slot = Arc::new(Mutex::new(None));
2856 let source = HeldEmitSource { slot: slot.clone() };
2857 let mut list = SearchList::builder(source, noop_redraw())
2858 .order_by(by_name_asc())
2859 .build();
2860 let emit = loop {
2861 if let Some(e) = slot.lock().unwrap().clone() {
2862 break e;
2863 }
2864 tokio::task::yield_now().await;
2865 };
2866
2867 emit.push(TestRow::new("charlie"));
2869 list.poll();
2870 assert_eq!(
2871 list.selected_row().map(|r| r.name.as_str()),
2872 Some("charlie")
2873 );
2874
2875 emit.push(TestRow::new("alpha"));
2877 list.poll();
2878 assert_eq!(names(&list), ["alpha", "charlie"]);
2879 assert_eq!(
2880 list.selected_row().map(|r| r.name.as_str()),
2881 Some("alpha"),
2882 "an untouched selection seeds to the top of the order, not to the first-arrived row"
2883 );
2884 emit.done();
2885 }
2886
2887 #[tokio::test]
2891 async fn a_selection_moved_back_to_the_top_row_is_still_pinned_to_it() {
2892 let slot = Arc::new(Mutex::new(None));
2893 let source = HeldEmitSource { slot: slot.clone() };
2894 let mut list = SearchList::builder(source, noop_redraw())
2895 .order_by(by_name_asc())
2896 .build();
2897 let emit = loop {
2898 if let Some(e) = slot.lock().unwrap().clone() {
2899 break e;
2900 }
2901 tokio::task::yield_now().await;
2902 };
2903
2904 emit.push(TestRow::new("bravo"));
2905 emit.push(TestRow::new("charlie"));
2906 list.poll();
2907 list.select_next();
2908 list.select_prev(); assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2910
2911 emit.push(TestRow::new("alpha"));
2912 list.poll();
2913 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2914 assert_eq!(
2915 list.selected_row().map(|r| r.name.as_str()),
2916 Some("bravo"),
2917 "a selection the user placed tracks its row even when it sits at the top"
2918 );
2919 emit.done();
2920 }
2921}