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 applied_generation: u64,
151 accepted_saved_search: Option<String>,
155 last_click_pos: Option<usize>,
159 highlight_query: bool,
162 focus: Focus,
164 focus_enabled: bool,
169 list_verbs: Vec<char>,
173}
174
175#[derive(Debug, PartialEq, Eq)]
177pub enum SearchMouse {
178 Selected(usize),
179 Activated(usize),
180 Context(usize),
183 Scrolled,
184 ContentScrollUp,
188 ContentScrollDown,
189 None,
190}
191
192pub struct SearchListBuilder<R: SearchRow> {
193 source: Arc<dyn RowSource<R>>,
194 redraw: Arc<dyn Fn() + Send + Sync>,
195 initial_query: String,
196 filter: Filter<R>,
197 order: Option<OrderFn<R>>,
198 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
199 intercept: Vec<KeyCombo>,
200 yank_combos: Vec<KeyCombo>,
201 icons: Icons,
202 debounce: Option<std::time::Duration>,
203 highlight_query: bool,
204 opening_focus: Focus,
205 list_verbs: Vec<char>,
206}
207
208impl<R: SearchRow> SearchList<R> {
209 pub fn builder(
210 source: impl RowSource<R>,
211 redraw: Arc<dyn Fn() + Send + Sync>,
212 ) -> SearchListBuilder<R> {
213 SearchListBuilder {
214 source: Arc::new(source),
215 redraw,
216 initial_query: String::new(),
217 filter: Filter::SourceOrder,
218 order: None,
219 autocomplete: None,
220 intercept: Vec::new(),
221 yank_combos: vec![crate::keys::default_yank_combo()],
222 icons: Icons::new(false),
223 debounce: None,
224 highlight_query: false,
225 opening_focus: Focus::Input,
226 list_verbs: Vec::new(),
227 }
228 }
229
230 fn new(b: SearchListBuilder<R>) -> Self {
232 let mut list = Self::assemble(b);
233 list.loader.start(list.source.clone(), list.query.clone());
234 list
235 }
236
237 fn with_rows(b: SearchListBuilder<R>, rows: Vec<R>) -> Self {
247 let mut list = Self::assemble(b);
248 list.rows = rows;
249 list.recompute_and_seed();
250 list
251 }
252
253 fn assemble(b: SearchListBuilder<R>) -> Self {
257 let loader = LoadEngine::new(b.redraw.clone());
258 let input = SingleLineInput::with_value(&b.initial_query);
259 let debounce = b.debounce;
260 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
261 let mut ac =
262 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
263 disambiguate_header: false,
264 apply_exclusion_zone: false,
265 ..TriggerOptions::default()
268 });
269 if let Some(d) = debounce {
270 ac = ac.with_debounce(d);
271 }
272 ac.set_redraw_callback(b.redraw.clone());
273 ac
274 });
275 Self {
276 source: b.source,
277 rows: Vec::new(),
278 display: Vec::new(),
279 leading: None,
280 selected: None,
281 selection_pinned: false,
282 offset: 0,
283 filter: b.filter,
284 order: b.order,
285 query: b.initial_query,
286 loader,
287 input,
288 highlight_query: b.highlight_query,
289 last_click_pos: None,
290 autocomplete,
291 intercept: b.intercept,
292 yank_combos: b.yank_combos,
293 icons: b.icons,
294 list_rect: Rect::default(),
295 panel_rect: Rect::default(),
296 content_rect: Rect::default(),
297 applied_generation: 0,
298 accepted_saved_search: None,
299 focus: b.opening_focus,
300 focus_enabled: b.opening_focus == Focus::List || !b.list_verbs.is_empty(),
303 list_verbs: b.list_verbs,
304 }
305 }
306
307 pub fn focus(&self) -> Focus {
309 self.focus
310 }
311
312 pub fn poll(&mut self) {
313 let drained = self.loader.drain();
314 if !drained.is_empty() {
315 let current_gen = self.loader.generation();
319 if current_gen != self.applied_generation {
320 self.rows.clear();
321 self.selected = None;
322 self.selection_pinned = false;
323 self.offset = 0;
324 self.applied_generation = current_gen;
325 }
326 let keep = if drained
330 .iter()
331 .any(|ev| matches!(ev, LoadedInner::Replace(_)))
332 {
333 None
334 } else {
335 self.selection_to_carry()
336 };
337 for ev in drained {
338 match ev {
339 LoadedInner::Replace(rows) => {
340 self.rows = rows;
341 }
342 LoadedInner::Push(row) => {
343 self.rows.push(row);
344 }
345 LoadedInner::Done => {}
346 }
347 }
348 self.recompute_and_seed();
349 self.reselect(keep);
350 }
351 if let Some(ac) = &mut self.autocomplete {
352 ac.poll_results();
353 }
354 }
355
356 fn recompute_and_seed(&mut self) {
361 self.recompute_display();
362 if self.selected.is_none() && self.visible_len() > 0 {
363 self.selected = Some(0);
364 }
365 }
366
367 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
371 let value = self.input.value().to_string();
372 let cursor_byte = self.input.cursor_byte();
373 let col = value[..cursor_byte.min(value.len())].chars().count();
374 host::SearchBoxHostSnapshot {
375 lines: vec![value],
376 cursor: (0, col),
377 caret_pos: self.input.last_caret_pos(),
378 }
379 }
380
381 fn clamp_selection(&mut self) {
382 let len = self.visible_len();
383 self.selected = if len == 0 {
384 None
385 } else {
386 Some(self.selected.unwrap_or(0).min(len - 1))
387 };
388 }
389
390 fn leading_offset(&self) -> usize {
392 self.leading.is_some() as usize
393 }
394
395 pub fn visible_len(&self) -> usize {
397 self.leading_offset() + self.display.len()
398 }
399
400 pub fn match_count(&self) -> usize {
403 self.display.len()
404 }
405
406 fn visible_row(&self, pos: usize) -> Option<&R> {
408 if self.leading.is_some() && pos == 0 {
409 self.leading.as_ref()
410 } else {
411 self.rows
412 .get(*self.display.get(pos - self.leading_offset())?)
413 }
414 }
415
416 pub fn rows(&self) -> &[R] {
420 &self.rows
421 }
422
423 pub fn selected_row(&self) -> Option<&R> {
424 self.selected.and_then(|p| self.visible_row(p))
425 }
426
427 pub fn visible_rows(&self) -> Vec<&R> {
428 (0..self.visible_len())
429 .filter_map(|p| self.visible_row(p))
430 .collect()
431 }
432
433 pub fn query(&self) -> &str {
434 &self.query
435 }
436
437 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
441 self.accepted_saved_search.take()
442 }
443
444 #[cfg(test)]
447 pub(crate) fn input_value(&self) -> &str {
448 self.input.value()
449 }
450 pub fn is_loading(&self) -> bool {
451 self.loader.loading
452 }
453
454 pub fn set_query(&mut self, q: impl Into<String>) {
463 let q = q.into();
464 self.input.set_value(q.clone());
465 self.query = q;
466 self.requery();
467 }
468
469 fn sync_query_from_input(&mut self) {
474 self.query = self.input.value().to_string();
475 self.requery();
476 }
477
478 fn requery(&mut self) {
481 if self.source.reload_on_query() {
482 self.loader.start(self.source.clone(), self.query.clone());
483 }
484 self.recompute_and_seed();
488 }
489
490 pub fn reload(&mut self) {
492 self.loader.start(self.source.clone(), self.query.clone());
493 }
494
495 pub fn set_order(&mut self, cmp: Option<OrderFn<R>>) {
501 let keep = self.selection_to_carry();
502 self.order = cmp;
503 self.recompute_display();
504 self.reselect(keep);
505 }
506
507 fn selection_to_carry(&self) -> Option<usize> {
512 self.selection_pinned
513 .then(|| self.selected_row_index())
514 .flatten()
515 }
516
517 fn selected_row_index(&self) -> Option<usize> {
520 let pos = self.selected?.checked_sub(self.leading_offset())?;
521 self.display.get(pos).copied()
522 }
523
524 fn reselect(&mut self, row: Option<usize>) {
528 if let Some(row) = row
529 && let Some(pos) = self.display.iter().position(|&i| i == row)
530 {
531 self.selected = Some(pos + self.leading_offset());
532 }
533 }
534
535 pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
549 let mut changed = false;
550 for row in &mut self.rows {
551 if mutate(row) {
552 changed = true;
553 }
554 }
555 if changed {
556 let keep = self.selection_to_carry();
557 self.recompute_display();
558 self.reselect(keep);
559 }
560 changed
561 }
562
563 pub fn select(&mut self, pos: usize) {
569 let n = self.visible_len();
570 self.selected = if n == 0 { None } else { Some(pos.min(n - 1)) };
571 self.selection_pinned = self.selected.is_some();
575 }
576
577 pub fn select_next(&mut self) {
578 let n = self.visible_len();
579 if n == 0 {
580 return;
581 }
582 self.move_selection(Some(self.selected.map_or(0, |i| (i + 1).min(n - 1))));
583 }
584
585 pub fn select_prev(&mut self) {
586 if self.visible_len() == 0 {
587 return;
588 }
589 self.move_selection(Some(self.selected.map_or(0, |i| i.saturating_sub(1))));
590 }
591
592 fn move_selection(&mut self, next: Option<usize>) {
598 if next != self.selected {
599 self.selected = next;
600 self.selection_pinned = next.is_some();
601 }
602 }
603
604 fn max_scroll_offset(&self) -> usize {
609 let viewport = self.list_rect.height as usize;
610 let n = self.visible_len();
611 if viewport == 0 || n == 0 {
612 return 0;
613 }
614 let mut budget = viewport;
615 let mut first = n;
616 while first > 0 {
617 let h = self
618 .visible_row(first - 1)
619 .map(|r| r.visual_height() as usize)
620 .unwrap_or(1);
621 if h > budget {
622 break;
623 }
624 budget -= h;
625 first -= 1;
626 }
627 first.min(n - 1)
628 }
629
630 pub fn scroll_down(&mut self) {
634 let n = self.visible_len();
635 if n == 0 || self.offset >= self.max_scroll_offset() {
636 return;
637 }
638 self.offset += 1;
639 self.move_selection(self.selected.map(|i| (i + 1).min(n - 1)));
640 }
641
642 pub fn scroll_up(&mut self) {
645 if self.offset == 0 {
646 return;
647 }
648 self.offset -= 1;
649 self.move_selection(self.selected.map(|i| i.saturating_sub(1)));
650 }
651
652 #[cfg(test)]
655 pub(crate) fn scroll_offset(&self) -> usize {
656 self.offset
657 }
658
659 pub fn is_yank_chord(&self, key: &KeyEvent) -> bool {
665 crate::keys::key_event_to_combo(key).is_some_and(|c| self.yank_combos.contains(&c))
666 }
667
668 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
669 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
670
671 if let Some(combo) = crate::keys::key_event_to_combo(key)
674 && self.intercept.contains(&combo)
675 {
676 return KeyReaction::Intercepted(combo);
677 }
678
679 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
683 let snap = self.autocomplete_snapshot();
684 if let Some(ac) = &mut self.autocomplete {
685 match ac.handle_key(*key, &snap) {
686 HandleKeyOutcome::Accepted(action) => {
687 self.input.replace_range_bytes(
688 action.range.clone(),
689 &action.new_text,
690 action.new_cursor_byte,
691 );
692 self.accepted_saved_search = action.saved_search_name;
697 self.sync_query_from_input();
698 return KeyReaction::Consumed;
699 }
700 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
701 return KeyReaction::Consumed;
702 }
703 HandleKeyOutcome::NotHandled => {}
704 }
705 }
706 }
707
708 match key.code {
710 KeyCode::Up => {
711 self.select_prev();
712 return KeyReaction::Consumed;
713 }
714 KeyCode::Down => {
715 self.select_next();
716 return KeyReaction::Consumed;
717 }
718 KeyCode::Enter => return KeyReaction::Submit,
719 _ => {}
720 }
721 if key.code == KeyCode::Esc {
725 if self.focus_enabled && self.focus == Focus::Input {
726 self.focus = Focus::List;
727 self.close_autocomplete();
728 return KeyReaction::Consumed;
729 }
730 return KeyReaction::Cancel;
731 }
732 if let Some(combo) = crate::keys::key_event_to_combo(key)
738 && self.yank_combos.contains(&combo)
739 {
740 return KeyReaction::Yank(self.selected_row().and_then(|r| r.yank_target()));
741 }
742 if let KeyCode::Char(_) = key.code {
745 let non_shift = key.modifiers - KeyModifiers::SHIFT;
746 if !non_shift.is_empty() {
747 return KeyReaction::Unhandled;
748 }
749 }
750 if self.focus == Focus::List {
752 if let KeyCode::Char(c) = key.code {
753 return match c {
754 'i' | '/' => {
756 self.focus = Focus::Input;
757 KeyReaction::Consumed
758 }
759 'j' => {
760 self.select_next();
761 KeyReaction::Consumed
762 }
763 'k' => {
764 self.select_prev();
765 KeyReaction::Consumed
766 }
767 _ if self.list_verbs.contains(&c) => KeyReaction::ListVerb(c),
768 _ => KeyReaction::Consumed,
770 };
771 }
772 return KeyReaction::Unhandled;
774 }
775 let outcome = self.input.handle_key(key);
776 let snap = self.autocomplete_snapshot();
779 match outcome {
780 InputOutcome::Changed => {
781 if let Some(ac) = &mut self.autocomplete {
782 ac.sync(&snap);
783 }
784 }
785 InputOutcome::Consumed => {
786 if let Some(ac) = &mut self.autocomplete {
787 ac.refresh_if_open(&snap);
788 }
789 }
790 InputOutcome::Cancel | InputOutcome::Submit => {
791 if let Some(ac) = &mut self.autocomplete {
792 ac.close();
793 }
794 }
795 InputOutcome::NotConsumed => {}
796 }
797 match outcome {
798 InputOutcome::Changed => {
799 self.sync_query_from_input();
800 KeyReaction::Consumed
801 }
802 InputOutcome::Consumed => KeyReaction::Consumed,
803 InputOutcome::Submit => KeyReaction::Submit,
804 InputOutcome::Cancel => KeyReaction::Cancel,
805 InputOutcome::NotConsumed => KeyReaction::Unhandled,
806 }
807 }
808
809 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
810 let focused = focused && self.focus == Focus::Input;
815 let base = Style::default()
816 .fg(theme.fg.to_ratatui())
817 .bg(theme.bg_panel.to_ratatui());
818 if self.highlight_query {
819 let line =
820 crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
821 self.input.render_line(f, area, line, base, 0, focused);
822 } else {
823 self.input.render(f, area, base, 0, focused);
824 }
825 }
826
827 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
828 self.poll();
829 let sel = self.selected;
830 let items: Vec<ListItem> = (0..self.visible_len())
831 .filter_map(|pos| {
832 self.visible_row(pos)
833 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
834 })
835 .collect();
836 let mut state = ListState::default().with_offset(self.offset);
837 state.select(self.selected);
838 let list =
839 List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
840 f.render_stateful_widget(list, area, &mut state);
841 self.offset = state.offset();
845 self.list_rect = area;
846 let _ = focused;
847 }
848
849 pub fn set_list_rect(&mut self, rect: Rect) {
858 self.list_rect = rect;
859 }
860
861 pub fn set_panel_rect(&mut self, rect: Rect) {
866 self.panel_rect = rect;
867 }
868
869 pub fn set_content_rect(&mut self, rect: Rect) {
877 self.content_rect = rect;
878 }
879
880 #[cfg(test)]
884 pub(crate) fn content_rect(&self) -> Rect {
885 self.content_rect
886 }
887
888 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
889 if let Some(ac) = &mut self.autocomplete {
890 ac.poll_results();
891 let caret = self.input.last_caret_pos();
892 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
893 state.anchor = anchor;
894 }
895 if let Some(state) = ac.state() {
896 crate::components::autocomplete::render(f, state, clamp, theme);
897 }
898 }
899 }
900
901 pub fn close_autocomplete(&mut self) {
908 if let Some(ac) = &mut self.autocomplete {
909 ac.close();
910 }
911 }
912
913 #[cfg(test)]
916 pub(crate) fn autocomplete_is_open(&self) -> bool {
917 self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
918 }
919
920 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
921 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
922 use ratatui::layout::Position;
923 self.close_autocomplete();
926 let pos = Position {
927 x: m.column,
928 y: m.row,
929 };
930 if matches!(
934 m.kind,
935 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
936 ) {
937 if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
941 return if m.kind == MouseEventKind::ScrollUp {
942 SearchMouse::ContentScrollUp
943 } else {
944 SearchMouse::ContentScrollDown
945 };
946 }
947 let bounds = if self.panel_rect.is_empty() {
948 self.list_rect
949 } else {
950 self.panel_rect
951 };
952 if !bounds.contains(pos) {
953 return SearchMouse::None;
954 }
955 if m.kind == MouseEventKind::ScrollUp {
956 self.scroll_up();
957 } else {
958 self.scroll_down();
959 }
960 return SearchMouse::Scrolled;
961 }
962 let r = self.list_rect;
963 if !r.contains(pos) {
964 return SearchMouse::None;
965 }
966 match m.kind {
967 MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
968 let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
969 let target_visual = m.row - r.y; let mut acc: u16 = 0;
971 let mut hit: Option<usize> = None;
972 for pos in self.offset..self.visible_len() {
977 let h = self
978 .visible_row(pos)
979 .map(|r| r.visual_height())
980 .unwrap_or(1);
981 if target_visual < acc + h {
982 hit = Some(pos);
983 break;
984 }
985 acc += h;
986 }
987 if let Some(pos) = hit {
988 let prev = self.selected;
989 let prev_click = self.last_click_pos.replace(pos);
990 self.selected = Some(pos);
991 self.selection_pinned = true;
992 return if right_click {
993 SearchMouse::Context(pos)
994 } else if prev == Some(pos) && prev_click == Some(pos) {
995 SearchMouse::Activated(pos)
998 } else {
999 SearchMouse::Selected(pos)
1000 };
1001 }
1002 SearchMouse::None
1003 }
1004 _ => SearchMouse::None,
1005 }
1006 }
1007
1008 fn recompute_display(&mut self) {
1009 let q = self.query.trim();
1010 self.leading = self.source.leading_row(q);
1013 let mut base: Vec<usize> = (0..self.rows.len()).collect();
1015 if let Some(cmp) = &self.order {
1016 let rows = &self.rows;
1017 base.sort_by(|&a, &b| cmp(&rows[a], &rows[b]));
1018 }
1019 let mut idx: Vec<usize> = match &self.filter {
1020 Filter::SourceOrder => base,
1021 Filter::Fuzzy if q.is_empty() => base,
1022 Filter::Fuzzy => fuzzy_indices(&self.rows, &base, q),
1023 Filter::Rank(_) if q.is_empty() => base,
1024 Filter::Rank(f) => {
1025 let f = f.clone();
1026 f(&self.rows, &base, q)
1027 }
1028 };
1029 for i in 0..self.rows.len() {
1032 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
1033 idx.insert(0, i);
1034 }
1035 }
1036 self.display = idx;
1037 self.clamp_selection();
1038 }
1039
1040 #[cfg(test)]
1041 pub(crate) async fn poll_until_idle(&mut self) {
1042 for _ in 0..600 {
1048 tokio::task::yield_now().await;
1049 self.poll();
1050 if !self.is_loading() {
1051 break;
1052 }
1053 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1054 }
1055 self.poll();
1056 }
1057}
1058
1059impl<R: SearchRow> SearchListBuilder<R> {
1060 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
1061 self.initial_query = q.into();
1062 self
1063 }
1064 pub fn filter(mut self, f: Filter<R>) -> Self {
1065 self.filter = f;
1066 self
1067 }
1068 pub fn order_by(mut self, cmp: OrderFn<R>) -> Self {
1073 self.order = Some(cmp);
1074 self
1075 }
1076 pub fn autocomplete(
1077 mut self,
1078 suggestions: Arc<dyn SuggestionSource>,
1079 mode: AutocompleteMode,
1080 ) -> Self {
1081 self.autocomplete = Some((suggestions, mode));
1082 self
1083 }
1084 pub fn yank_combos_from(self, bindings: &crate::keys::KeyBindings) -> Self {
1089 self.yank_combos(
1090 bindings.combos_for(&crate::keys::action_shortcuts::ActionShortcuts::YankRow),
1091 )
1092 }
1093
1094 pub fn yank_combos(mut self, combos: Vec<KeyCombo>) -> Self {
1097 self.yank_combos = combos;
1098 self
1099 }
1100
1101 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
1102 self.intercept = v;
1103 self
1104 }
1105 pub fn highlight_query(mut self) -> Self {
1107 self.highlight_query = true;
1108 self
1109 }
1110 pub fn icons(mut self, icons: Icons) -> Self {
1111 self.icons = icons;
1112 self
1113 }
1114 pub fn opening_focus(mut self, focus: Focus) -> Self {
1118 self.opening_focus = focus;
1119 self
1120 }
1121 pub fn list_verb(mut self, c: char) -> Self {
1128 self.list_verbs.push(c);
1129 self
1130 }
1131 pub fn debounce(mut self, d: std::time::Duration) -> Self {
1134 self.debounce = Some(d);
1135 self
1136 }
1137 pub fn build(self) -> SearchList<R> {
1138 SearchList::new(self)
1139 }
1140
1141 pub fn build_with_rows(self, rows: Vec<R>) -> SearchList<R> {
1151 SearchList::with_rows(self, rows)
1152 }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::adapters::{
1158 ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
1159 VecSource, VecSourceWithLead,
1160 };
1161 use super::*;
1162 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1163
1164 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
1165 std::sync::Arc::new(|| {})
1166 }
1167
1168 fn key(c: KeyCode) -> KeyEvent {
1169 KeyEvent::new(c, KeyModifiers::NONE)
1170 }
1171
1172 fn yank_list(rows: &[&str]) -> SearchList<TestRow> {
1179 SearchList::builder(
1180 VecSource {
1181 rows: vec![],
1182 reload: false,
1183 },
1184 noop_redraw(),
1185 )
1186 .build_with_rows(rows.iter().map(|n| TestRow::new(n)).collect())
1187 }
1188
1189 fn ctrl(c: char) -> KeyEvent {
1190 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1191 }
1192
1193 #[test]
1194 fn yank_chord_reports_the_selected_rows_target() {
1195 let mut list = yank_list(&["alpha", "beta"]);
1196 match list.handle_key(&ctrl('y')) {
1197 KeyReaction::Yank(Some(t)) => {
1198 assert_eq!(t.text, "alpha");
1199 assert_eq!(t.noun, "path");
1200 }
1201 r => panic!("got {r:?}"),
1202 }
1203 }
1204
1205 #[test]
1206 fn yank_chord_reports_none_for_a_row_with_nothing_to_copy() {
1207 let mut list = yank_list(&["quiet"]);
1210 list.select_next();
1211 assert!(matches!(
1212 list.handle_key(&ctrl('y')),
1213 KeyReaction::Yank(None)
1214 ));
1215 }
1216
1217 #[test]
1218 fn yank_chord_reports_none_when_nothing_is_selected() {
1219 let mut list = yank_list(&[]);
1220 assert!(matches!(
1221 list.handle_key(&ctrl('y')),
1222 KeyReaction::Yank(None)
1223 ));
1224 }
1225
1226 #[test]
1227 fn yank_chord_is_claimed_before_ctrl_chars_are_dropped() {
1228 let mut list = yank_list(&["alpha"]);
1232 list.select_next();
1233 assert!(
1234 !matches!(list.handle_key(&ctrl('y')), KeyReaction::Unhandled),
1235 "the yank chord must not fall through to the Ctrl-char drop"
1236 );
1237 }
1238
1239 #[test]
1240 fn a_rebound_yank_combo_replaces_the_default() {
1241 let mut list = SearchList::builder(
1242 VecSource {
1243 rows: vec![],
1244 reload: false,
1245 },
1246 noop_redraw(),
1247 )
1248 .yank_combos(vec![crate::keys::key_event_to_combo(&ctrl('k')).unwrap()])
1249 .build_with_rows(vec![TestRow::new("alpha")]);
1250 list.select_next();
1251 assert!(matches!(
1252 list.handle_key(&ctrl('k')),
1253 KeyReaction::Yank(Some(_))
1254 ));
1255 assert!(
1256 !matches!(list.handle_key(&ctrl('y')), KeyReaction::Yank(_)),
1257 "the default chord must stop yanking once overridden"
1258 );
1259 }
1260
1261 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
1262 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1263 MouseEvent {
1264 kind: MouseEventKind::Down(MouseButton::Left),
1265 column: col,
1266 row,
1267 modifiers: KeyModifiers::NONE,
1268 }
1269 }
1270
1271 #[derive(Clone, Debug, PartialEq)]
1272 struct TallRow {
1273 name: String,
1274 height: u16,
1275 }
1276 impl SearchRow for TallRow {
1277 fn to_list_item(
1278 &self,
1279 _t: &crate::settings::themes::Theme,
1280 _i: &crate::settings::icons::Icons,
1281 _s: bool,
1282 ) -> ratatui::widgets::ListItem<'static> {
1283 ratatui::widgets::ListItem::new(self.name.clone())
1284 }
1285 fn visual_height(&self) -> u16 {
1286 self.height
1287 }
1288 fn match_text(&self) -> Option<&str> {
1289 Some(&self.name)
1290 }
1291 }
1292 struct TallSource(Vec<TallRow>);
1293 #[async_trait::async_trait]
1294 impl RowSource<TallRow> for TallSource {
1295 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
1296 emit.replace(self.0.clone());
1297 }
1298 }
1299
1300 #[tokio::test]
1304 async fn wheel_in_content_rect_routes_to_host() {
1305 use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
1306 let rows: Vec<TallRow> = (0..10)
1307 .map(|i| TallRow {
1308 name: format!("r{}", i),
1309 height: 1,
1310 })
1311 .collect();
1312 let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
1313 list.poll_until_idle().await;
1314 let rect = |y: u16, h: u16| ratatui::layout::Rect {
1315 x: 0,
1316 y,
1317 width: 20,
1318 height: h,
1319 };
1320 list.set_panel_rect(rect(0, 10));
1322 list.set_list_rect(rect(0, 4));
1323 list.set_content_rect(rect(5, 5));
1324 let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
1325 kind,
1326 column: 2,
1327 row,
1328 modifiers: KeyModifiers::NONE,
1329 };
1330
1331 let m = wheel(MouseEventKind::ScrollDown, 6);
1333 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
1334 assert_eq!(list.offset, 0, "list viewport must not move");
1335 let m = wheel(MouseEventKind::ScrollUp, 6);
1336 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
1337
1338 let m = wheel(MouseEventKind::ScrollDown, 2);
1340 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1341
1342 list.set_content_rect(ratatui::layout::Rect::default());
1344 let m = wheel(MouseEventKind::ScrollDown, 6);
1345 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1346 }
1347
1348 #[tokio::test]
1349 async fn mouse_maps_visual_row_to_display_index_by_height() {
1350 let src = TallSource(vec![
1353 TallRow {
1354 name: "a".into(),
1355 height: 3,
1356 },
1357 TallRow {
1358 name: "b".into(),
1359 height: 1,
1360 },
1361 ]);
1362 let mut list = SearchList::builder(src, noop_redraw()).build();
1363 list.poll_until_idle().await;
1364 list.set_list_rect(ratatui::layout::Rect {
1366 x: 0,
1367 y: 0,
1368 width: 20,
1369 height: 10,
1370 });
1371 let m = mouse_down_at(2, 3);
1373 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
1374 assert_eq!(list.selected_row().unwrap().name, "b");
1375 let m = mouse_down_at(2, 1);
1377 list.handle_mouse(&m);
1378 assert_eq!(list.selected_row().unwrap().name, "a");
1379 }
1380
1381 #[tokio::test]
1385 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
1386 let src = VecSource {
1387 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1388 reload: true,
1389 };
1390 let mut list = SearchList::builder(src, noop_redraw()).build();
1391 list.poll_until_idle().await;
1392 list.set_list_rect(ratatui::layout::Rect {
1394 x: 0,
1395 y: 0,
1396 width: 20,
1397 height: 4,
1398 });
1399 list.select_next();
1401 list.select_next();
1402 assert_eq!(list.selected_row().unwrap().name, "row2");
1403
1404 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1405 kind,
1406 column: 1,
1407 row: 1,
1408 modifiers: KeyModifiers::NONE,
1409 };
1410 use ratatui::crossterm::event::MouseEventKind;
1411
1412 assert_eq!(
1414 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1415 SearchMouse::Scrolled
1416 );
1417 assert_eq!(list.scroll_offset(), 1);
1418 assert_eq!(list.selected_row().unwrap().name, "row3");
1419
1420 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1422 assert_eq!(list.scroll_offset(), 0);
1423 assert_eq!(list.selected_row().unwrap().name, "row2");
1424
1425 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1427 assert_eq!(list.scroll_offset(), 0);
1428 assert_eq!(list.selected_row().unwrap().name, "row2");
1429
1430 for _ in 0..20 {
1433 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1434 }
1435 assert_eq!(list.scroll_offset(), 6);
1436 assert_eq!(list.selected_row().unwrap().name, "row8");
1437 }
1440
1441 #[tokio::test]
1445 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1446 let src = VecSource {
1447 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1448 reload: true,
1449 };
1450 let mut list = SearchList::builder(src, noop_redraw()).build();
1451 list.poll_until_idle().await;
1452 list.set_list_rect(ratatui::layout::Rect {
1454 x: 0,
1455 y: 5,
1456 width: 20,
1457 height: 4,
1458 });
1459 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1460 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1461 column: 1,
1462 row,
1463 modifiers: KeyModifiers::NONE,
1464 };
1465 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1467 assert_eq!(list.scroll_offset(), 0);
1468 list.set_panel_rect(ratatui::layout::Rect {
1469 x: 0,
1470 y: 0,
1471 width: 20,
1472 height: 20,
1473 });
1474 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1476 assert_eq!(list.scroll_offset(), 1);
1477 let before = list.selected_row().unwrap().name.clone();
1480 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1481 assert_eq!(list.selected_row().unwrap().name, before);
1482 }
1483
1484 #[tokio::test]
1488 async fn click_after_scroll_selects_the_clicked_row() {
1489 let src = VecSource {
1490 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1491 reload: true,
1492 };
1493 let mut list = SearchList::builder(src, noop_redraw()).build();
1494 list.poll_until_idle().await;
1495 list.set_list_rect(ratatui::layout::Rect {
1496 x: 0,
1497 y: 0,
1498 width: 20,
1499 height: 4,
1500 });
1501 let scroll_down = ratatui::crossterm::event::MouseEvent {
1502 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1503 column: 1,
1504 row: 1,
1505 modifiers: KeyModifiers::NONE,
1506 };
1507 for _ in 0..3 {
1508 list.handle_mouse(&scroll_down);
1509 }
1510 assert_eq!(list.scroll_offset(), 3);
1511 assert!(matches!(
1513 list.handle_mouse(&mouse_down_at(2, 2)),
1514 SearchMouse::Selected(5)
1515 ));
1516 assert_eq!(list.selected_row().unwrap().name, "row5");
1517 list.handle_mouse(&mouse_down_at(2, 0));
1519 assert_eq!(list.selected_row().unwrap().name, "row3");
1520 }
1521
1522 #[tokio::test]
1527 async fn build_with_rows_applies_synchronously_without_a_poll() {
1528 let list = SearchList::builder(StaticRowSource, noop_redraw())
1529 .filter(Filter::Fuzzy)
1530 .build_with_rows(vec![TestRow::new("alpha"), TestRow::new("beta")]);
1531 assert!(!list.is_loading(), "static build is not loading");
1533 assert_eq!(list.rows().len(), 2);
1534 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1535 }
1536
1537 #[tokio::test]
1538 async fn initial_load_populates_rows() {
1539 let src = VecSource {
1540 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1541 reload: true,
1542 };
1543 let mut list = SearchList::builder(src, noop_redraw()).build();
1544 list.poll_until_idle().await;
1545 assert_eq!(list.rows().len(), 2);
1546 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1547 }
1548
1549 #[tokio::test]
1550 async fn requery_supersedes_and_reloads() {
1551 let src = VecSource {
1552 rows: vec![
1553 TestRow::new("alpha"),
1554 TestRow::new("alps"),
1555 TestRow::new("beta"),
1556 ],
1557 reload: true,
1558 };
1559 let mut list = SearchList::builder(src, noop_redraw()).build();
1560 list.poll_until_idle().await;
1561 assert_eq!(list.rows().len(), 3);
1562 list.set_query("alp");
1563 list.poll_until_idle().await;
1564 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1566 }
1567
1568 #[tokio::test]
1569 async fn arrows_navigate_and_enter_submits() {
1570 let src = VecSource {
1571 rows: vec![TestRow::new("a"), TestRow::new("b")],
1572 reload: true,
1573 };
1574 let mut list = SearchList::builder(src, noop_redraw()).build();
1575 list.poll_until_idle().await;
1576 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1577 assert_eq!(list.selected_row().unwrap().name, "b");
1578 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1579 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1580 }
1581
1582 #[tokio::test]
1583 async fn typing_a_char_changes_query() {
1584 let src = VecSource {
1585 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1586 reload: true,
1587 };
1588 let mut list = SearchList::builder(src, noop_redraw()).build();
1589 list.poll_until_idle().await;
1590 assert_eq!(
1591 list.handle_key(&key(KeyCode::Char('a'))),
1592 KeyReaction::Consumed
1593 );
1594 list.poll_until_idle().await;
1595 assert_eq!(list.query(), "a");
1596 }
1597
1598 #[tokio::test]
1599 async fn rank_filter_orders_by_closure() {
1600 let src = VecSource {
1601 rows: vec![
1602 TestRow::new("todo"),
1603 TestRow::new("today"),
1604 TestRow::new("misc"),
1605 ],
1606 reload: false,
1607 };
1608 let rank = std::sync::Arc::new(|rows: &[TestRow], base: &[usize], q: &str| -> Vec<usize> {
1609 let mut idx: Vec<usize> = base
1610 .iter()
1611 .copied()
1612 .filter(|&i| rows[i].name.contains(q))
1613 .collect();
1614 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1615 idx
1616 });
1617 let mut list = SearchList::builder(src, noop_redraw())
1618 .filter(Filter::Rank(rank))
1619 .build();
1620 list.poll_until_idle().await;
1621 list.set_query("today");
1622 list.poll();
1623 assert_eq!(list.selected_row().unwrap().name, "today");
1624 }
1625
1626 #[tokio::test]
1627 async fn fuzzy_filter_narrows_local_set() {
1628 let src = VecSource {
1629 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1630 reload: false,
1631 };
1632 let mut list = SearchList::builder(src, noop_redraw())
1633 .filter(Filter::Fuzzy)
1634 .build();
1635 list.poll_until_idle().await;
1636 list.set_query("alp");
1637 list.poll();
1638 assert_eq!(list.visible_rows().len(), 1);
1639 assert_eq!(list.selected_row().unwrap().name, "alpha");
1640 }
1641
1642 #[tokio::test]
1643 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1644 let src = ScriptedStreamSource {
1645 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1646 };
1647 let mut list = SearchList::builder(src, noop_redraw())
1648 .filter(Filter::Fuzzy)
1649 .build();
1650 list.poll_until_idle().await;
1651 assert_eq!(list.rows().len(), 2);
1652 assert!(!list.is_loading());
1653 list.set_query("alp");
1654 list.poll();
1655 assert_eq!(list.visible_rows().len(), 1);
1656 }
1657
1658 #[tokio::test]
1659 async fn source_order_unfiltered_passthrough() {
1660 let src = VecSource {
1661 rows: vec![TestRow::new("a"), TestRow::new("b")],
1662 reload: true,
1663 };
1664 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1666 assert_eq!(list.visible_rows().len(), 2);
1667 assert_eq!(list.selected_row().unwrap().name, "a");
1668 }
1669
1670 #[tokio::test]
1671 async fn intercepted_combo_returns_intercepted_without_acting() {
1672 let src = VecSource {
1673 rows: vec![TestRow::new("a")],
1674 reload: true,
1675 };
1676 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1677 let mut list = SearchList::builder(src, noop_redraw())
1678 .intercept(vec![combo])
1679 .build();
1680 list.poll_until_idle().await;
1681 assert_eq!(
1683 list.handle_key(&key(KeyCode::Enter)),
1684 KeyReaction::Intercepted(combo)
1685 );
1686 }
1687
1688 #[tokio::test]
1689 async fn autocomplete_accept_rewrites_query_without_vault() {
1690 struct Mem;
1691 #[async_trait::async_trait]
1692 impl crate::components::search_list::SuggestionSource for Mem {
1693 async fn notes_by_prefix(
1694 &self,
1695 _p: &str,
1696 _n: usize,
1697 ) -> Vec<crate::components::search_list::SuggestionItem> {
1698 vec![]
1699 }
1700 async fn tags_by_prefix(
1701 &self,
1702 p: &str,
1703 _n: usize,
1704 ) -> Vec<crate::components::search_list::SuggestionItem> {
1705 if "projects".starts_with(p) {
1706 vec![crate::components::search_list::SuggestionItem::plain(
1707 "projects",
1708 )]
1709 } else {
1710 vec![]
1711 }
1712 }
1713 }
1714 let src = VecSource {
1715 rows: vec![],
1716 reload: true,
1717 };
1718 let mut list = SearchList::builder(src, noop_redraw())
1719 .autocomplete(
1720 std::sync::Arc::new(Mem),
1721 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1722 )
1723 .debounce(std::time::Duration::ZERO)
1724 .build();
1725 for c in ['#', 'p', 'r', 'o'] {
1726 let _ = list.handle_key(&key(KeyCode::Char(c)));
1727 }
1728 for _ in 0..50 {
1729 tokio::task::yield_now().await;
1730 list.poll();
1731 }
1732 let _ = list.handle_key(&key(KeyCode::Tab));
1733 assert_eq!(list.query(), "#projects");
1734 }
1735
1736 #[tokio::test]
1740 async fn accepting_saved_search_expands_query_and_exposes_name() {
1741 struct Mem;
1742 #[async_trait::async_trait]
1743 impl crate::components::search_list::SuggestionSource for Mem {
1744 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1745 vec![]
1746 }
1747 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1748 vec![]
1749 }
1750 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1751 if "todo-week".starts_with(p) {
1752 vec![SuggestionItem {
1753 display: "todo-week".into(),
1754 secondary: Some("#todo ^modified".into()),
1755 }]
1756 } else {
1757 vec![]
1758 }
1759 }
1760 }
1761 let src = VecSource {
1762 rows: vec![],
1763 reload: true,
1764 };
1765 let mut list = SearchList::builder(src, noop_redraw())
1766 .autocomplete(
1767 std::sync::Arc::new(Mem),
1768 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1769 )
1770 .debounce(std::time::Duration::ZERO)
1771 .build();
1772 for c in ['?', 't', 'o'] {
1773 let _ = list.handle_key(&key(KeyCode::Char(c)));
1774 }
1775 for _ in 0..50 {
1776 tokio::task::yield_now().await;
1777 list.poll();
1778 }
1779 let _ = list.handle_key(&key(KeyCode::Tab));
1780 assert_eq!(list.query(), "#todo ^modified");
1782 assert_eq!(
1784 list.take_accepted_saved_search().as_deref(),
1785 Some("todo-week")
1786 );
1787 assert_eq!(list.take_accepted_saved_search(), None);
1788 }
1789
1790 #[tokio::test]
1795 async fn enter_accepts_open_popup_and_reports_consumed() {
1796 struct Mem;
1797 #[async_trait::async_trait]
1798 impl crate::components::search_list::SuggestionSource for Mem {
1799 async fn notes_by_prefix(
1800 &self,
1801 _p: &str,
1802 _n: usize,
1803 ) -> Vec<crate::components::search_list::SuggestionItem> {
1804 vec![]
1805 }
1806 async fn tags_by_prefix(
1807 &self,
1808 p: &str,
1809 _n: usize,
1810 ) -> Vec<crate::components::search_list::SuggestionItem> {
1811 if "projects".starts_with(p) {
1812 vec![crate::components::search_list::SuggestionItem::plain(
1813 "projects",
1814 )]
1815 } else {
1816 vec![]
1817 }
1818 }
1819 }
1820 let src = VecSource {
1821 rows: vec![],
1822 reload: true,
1823 };
1824 let mut list = SearchList::builder(src, noop_redraw())
1825 .autocomplete(
1826 std::sync::Arc::new(Mem),
1827 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1828 )
1829 .debounce(std::time::Duration::ZERO)
1830 .build();
1831 for c in ['#', 'p', 'r', 'o'] {
1832 let _ = list.handle_key(&key(KeyCode::Char(c)));
1833 }
1834 for _ in 0..50 {
1835 tokio::task::yield_now().await;
1836 list.poll();
1837 }
1838 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1840 assert_eq!(list.query(), "#projects");
1841 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1843 }
1844
1845 #[tokio::test]
1850 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1851 let src = ScriptedStreamLeadSource {
1852 items: vec!["alpha".into(), "beta".into()],
1853 };
1854 let mut list = SearchList::builder(src, noop_redraw())
1855 .filter(Filter::Fuzzy)
1856 .initial_query("zz")
1857 .build();
1858 list.poll_until_idle().await;
1859 let vis = list.visible_rows();
1861 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1862 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1865 list.poll();
1866 let vis = list.visible_rows();
1867 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1868 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1869 assert_eq!(list.visible_len(), 2);
1870 list.set_query("");
1872 list.poll();
1873 assert!(
1874 list.visible_rows()
1875 .iter()
1876 .all(|r| matches!(r, StreamRow::Item(_)))
1877 );
1878 assert_eq!(list.visible_len(), 2);
1879 }
1880
1881 #[tokio::test]
1884 async fn oneshot_source_leading_row_still_works() {
1885 let src = VecSourceWithLead {
1886 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1887 };
1888 let mut list = SearchList::builder(src, noop_redraw())
1889 .filter(Filter::Fuzzy)
1890 .initial_query("alp")
1891 .build();
1892 list.poll_until_idle().await;
1893 let vis = list.visible_rows();
1894 assert_eq!(vis[0].name, "create:alp");
1895 assert_eq!(vis[1].name, "alpha");
1896 assert_eq!(list.visible_len(), 2);
1897 }
1898
1899 #[tokio::test]
1902 async fn selection_includes_leading_at_position_zero() {
1903 let src = VecSourceWithLead {
1904 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1905 };
1906 let mut list = SearchList::builder(src, noop_redraw())
1907 .filter(Filter::Fuzzy)
1908 .initial_query("alp")
1909 .build();
1910 list.poll_until_idle().await;
1911 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1913 list.handle_key(&key(KeyCode::Down));
1914 assert_eq!(list.selected_row().unwrap().name, "alpha");
1915 }
1916
1917 #[tokio::test]
1919 async fn no_leading_row_visible_len_matches_display() {
1920 let src = VecSource {
1921 rows: vec![TestRow::new("a"), TestRow::new("b")],
1922 reload: true,
1923 };
1924 let mut list = SearchList::builder(src, noop_redraw()).build();
1925 list.poll_until_idle().await;
1926 assert_eq!(list.visible_len(), 2);
1927 assert_eq!(list.visible_rows().len(), 2);
1928 assert_eq!(list.selected_row().unwrap().name, "a");
1929 }
1930
1931 #[tokio::test]
1934 async fn update_rows_refilters_visible_view() {
1935 let source = VecSource {
1936 rows: vec![
1937 TestRow::new("alpha"),
1938 TestRow::new("beta"),
1939 TestRow::new("gamma"),
1940 ],
1941 reload: false,
1942 };
1943 let mut list = SearchList::builder(source, noop_redraw())
1944 .filter(Filter::Fuzzy)
1945 .build();
1946 list.poll_until_idle().await;
1947
1948 list.set_query("alp");
1950 list.poll();
1951 assert_eq!(
1952 list.visible_rows()
1953 .iter()
1954 .map(|r| r.name.as_str())
1955 .collect::<Vec<_>>(),
1956 vec!["alpha"],
1957 "before update: only 'alpha' matches 'alp'"
1958 );
1959
1960 let changed = list.update_rows(|r| {
1962 if r.name == "alpha" {
1963 r.name = "renamed".to_string();
1964 true
1965 } else {
1966 false
1967 }
1968 });
1969 assert!(changed);
1970
1971 assert_eq!(
1973 list.visible_rows().len(),
1974 0,
1975 "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
1976 );
1977 }
1978
1979 #[tokio::test]
1980 async fn update_rows_mutates_in_place_and_recomputes() {
1981 let source = VecSource {
1982 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1983 reload: false,
1984 };
1985 let mut list = SearchList::builder(source, noop_redraw()).build();
1986 list.poll_until_idle().await;
1987
1988 let changed = list.update_rows(|r| {
1990 if r.name == "alpha" {
1991 r.name = "renamed".to_string();
1992 true
1993 } else {
1994 false
1995 }
1996 });
1997 assert!(changed, "a row was changed");
1998 assert!(
1999 list.rows().iter().any(|r| r.name == "renamed"),
2000 "the mutation is visible in rows()"
2001 );
2002
2003 let changed_again = list.update_rows(|_| false);
2005 assert!(!changed_again, "no row changed");
2006 }
2007
2008 #[tokio::test]
2015 async fn reload_source_leading_row_updates_synchronously_on_set_query() {
2016 let src = ReloadWithLeadSource {
2017 rows: vec![
2018 TestRow::new("alpha"),
2019 TestRow::new("beta"),
2020 TestRow::new("gamma"),
2021 ],
2022 };
2023 let mut list = SearchList::builder(src, noop_redraw()).build();
2024 list.poll_until_idle().await;
2025 assert!(list.leading.is_none(), "no leading row for empty query");
2027
2028 list.set_query("alp");
2030
2031 let vis = list.visible_rows();
2033 assert!(
2034 !vis.is_empty(),
2035 "visible_rows must not be empty right after set_query"
2036 );
2037 assert_eq!(
2038 vis[0].name, "create:alp",
2039 "leading row must show new query synchronously, before any poll/drain"
2040 );
2041
2042 list.poll_until_idle().await;
2045 let vis = list.visible_rows();
2046 assert_eq!(
2047 vis[0].name, "create:alp",
2048 "leading row correct after drain too"
2049 );
2050 assert_eq!(vis.len(), 2, "leading + alpha");
2052 assert_eq!(vis[1].name, "alpha");
2053 }
2054
2055 #[tokio::test]
2063 async fn local_filter_reseed_after_empty_then_repopulate() {
2064 let src = VecSource {
2065 rows: vec![
2066 TestRow::new("alpha"),
2067 TestRow::new("beta"),
2068 TestRow::new("gamma"),
2069 ],
2070 reload: false,
2071 };
2072 let mut list = SearchList::builder(src, noop_redraw())
2073 .filter(Filter::Fuzzy)
2074 .build();
2075 list.poll_until_idle().await;
2076
2077 assert!(
2079 list.selected_row().is_some(),
2080 "should have a selection after initial load"
2081 );
2082
2083 list.set_query("zzznomatch");
2085 assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
2086 assert!(
2087 list.selected_row().is_none(),
2088 "selection must be None when list is empty"
2089 );
2090
2091 list.set_query("alp");
2093 assert!(
2094 list.visible_len() > 0,
2095 "at least 'alpha' should match 'alp'"
2096 );
2097 assert!(
2100 list.selected_row().is_some(),
2101 "selection must be reseeded to first visible row after repopulation"
2102 );
2103 assert_eq!(
2104 list.selected_row().unwrap().name,
2105 "alpha",
2106 "first visible row must be selected after reseeding"
2107 );
2108 }
2109
2110 async fn focus_list(verbs: &[char]) -> SearchList<TestRow> {
2113 let src = VecSource {
2114 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2115 reload: false,
2116 };
2117 let mut b = SearchList::builder(src, noop_redraw()).filter(Filter::Fuzzy);
2118 for &c in verbs {
2119 b = b.list_verb(c);
2120 }
2121 let mut list = b.build();
2122 list.poll_until_idle().await;
2123 list
2124 }
2125
2126 #[tokio::test]
2129 async fn esc_enters_list_focus_then_cancels() {
2130 let mut list = focus_list(&['l']).await;
2131 assert_eq!(list.focus(), Focus::Input);
2132 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Consumed);
2133 assert_eq!(list.focus(), Focus::List);
2134 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2135 assert_eq!(list.focus(), Focus::List, "Cancel does not change focus");
2136 }
2137
2138 #[tokio::test]
2140 async fn esc_cancels_immediately_when_focus_disabled() {
2141 let mut list = focus_list(&[]).await;
2142 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2143 assert_eq!(list.focus(), Focus::Input);
2144 }
2145
2146 #[tokio::test]
2148 async fn i_and_slash_return_to_input_focus() {
2149 for ret in ['i', '/'] {
2150 let mut list = focus_list(&['l']).await;
2151 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.focus(), Focus::List);
2153 assert_eq!(
2154 list.handle_key(&key(KeyCode::Char(ret))),
2155 KeyReaction::Consumed
2156 );
2157 assert_eq!(list.focus(), Focus::Input);
2158 assert_eq!(list.query(), "", "switching focus must not type a char");
2159 }
2160 }
2161
2162 #[tokio::test]
2164 async fn list_focus_j_k_navigate() {
2165 let mut list = focus_list(&['l']).await;
2166 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.selected_row().unwrap().name, "alpha");
2168 assert_eq!(
2169 list.handle_key(&key(KeyCode::Char('j'))),
2170 KeyReaction::Consumed
2171 );
2172 assert_eq!(list.selected_row().unwrap().name, "beta");
2173 assert_eq!(
2174 list.handle_key(&key(KeyCode::Char('k'))),
2175 KeyReaction::Consumed
2176 );
2177 assert_eq!(list.selected_row().unwrap().name, "alpha");
2178 }
2179
2180 #[tokio::test]
2183 async fn registered_verb_fires_unregistered_letter_does_nothing() {
2184 let mut list = focus_list(&['l', 'o']).await;
2185 list.handle_key(&key(KeyCode::Esc)); assert_eq!(
2187 list.handle_key(&key(KeyCode::Char('l'))),
2188 KeyReaction::ListVerb('l')
2189 );
2190 assert_eq!(
2191 list.handle_key(&key(KeyCode::Char('o'))),
2192 KeyReaction::ListVerb('o')
2193 );
2194 assert_eq!(
2196 list.handle_key(&key(KeyCode::Char('z'))),
2197 KeyReaction::Consumed
2198 );
2199 assert_eq!(list.query(), "");
2200 }
2201
2202 #[tokio::test]
2205 async fn verbs_are_inert_in_input_focus() {
2206 let mut list = focus_list(&['l', 'o']).await;
2207 assert_eq!(list.focus(), Focus::Input);
2208 assert_eq!(
2209 list.handle_key(&key(KeyCode::Char('l'))),
2210 KeyReaction::Consumed
2211 );
2212 list.poll_until_idle().await;
2213 assert_eq!(list.query(), "l", "verb letters still type in Input focus");
2214 }
2215
2216 #[tokio::test]
2219 async fn opening_focus_list_starts_in_list() {
2220 let src = VecSource {
2221 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2222 reload: false,
2223 };
2224 let mut list = SearchList::builder(src, noop_redraw())
2225 .filter(Filter::Fuzzy)
2226 .opening_focus(Focus::List)
2227 .build();
2228 list.poll_until_idle().await;
2229 assert_eq!(list.focus(), Focus::List);
2230 assert_eq!(
2231 list.handle_key(&key(KeyCode::Char('a'))),
2232 KeyReaction::Consumed
2233 );
2234 assert_eq!(list.query(), "");
2235 list.handle_key(&key(KeyCode::Char('i')));
2237 assert_eq!(list.focus(), Focus::Input);
2238 list.handle_key(&key(KeyCode::Char('a')));
2239 list.poll_until_idle().await;
2240 assert_eq!(list.query(), "a");
2241 }
2242
2243 #[tokio::test]
2245 async fn intercept_fires_in_both_foci() {
2246 let src = VecSource {
2247 rows: vec![TestRow::new("a")],
2248 reload: false,
2249 };
2250 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
2251 let mut list = SearchList::builder(src, noop_redraw())
2252 .intercept(vec![combo])
2253 .list_verb('l')
2254 .build();
2255 list.poll_until_idle().await;
2256 assert_eq!(
2258 list.handle_key(&key(KeyCode::Enter)),
2259 KeyReaction::Intercepted(combo)
2260 );
2261 list.handle_key(&key(KeyCode::Esc));
2263 assert_eq!(list.focus(), Focus::List);
2264 assert_eq!(
2265 list.handle_key(&key(KeyCode::Enter)),
2266 KeyReaction::Intercepted(combo)
2267 );
2268 }
2269}
2270
2271#[cfg(test)]
2272mod order_tests {
2273 use super::adapters::{HeldEmitSource, ScriptedStreamSource, TestRow};
2274 use super::seams::RankFn;
2275 use super::*;
2276 use std::sync::{Arc, Mutex};
2277
2278 fn noop_redraw() -> Arc<dyn Fn() + Send + Sync> {
2279 Arc::new(|| {})
2280 }
2281
2282 fn by_name_asc() -> OrderFn<TestRow> {
2283 Arc::new(|a: &TestRow, b: &TestRow| a.name.cmp(&b.name))
2284 }
2285
2286 fn by_name_desc() -> OrderFn<TestRow> {
2287 Arc::new(|a: &TestRow, b: &TestRow| b.name.cmp(&a.name))
2288 }
2289
2290 fn names(list: &SearchList<TestRow>) -> Vec<String> {
2291 list.visible_rows().iter().map(|r| r.name.clone()).collect()
2292 }
2293
2294 #[tokio::test]
2297 async fn order_by_sorts_pushed_rows_as_they_arrive() {
2298 let source = ScriptedStreamSource {
2299 batches: vec![
2300 vec![TestRow::new("charlie")],
2301 vec![TestRow::new("alpha")],
2302 vec![TestRow::new("bravo")],
2303 ],
2304 };
2305 let mut list = SearchList::builder(source, noop_redraw())
2306 .filter(Filter::Fuzzy)
2307 .order_by(by_name_asc())
2308 .build();
2309 list.poll_until_idle().await;
2310 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2311 }
2312
2313 #[tokio::test]
2316 async fn set_order_reorders_in_place_without_reload() {
2317 let source = ScriptedStreamSource {
2318 batches: vec![vec![
2319 TestRow::new("bravo"),
2320 TestRow::new("alpha"),
2321 TestRow::new("charlie"),
2322 ]],
2323 };
2324 let mut list = SearchList::builder(source, noop_redraw())
2325 .order_by(by_name_asc())
2326 .build();
2327 list.poll_until_idle().await;
2328 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2329
2330 list.set_order(Some(by_name_desc()));
2331
2332 assert!(!list.is_loading(), "reordering must not start a load");
2333 assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2334 }
2335
2336 #[tokio::test]
2345 async fn changing_the_order_keeps_a_seeded_selection_on_the_top_row() {
2346 let slot = Arc::new(Mutex::new(None));
2347 let source = HeldEmitSource { slot: slot.clone() };
2348 let mut list = SearchList::builder(source, noop_redraw())
2349 .order_by(by_name_asc())
2350 .build();
2351 let emit = loop {
2352 if let Some(e) = slot.lock().unwrap().clone() {
2353 break e;
2354 }
2355 tokio::task::yield_now().await;
2356 };
2357
2358 emit.push(TestRow::new("bravo"));
2359 emit.push(TestRow::new("charlie"));
2360 list.poll();
2361 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2362
2363 list.set_order(Some(by_name_desc()));
2364 assert_eq!(names(&list), ["charlie", "bravo"]);
2365 assert_eq!(
2366 list.selected_row().map(|r| r.name.as_str()),
2367 Some("charlie"),
2368 "an untouched selection keeps the top slot of the new order"
2369 );
2370
2371 emit.push(TestRow::new("delta"));
2373 list.poll();
2374 assert_eq!(names(&list), ["delta", "charlie", "bravo"]);
2375 assert_eq!(
2376 list.selected_row().map(|r| r.name.as_str()),
2377 Some("delta"),
2378 "a sort change must not silently turn a seed into a choice"
2379 );
2380 emit.done();
2381 }
2382
2383 #[tokio::test]
2386 async fn changing_the_order_keeps_a_chosen_selection_on_its_row() {
2387 let source = ScriptedStreamSource {
2388 batches: vec![vec![
2389 TestRow::new("bravo"),
2390 TestRow::new("alpha"),
2391 TestRow::new("charlie"),
2392 ]],
2393 };
2394 let mut list = SearchList::builder(source, noop_redraw())
2395 .order_by(by_name_asc())
2396 .build();
2397 list.poll_until_idle().await;
2398 list.select_next(); assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2400
2401 list.set_order(Some(by_name_desc()));
2402 assert_eq!(names(&list), ["charlie", "bravo", "alpha"]);
2403 assert_eq!(
2404 list.selected_row().map(|r| r.name.as_str()),
2405 Some("bravo"),
2406 "a chosen selection tracks its row through a re-sort"
2407 );
2408 }
2409
2410 #[tokio::test]
2414 async fn a_nudge_that_moves_nothing_leaves_the_seed_a_seed() {
2415 let slot = Arc::new(Mutex::new(None));
2416 let source = HeldEmitSource { slot: slot.clone() };
2417 let mut list = SearchList::builder(source, noop_redraw())
2418 .order_by(by_name_asc())
2419 .build();
2420 let emit = loop {
2421 if let Some(e) = slot.lock().unwrap().clone() {
2422 break e;
2423 }
2424 tokio::task::yield_now().await;
2425 };
2426
2427 emit.push(TestRow::new("charlie"));
2428 list.poll();
2429 list.select_prev(); list.select_next(); assert_eq!(
2432 list.selected_row().map(|r| r.name.as_str()),
2433 Some("charlie")
2434 );
2435
2436 emit.push(TestRow::new("alpha"));
2437 list.poll();
2438 assert_eq!(names(&list), ["alpha", "charlie"]);
2439 assert_eq!(
2440 list.selected_row().map(|r| r.name.as_str()),
2441 Some("alpha"),
2442 "a keypress that changed nothing must leave the selection seeded"
2443 );
2444 emit.done();
2445 }
2446
2447 #[tokio::test]
2450 async fn selection_follows_its_row_while_rows_stream_in() {
2451 let slot = Arc::new(Mutex::new(None));
2452 let source = HeldEmitSource { slot: slot.clone() };
2453 let mut list = SearchList::builder(source, noop_redraw())
2454 .order_by(by_name_asc())
2455 .build();
2456 let emit = loop {
2457 if let Some(e) = slot.lock().unwrap().clone() {
2458 break e;
2459 }
2460 tokio::task::yield_now().await;
2461 };
2462
2463 emit.push(TestRow::new("bravo"));
2464 emit.push(TestRow::new("charlie"));
2465 list.poll();
2466 list.select_next();
2467 assert_eq!(
2468 list.selected_row().map(|r| r.name.as_str()),
2469 Some("charlie")
2470 );
2471
2472 emit.push(TestRow::new("alpha"));
2473 list.poll();
2474
2475 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2476 assert_eq!(
2477 list.selected_row().map(|r| r.name.as_str()),
2478 Some("charlie"),
2479 "selection tracks the row, not its old position"
2480 );
2481 emit.done();
2482 }
2483
2484 #[tokio::test]
2491 async fn order_by_composes_with_a_rank_filter() {
2492 let rank: RankFn<TestRow> = Arc::new(|rows: &[TestRow], base: &[usize], q: &str| {
2494 base.iter()
2495 .copied()
2496 .filter(|&i| rows[i].name.contains(q))
2497 .collect()
2498 });
2499 let source = ScriptedStreamSource {
2500 batches: vec![vec![
2501 TestRow::new("charlie"),
2502 TestRow::new("alpha"),
2503 TestRow::new("bravo"),
2504 ]],
2505 };
2506 let mut list = SearchList::builder(source, noop_redraw())
2507 .filter(Filter::Rank(rank))
2508 .order_by(by_name_asc())
2509 .build();
2510 list.poll_until_idle().await;
2511 list.set_query("a"); assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2513 }
2514
2515 #[tokio::test]
2524 async fn a_seeded_selection_stays_on_the_top_row_as_rows_stream_in() {
2525 let slot = Arc::new(Mutex::new(None));
2526 let source = HeldEmitSource { slot: slot.clone() };
2527 let mut list = SearchList::builder(source, noop_redraw())
2528 .order_by(by_name_asc())
2529 .build();
2530 let emit = loop {
2531 if let Some(e) = slot.lock().unwrap().clone() {
2532 break e;
2533 }
2534 tokio::task::yield_now().await;
2535 };
2536
2537 emit.push(TestRow::new("charlie"));
2539 list.poll();
2540 assert_eq!(
2541 list.selected_row().map(|r| r.name.as_str()),
2542 Some("charlie")
2543 );
2544
2545 emit.push(TestRow::new("alpha"));
2547 list.poll();
2548 assert_eq!(names(&list), ["alpha", "charlie"]);
2549 assert_eq!(
2550 list.selected_row().map(|r| r.name.as_str()),
2551 Some("alpha"),
2552 "an untouched selection seeds to the top of the order, not to the first-arrived row"
2553 );
2554 emit.done();
2555 }
2556
2557 #[tokio::test]
2561 async fn a_selection_moved_back_to_the_top_row_is_still_pinned_to_it() {
2562 let slot = Arc::new(Mutex::new(None));
2563 let source = HeldEmitSource { slot: slot.clone() };
2564 let mut list = SearchList::builder(source, noop_redraw())
2565 .order_by(by_name_asc())
2566 .build();
2567 let emit = loop {
2568 if let Some(e) = slot.lock().unwrap().clone() {
2569 break e;
2570 }
2571 tokio::task::yield_now().await;
2572 };
2573
2574 emit.push(TestRow::new("bravo"));
2575 emit.push(TestRow::new("charlie"));
2576 list.poll();
2577 list.select_next();
2578 list.select_prev(); assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("bravo"));
2580
2581 emit.push(TestRow::new("alpha"));
2582 list.poll();
2583 assert_eq!(names(&list), ["alpha", "bravo", "charlie"]);
2584 assert_eq!(
2585 list.selected_row().map(|r| r.name.as_str()),
2586 Some("bravo"),
2587 "a selection the user placed tracks its row even when it sits at the top"
2588 );
2589 emit.done();
2590 }
2591}