1#[cfg(test)]
5mod adapters;
6mod host;
7mod load;
8mod seams;
9
10pub use seams::{
11 Emit, Filter, Loaded, RowSource, SearchRow, SuggestionItem, SuggestionSource, VaultSuggestions,
12};
13
14use crate::components::autocomplete::{
15 AutocompleteController, AutocompleteMode, HandleKeyOutcome, TriggerOptions,
16};
17use crate::components::single_line_input::{InputOutcome, SingleLineInput};
18use crate::keys::key_combo::KeyCombo;
19use crate::settings::icons::Icons;
20use crate::settings::themes::Theme;
21use load::LoadEngine;
22use ratatui::crossterm::event::KeyEvent;
23use ratatui::{
24 Frame,
25 layout::Rect,
26 style::Style,
27 widgets::{List, ListItem, ListState},
28};
29use seams::Loaded as LoadedInner;
30use std::sync::Arc;
31
32fn fuzzy_indices<R: SearchRow>(rows: &[R], query: &str) -> Vec<usize> {
33 use nucleo::pattern::{CaseMatching, Normalization, Pattern};
34 use nucleo::{Matcher, Utf32Str};
35 let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
36 let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
37 let mut scored: Vec<(usize, u32)> = rows
38 .iter()
39 .enumerate()
40 .filter_map(|(i, r)| {
41 let hay = r.match_text()?;
42 let mut buf = Vec::new();
43 let h = Utf32Str::new(hay, &mut buf);
44 pat.score(h, &mut matcher).map(|s| (i, s))
45 })
46 .collect();
47 scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
48 scored.into_iter().map(|(i, _)| i).collect()
49}
50
51#[derive(Debug, PartialEq, Eq)]
53pub enum KeyReaction {
54 Consumed,
55 Submit,
56 Cancel,
57 Intercepted(crate::keys::key_combo::KeyCombo),
58 Unhandled,
59}
60
61pub struct SearchList<R: SearchRow> {
62 source: Arc<dyn RowSource<R>>,
63 rows: Vec<R>,
64 display: Vec<usize>,
66 leading: Option<R>,
72 selected: Option<usize>,
75 offset: usize,
80 filter: Filter<R>,
81 query: String,
82 loader: LoadEngine<R>,
83 input: SingleLineInput,
84 autocomplete: Option<AutocompleteController>,
85 intercept: Vec<KeyCombo>,
87 icons: Icons,
88 list_rect: Rect,
89 panel_rect: Rect,
95 applied_generation: u64,
100 accepted_saved_search: Option<String>,
104}
105
106#[derive(Debug, PartialEq, Eq)]
108pub enum SearchMouse {
109 Selected(usize),
110 Activated(usize),
111 Scrolled,
112 None,
113}
114
115pub struct SearchListBuilder<R: SearchRow> {
116 source: Arc<dyn RowSource<R>>,
117 redraw: Arc<dyn Fn() + Send + Sync>,
118 initial_query: String,
119 filter: Filter<R>,
120 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
121 intercept: Vec<KeyCombo>,
122 icons: Icons,
123 debounce: Option<std::time::Duration>,
124}
125
126impl<R: SearchRow> SearchList<R> {
127 pub fn builder(
128 source: impl RowSource<R>,
129 redraw: Arc<dyn Fn() + Send + Sync>,
130 ) -> SearchListBuilder<R> {
131 SearchListBuilder {
132 source: Arc::new(source),
133 redraw,
134 initial_query: String::new(),
135 filter: Filter::SourceOrder,
136 autocomplete: None,
137 intercept: Vec::new(),
138 icons: Icons::new(false),
139 debounce: None,
140 }
141 }
142
143 fn new(b: SearchListBuilder<R>) -> Self {
144 let mut loader = LoadEngine::new(b.redraw.clone());
145 loader.start(b.source.clone(), b.initial_query.clone());
146 let input = SingleLineInput::with_value(&b.initial_query);
147 let debounce = b.debounce;
148 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
149 let mut ac =
150 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
151 disambiguate_header: false,
152 apply_exclusion_zone: false,
153 ..TriggerOptions::default()
156 });
157 if let Some(d) = debounce {
158 ac = ac.with_debounce(d);
159 }
160 ac.set_redraw_callback(b.redraw.clone());
161 ac
162 });
163 Self {
164 source: b.source,
165 rows: Vec::new(),
166 display: Vec::new(),
167 leading: None,
168 selected: None,
169 offset: 0,
170 filter: b.filter,
171 query: b.initial_query,
172 loader,
173 input,
174 autocomplete,
175 intercept: b.intercept,
176 icons: b.icons,
177 list_rect: Rect::default(),
178 panel_rect: Rect::default(),
179 applied_generation: 0,
180 accepted_saved_search: None,
181 }
182 }
183
184 pub fn poll(&mut self) {
185 let drained = self.loader.drain();
186 if !drained.is_empty() {
187 let current_gen = self.loader.generation();
191 if current_gen != self.applied_generation {
192 self.rows.clear();
193 self.selected = None;
194 self.offset = 0;
195 self.applied_generation = current_gen;
196 }
197 }
198 for ev in drained {
199 match ev {
200 LoadedInner::Replace(rows) => {
201 self.rows = rows;
202 }
203 LoadedInner::Push(row) => {
204 self.rows.push(row);
205 }
206 LoadedInner::Done => {}
207 }
208 }
209 self.recompute_display();
210 if self.selected.is_none() && self.visible_len() > 0 {
211 self.selected = Some(0);
212 }
213 if let Some(ac) = &mut self.autocomplete {
214 ac.poll_results();
215 }
216 }
217
218 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
222 let value = self.input.value().to_string();
223 let cursor_byte = self.input.cursor_byte();
224 let col = value[..cursor_byte.min(value.len())].chars().count();
225 host::SearchBoxHostSnapshot {
226 lines: vec![value],
227 cursor: (0, col),
228 caret_pos: self.input.last_caret_pos(),
229 }
230 }
231
232 fn clamp_selection(&mut self) {
233 let len = self.visible_len();
234 self.selected = if len == 0 {
235 None
236 } else {
237 Some(self.selected.unwrap_or(0).min(len - 1))
238 };
239 }
240
241 fn leading_offset(&self) -> usize {
243 self.leading.is_some() as usize
244 }
245
246 pub fn visible_len(&self) -> usize {
248 self.leading_offset() + self.display.len()
249 }
250
251 fn visible_row(&self, pos: usize) -> Option<&R> {
253 if self.leading.is_some() && pos == 0 {
254 self.leading.as_ref()
255 } else {
256 self.rows
257 .get(*self.display.get(pos - self.leading_offset())?)
258 }
259 }
260
261 pub fn rows(&self) -> &[R] {
265 &self.rows
266 }
267
268 pub fn selected_row(&self) -> Option<&R> {
269 self.selected.and_then(|p| self.visible_row(p))
270 }
271
272 pub fn visible_rows(&self) -> Vec<&R> {
273 (0..self.visible_len())
274 .filter_map(|p| self.visible_row(p))
275 .collect()
276 }
277
278 pub fn query(&self) -> &str {
279 &self.query
280 }
281
282 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
286 self.accepted_saved_search.take()
287 }
288
289 #[cfg(test)]
292 pub(crate) fn input_value(&self) -> &str {
293 self.input.value()
294 }
295 pub fn is_loading(&self) -> bool {
296 self.loader.loading
297 }
298
299 pub fn set_query(&mut self, q: impl Into<String>) {
308 let q = q.into();
309 self.input.set_value(q.clone());
310 self.query = q;
311 self.requery();
312 }
313
314 fn sync_query_from_input(&mut self) {
319 self.query = self.input.value().to_string();
320 self.requery();
321 }
322
323 fn requery(&mut self) {
326 if self.source.reload_on_query() {
327 self.loader.start(self.source.clone(), self.query.clone());
328 } else {
329 self.recompute_display();
330 }
331 }
332
333 pub fn reload(&mut self) {
335 self.loader.start(self.source.clone(), self.query.clone());
336 }
337
338 pub fn select_next(&mut self) {
339 let n = self.visible_len();
340 if n == 0 {
341 return;
342 }
343 self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
344 }
345
346 pub fn select_prev(&mut self) {
347 if self.visible_len() == 0 {
348 return;
349 }
350 self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
351 }
352
353 fn max_scroll_offset(&self) -> usize {
358 let viewport = self.list_rect.height as usize;
359 let n = self.visible_len();
360 if viewport == 0 || n == 0 {
361 return 0;
362 }
363 let mut budget = viewport;
364 let mut first = n;
365 while first > 0 {
366 let h = self
367 .visible_row(first - 1)
368 .map(|r| r.visual_height() as usize)
369 .unwrap_or(1);
370 if h > budget {
371 break;
372 }
373 budget -= h;
374 first -= 1;
375 }
376 first.min(n - 1)
377 }
378
379 pub fn scroll_down(&mut self) {
383 let n = self.visible_len();
384 if n == 0 || self.offset >= self.max_scroll_offset() {
385 return;
386 }
387 self.offset += 1;
388 self.selected = self.selected.map(|i| (i + 1).min(n - 1));
389 }
390
391 pub fn scroll_up(&mut self) {
394 if self.offset == 0 {
395 return;
396 }
397 self.offset -= 1;
398 self.selected = self.selected.map(|i| i.saturating_sub(1));
399 }
400
401 #[cfg(test)]
404 pub(crate) fn scroll_offset(&self) -> usize {
405 self.offset
406 }
407
408 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
409 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
410
411 if let Some(combo) = crate::keys::key_event_to_combo(key)
414 && self.intercept.contains(&combo)
415 {
416 return KeyReaction::Intercepted(combo);
417 }
418
419 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
423 let snap = self.autocomplete_snapshot();
424 if let Some(ac) = &mut self.autocomplete {
425 match ac.handle_key(*key, &snap) {
426 HandleKeyOutcome::Accepted(action) => {
427 self.input.replace_range_bytes(
428 action.range.clone(),
429 &action.new_text,
430 action.new_cursor_byte,
431 );
432 self.accepted_saved_search = action.saved_search_name;
437 self.sync_query_from_input();
438 return KeyReaction::Consumed;
439 }
440 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
441 return KeyReaction::Consumed;
442 }
443 HandleKeyOutcome::NotHandled => {}
444 }
445 }
446 }
447
448 match key.code {
449 KeyCode::Up => {
450 self.select_prev();
451 return KeyReaction::Consumed;
452 }
453 KeyCode::Down => {
454 self.select_next();
455 return KeyReaction::Consumed;
456 }
457 KeyCode::Enter => return KeyReaction::Submit,
458 KeyCode::Esc => return KeyReaction::Cancel,
459 _ => {}
460 }
461 if let KeyCode::Char(_) = key.code {
463 let non_shift = key.modifiers - KeyModifiers::SHIFT;
464 if !non_shift.is_empty() {
465 return KeyReaction::Unhandled;
466 }
467 }
468 let outcome = self.input.handle_key(key);
469 let snap = self.autocomplete_snapshot();
472 match outcome {
473 InputOutcome::Changed => {
474 if let Some(ac) = &mut self.autocomplete {
475 ac.sync(&snap);
476 }
477 }
478 InputOutcome::Consumed => {
479 if let Some(ac) = &mut self.autocomplete {
480 ac.refresh_if_open(&snap);
481 }
482 }
483 InputOutcome::Cancel | InputOutcome::Submit => {
484 if let Some(ac) = &mut self.autocomplete {
485 ac.close();
486 }
487 }
488 InputOutcome::NotConsumed => {}
489 }
490 match outcome {
491 InputOutcome::Changed => {
492 self.sync_query_from_input();
493 KeyReaction::Consumed
494 }
495 InputOutcome::Consumed => KeyReaction::Consumed,
496 InputOutcome::Submit => KeyReaction::Submit,
497 InputOutcome::Cancel => KeyReaction::Cancel,
498 InputOutcome::NotConsumed => KeyReaction::Unhandled,
499 }
500 }
501
502 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
503 self.input.render(
504 f,
505 area,
506 Style::default()
507 .fg(theme.fg.to_ratatui())
508 .bg(theme.bg_panel.to_ratatui()),
509 0,
510 focused,
511 );
512 }
513
514 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
515 self.poll();
516 let sel = self.selected;
517 let items: Vec<ListItem> = (0..self.visible_len())
518 .filter_map(|pos| {
519 self.visible_row(pos)
520 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
521 })
522 .collect();
523 let mut state = ListState::default().with_offset(self.offset);
524 state.select(self.selected);
525 let list =
526 List::new(items).highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
527 f.render_stateful_widget(list, area, &mut state);
528 self.offset = state.offset();
532 self.list_rect = area;
533 let _ = focused;
534 }
535
536 pub fn set_list_rect(&mut self, rect: Rect) {
545 self.list_rect = rect;
546 }
547
548 pub fn set_panel_rect(&mut self, rect: Rect) {
553 self.panel_rect = rect;
554 }
555
556 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
557 if let Some(ac) = &mut self.autocomplete {
558 ac.poll_results();
559 let caret = self.input.last_caret_pos();
560 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
561 state.anchor = anchor;
562 }
563 if let Some(state) = ac.state() {
564 crate::components::autocomplete::render(f, state, clamp, theme);
565 }
566 }
567 }
568
569 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
570 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
571 use ratatui::layout::Position;
572 if let Some(ac) = &mut self.autocomplete {
575 ac.close();
576 }
577 let pos = Position {
578 x: m.column,
579 y: m.row,
580 };
581 if matches!(
585 m.kind,
586 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
587 ) {
588 let bounds = if self.panel_rect.is_empty() {
589 self.list_rect
590 } else {
591 self.panel_rect
592 };
593 if !bounds.contains(pos) {
594 return SearchMouse::None;
595 }
596 if m.kind == MouseEventKind::ScrollUp {
597 self.scroll_up();
598 } else {
599 self.scroll_down();
600 }
601 return SearchMouse::Scrolled;
602 }
603 let r = self.list_rect;
604 if !r.contains(pos) {
605 return SearchMouse::None;
606 }
607 match m.kind {
608 MouseEventKind::Down(MouseButton::Left) if m.row >= r.y => {
609 let target_visual = m.row - r.y; let mut acc: u16 = 0;
611 let mut hit: Option<usize> = None;
612 for pos in self.offset..self.visible_len() {
617 let h = self
618 .visible_row(pos)
619 .map(|r| r.visual_height())
620 .unwrap_or(1);
621 if target_visual < acc + h {
622 hit = Some(pos);
623 break;
624 }
625 acc += h;
626 }
627 if let Some(pos) = hit {
628 let prev = self.selected;
629 self.selected = Some(pos);
630 return if prev == Some(pos) {
631 SearchMouse::Activated(pos)
632 } else {
633 SearchMouse::Selected(pos)
634 };
635 }
636 SearchMouse::None
637 }
638 _ => SearchMouse::None,
639 }
640 }
641
642 fn recompute_display(&mut self) {
643 let q = self.query.trim();
644 self.leading = self.source.leading_row(q);
647 let mut idx: Vec<usize> = match &self.filter {
648 Filter::SourceOrder => (0..self.rows.len()).collect(),
649 Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
650 Filter::Fuzzy => fuzzy_indices(&self.rows, q),
651 Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
652 Filter::Rank(f) => {
653 let f = f.clone();
654 f(&self.rows, q)
655 }
656 };
657 for i in 0..self.rows.len() {
660 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
661 idx.insert(0, i);
662 }
663 }
664 self.display = idx;
665 self.clamp_selection();
666 }
667
668 #[cfg(test)]
669 pub(crate) async fn poll_until_idle(&mut self) {
670 for _ in 0..600 {
676 tokio::task::yield_now().await;
677 self.poll();
678 if !self.is_loading() {
679 break;
680 }
681 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
682 }
683 self.poll();
684 }
685}
686
687impl<R: SearchRow> SearchListBuilder<R> {
688 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
689 self.initial_query = q.into();
690 self
691 }
692 pub fn filter(mut self, f: Filter<R>) -> Self {
693 self.filter = f;
694 self
695 }
696 pub fn autocomplete(
697 mut self,
698 suggestions: Arc<dyn SuggestionSource>,
699 mode: AutocompleteMode,
700 ) -> Self {
701 self.autocomplete = Some((suggestions, mode));
702 self
703 }
704 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
705 self.intercept = v;
706 self
707 }
708 pub fn icons(mut self, icons: Icons) -> Self {
709 self.icons = icons;
710 self
711 }
712 pub fn debounce(mut self, d: std::time::Duration) -> Self {
715 self.debounce = Some(d);
716 self
717 }
718 pub fn build(self) -> SearchList<R> {
719 SearchList::new(self)
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::adapters::{
726 ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow, VecSource,
727 VecSourceWithLead,
728 };
729 use super::*;
730 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
731
732 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
733 std::sync::Arc::new(|| {})
734 }
735
736 fn key(c: KeyCode) -> KeyEvent {
737 KeyEvent::new(c, KeyModifiers::NONE)
738 }
739
740 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
741 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
742 MouseEvent {
743 kind: MouseEventKind::Down(MouseButton::Left),
744 column: col,
745 row,
746 modifiers: KeyModifiers::NONE,
747 }
748 }
749
750 #[derive(Clone, Debug, PartialEq)]
751 struct TallRow {
752 name: String,
753 height: u16,
754 }
755 impl SearchRow for TallRow {
756 fn to_list_item(
757 &self,
758 _t: &crate::settings::themes::Theme,
759 _i: &crate::settings::icons::Icons,
760 _s: bool,
761 ) -> ratatui::widgets::ListItem<'static> {
762 ratatui::widgets::ListItem::new(self.name.clone())
763 }
764 fn visual_height(&self) -> u16 {
765 self.height
766 }
767 fn match_text(&self) -> Option<&str> {
768 Some(&self.name)
769 }
770 }
771 struct TallSource(Vec<TallRow>);
772 #[async_trait::async_trait]
773 impl RowSource<TallRow> for TallSource {
774 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
775 emit.replace(self.0.clone());
776 }
777 }
778
779 #[tokio::test]
780 async fn mouse_maps_visual_row_to_display_index_by_height() {
781 let src = TallSource(vec![
784 TallRow {
785 name: "a".into(),
786 height: 3,
787 },
788 TallRow {
789 name: "b".into(),
790 height: 1,
791 },
792 ]);
793 let mut list = SearchList::builder(src, noop_redraw()).build();
794 list.poll_until_idle().await;
795 list.set_list_rect(ratatui::layout::Rect {
797 x: 0,
798 y: 0,
799 width: 20,
800 height: 10,
801 });
802 let m = mouse_down_at(2, 3);
804 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
805 assert_eq!(list.selected_row().unwrap().name, "b");
806 let m = mouse_down_at(2, 1);
808 list.handle_mouse(&m);
809 assert_eq!(list.selected_row().unwrap().name, "a");
810 }
811
812 #[tokio::test]
816 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
817 let src = VecSource {
818 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
819 reload: true,
820 };
821 let mut list = SearchList::builder(src, noop_redraw()).build();
822 list.poll_until_idle().await;
823 list.set_list_rect(ratatui::layout::Rect {
825 x: 0,
826 y: 0,
827 width: 20,
828 height: 4,
829 });
830 list.select_next();
832 list.select_next();
833 assert_eq!(list.selected_row().unwrap().name, "row2");
834
835 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
836 kind,
837 column: 1,
838 row: 1,
839 modifiers: KeyModifiers::NONE,
840 };
841 use ratatui::crossterm::event::MouseEventKind;
842
843 assert_eq!(
845 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
846 SearchMouse::Scrolled
847 );
848 assert_eq!(list.scroll_offset(), 1);
849 assert_eq!(list.selected_row().unwrap().name, "row3");
850
851 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
853 assert_eq!(list.scroll_offset(), 0);
854 assert_eq!(list.selected_row().unwrap().name, "row2");
855
856 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
858 assert_eq!(list.scroll_offset(), 0);
859 assert_eq!(list.selected_row().unwrap().name, "row2");
860
861 for _ in 0..20 {
864 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
865 }
866 assert_eq!(list.scroll_offset(), 6);
867 assert_eq!(list.selected_row().unwrap().name, "row8");
868 }
871
872 #[tokio::test]
876 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
877 let src = VecSource {
878 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
879 reload: true,
880 };
881 let mut list = SearchList::builder(src, noop_redraw()).build();
882 list.poll_until_idle().await;
883 list.set_list_rect(ratatui::layout::Rect {
885 x: 0,
886 y: 5,
887 width: 20,
888 height: 4,
889 });
890 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
891 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
892 column: 1,
893 row,
894 modifiers: KeyModifiers::NONE,
895 };
896 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
898 assert_eq!(list.scroll_offset(), 0);
899 list.set_panel_rect(ratatui::layout::Rect {
900 x: 0,
901 y: 0,
902 width: 20,
903 height: 20,
904 });
905 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
907 assert_eq!(list.scroll_offset(), 1);
908 let before = list.selected_row().unwrap().name.clone();
911 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
912 assert_eq!(list.selected_row().unwrap().name, before);
913 }
914
915 #[tokio::test]
919 async fn click_after_scroll_selects_the_clicked_row() {
920 let src = VecSource {
921 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
922 reload: true,
923 };
924 let mut list = SearchList::builder(src, noop_redraw()).build();
925 list.poll_until_idle().await;
926 list.set_list_rect(ratatui::layout::Rect {
927 x: 0,
928 y: 0,
929 width: 20,
930 height: 4,
931 });
932 let scroll_down = ratatui::crossterm::event::MouseEvent {
933 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
934 column: 1,
935 row: 1,
936 modifiers: KeyModifiers::NONE,
937 };
938 for _ in 0..3 {
939 list.handle_mouse(&scroll_down);
940 }
941 assert_eq!(list.scroll_offset(), 3);
942 assert!(matches!(
944 list.handle_mouse(&mouse_down_at(2, 2)),
945 SearchMouse::Selected(5)
946 ));
947 assert_eq!(list.selected_row().unwrap().name, "row5");
948 list.handle_mouse(&mouse_down_at(2, 0));
950 assert_eq!(list.selected_row().unwrap().name, "row3");
951 }
952
953 #[tokio::test]
954 async fn initial_load_populates_rows() {
955 let src = VecSource {
956 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
957 reload: true,
958 };
959 let mut list = SearchList::builder(src, noop_redraw()).build();
960 list.poll_until_idle().await;
961 assert_eq!(list.rows().len(), 2);
962 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
963 }
964
965 #[tokio::test]
966 async fn requery_supersedes_and_reloads() {
967 let src = VecSource {
968 rows: vec![
969 TestRow::new("alpha"),
970 TestRow::new("alps"),
971 TestRow::new("beta"),
972 ],
973 reload: true,
974 };
975 let mut list = SearchList::builder(src, noop_redraw()).build();
976 list.poll_until_idle().await;
977 assert_eq!(list.rows().len(), 3);
978 list.set_query("alp");
979 list.poll_until_idle().await;
980 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
982 }
983
984 #[tokio::test]
985 async fn arrows_navigate_and_enter_submits() {
986 let src = VecSource {
987 rows: vec![TestRow::new("a"), TestRow::new("b")],
988 reload: true,
989 };
990 let mut list = SearchList::builder(src, noop_redraw()).build();
991 list.poll_until_idle().await;
992 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
993 assert_eq!(list.selected_row().unwrap().name, "b");
994 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
995 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
996 }
997
998 #[tokio::test]
999 async fn typing_a_char_changes_query() {
1000 let src = VecSource {
1001 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1002 reload: true,
1003 };
1004 let mut list = SearchList::builder(src, noop_redraw()).build();
1005 list.poll_until_idle().await;
1006 assert_eq!(
1007 list.handle_key(&key(KeyCode::Char('a'))),
1008 KeyReaction::Consumed
1009 );
1010 list.poll_until_idle().await;
1011 assert_eq!(list.query(), "a");
1012 }
1013
1014 #[tokio::test]
1015 async fn rank_filter_orders_by_closure() {
1016 let src = VecSource {
1017 rows: vec![
1018 TestRow::new("todo"),
1019 TestRow::new("today"),
1020 TestRow::new("misc"),
1021 ],
1022 reload: false,
1023 };
1024 let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1025 let mut idx: Vec<usize> = (0..rows.len())
1026 .filter(|&i| rows[i].name.contains(q))
1027 .collect();
1028 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1029 idx
1030 });
1031 let mut list = SearchList::builder(src, noop_redraw())
1032 .filter(Filter::Rank(rank))
1033 .build();
1034 list.poll_until_idle().await;
1035 list.set_query("today");
1036 list.poll();
1037 assert_eq!(list.selected_row().unwrap().name, "today");
1038 }
1039
1040 #[tokio::test]
1041 async fn fuzzy_filter_narrows_local_set() {
1042 let src = VecSource {
1043 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1044 reload: false,
1045 };
1046 let mut list = SearchList::builder(src, noop_redraw())
1047 .filter(Filter::Fuzzy)
1048 .build();
1049 list.poll_until_idle().await;
1050 list.set_query("alp");
1051 list.poll();
1052 assert_eq!(list.visible_rows().len(), 1);
1053 assert_eq!(list.selected_row().unwrap().name, "alpha");
1054 }
1055
1056 #[tokio::test]
1057 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1058 let src = ScriptedStreamSource {
1059 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1060 };
1061 let mut list = SearchList::builder(src, noop_redraw())
1062 .filter(Filter::Fuzzy)
1063 .build();
1064 list.poll_until_idle().await;
1065 assert_eq!(list.rows().len(), 2);
1066 assert!(!list.is_loading());
1067 list.set_query("alp");
1068 list.poll();
1069 assert_eq!(list.visible_rows().len(), 1);
1070 }
1071
1072 #[tokio::test]
1073 async fn source_order_unfiltered_passthrough() {
1074 let src = VecSource {
1075 rows: vec![TestRow::new("a"), TestRow::new("b")],
1076 reload: true,
1077 };
1078 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1080 assert_eq!(list.visible_rows().len(), 2);
1081 assert_eq!(list.selected_row().unwrap().name, "a");
1082 }
1083
1084 #[tokio::test]
1085 async fn intercepted_combo_returns_intercepted_without_acting() {
1086 let src = VecSource {
1087 rows: vec![TestRow::new("a")],
1088 reload: true,
1089 };
1090 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1091 let mut list = SearchList::builder(src, noop_redraw())
1092 .intercept(vec![combo])
1093 .build();
1094 list.poll_until_idle().await;
1095 assert_eq!(
1097 list.handle_key(&key(KeyCode::Enter)),
1098 KeyReaction::Intercepted(combo)
1099 );
1100 }
1101
1102 #[tokio::test]
1103 async fn autocomplete_accept_rewrites_query_without_vault() {
1104 struct Mem;
1105 #[async_trait::async_trait]
1106 impl crate::components::search_list::SuggestionSource for Mem {
1107 async fn notes_by_prefix(
1108 &self,
1109 _p: &str,
1110 _n: usize,
1111 ) -> Vec<crate::components::search_list::SuggestionItem> {
1112 vec![]
1113 }
1114 async fn tags_by_prefix(
1115 &self,
1116 p: &str,
1117 _n: usize,
1118 ) -> Vec<crate::components::search_list::SuggestionItem> {
1119 if "projects".starts_with(p) {
1120 vec![crate::components::search_list::SuggestionItem::plain(
1121 "projects",
1122 )]
1123 } else {
1124 vec![]
1125 }
1126 }
1127 }
1128 let src = VecSource {
1129 rows: vec![],
1130 reload: true,
1131 };
1132 let mut list = SearchList::builder(src, noop_redraw())
1133 .autocomplete(
1134 std::sync::Arc::new(Mem),
1135 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1136 )
1137 .debounce(std::time::Duration::ZERO)
1138 .build();
1139 for c in ['#', 'p', 'r', 'o'] {
1140 let _ = list.handle_key(&key(KeyCode::Char(c)));
1141 }
1142 for _ in 0..50 {
1143 tokio::task::yield_now().await;
1144 list.poll();
1145 }
1146 let _ = list.handle_key(&key(KeyCode::Tab));
1147 assert_eq!(list.query(), "#projects");
1148 }
1149
1150 #[tokio::test]
1154 async fn accepting_saved_search_expands_query_and_exposes_name() {
1155 struct Mem;
1156 #[async_trait::async_trait]
1157 impl crate::components::search_list::SuggestionSource for Mem {
1158 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1159 vec![]
1160 }
1161 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1162 vec![]
1163 }
1164 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1165 if "todo-week".starts_with(p) {
1166 vec![SuggestionItem {
1167 display: "todo-week".into(),
1168 secondary: Some("#todo ^modified".into()),
1169 }]
1170 } else {
1171 vec![]
1172 }
1173 }
1174 }
1175 let src = VecSource {
1176 rows: vec![],
1177 reload: true,
1178 };
1179 let mut list = SearchList::builder(src, noop_redraw())
1180 .autocomplete(
1181 std::sync::Arc::new(Mem),
1182 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1183 )
1184 .debounce(std::time::Duration::ZERO)
1185 .build();
1186 for c in ['?', 't', 'o'] {
1187 let _ = list.handle_key(&key(KeyCode::Char(c)));
1188 }
1189 for _ in 0..50 {
1190 tokio::task::yield_now().await;
1191 list.poll();
1192 }
1193 let _ = list.handle_key(&key(KeyCode::Tab));
1194 assert_eq!(list.query(), "#todo ^modified");
1196 assert_eq!(
1198 list.take_accepted_saved_search().as_deref(),
1199 Some("todo-week")
1200 );
1201 assert_eq!(list.take_accepted_saved_search(), None);
1202 }
1203
1204 #[tokio::test]
1209 async fn enter_accepts_open_popup_and_reports_consumed() {
1210 struct Mem;
1211 #[async_trait::async_trait]
1212 impl crate::components::search_list::SuggestionSource for Mem {
1213 async fn notes_by_prefix(
1214 &self,
1215 _p: &str,
1216 _n: usize,
1217 ) -> Vec<crate::components::search_list::SuggestionItem> {
1218 vec![]
1219 }
1220 async fn tags_by_prefix(
1221 &self,
1222 p: &str,
1223 _n: usize,
1224 ) -> Vec<crate::components::search_list::SuggestionItem> {
1225 if "projects".starts_with(p) {
1226 vec![crate::components::search_list::SuggestionItem::plain(
1227 "projects",
1228 )]
1229 } else {
1230 vec![]
1231 }
1232 }
1233 }
1234 let src = VecSource {
1235 rows: vec![],
1236 reload: true,
1237 };
1238 let mut list = SearchList::builder(src, noop_redraw())
1239 .autocomplete(
1240 std::sync::Arc::new(Mem),
1241 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1242 )
1243 .debounce(std::time::Duration::ZERO)
1244 .build();
1245 for c in ['#', 'p', 'r', 'o'] {
1246 let _ = list.handle_key(&key(KeyCode::Char(c)));
1247 }
1248 for _ in 0..50 {
1249 tokio::task::yield_now().await;
1250 list.poll();
1251 }
1252 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1254 assert_eq!(list.query(), "#projects");
1255 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1257 }
1258
1259 #[tokio::test]
1264 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1265 let src = ScriptedStreamLeadSource {
1266 items: vec!["alpha".into(), "beta".into()],
1267 };
1268 let mut list = SearchList::builder(src, noop_redraw())
1269 .filter(Filter::Fuzzy)
1270 .initial_query("zz")
1271 .build();
1272 list.poll_until_idle().await;
1273 let vis = list.visible_rows();
1275 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1276 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1279 list.poll();
1280 let vis = list.visible_rows();
1281 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1282 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1283 assert_eq!(list.visible_len(), 2);
1284 list.set_query("");
1286 list.poll();
1287 assert!(
1288 list.visible_rows()
1289 .iter()
1290 .all(|r| matches!(r, StreamRow::Item(_)))
1291 );
1292 assert_eq!(list.visible_len(), 2);
1293 }
1294
1295 #[tokio::test]
1298 async fn oneshot_source_leading_row_still_works() {
1299 let src = VecSourceWithLead {
1300 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1301 };
1302 let mut list = SearchList::builder(src, noop_redraw())
1303 .filter(Filter::Fuzzy)
1304 .initial_query("alp")
1305 .build();
1306 list.poll_until_idle().await;
1307 let vis = list.visible_rows();
1308 assert_eq!(vis[0].name, "create:alp");
1309 assert_eq!(vis[1].name, "alpha");
1310 assert_eq!(list.visible_len(), 2);
1311 }
1312
1313 #[tokio::test]
1316 async fn selection_includes_leading_at_position_zero() {
1317 let src = VecSourceWithLead {
1318 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1319 };
1320 let mut list = SearchList::builder(src, noop_redraw())
1321 .filter(Filter::Fuzzy)
1322 .initial_query("alp")
1323 .build();
1324 list.poll_until_idle().await;
1325 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1327 list.handle_key(&key(KeyCode::Down));
1328 assert_eq!(list.selected_row().unwrap().name, "alpha");
1329 }
1330
1331 #[tokio::test]
1333 async fn no_leading_row_visible_len_matches_display() {
1334 let src = VecSource {
1335 rows: vec![TestRow::new("a"), TestRow::new("b")],
1336 reload: true,
1337 };
1338 let mut list = SearchList::builder(src, noop_redraw()).build();
1339 list.poll_until_idle().await;
1340 assert_eq!(list.visible_len(), 2);
1341 assert_eq!(list.visible_rows().len(), 2);
1342 assert_eq!(list.selected_row().unwrap().name, "a");
1343 }
1344}