1#[cfg(test)]
5mod adapters;
6mod host;
7mod load;
8mod resolving;
9mod seams;
10
11pub use resolving::{ResolvingRowSource, Unresolvable};
12pub use seams::{
13 Emit, Filter, Loaded, RowSource, SearchRow, StaticRowSource, SuggestionItem, SuggestionSource,
14 VaultSuggestions, 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], query: &str) -> Vec<usize> {
36 use nucleo::pattern::{CaseMatching, Normalization, Pattern};
37 use nucleo::{Matcher, Utf32Str};
38 let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
39 let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
40 let mut scored: Vec<(usize, u32)> = rows
41 .iter()
42 .enumerate()
43 .filter_map(|(i, r)| {
44 let hay = r.match_text()?;
45 let mut buf = Vec::new();
46 let h = Utf32Str::new(hay, &mut buf);
47 pat.score(h, &mut matcher).map(|s| (i, s))
48 })
49 .collect();
50 scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
51 scored.into_iter().map(|(i, _)| i).collect()
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Focus {
60 Input,
61 List,
62}
63
64#[derive(Debug, PartialEq, Eq)]
66pub enum KeyReaction {
67 Consumed,
68 Submit,
69 Cancel,
70 Intercepted(crate::keys::key_combo::KeyCombo),
71 ListVerb(char),
75 Yank(Option<YankTarget>),
83 Unhandled,
84}
85
86pub struct SearchList<R: SearchRow> {
87 source: Arc<dyn RowSource<R>>,
88 rows: Vec<R>,
89 display: Vec<usize>,
91 leading: Option<R>,
97 selected: Option<usize>,
100 offset: usize,
105 filter: Filter<R>,
106 query: String,
107 loader: LoadEngine<R>,
108 input: SingleLineInput,
109 autocomplete: Option<AutocompleteController>,
110 intercept: Vec<KeyCombo>,
112 yank_combos: Vec<KeyCombo>,
117 icons: Icons,
118 list_rect: Rect,
119 panel_rect: Rect,
125 content_rect: Rect,
134 applied_generation: u64,
139 accepted_saved_search: Option<String>,
143 last_click_pos: Option<usize>,
147 highlight_query: bool,
150 focus: Focus,
152 focus_enabled: bool,
157 list_verbs: Vec<char>,
161}
162
163#[derive(Debug, PartialEq, Eq)]
165pub enum SearchMouse {
166 Selected(usize),
167 Activated(usize),
168 Context(usize),
171 Scrolled,
172 ContentScrollUp,
176 ContentScrollDown,
177 None,
178}
179
180pub struct SearchListBuilder<R: SearchRow> {
181 source: Arc<dyn RowSource<R>>,
182 redraw: Arc<dyn Fn() + Send + Sync>,
183 initial_query: String,
184 filter: Filter<R>,
185 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
186 intercept: Vec<KeyCombo>,
187 yank_combos: Vec<KeyCombo>,
188 icons: Icons,
189 debounce: Option<std::time::Duration>,
190 highlight_query: bool,
191 opening_focus: Focus,
192 list_verbs: Vec<char>,
193}
194
195impl<R: SearchRow> SearchList<R> {
196 pub fn builder(
197 source: impl RowSource<R>,
198 redraw: Arc<dyn Fn() + Send + Sync>,
199 ) -> SearchListBuilder<R> {
200 SearchListBuilder {
201 source: Arc::new(source),
202 redraw,
203 initial_query: String::new(),
204 filter: Filter::SourceOrder,
205 autocomplete: None,
206 intercept: Vec::new(),
207 yank_combos: vec![crate::keys::default_yank_combo()],
208 icons: Icons::new(false),
209 debounce: None,
210 highlight_query: false,
211 opening_focus: Focus::Input,
212 list_verbs: Vec::new(),
213 }
214 }
215
216 fn new(b: SearchListBuilder<R>) -> Self {
218 let mut list = Self::assemble(b);
219 list.loader.start(list.source.clone(), list.query.clone());
220 list
221 }
222
223 fn with_rows(b: SearchListBuilder<R>, rows: Vec<R>) -> Self {
233 let mut list = Self::assemble(b);
234 list.rows = rows;
235 list.recompute_and_seed();
236 list
237 }
238
239 fn assemble(b: SearchListBuilder<R>) -> Self {
243 let loader = LoadEngine::new(b.redraw.clone());
244 let input = SingleLineInput::with_value(&b.initial_query);
245 let debounce = b.debounce;
246 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
247 let mut ac =
248 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
249 disambiguate_header: false,
250 apply_exclusion_zone: false,
251 ..TriggerOptions::default()
254 });
255 if let Some(d) = debounce {
256 ac = ac.with_debounce(d);
257 }
258 ac.set_redraw_callback(b.redraw.clone());
259 ac
260 });
261 Self {
262 source: b.source,
263 rows: Vec::new(),
264 display: Vec::new(),
265 leading: None,
266 selected: None,
267 offset: 0,
268 filter: b.filter,
269 query: b.initial_query,
270 loader,
271 input,
272 highlight_query: b.highlight_query,
273 last_click_pos: None,
274 autocomplete,
275 intercept: b.intercept,
276 yank_combos: b.yank_combos,
277 icons: b.icons,
278 list_rect: Rect::default(),
279 panel_rect: Rect::default(),
280 content_rect: Rect::default(),
281 applied_generation: 0,
282 accepted_saved_search: None,
283 focus: b.opening_focus,
284 focus_enabled: b.opening_focus == Focus::List || !b.list_verbs.is_empty(),
287 list_verbs: b.list_verbs,
288 }
289 }
290
291 pub fn focus(&self) -> Focus {
293 self.focus
294 }
295
296 pub fn poll(&mut self) {
297 let drained = self.loader.drain();
298 if !drained.is_empty() {
299 let current_gen = self.loader.generation();
303 if current_gen != self.applied_generation {
304 self.rows.clear();
305 self.selected = None;
306 self.offset = 0;
307 self.applied_generation = current_gen;
308 }
309 for ev in drained {
310 match ev {
311 LoadedInner::Replace(rows) => {
312 self.rows = rows;
313 }
314 LoadedInner::Push(row) => {
315 self.rows.push(row);
316 }
317 LoadedInner::Done => {}
318 }
319 }
320 self.recompute_and_seed();
321 }
322 if let Some(ac) = &mut self.autocomplete {
323 ac.poll_results();
324 }
325 }
326
327 fn recompute_and_seed(&mut self) {
332 self.recompute_display();
333 if self.selected.is_none() && self.visible_len() > 0 {
334 self.selected = Some(0);
335 }
336 }
337
338 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
342 let value = self.input.value().to_string();
343 let cursor_byte = self.input.cursor_byte();
344 let col = value[..cursor_byte.min(value.len())].chars().count();
345 host::SearchBoxHostSnapshot {
346 lines: vec![value],
347 cursor: (0, col),
348 caret_pos: self.input.last_caret_pos(),
349 }
350 }
351
352 fn clamp_selection(&mut self) {
353 let len = self.visible_len();
354 self.selected = if len == 0 {
355 None
356 } else {
357 Some(self.selected.unwrap_or(0).min(len - 1))
358 };
359 }
360
361 fn leading_offset(&self) -> usize {
363 self.leading.is_some() as usize
364 }
365
366 pub fn visible_len(&self) -> usize {
368 self.leading_offset() + self.display.len()
369 }
370
371 pub fn match_count(&self) -> usize {
374 self.display.len()
375 }
376
377 fn visible_row(&self, pos: usize) -> Option<&R> {
379 if self.leading.is_some() && pos == 0 {
380 self.leading.as_ref()
381 } else {
382 self.rows
383 .get(*self.display.get(pos - self.leading_offset())?)
384 }
385 }
386
387 pub fn rows(&self) -> &[R] {
391 &self.rows
392 }
393
394 pub fn selected_row(&self) -> Option<&R> {
395 self.selected.and_then(|p| self.visible_row(p))
396 }
397
398 pub fn visible_rows(&self) -> Vec<&R> {
399 (0..self.visible_len())
400 .filter_map(|p| self.visible_row(p))
401 .collect()
402 }
403
404 pub fn query(&self) -> &str {
405 &self.query
406 }
407
408 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
412 self.accepted_saved_search.take()
413 }
414
415 #[cfg(test)]
418 pub(crate) fn input_value(&self) -> &str {
419 self.input.value()
420 }
421 pub fn is_loading(&self) -> bool {
422 self.loader.loading
423 }
424
425 pub fn set_query(&mut self, q: impl Into<String>) {
434 let q = q.into();
435 self.input.set_value(q.clone());
436 self.query = q;
437 self.requery();
438 }
439
440 fn sync_query_from_input(&mut self) {
445 self.query = self.input.value().to_string();
446 self.requery();
447 }
448
449 fn requery(&mut self) {
452 if self.source.reload_on_query() {
453 self.loader.start(self.source.clone(), self.query.clone());
454 }
455 self.recompute_and_seed();
459 }
460
461 pub fn reload(&mut self) {
463 self.loader.start(self.source.clone(), self.query.clone());
464 }
465
466 pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
476 let mut changed = false;
477 for row in &mut self.rows {
478 if mutate(row) {
479 changed = true;
480 }
481 }
482 if changed {
483 self.recompute_display();
484 }
485 changed
486 }
487
488 pub fn select(&mut self, pos: usize) {
494 let n = self.visible_len();
495 self.selected = if n == 0 { None } else { Some(pos.min(n - 1)) };
496 }
497
498 pub fn select_next(&mut self) {
499 let n = self.visible_len();
500 if n == 0 {
501 return;
502 }
503 self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
504 }
505
506 pub fn select_prev(&mut self) {
507 if self.visible_len() == 0 {
508 return;
509 }
510 self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
511 }
512
513 fn max_scroll_offset(&self) -> usize {
518 let viewport = self.list_rect.height as usize;
519 let n = self.visible_len();
520 if viewport == 0 || n == 0 {
521 return 0;
522 }
523 let mut budget = viewport;
524 let mut first = n;
525 while first > 0 {
526 let h = self
527 .visible_row(first - 1)
528 .map(|r| r.visual_height() as usize)
529 .unwrap_or(1);
530 if h > budget {
531 break;
532 }
533 budget -= h;
534 first -= 1;
535 }
536 first.min(n - 1)
537 }
538
539 pub fn scroll_down(&mut self) {
543 let n = self.visible_len();
544 if n == 0 || self.offset >= self.max_scroll_offset() {
545 return;
546 }
547 self.offset += 1;
548 self.selected = self.selected.map(|i| (i + 1).min(n - 1));
549 }
550
551 pub fn scroll_up(&mut self) {
554 if self.offset == 0 {
555 return;
556 }
557 self.offset -= 1;
558 self.selected = self.selected.map(|i| i.saturating_sub(1));
559 }
560
561 #[cfg(test)]
564 pub(crate) fn scroll_offset(&self) -> usize {
565 self.offset
566 }
567
568 pub fn is_yank_chord(&self, key: &KeyEvent) -> bool {
574 crate::keys::key_event_to_combo(key).is_some_and(|c| self.yank_combos.contains(&c))
575 }
576
577 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
578 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
579
580 if let Some(combo) = crate::keys::key_event_to_combo(key)
583 && self.intercept.contains(&combo)
584 {
585 return KeyReaction::Intercepted(combo);
586 }
587
588 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
592 let snap = self.autocomplete_snapshot();
593 if let Some(ac) = &mut self.autocomplete {
594 match ac.handle_key(*key, &snap) {
595 HandleKeyOutcome::Accepted(action) => {
596 self.input.replace_range_bytes(
597 action.range.clone(),
598 &action.new_text,
599 action.new_cursor_byte,
600 );
601 self.accepted_saved_search = action.saved_search_name;
606 self.sync_query_from_input();
607 return KeyReaction::Consumed;
608 }
609 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
610 return KeyReaction::Consumed;
611 }
612 HandleKeyOutcome::NotHandled => {}
613 }
614 }
615 }
616
617 match key.code {
619 KeyCode::Up => {
620 self.select_prev();
621 return KeyReaction::Consumed;
622 }
623 KeyCode::Down => {
624 self.select_next();
625 return KeyReaction::Consumed;
626 }
627 KeyCode::Enter => return KeyReaction::Submit,
628 _ => {}
629 }
630 if key.code == KeyCode::Esc {
634 if self.focus_enabled && self.focus == Focus::Input {
635 self.focus = Focus::List;
636 self.close_autocomplete();
637 return KeyReaction::Consumed;
638 }
639 return KeyReaction::Cancel;
640 }
641 if let Some(combo) = crate::keys::key_event_to_combo(key)
647 && self.yank_combos.contains(&combo)
648 {
649 return KeyReaction::Yank(self.selected_row().and_then(|r| r.yank_target()));
650 }
651 if let KeyCode::Char(_) = key.code {
654 let non_shift = key.modifiers - KeyModifiers::SHIFT;
655 if !non_shift.is_empty() {
656 return KeyReaction::Unhandled;
657 }
658 }
659 if self.focus == Focus::List {
661 if let KeyCode::Char(c) = key.code {
662 return match c {
663 'i' | '/' => {
665 self.focus = Focus::Input;
666 KeyReaction::Consumed
667 }
668 'j' => {
669 self.select_next();
670 KeyReaction::Consumed
671 }
672 'k' => {
673 self.select_prev();
674 KeyReaction::Consumed
675 }
676 _ if self.list_verbs.contains(&c) => KeyReaction::ListVerb(c),
677 _ => KeyReaction::Consumed,
679 };
680 }
681 return KeyReaction::Unhandled;
683 }
684 let outcome = self.input.handle_key(key);
685 let snap = self.autocomplete_snapshot();
688 match outcome {
689 InputOutcome::Changed => {
690 if let Some(ac) = &mut self.autocomplete {
691 ac.sync(&snap);
692 }
693 }
694 InputOutcome::Consumed => {
695 if let Some(ac) = &mut self.autocomplete {
696 ac.refresh_if_open(&snap);
697 }
698 }
699 InputOutcome::Cancel | InputOutcome::Submit => {
700 if let Some(ac) = &mut self.autocomplete {
701 ac.close();
702 }
703 }
704 InputOutcome::NotConsumed => {}
705 }
706 match outcome {
707 InputOutcome::Changed => {
708 self.sync_query_from_input();
709 KeyReaction::Consumed
710 }
711 InputOutcome::Consumed => KeyReaction::Consumed,
712 InputOutcome::Submit => KeyReaction::Submit,
713 InputOutcome::Cancel => KeyReaction::Cancel,
714 InputOutcome::NotConsumed => KeyReaction::Unhandled,
715 }
716 }
717
718 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
719 let focused = focused && self.focus == Focus::Input;
724 let base = Style::default()
725 .fg(theme.fg.to_ratatui())
726 .bg(theme.bg_panel.to_ratatui());
727 if self.highlight_query {
728 let line =
729 crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
730 self.input.render_line(f, area, line, base, 0, focused);
731 } else {
732 self.input.render(f, area, base, 0, focused);
733 }
734 }
735
736 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
737 self.poll();
738 let sel = self.selected;
739 let items: Vec<ListItem> = (0..self.visible_len())
740 .filter_map(|pos| {
741 self.visible_row(pos)
742 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
743 })
744 .collect();
745 let mut state = ListState::default().with_offset(self.offset);
746 state.select(self.selected);
747 let list =
748 List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
749 f.render_stateful_widget(list, area, &mut state);
750 self.offset = state.offset();
754 self.list_rect = area;
755 let _ = focused;
756 }
757
758 pub fn set_list_rect(&mut self, rect: Rect) {
767 self.list_rect = rect;
768 }
769
770 pub fn set_panel_rect(&mut self, rect: Rect) {
775 self.panel_rect = rect;
776 }
777
778 pub fn set_content_rect(&mut self, rect: Rect) {
786 self.content_rect = rect;
787 }
788
789 #[cfg(test)]
793 pub(crate) fn content_rect(&self) -> Rect {
794 self.content_rect
795 }
796
797 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
798 if let Some(ac) = &mut self.autocomplete {
799 ac.poll_results();
800 let caret = self.input.last_caret_pos();
801 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
802 state.anchor = anchor;
803 }
804 if let Some(state) = ac.state() {
805 crate::components::autocomplete::render(f, state, clamp, theme);
806 }
807 }
808 }
809
810 pub fn close_autocomplete(&mut self) {
817 if let Some(ac) = &mut self.autocomplete {
818 ac.close();
819 }
820 }
821
822 #[cfg(test)]
825 pub(crate) fn autocomplete_is_open(&self) -> bool {
826 self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
827 }
828
829 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
830 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
831 use ratatui::layout::Position;
832 self.close_autocomplete();
835 let pos = Position {
836 x: m.column,
837 y: m.row,
838 };
839 if matches!(
843 m.kind,
844 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
845 ) {
846 if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
850 return if m.kind == MouseEventKind::ScrollUp {
851 SearchMouse::ContentScrollUp
852 } else {
853 SearchMouse::ContentScrollDown
854 };
855 }
856 let bounds = if self.panel_rect.is_empty() {
857 self.list_rect
858 } else {
859 self.panel_rect
860 };
861 if !bounds.contains(pos) {
862 return SearchMouse::None;
863 }
864 if m.kind == MouseEventKind::ScrollUp {
865 self.scroll_up();
866 } else {
867 self.scroll_down();
868 }
869 return SearchMouse::Scrolled;
870 }
871 let r = self.list_rect;
872 if !r.contains(pos) {
873 return SearchMouse::None;
874 }
875 match m.kind {
876 MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
877 let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
878 let target_visual = m.row - r.y; let mut acc: u16 = 0;
880 let mut hit: Option<usize> = None;
881 for pos in self.offset..self.visible_len() {
886 let h = self
887 .visible_row(pos)
888 .map(|r| r.visual_height())
889 .unwrap_or(1);
890 if target_visual < acc + h {
891 hit = Some(pos);
892 break;
893 }
894 acc += h;
895 }
896 if let Some(pos) = hit {
897 let prev = self.selected;
898 let prev_click = self.last_click_pos.replace(pos);
899 self.selected = Some(pos);
900 return if right_click {
901 SearchMouse::Context(pos)
902 } else if prev == Some(pos) && prev_click == Some(pos) {
903 SearchMouse::Activated(pos)
906 } else {
907 SearchMouse::Selected(pos)
908 };
909 }
910 SearchMouse::None
911 }
912 _ => SearchMouse::None,
913 }
914 }
915
916 fn recompute_display(&mut self) {
917 let q = self.query.trim();
918 self.leading = self.source.leading_row(q);
921 let mut idx: Vec<usize> = match &self.filter {
922 Filter::SourceOrder => (0..self.rows.len()).collect(),
923 Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
924 Filter::Fuzzy => fuzzy_indices(&self.rows, q),
925 Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
926 Filter::Rank(f) => {
927 let f = f.clone();
928 f(&self.rows, q)
929 }
930 };
931 for i in 0..self.rows.len() {
934 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
935 idx.insert(0, i);
936 }
937 }
938 self.display = idx;
939 self.clamp_selection();
940 }
941
942 #[cfg(test)]
943 pub(crate) async fn poll_until_idle(&mut self) {
944 for _ in 0..600 {
950 tokio::task::yield_now().await;
951 self.poll();
952 if !self.is_loading() {
953 break;
954 }
955 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
956 }
957 self.poll();
958 }
959}
960
961impl<R: SearchRow> SearchListBuilder<R> {
962 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
963 self.initial_query = q.into();
964 self
965 }
966 pub fn filter(mut self, f: Filter<R>) -> Self {
967 self.filter = f;
968 self
969 }
970 pub fn autocomplete(
971 mut self,
972 suggestions: Arc<dyn SuggestionSource>,
973 mode: AutocompleteMode,
974 ) -> Self {
975 self.autocomplete = Some((suggestions, mode));
976 self
977 }
978 pub fn yank_combos_from(self, bindings: &crate::keys::KeyBindings) -> Self {
983 self.yank_combos(
984 bindings.combos_for(&crate::keys::action_shortcuts::ActionShortcuts::YankRow),
985 )
986 }
987
988 pub fn yank_combos(mut self, combos: Vec<KeyCombo>) -> Self {
991 self.yank_combos = combos;
992 self
993 }
994
995 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
996 self.intercept = v;
997 self
998 }
999 pub fn highlight_query(mut self) -> Self {
1001 self.highlight_query = true;
1002 self
1003 }
1004 pub fn icons(mut self, icons: Icons) -> Self {
1005 self.icons = icons;
1006 self
1007 }
1008 pub fn opening_focus(mut self, focus: Focus) -> Self {
1012 self.opening_focus = focus;
1013 self
1014 }
1015 pub fn list_verb(mut self, c: char) -> Self {
1022 self.list_verbs.push(c);
1023 self
1024 }
1025 pub fn debounce(mut self, d: std::time::Duration) -> Self {
1028 self.debounce = Some(d);
1029 self
1030 }
1031 pub fn build(self) -> SearchList<R> {
1032 SearchList::new(self)
1033 }
1034
1035 pub fn build_with_rows(self, rows: Vec<R>) -> SearchList<R> {
1045 SearchList::with_rows(self, rows)
1046 }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::adapters::{
1052 ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
1053 VecSource, VecSourceWithLead,
1054 };
1055 use super::*;
1056 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1057
1058 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
1059 std::sync::Arc::new(|| {})
1060 }
1061
1062 fn key(c: KeyCode) -> KeyEvent {
1063 KeyEvent::new(c, KeyModifiers::NONE)
1064 }
1065
1066 fn yank_list(rows: &[&str]) -> SearchList<TestRow> {
1073 SearchList::builder(
1074 VecSource {
1075 rows: vec![],
1076 reload: false,
1077 },
1078 noop_redraw(),
1079 )
1080 .build_with_rows(rows.iter().map(|n| TestRow::new(n)).collect())
1081 }
1082
1083 fn ctrl(c: char) -> KeyEvent {
1084 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1085 }
1086
1087 #[test]
1088 fn yank_chord_reports_the_selected_rows_target() {
1089 let mut list = yank_list(&["alpha", "beta"]);
1090 match list.handle_key(&ctrl('y')) {
1091 KeyReaction::Yank(Some(t)) => {
1092 assert_eq!(t.text, "alpha");
1093 assert_eq!(t.noun, "path");
1094 }
1095 r => panic!("got {r:?}"),
1096 }
1097 }
1098
1099 #[test]
1100 fn yank_chord_reports_none_for_a_row_with_nothing_to_copy() {
1101 let mut list = yank_list(&["quiet"]);
1104 list.select_next();
1105 assert!(matches!(
1106 list.handle_key(&ctrl('y')),
1107 KeyReaction::Yank(None)
1108 ));
1109 }
1110
1111 #[test]
1112 fn yank_chord_reports_none_when_nothing_is_selected() {
1113 let mut list = yank_list(&[]);
1114 assert!(matches!(
1115 list.handle_key(&ctrl('y')),
1116 KeyReaction::Yank(None)
1117 ));
1118 }
1119
1120 #[test]
1121 fn yank_chord_is_claimed_before_ctrl_chars_are_dropped() {
1122 let mut list = yank_list(&["alpha"]);
1126 list.select_next();
1127 assert!(
1128 !matches!(list.handle_key(&ctrl('y')), KeyReaction::Unhandled),
1129 "the yank chord must not fall through to the Ctrl-char drop"
1130 );
1131 }
1132
1133 #[test]
1134 fn a_rebound_yank_combo_replaces_the_default() {
1135 let mut list = SearchList::builder(
1136 VecSource {
1137 rows: vec![],
1138 reload: false,
1139 },
1140 noop_redraw(),
1141 )
1142 .yank_combos(vec![crate::keys::key_event_to_combo(&ctrl('k')).unwrap()])
1143 .build_with_rows(vec![TestRow::new("alpha")]);
1144 list.select_next();
1145 assert!(matches!(
1146 list.handle_key(&ctrl('k')),
1147 KeyReaction::Yank(Some(_))
1148 ));
1149 assert!(
1150 !matches!(list.handle_key(&ctrl('y')), KeyReaction::Yank(_)),
1151 "the default chord must stop yanking once overridden"
1152 );
1153 }
1154
1155 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
1156 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1157 MouseEvent {
1158 kind: MouseEventKind::Down(MouseButton::Left),
1159 column: col,
1160 row,
1161 modifiers: KeyModifiers::NONE,
1162 }
1163 }
1164
1165 #[derive(Clone, Debug, PartialEq)]
1166 struct TallRow {
1167 name: String,
1168 height: u16,
1169 }
1170 impl SearchRow for TallRow {
1171 fn to_list_item(
1172 &self,
1173 _t: &crate::settings::themes::Theme,
1174 _i: &crate::settings::icons::Icons,
1175 _s: bool,
1176 ) -> ratatui::widgets::ListItem<'static> {
1177 ratatui::widgets::ListItem::new(self.name.clone())
1178 }
1179 fn visual_height(&self) -> u16 {
1180 self.height
1181 }
1182 fn match_text(&self) -> Option<&str> {
1183 Some(&self.name)
1184 }
1185 }
1186 struct TallSource(Vec<TallRow>);
1187 #[async_trait::async_trait]
1188 impl RowSource<TallRow> for TallSource {
1189 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
1190 emit.replace(self.0.clone());
1191 }
1192 }
1193
1194 #[tokio::test]
1198 async fn wheel_in_content_rect_routes_to_host() {
1199 use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
1200 let rows: Vec<TallRow> = (0..10)
1201 .map(|i| TallRow {
1202 name: format!("r{}", i),
1203 height: 1,
1204 })
1205 .collect();
1206 let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
1207 list.poll_until_idle().await;
1208 let rect = |y: u16, h: u16| ratatui::layout::Rect {
1209 x: 0,
1210 y,
1211 width: 20,
1212 height: h,
1213 };
1214 list.set_panel_rect(rect(0, 10));
1216 list.set_list_rect(rect(0, 4));
1217 list.set_content_rect(rect(5, 5));
1218 let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
1219 kind,
1220 column: 2,
1221 row,
1222 modifiers: KeyModifiers::NONE,
1223 };
1224
1225 let m = wheel(MouseEventKind::ScrollDown, 6);
1227 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
1228 assert_eq!(list.offset, 0, "list viewport must not move");
1229 let m = wheel(MouseEventKind::ScrollUp, 6);
1230 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
1231
1232 let m = wheel(MouseEventKind::ScrollDown, 2);
1234 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1235
1236 list.set_content_rect(ratatui::layout::Rect::default());
1238 let m = wheel(MouseEventKind::ScrollDown, 6);
1239 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
1240 }
1241
1242 #[tokio::test]
1243 async fn mouse_maps_visual_row_to_display_index_by_height() {
1244 let src = TallSource(vec![
1247 TallRow {
1248 name: "a".into(),
1249 height: 3,
1250 },
1251 TallRow {
1252 name: "b".into(),
1253 height: 1,
1254 },
1255 ]);
1256 let mut list = SearchList::builder(src, noop_redraw()).build();
1257 list.poll_until_idle().await;
1258 list.set_list_rect(ratatui::layout::Rect {
1260 x: 0,
1261 y: 0,
1262 width: 20,
1263 height: 10,
1264 });
1265 let m = mouse_down_at(2, 3);
1267 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
1268 assert_eq!(list.selected_row().unwrap().name, "b");
1269 let m = mouse_down_at(2, 1);
1271 list.handle_mouse(&m);
1272 assert_eq!(list.selected_row().unwrap().name, "a");
1273 }
1274
1275 #[tokio::test]
1279 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
1280 let src = VecSource {
1281 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1282 reload: true,
1283 };
1284 let mut list = SearchList::builder(src, noop_redraw()).build();
1285 list.poll_until_idle().await;
1286 list.set_list_rect(ratatui::layout::Rect {
1288 x: 0,
1289 y: 0,
1290 width: 20,
1291 height: 4,
1292 });
1293 list.select_next();
1295 list.select_next();
1296 assert_eq!(list.selected_row().unwrap().name, "row2");
1297
1298 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1299 kind,
1300 column: 1,
1301 row: 1,
1302 modifiers: KeyModifiers::NONE,
1303 };
1304 use ratatui::crossterm::event::MouseEventKind;
1305
1306 assert_eq!(
1308 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1309 SearchMouse::Scrolled
1310 );
1311 assert_eq!(list.scroll_offset(), 1);
1312 assert_eq!(list.selected_row().unwrap().name, "row3");
1313
1314 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1316 assert_eq!(list.scroll_offset(), 0);
1317 assert_eq!(list.selected_row().unwrap().name, "row2");
1318
1319 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1321 assert_eq!(list.scroll_offset(), 0);
1322 assert_eq!(list.selected_row().unwrap().name, "row2");
1323
1324 for _ in 0..20 {
1327 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1328 }
1329 assert_eq!(list.scroll_offset(), 6);
1330 assert_eq!(list.selected_row().unwrap().name, "row8");
1331 }
1334
1335 #[tokio::test]
1339 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1340 let src = VecSource {
1341 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1342 reload: true,
1343 };
1344 let mut list = SearchList::builder(src, noop_redraw()).build();
1345 list.poll_until_idle().await;
1346 list.set_list_rect(ratatui::layout::Rect {
1348 x: 0,
1349 y: 5,
1350 width: 20,
1351 height: 4,
1352 });
1353 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1354 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1355 column: 1,
1356 row,
1357 modifiers: KeyModifiers::NONE,
1358 };
1359 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1361 assert_eq!(list.scroll_offset(), 0);
1362 list.set_panel_rect(ratatui::layout::Rect {
1363 x: 0,
1364 y: 0,
1365 width: 20,
1366 height: 20,
1367 });
1368 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1370 assert_eq!(list.scroll_offset(), 1);
1371 let before = list.selected_row().unwrap().name.clone();
1374 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1375 assert_eq!(list.selected_row().unwrap().name, before);
1376 }
1377
1378 #[tokio::test]
1382 async fn click_after_scroll_selects_the_clicked_row() {
1383 let src = VecSource {
1384 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1385 reload: true,
1386 };
1387 let mut list = SearchList::builder(src, noop_redraw()).build();
1388 list.poll_until_idle().await;
1389 list.set_list_rect(ratatui::layout::Rect {
1390 x: 0,
1391 y: 0,
1392 width: 20,
1393 height: 4,
1394 });
1395 let scroll_down = ratatui::crossterm::event::MouseEvent {
1396 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1397 column: 1,
1398 row: 1,
1399 modifiers: KeyModifiers::NONE,
1400 };
1401 for _ in 0..3 {
1402 list.handle_mouse(&scroll_down);
1403 }
1404 assert_eq!(list.scroll_offset(), 3);
1405 assert!(matches!(
1407 list.handle_mouse(&mouse_down_at(2, 2)),
1408 SearchMouse::Selected(5)
1409 ));
1410 assert_eq!(list.selected_row().unwrap().name, "row5");
1411 list.handle_mouse(&mouse_down_at(2, 0));
1413 assert_eq!(list.selected_row().unwrap().name, "row3");
1414 }
1415
1416 #[tokio::test]
1421 async fn build_with_rows_applies_synchronously_without_a_poll() {
1422 let list = SearchList::builder(StaticRowSource, noop_redraw())
1423 .filter(Filter::Fuzzy)
1424 .build_with_rows(vec![TestRow::new("alpha"), TestRow::new("beta")]);
1425 assert!(!list.is_loading(), "static build is not loading");
1427 assert_eq!(list.rows().len(), 2);
1428 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1429 }
1430
1431 #[tokio::test]
1432 async fn initial_load_populates_rows() {
1433 let src = VecSource {
1434 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1435 reload: true,
1436 };
1437 let mut list = SearchList::builder(src, noop_redraw()).build();
1438 list.poll_until_idle().await;
1439 assert_eq!(list.rows().len(), 2);
1440 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1441 }
1442
1443 #[tokio::test]
1444 async fn requery_supersedes_and_reloads() {
1445 let src = VecSource {
1446 rows: vec![
1447 TestRow::new("alpha"),
1448 TestRow::new("alps"),
1449 TestRow::new("beta"),
1450 ],
1451 reload: true,
1452 };
1453 let mut list = SearchList::builder(src, noop_redraw()).build();
1454 list.poll_until_idle().await;
1455 assert_eq!(list.rows().len(), 3);
1456 list.set_query("alp");
1457 list.poll_until_idle().await;
1458 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1460 }
1461
1462 #[tokio::test]
1463 async fn arrows_navigate_and_enter_submits() {
1464 let src = VecSource {
1465 rows: vec![TestRow::new("a"), TestRow::new("b")],
1466 reload: true,
1467 };
1468 let mut list = SearchList::builder(src, noop_redraw()).build();
1469 list.poll_until_idle().await;
1470 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1471 assert_eq!(list.selected_row().unwrap().name, "b");
1472 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1473 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1474 }
1475
1476 #[tokio::test]
1477 async fn typing_a_char_changes_query() {
1478 let src = VecSource {
1479 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1480 reload: true,
1481 };
1482 let mut list = SearchList::builder(src, noop_redraw()).build();
1483 list.poll_until_idle().await;
1484 assert_eq!(
1485 list.handle_key(&key(KeyCode::Char('a'))),
1486 KeyReaction::Consumed
1487 );
1488 list.poll_until_idle().await;
1489 assert_eq!(list.query(), "a");
1490 }
1491
1492 #[tokio::test]
1493 async fn rank_filter_orders_by_closure() {
1494 let src = VecSource {
1495 rows: vec![
1496 TestRow::new("todo"),
1497 TestRow::new("today"),
1498 TestRow::new("misc"),
1499 ],
1500 reload: false,
1501 };
1502 let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1503 let mut idx: Vec<usize> = (0..rows.len())
1504 .filter(|&i| rows[i].name.contains(q))
1505 .collect();
1506 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1507 idx
1508 });
1509 let mut list = SearchList::builder(src, noop_redraw())
1510 .filter(Filter::Rank(rank))
1511 .build();
1512 list.poll_until_idle().await;
1513 list.set_query("today");
1514 list.poll();
1515 assert_eq!(list.selected_row().unwrap().name, "today");
1516 }
1517
1518 #[tokio::test]
1519 async fn fuzzy_filter_narrows_local_set() {
1520 let src = VecSource {
1521 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1522 reload: false,
1523 };
1524 let mut list = SearchList::builder(src, noop_redraw())
1525 .filter(Filter::Fuzzy)
1526 .build();
1527 list.poll_until_idle().await;
1528 list.set_query("alp");
1529 list.poll();
1530 assert_eq!(list.visible_rows().len(), 1);
1531 assert_eq!(list.selected_row().unwrap().name, "alpha");
1532 }
1533
1534 #[tokio::test]
1535 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1536 let src = ScriptedStreamSource {
1537 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1538 };
1539 let mut list = SearchList::builder(src, noop_redraw())
1540 .filter(Filter::Fuzzy)
1541 .build();
1542 list.poll_until_idle().await;
1543 assert_eq!(list.rows().len(), 2);
1544 assert!(!list.is_loading());
1545 list.set_query("alp");
1546 list.poll();
1547 assert_eq!(list.visible_rows().len(), 1);
1548 }
1549
1550 #[tokio::test]
1551 async fn source_order_unfiltered_passthrough() {
1552 let src = VecSource {
1553 rows: vec![TestRow::new("a"), TestRow::new("b")],
1554 reload: true,
1555 };
1556 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1558 assert_eq!(list.visible_rows().len(), 2);
1559 assert_eq!(list.selected_row().unwrap().name, "a");
1560 }
1561
1562 #[tokio::test]
1563 async fn intercepted_combo_returns_intercepted_without_acting() {
1564 let src = VecSource {
1565 rows: vec![TestRow::new("a")],
1566 reload: true,
1567 };
1568 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1569 let mut list = SearchList::builder(src, noop_redraw())
1570 .intercept(vec![combo])
1571 .build();
1572 list.poll_until_idle().await;
1573 assert_eq!(
1575 list.handle_key(&key(KeyCode::Enter)),
1576 KeyReaction::Intercepted(combo)
1577 );
1578 }
1579
1580 #[tokio::test]
1581 async fn autocomplete_accept_rewrites_query_without_vault() {
1582 struct Mem;
1583 #[async_trait::async_trait]
1584 impl crate::components::search_list::SuggestionSource for Mem {
1585 async fn notes_by_prefix(
1586 &self,
1587 _p: &str,
1588 _n: usize,
1589 ) -> Vec<crate::components::search_list::SuggestionItem> {
1590 vec![]
1591 }
1592 async fn tags_by_prefix(
1593 &self,
1594 p: &str,
1595 _n: usize,
1596 ) -> Vec<crate::components::search_list::SuggestionItem> {
1597 if "projects".starts_with(p) {
1598 vec![crate::components::search_list::SuggestionItem::plain(
1599 "projects",
1600 )]
1601 } else {
1602 vec![]
1603 }
1604 }
1605 }
1606 let src = VecSource {
1607 rows: vec![],
1608 reload: true,
1609 };
1610 let mut list = SearchList::builder(src, noop_redraw())
1611 .autocomplete(
1612 std::sync::Arc::new(Mem),
1613 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1614 )
1615 .debounce(std::time::Duration::ZERO)
1616 .build();
1617 for c in ['#', 'p', 'r', 'o'] {
1618 let _ = list.handle_key(&key(KeyCode::Char(c)));
1619 }
1620 for _ in 0..50 {
1621 tokio::task::yield_now().await;
1622 list.poll();
1623 }
1624 let _ = list.handle_key(&key(KeyCode::Tab));
1625 assert_eq!(list.query(), "#projects");
1626 }
1627
1628 #[tokio::test]
1632 async fn accepting_saved_search_expands_query_and_exposes_name() {
1633 struct Mem;
1634 #[async_trait::async_trait]
1635 impl crate::components::search_list::SuggestionSource for Mem {
1636 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1637 vec![]
1638 }
1639 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1640 vec![]
1641 }
1642 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1643 if "todo-week".starts_with(p) {
1644 vec![SuggestionItem {
1645 display: "todo-week".into(),
1646 secondary: Some("#todo ^modified".into()),
1647 }]
1648 } else {
1649 vec![]
1650 }
1651 }
1652 }
1653 let src = VecSource {
1654 rows: vec![],
1655 reload: true,
1656 };
1657 let mut list = SearchList::builder(src, noop_redraw())
1658 .autocomplete(
1659 std::sync::Arc::new(Mem),
1660 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1661 )
1662 .debounce(std::time::Duration::ZERO)
1663 .build();
1664 for c in ['?', 't', 'o'] {
1665 let _ = list.handle_key(&key(KeyCode::Char(c)));
1666 }
1667 for _ in 0..50 {
1668 tokio::task::yield_now().await;
1669 list.poll();
1670 }
1671 let _ = list.handle_key(&key(KeyCode::Tab));
1672 assert_eq!(list.query(), "#todo ^modified");
1674 assert_eq!(
1676 list.take_accepted_saved_search().as_deref(),
1677 Some("todo-week")
1678 );
1679 assert_eq!(list.take_accepted_saved_search(), None);
1680 }
1681
1682 #[tokio::test]
1687 async fn enter_accepts_open_popup_and_reports_consumed() {
1688 struct Mem;
1689 #[async_trait::async_trait]
1690 impl crate::components::search_list::SuggestionSource for Mem {
1691 async fn notes_by_prefix(
1692 &self,
1693 _p: &str,
1694 _n: usize,
1695 ) -> Vec<crate::components::search_list::SuggestionItem> {
1696 vec![]
1697 }
1698 async fn tags_by_prefix(
1699 &self,
1700 p: &str,
1701 _n: usize,
1702 ) -> Vec<crate::components::search_list::SuggestionItem> {
1703 if "projects".starts_with(p) {
1704 vec![crate::components::search_list::SuggestionItem::plain(
1705 "projects",
1706 )]
1707 } else {
1708 vec![]
1709 }
1710 }
1711 }
1712 let src = VecSource {
1713 rows: vec![],
1714 reload: true,
1715 };
1716 let mut list = SearchList::builder(src, noop_redraw())
1717 .autocomplete(
1718 std::sync::Arc::new(Mem),
1719 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1720 )
1721 .debounce(std::time::Duration::ZERO)
1722 .build();
1723 for c in ['#', 'p', 'r', 'o'] {
1724 let _ = list.handle_key(&key(KeyCode::Char(c)));
1725 }
1726 for _ in 0..50 {
1727 tokio::task::yield_now().await;
1728 list.poll();
1729 }
1730 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1732 assert_eq!(list.query(), "#projects");
1733 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1735 }
1736
1737 #[tokio::test]
1742 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1743 let src = ScriptedStreamLeadSource {
1744 items: vec!["alpha".into(), "beta".into()],
1745 };
1746 let mut list = SearchList::builder(src, noop_redraw())
1747 .filter(Filter::Fuzzy)
1748 .initial_query("zz")
1749 .build();
1750 list.poll_until_idle().await;
1751 let vis = list.visible_rows();
1753 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1754 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1757 list.poll();
1758 let vis = list.visible_rows();
1759 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1760 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1761 assert_eq!(list.visible_len(), 2);
1762 list.set_query("");
1764 list.poll();
1765 assert!(
1766 list.visible_rows()
1767 .iter()
1768 .all(|r| matches!(r, StreamRow::Item(_)))
1769 );
1770 assert_eq!(list.visible_len(), 2);
1771 }
1772
1773 #[tokio::test]
1776 async fn oneshot_source_leading_row_still_works() {
1777 let src = VecSourceWithLead {
1778 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1779 };
1780 let mut list = SearchList::builder(src, noop_redraw())
1781 .filter(Filter::Fuzzy)
1782 .initial_query("alp")
1783 .build();
1784 list.poll_until_idle().await;
1785 let vis = list.visible_rows();
1786 assert_eq!(vis[0].name, "create:alp");
1787 assert_eq!(vis[1].name, "alpha");
1788 assert_eq!(list.visible_len(), 2);
1789 }
1790
1791 #[tokio::test]
1794 async fn selection_includes_leading_at_position_zero() {
1795 let src = VecSourceWithLead {
1796 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1797 };
1798 let mut list = SearchList::builder(src, noop_redraw())
1799 .filter(Filter::Fuzzy)
1800 .initial_query("alp")
1801 .build();
1802 list.poll_until_idle().await;
1803 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1805 list.handle_key(&key(KeyCode::Down));
1806 assert_eq!(list.selected_row().unwrap().name, "alpha");
1807 }
1808
1809 #[tokio::test]
1811 async fn no_leading_row_visible_len_matches_display() {
1812 let src = VecSource {
1813 rows: vec![TestRow::new("a"), TestRow::new("b")],
1814 reload: true,
1815 };
1816 let mut list = SearchList::builder(src, noop_redraw()).build();
1817 list.poll_until_idle().await;
1818 assert_eq!(list.visible_len(), 2);
1819 assert_eq!(list.visible_rows().len(), 2);
1820 assert_eq!(list.selected_row().unwrap().name, "a");
1821 }
1822
1823 #[tokio::test]
1826 async fn update_rows_refilters_visible_view() {
1827 let source = VecSource {
1828 rows: vec![
1829 TestRow::new("alpha"),
1830 TestRow::new("beta"),
1831 TestRow::new("gamma"),
1832 ],
1833 reload: false,
1834 };
1835 let mut list = SearchList::builder(source, noop_redraw())
1836 .filter(Filter::Fuzzy)
1837 .build();
1838 list.poll_until_idle().await;
1839
1840 list.set_query("alp");
1842 list.poll();
1843 assert_eq!(
1844 list.visible_rows()
1845 .iter()
1846 .map(|r| r.name.as_str())
1847 .collect::<Vec<_>>(),
1848 vec!["alpha"],
1849 "before update: only 'alpha' matches 'alp'"
1850 );
1851
1852 let changed = list.update_rows(|r| {
1854 if r.name == "alpha" {
1855 r.name = "renamed".to_string();
1856 true
1857 } else {
1858 false
1859 }
1860 });
1861 assert!(changed);
1862
1863 assert_eq!(
1865 list.visible_rows().len(),
1866 0,
1867 "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
1868 );
1869 }
1870
1871 #[tokio::test]
1872 async fn update_rows_mutates_in_place_and_recomputes() {
1873 let source = VecSource {
1874 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1875 reload: false,
1876 };
1877 let mut list = SearchList::builder(source, noop_redraw()).build();
1878 list.poll_until_idle().await;
1879
1880 let changed = list.update_rows(|r| {
1882 if r.name == "alpha" {
1883 r.name = "renamed".to_string();
1884 true
1885 } else {
1886 false
1887 }
1888 });
1889 assert!(changed, "a row was changed");
1890 assert!(
1891 list.rows().iter().any(|r| r.name == "renamed"),
1892 "the mutation is visible in rows()"
1893 );
1894
1895 let changed_again = list.update_rows(|_| false);
1897 assert!(!changed_again, "no row changed");
1898 }
1899
1900 #[tokio::test]
1907 async fn reload_source_leading_row_updates_synchronously_on_set_query() {
1908 let src = ReloadWithLeadSource {
1909 rows: vec![
1910 TestRow::new("alpha"),
1911 TestRow::new("beta"),
1912 TestRow::new("gamma"),
1913 ],
1914 };
1915 let mut list = SearchList::builder(src, noop_redraw()).build();
1916 list.poll_until_idle().await;
1917 assert!(list.leading.is_none(), "no leading row for empty query");
1919
1920 list.set_query("alp");
1922
1923 let vis = list.visible_rows();
1925 assert!(
1926 !vis.is_empty(),
1927 "visible_rows must not be empty right after set_query"
1928 );
1929 assert_eq!(
1930 vis[0].name, "create:alp",
1931 "leading row must show new query synchronously, before any poll/drain"
1932 );
1933
1934 list.poll_until_idle().await;
1937 let vis = list.visible_rows();
1938 assert_eq!(
1939 vis[0].name, "create:alp",
1940 "leading row correct after drain too"
1941 );
1942 assert_eq!(vis.len(), 2, "leading + alpha");
1944 assert_eq!(vis[1].name, "alpha");
1945 }
1946
1947 #[tokio::test]
1955 async fn local_filter_reseed_after_empty_then_repopulate() {
1956 let src = VecSource {
1957 rows: vec![
1958 TestRow::new("alpha"),
1959 TestRow::new("beta"),
1960 TestRow::new("gamma"),
1961 ],
1962 reload: false,
1963 };
1964 let mut list = SearchList::builder(src, noop_redraw())
1965 .filter(Filter::Fuzzy)
1966 .build();
1967 list.poll_until_idle().await;
1968
1969 assert!(
1971 list.selected_row().is_some(),
1972 "should have a selection after initial load"
1973 );
1974
1975 list.set_query("zzznomatch");
1977 assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
1978 assert!(
1979 list.selected_row().is_none(),
1980 "selection must be None when list is empty"
1981 );
1982
1983 list.set_query("alp");
1985 assert!(
1986 list.visible_len() > 0,
1987 "at least 'alpha' should match 'alp'"
1988 );
1989 assert!(
1992 list.selected_row().is_some(),
1993 "selection must be reseeded to first visible row after repopulation"
1994 );
1995 assert_eq!(
1996 list.selected_row().unwrap().name,
1997 "alpha",
1998 "first visible row must be selected after reseeding"
1999 );
2000 }
2001
2002 async fn focus_list(verbs: &[char]) -> SearchList<TestRow> {
2005 let src = VecSource {
2006 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2007 reload: false,
2008 };
2009 let mut b = SearchList::builder(src, noop_redraw()).filter(Filter::Fuzzy);
2010 for &c in verbs {
2011 b = b.list_verb(c);
2012 }
2013 let mut list = b.build();
2014 list.poll_until_idle().await;
2015 list
2016 }
2017
2018 #[tokio::test]
2021 async fn esc_enters_list_focus_then_cancels() {
2022 let mut list = focus_list(&['l']).await;
2023 assert_eq!(list.focus(), Focus::Input);
2024 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Consumed);
2025 assert_eq!(list.focus(), Focus::List);
2026 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2027 assert_eq!(list.focus(), Focus::List, "Cancel does not change focus");
2028 }
2029
2030 #[tokio::test]
2032 async fn esc_cancels_immediately_when_focus_disabled() {
2033 let mut list = focus_list(&[]).await;
2034 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
2035 assert_eq!(list.focus(), Focus::Input);
2036 }
2037
2038 #[tokio::test]
2040 async fn i_and_slash_return_to_input_focus() {
2041 for ret in ['i', '/'] {
2042 let mut list = focus_list(&['l']).await;
2043 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.focus(), Focus::List);
2045 assert_eq!(
2046 list.handle_key(&key(KeyCode::Char(ret))),
2047 KeyReaction::Consumed
2048 );
2049 assert_eq!(list.focus(), Focus::Input);
2050 assert_eq!(list.query(), "", "switching focus must not type a char");
2051 }
2052 }
2053
2054 #[tokio::test]
2056 async fn list_focus_j_k_navigate() {
2057 let mut list = focus_list(&['l']).await;
2058 list.handle_key(&key(KeyCode::Esc)); assert_eq!(list.selected_row().unwrap().name, "alpha");
2060 assert_eq!(
2061 list.handle_key(&key(KeyCode::Char('j'))),
2062 KeyReaction::Consumed
2063 );
2064 assert_eq!(list.selected_row().unwrap().name, "beta");
2065 assert_eq!(
2066 list.handle_key(&key(KeyCode::Char('k'))),
2067 KeyReaction::Consumed
2068 );
2069 assert_eq!(list.selected_row().unwrap().name, "alpha");
2070 }
2071
2072 #[tokio::test]
2075 async fn registered_verb_fires_unregistered_letter_does_nothing() {
2076 let mut list = focus_list(&['l', 'o']).await;
2077 list.handle_key(&key(KeyCode::Esc)); assert_eq!(
2079 list.handle_key(&key(KeyCode::Char('l'))),
2080 KeyReaction::ListVerb('l')
2081 );
2082 assert_eq!(
2083 list.handle_key(&key(KeyCode::Char('o'))),
2084 KeyReaction::ListVerb('o')
2085 );
2086 assert_eq!(
2088 list.handle_key(&key(KeyCode::Char('z'))),
2089 KeyReaction::Consumed
2090 );
2091 assert_eq!(list.query(), "");
2092 }
2093
2094 #[tokio::test]
2097 async fn verbs_are_inert_in_input_focus() {
2098 let mut list = focus_list(&['l', 'o']).await;
2099 assert_eq!(list.focus(), Focus::Input);
2100 assert_eq!(
2101 list.handle_key(&key(KeyCode::Char('l'))),
2102 KeyReaction::Consumed
2103 );
2104 list.poll_until_idle().await;
2105 assert_eq!(list.query(), "l", "verb letters still type in Input focus");
2106 }
2107
2108 #[tokio::test]
2111 async fn opening_focus_list_starts_in_list() {
2112 let src = VecSource {
2113 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
2114 reload: false,
2115 };
2116 let mut list = SearchList::builder(src, noop_redraw())
2117 .filter(Filter::Fuzzy)
2118 .opening_focus(Focus::List)
2119 .build();
2120 list.poll_until_idle().await;
2121 assert_eq!(list.focus(), Focus::List);
2122 assert_eq!(
2123 list.handle_key(&key(KeyCode::Char('a'))),
2124 KeyReaction::Consumed
2125 );
2126 assert_eq!(list.query(), "");
2127 list.handle_key(&key(KeyCode::Char('i')));
2129 assert_eq!(list.focus(), Focus::Input);
2130 list.handle_key(&key(KeyCode::Char('a')));
2131 list.poll_until_idle().await;
2132 assert_eq!(list.query(), "a");
2133 }
2134
2135 #[tokio::test]
2137 async fn intercept_fires_in_both_foci() {
2138 let src = VecSource {
2139 rows: vec![TestRow::new("a")],
2140 reload: false,
2141 };
2142 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
2143 let mut list = SearchList::builder(src, noop_redraw())
2144 .intercept(vec![combo])
2145 .list_verb('l')
2146 .build();
2147 list.poll_until_idle().await;
2148 assert_eq!(
2150 list.handle_key(&key(KeyCode::Enter)),
2151 KeyReaction::Intercepted(combo)
2152 );
2153 list.handle_key(&key(KeyCode::Esc));
2155 assert_eq!(list.focus(), Focus::List);
2156 assert_eq!(
2157 list.handle_key(&key(KeyCode::Enter)),
2158 KeyReaction::Intercepted(combo)
2159 );
2160 }
2161}