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 content_rect: Rect,
104 applied_generation: u64,
109 accepted_saved_search: Option<String>,
113}
114
115#[derive(Debug, PartialEq, Eq)]
117pub enum SearchMouse {
118 Selected(usize),
119 Activated(usize),
120 Scrolled,
121 ContentScrollUp,
125 ContentScrollDown,
126 None,
127}
128
129pub struct SearchListBuilder<R: SearchRow> {
130 source: Arc<dyn RowSource<R>>,
131 redraw: Arc<dyn Fn() + Send + Sync>,
132 initial_query: String,
133 filter: Filter<R>,
134 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
135 intercept: Vec<KeyCombo>,
136 icons: Icons,
137 debounce: Option<std::time::Duration>,
138}
139
140impl<R: SearchRow> SearchList<R> {
141 pub fn builder(
142 source: impl RowSource<R>,
143 redraw: Arc<dyn Fn() + Send + Sync>,
144 ) -> SearchListBuilder<R> {
145 SearchListBuilder {
146 source: Arc::new(source),
147 redraw,
148 initial_query: String::new(),
149 filter: Filter::SourceOrder,
150 autocomplete: None,
151 intercept: Vec::new(),
152 icons: Icons::new(false),
153 debounce: None,
154 }
155 }
156
157 fn new(b: SearchListBuilder<R>) -> Self {
158 let mut loader = LoadEngine::new(b.redraw.clone());
159 loader.start(b.source.clone(), b.initial_query.clone());
160 let input = SingleLineInput::with_value(&b.initial_query);
161 let debounce = b.debounce;
162 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
163 let mut ac =
164 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
165 disambiguate_header: false,
166 apply_exclusion_zone: false,
167 ..TriggerOptions::default()
170 });
171 if let Some(d) = debounce {
172 ac = ac.with_debounce(d);
173 }
174 ac.set_redraw_callback(b.redraw.clone());
175 ac
176 });
177 Self {
178 source: b.source,
179 rows: Vec::new(),
180 display: Vec::new(),
181 leading: None,
182 selected: None,
183 offset: 0,
184 filter: b.filter,
185 query: b.initial_query,
186 loader,
187 input,
188 autocomplete,
189 intercept: b.intercept,
190 icons: b.icons,
191 list_rect: Rect::default(),
192 panel_rect: Rect::default(),
193 content_rect: Rect::default(),
194 applied_generation: 0,
195 accepted_saved_search: None,
196 }
197 }
198
199 pub fn poll(&mut self) {
200 let drained = self.loader.drain();
201 if !drained.is_empty() {
202 let current_gen = self.loader.generation();
206 if current_gen != self.applied_generation {
207 self.rows.clear();
208 self.selected = None;
209 self.offset = 0;
210 self.applied_generation = current_gen;
211 }
212 }
213 for ev in drained {
214 match ev {
215 LoadedInner::Replace(rows) => {
216 self.rows = rows;
217 }
218 LoadedInner::Push(row) => {
219 self.rows.push(row);
220 }
221 LoadedInner::Done => {}
222 }
223 }
224 self.recompute_display();
225 if self.selected.is_none() && self.visible_len() > 0 {
226 self.selected = Some(0);
227 }
228 if let Some(ac) = &mut self.autocomplete {
229 ac.poll_results();
230 }
231 }
232
233 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
237 let value = self.input.value().to_string();
238 let cursor_byte = self.input.cursor_byte();
239 let col = value[..cursor_byte.min(value.len())].chars().count();
240 host::SearchBoxHostSnapshot {
241 lines: vec![value],
242 cursor: (0, col),
243 caret_pos: self.input.last_caret_pos(),
244 }
245 }
246
247 fn clamp_selection(&mut self) {
248 let len = self.visible_len();
249 self.selected = if len == 0 {
250 None
251 } else {
252 Some(self.selected.unwrap_or(0).min(len - 1))
253 };
254 }
255
256 fn leading_offset(&self) -> usize {
258 self.leading.is_some() as usize
259 }
260
261 pub fn visible_len(&self) -> usize {
263 self.leading_offset() + self.display.len()
264 }
265
266 fn visible_row(&self, pos: usize) -> Option<&R> {
268 if self.leading.is_some() && pos == 0 {
269 self.leading.as_ref()
270 } else {
271 self.rows
272 .get(*self.display.get(pos - self.leading_offset())?)
273 }
274 }
275
276 pub fn rows(&self) -> &[R] {
280 &self.rows
281 }
282
283 pub fn selected_row(&self) -> Option<&R> {
284 self.selected.and_then(|p| self.visible_row(p))
285 }
286
287 pub fn visible_rows(&self) -> Vec<&R> {
288 (0..self.visible_len())
289 .filter_map(|p| self.visible_row(p))
290 .collect()
291 }
292
293 pub fn query(&self) -> &str {
294 &self.query
295 }
296
297 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
301 self.accepted_saved_search.take()
302 }
303
304 #[cfg(test)]
307 pub(crate) fn input_value(&self) -> &str {
308 self.input.value()
309 }
310 pub fn is_loading(&self) -> bool {
311 self.loader.loading
312 }
313
314 pub fn set_query(&mut self, q: impl Into<String>) {
323 let q = q.into();
324 self.input.set_value(q.clone());
325 self.query = q;
326 self.requery();
327 }
328
329 fn sync_query_from_input(&mut self) {
334 self.query = self.input.value().to_string();
335 self.requery();
336 }
337
338 fn requery(&mut self) {
341 if self.source.reload_on_query() {
342 self.loader.start(self.source.clone(), self.query.clone());
343 } else {
344 self.recompute_display();
345 }
346 }
347
348 pub fn reload(&mut self) {
350 self.loader.start(self.source.clone(), self.query.clone());
351 }
352
353 pub fn select_next(&mut self) {
354 let n = self.visible_len();
355 if n == 0 {
356 return;
357 }
358 self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
359 }
360
361 pub fn select_prev(&mut self) {
362 if self.visible_len() == 0 {
363 return;
364 }
365 self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
366 }
367
368 fn max_scroll_offset(&self) -> usize {
373 let viewport = self.list_rect.height as usize;
374 let n = self.visible_len();
375 if viewport == 0 || n == 0 {
376 return 0;
377 }
378 let mut budget = viewport;
379 let mut first = n;
380 while first > 0 {
381 let h = self
382 .visible_row(first - 1)
383 .map(|r| r.visual_height() as usize)
384 .unwrap_or(1);
385 if h > budget {
386 break;
387 }
388 budget -= h;
389 first -= 1;
390 }
391 first.min(n - 1)
392 }
393
394 pub fn scroll_down(&mut self) {
398 let n = self.visible_len();
399 if n == 0 || self.offset >= self.max_scroll_offset() {
400 return;
401 }
402 self.offset += 1;
403 self.selected = self.selected.map(|i| (i + 1).min(n - 1));
404 }
405
406 pub fn scroll_up(&mut self) {
409 if self.offset == 0 {
410 return;
411 }
412 self.offset -= 1;
413 self.selected = self.selected.map(|i| i.saturating_sub(1));
414 }
415
416 #[cfg(test)]
419 pub(crate) fn scroll_offset(&self) -> usize {
420 self.offset
421 }
422
423 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
424 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
425
426 if let Some(combo) = crate::keys::key_event_to_combo(key)
429 && self.intercept.contains(&combo)
430 {
431 return KeyReaction::Intercepted(combo);
432 }
433
434 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
438 let snap = self.autocomplete_snapshot();
439 if let Some(ac) = &mut self.autocomplete {
440 match ac.handle_key(*key, &snap) {
441 HandleKeyOutcome::Accepted(action) => {
442 self.input.replace_range_bytes(
443 action.range.clone(),
444 &action.new_text,
445 action.new_cursor_byte,
446 );
447 self.accepted_saved_search = action.saved_search_name;
452 self.sync_query_from_input();
453 return KeyReaction::Consumed;
454 }
455 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
456 return KeyReaction::Consumed;
457 }
458 HandleKeyOutcome::NotHandled => {}
459 }
460 }
461 }
462
463 match key.code {
464 KeyCode::Up => {
465 self.select_prev();
466 return KeyReaction::Consumed;
467 }
468 KeyCode::Down => {
469 self.select_next();
470 return KeyReaction::Consumed;
471 }
472 KeyCode::Enter => return KeyReaction::Submit,
473 KeyCode::Esc => return KeyReaction::Cancel,
474 _ => {}
475 }
476 if let KeyCode::Char(_) = key.code {
478 let non_shift = key.modifiers - KeyModifiers::SHIFT;
479 if !non_shift.is_empty() {
480 return KeyReaction::Unhandled;
481 }
482 }
483 let outcome = self.input.handle_key(key);
484 let snap = self.autocomplete_snapshot();
487 match outcome {
488 InputOutcome::Changed => {
489 if let Some(ac) = &mut self.autocomplete {
490 ac.sync(&snap);
491 }
492 }
493 InputOutcome::Consumed => {
494 if let Some(ac) = &mut self.autocomplete {
495 ac.refresh_if_open(&snap);
496 }
497 }
498 InputOutcome::Cancel | InputOutcome::Submit => {
499 if let Some(ac) = &mut self.autocomplete {
500 ac.close();
501 }
502 }
503 InputOutcome::NotConsumed => {}
504 }
505 match outcome {
506 InputOutcome::Changed => {
507 self.sync_query_from_input();
508 KeyReaction::Consumed
509 }
510 InputOutcome::Consumed => KeyReaction::Consumed,
511 InputOutcome::Submit => KeyReaction::Submit,
512 InputOutcome::Cancel => KeyReaction::Cancel,
513 InputOutcome::NotConsumed => KeyReaction::Unhandled,
514 }
515 }
516
517 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
518 self.input.render(
519 f,
520 area,
521 Style::default()
522 .fg(theme.fg.to_ratatui())
523 .bg(theme.bg_panel.to_ratatui()),
524 0,
525 focused,
526 );
527 }
528
529 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
530 self.poll();
531 let sel = self.selected;
532 let items: Vec<ListItem> = (0..self.visible_len())
533 .filter_map(|pos| {
534 self.visible_row(pos)
535 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
536 })
537 .collect();
538 let mut state = ListState::default().with_offset(self.offset);
539 state.select(self.selected);
540 let list =
541 List::new(items).highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
542 f.render_stateful_widget(list, area, &mut state);
543 self.offset = state.offset();
547 self.list_rect = area;
548 let _ = focused;
549 }
550
551 pub fn set_list_rect(&mut self, rect: Rect) {
560 self.list_rect = rect;
561 }
562
563 pub fn set_panel_rect(&mut self, rect: Rect) {
568 self.panel_rect = rect;
569 }
570
571 pub fn set_content_rect(&mut self, rect: Rect) {
579 self.content_rect = rect;
580 }
581
582 #[cfg(test)]
586 pub(crate) fn content_rect(&self) -> Rect {
587 self.content_rect
588 }
589
590 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
591 if let Some(ac) = &mut self.autocomplete {
592 ac.poll_results();
593 let caret = self.input.last_caret_pos();
594 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
595 state.anchor = anchor;
596 }
597 if let Some(state) = ac.state() {
598 crate::components::autocomplete::render(f, state, clamp, theme);
599 }
600 }
601 }
602
603 pub fn close_autocomplete(&mut self) {
610 if let Some(ac) = &mut self.autocomplete {
611 ac.close();
612 }
613 }
614
615 #[cfg(test)]
618 pub(crate) fn autocomplete_is_open(&self) -> bool {
619 self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
620 }
621
622 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
623 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
624 use ratatui::layout::Position;
625 self.close_autocomplete();
628 let pos = Position {
629 x: m.column,
630 y: m.row,
631 };
632 if matches!(
636 m.kind,
637 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
638 ) {
639 if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
643 return if m.kind == MouseEventKind::ScrollUp {
644 SearchMouse::ContentScrollUp
645 } else {
646 SearchMouse::ContentScrollDown
647 };
648 }
649 let bounds = if self.panel_rect.is_empty() {
650 self.list_rect
651 } else {
652 self.panel_rect
653 };
654 if !bounds.contains(pos) {
655 return SearchMouse::None;
656 }
657 if m.kind == MouseEventKind::ScrollUp {
658 self.scroll_up();
659 } else {
660 self.scroll_down();
661 }
662 return SearchMouse::Scrolled;
663 }
664 let r = self.list_rect;
665 if !r.contains(pos) {
666 return SearchMouse::None;
667 }
668 match m.kind {
669 MouseEventKind::Down(MouseButton::Left) if m.row >= r.y => {
670 let target_visual = m.row - r.y; let mut acc: u16 = 0;
672 let mut hit: Option<usize> = None;
673 for pos in self.offset..self.visible_len() {
678 let h = self
679 .visible_row(pos)
680 .map(|r| r.visual_height())
681 .unwrap_or(1);
682 if target_visual < acc + h {
683 hit = Some(pos);
684 break;
685 }
686 acc += h;
687 }
688 if let Some(pos) = hit {
689 let prev = self.selected;
690 self.selected = Some(pos);
691 return if prev == Some(pos) {
692 SearchMouse::Activated(pos)
693 } else {
694 SearchMouse::Selected(pos)
695 };
696 }
697 SearchMouse::None
698 }
699 _ => SearchMouse::None,
700 }
701 }
702
703 fn recompute_display(&mut self) {
704 let q = self.query.trim();
705 self.leading = self.source.leading_row(q);
708 let mut idx: Vec<usize> = match &self.filter {
709 Filter::SourceOrder => (0..self.rows.len()).collect(),
710 Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
711 Filter::Fuzzy => fuzzy_indices(&self.rows, q),
712 Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
713 Filter::Rank(f) => {
714 let f = f.clone();
715 f(&self.rows, q)
716 }
717 };
718 for i in 0..self.rows.len() {
721 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
722 idx.insert(0, i);
723 }
724 }
725 self.display = idx;
726 self.clamp_selection();
727 }
728
729 #[cfg(test)]
730 pub(crate) async fn poll_until_idle(&mut self) {
731 for _ in 0..600 {
737 tokio::task::yield_now().await;
738 self.poll();
739 if !self.is_loading() {
740 break;
741 }
742 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
743 }
744 self.poll();
745 }
746}
747
748impl<R: SearchRow> SearchListBuilder<R> {
749 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
750 self.initial_query = q.into();
751 self
752 }
753 pub fn filter(mut self, f: Filter<R>) -> Self {
754 self.filter = f;
755 self
756 }
757 pub fn autocomplete(
758 mut self,
759 suggestions: Arc<dyn SuggestionSource>,
760 mode: AutocompleteMode,
761 ) -> Self {
762 self.autocomplete = Some((suggestions, mode));
763 self
764 }
765 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
766 self.intercept = v;
767 self
768 }
769 pub fn icons(mut self, icons: Icons) -> Self {
770 self.icons = icons;
771 self
772 }
773 pub fn debounce(mut self, d: std::time::Duration) -> Self {
776 self.debounce = Some(d);
777 self
778 }
779 pub fn build(self) -> SearchList<R> {
780 SearchList::new(self)
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use super::adapters::{
787 ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow, VecSource,
788 VecSourceWithLead,
789 };
790 use super::*;
791 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
792
793 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
794 std::sync::Arc::new(|| {})
795 }
796
797 fn key(c: KeyCode) -> KeyEvent {
798 KeyEvent::new(c, KeyModifiers::NONE)
799 }
800
801 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
802 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
803 MouseEvent {
804 kind: MouseEventKind::Down(MouseButton::Left),
805 column: col,
806 row,
807 modifiers: KeyModifiers::NONE,
808 }
809 }
810
811 #[derive(Clone, Debug, PartialEq)]
812 struct TallRow {
813 name: String,
814 height: u16,
815 }
816 impl SearchRow for TallRow {
817 fn to_list_item(
818 &self,
819 _t: &crate::settings::themes::Theme,
820 _i: &crate::settings::icons::Icons,
821 _s: bool,
822 ) -> ratatui::widgets::ListItem<'static> {
823 ratatui::widgets::ListItem::new(self.name.clone())
824 }
825 fn visual_height(&self) -> u16 {
826 self.height
827 }
828 fn match_text(&self) -> Option<&str> {
829 Some(&self.name)
830 }
831 }
832 struct TallSource(Vec<TallRow>);
833 #[async_trait::async_trait]
834 impl RowSource<TallRow> for TallSource {
835 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
836 emit.replace(self.0.clone());
837 }
838 }
839
840 #[tokio::test]
844 async fn wheel_in_content_rect_routes_to_host() {
845 use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
846 let rows: Vec<TallRow> = (0..10)
847 .map(|i| TallRow {
848 name: format!("r{}", i),
849 height: 1,
850 })
851 .collect();
852 let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
853 list.poll_until_idle().await;
854 let rect = |y: u16, h: u16| ratatui::layout::Rect {
855 x: 0,
856 y,
857 width: 20,
858 height: h,
859 };
860 list.set_panel_rect(rect(0, 10));
862 list.set_list_rect(rect(0, 4));
863 list.set_content_rect(rect(5, 5));
864 let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
865 kind,
866 column: 2,
867 row,
868 modifiers: KeyModifiers::NONE,
869 };
870
871 let m = wheel(MouseEventKind::ScrollDown, 6);
873 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
874 assert_eq!(list.offset, 0, "list viewport must not move");
875 let m = wheel(MouseEventKind::ScrollUp, 6);
876 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
877
878 let m = wheel(MouseEventKind::ScrollDown, 2);
880 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
881
882 list.set_content_rect(ratatui::layout::Rect::default());
884 let m = wheel(MouseEventKind::ScrollDown, 6);
885 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
886 }
887
888 #[tokio::test]
889 async fn mouse_maps_visual_row_to_display_index_by_height() {
890 let src = TallSource(vec![
893 TallRow {
894 name: "a".into(),
895 height: 3,
896 },
897 TallRow {
898 name: "b".into(),
899 height: 1,
900 },
901 ]);
902 let mut list = SearchList::builder(src, noop_redraw()).build();
903 list.poll_until_idle().await;
904 list.set_list_rect(ratatui::layout::Rect {
906 x: 0,
907 y: 0,
908 width: 20,
909 height: 10,
910 });
911 let m = mouse_down_at(2, 3);
913 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
914 assert_eq!(list.selected_row().unwrap().name, "b");
915 let m = mouse_down_at(2, 1);
917 list.handle_mouse(&m);
918 assert_eq!(list.selected_row().unwrap().name, "a");
919 }
920
921 #[tokio::test]
925 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
926 let src = VecSource {
927 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
928 reload: true,
929 };
930 let mut list = SearchList::builder(src, noop_redraw()).build();
931 list.poll_until_idle().await;
932 list.set_list_rect(ratatui::layout::Rect {
934 x: 0,
935 y: 0,
936 width: 20,
937 height: 4,
938 });
939 list.select_next();
941 list.select_next();
942 assert_eq!(list.selected_row().unwrap().name, "row2");
943
944 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
945 kind,
946 column: 1,
947 row: 1,
948 modifiers: KeyModifiers::NONE,
949 };
950 use ratatui::crossterm::event::MouseEventKind;
951
952 assert_eq!(
954 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
955 SearchMouse::Scrolled
956 );
957 assert_eq!(list.scroll_offset(), 1);
958 assert_eq!(list.selected_row().unwrap().name, "row3");
959
960 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
962 assert_eq!(list.scroll_offset(), 0);
963 assert_eq!(list.selected_row().unwrap().name, "row2");
964
965 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
967 assert_eq!(list.scroll_offset(), 0);
968 assert_eq!(list.selected_row().unwrap().name, "row2");
969
970 for _ in 0..20 {
973 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
974 }
975 assert_eq!(list.scroll_offset(), 6);
976 assert_eq!(list.selected_row().unwrap().name, "row8");
977 }
980
981 #[tokio::test]
985 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
986 let src = VecSource {
987 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
988 reload: true,
989 };
990 let mut list = SearchList::builder(src, noop_redraw()).build();
991 list.poll_until_idle().await;
992 list.set_list_rect(ratatui::layout::Rect {
994 x: 0,
995 y: 5,
996 width: 20,
997 height: 4,
998 });
999 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1000 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1001 column: 1,
1002 row,
1003 modifiers: KeyModifiers::NONE,
1004 };
1005 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1007 assert_eq!(list.scroll_offset(), 0);
1008 list.set_panel_rect(ratatui::layout::Rect {
1009 x: 0,
1010 y: 0,
1011 width: 20,
1012 height: 20,
1013 });
1014 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1016 assert_eq!(list.scroll_offset(), 1);
1017 let before = list.selected_row().unwrap().name.clone();
1020 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1021 assert_eq!(list.selected_row().unwrap().name, before);
1022 }
1023
1024 #[tokio::test]
1028 async fn click_after_scroll_selects_the_clicked_row() {
1029 let src = VecSource {
1030 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1031 reload: true,
1032 };
1033 let mut list = SearchList::builder(src, noop_redraw()).build();
1034 list.poll_until_idle().await;
1035 list.set_list_rect(ratatui::layout::Rect {
1036 x: 0,
1037 y: 0,
1038 width: 20,
1039 height: 4,
1040 });
1041 let scroll_down = ratatui::crossterm::event::MouseEvent {
1042 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1043 column: 1,
1044 row: 1,
1045 modifiers: KeyModifiers::NONE,
1046 };
1047 for _ in 0..3 {
1048 list.handle_mouse(&scroll_down);
1049 }
1050 assert_eq!(list.scroll_offset(), 3);
1051 assert!(matches!(
1053 list.handle_mouse(&mouse_down_at(2, 2)),
1054 SearchMouse::Selected(5)
1055 ));
1056 assert_eq!(list.selected_row().unwrap().name, "row5");
1057 list.handle_mouse(&mouse_down_at(2, 0));
1059 assert_eq!(list.selected_row().unwrap().name, "row3");
1060 }
1061
1062 #[tokio::test]
1063 async fn initial_load_populates_rows() {
1064 let src = VecSource {
1065 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1066 reload: true,
1067 };
1068 let mut list = SearchList::builder(src, noop_redraw()).build();
1069 list.poll_until_idle().await;
1070 assert_eq!(list.rows().len(), 2);
1071 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1072 }
1073
1074 #[tokio::test]
1075 async fn requery_supersedes_and_reloads() {
1076 let src = VecSource {
1077 rows: vec![
1078 TestRow::new("alpha"),
1079 TestRow::new("alps"),
1080 TestRow::new("beta"),
1081 ],
1082 reload: true,
1083 };
1084 let mut list = SearchList::builder(src, noop_redraw()).build();
1085 list.poll_until_idle().await;
1086 assert_eq!(list.rows().len(), 3);
1087 list.set_query("alp");
1088 list.poll_until_idle().await;
1089 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1091 }
1092
1093 #[tokio::test]
1094 async fn arrows_navigate_and_enter_submits() {
1095 let src = VecSource {
1096 rows: vec![TestRow::new("a"), TestRow::new("b")],
1097 reload: true,
1098 };
1099 let mut list = SearchList::builder(src, noop_redraw()).build();
1100 list.poll_until_idle().await;
1101 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1102 assert_eq!(list.selected_row().unwrap().name, "b");
1103 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1104 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1105 }
1106
1107 #[tokio::test]
1108 async fn typing_a_char_changes_query() {
1109 let src = VecSource {
1110 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1111 reload: true,
1112 };
1113 let mut list = SearchList::builder(src, noop_redraw()).build();
1114 list.poll_until_idle().await;
1115 assert_eq!(
1116 list.handle_key(&key(KeyCode::Char('a'))),
1117 KeyReaction::Consumed
1118 );
1119 list.poll_until_idle().await;
1120 assert_eq!(list.query(), "a");
1121 }
1122
1123 #[tokio::test]
1124 async fn rank_filter_orders_by_closure() {
1125 let src = VecSource {
1126 rows: vec![
1127 TestRow::new("todo"),
1128 TestRow::new("today"),
1129 TestRow::new("misc"),
1130 ],
1131 reload: false,
1132 };
1133 let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1134 let mut idx: Vec<usize> = (0..rows.len())
1135 .filter(|&i| rows[i].name.contains(q))
1136 .collect();
1137 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1138 idx
1139 });
1140 let mut list = SearchList::builder(src, noop_redraw())
1141 .filter(Filter::Rank(rank))
1142 .build();
1143 list.poll_until_idle().await;
1144 list.set_query("today");
1145 list.poll();
1146 assert_eq!(list.selected_row().unwrap().name, "today");
1147 }
1148
1149 #[tokio::test]
1150 async fn fuzzy_filter_narrows_local_set() {
1151 let src = VecSource {
1152 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1153 reload: false,
1154 };
1155 let mut list = SearchList::builder(src, noop_redraw())
1156 .filter(Filter::Fuzzy)
1157 .build();
1158 list.poll_until_idle().await;
1159 list.set_query("alp");
1160 list.poll();
1161 assert_eq!(list.visible_rows().len(), 1);
1162 assert_eq!(list.selected_row().unwrap().name, "alpha");
1163 }
1164
1165 #[tokio::test]
1166 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1167 let src = ScriptedStreamSource {
1168 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1169 };
1170 let mut list = SearchList::builder(src, noop_redraw())
1171 .filter(Filter::Fuzzy)
1172 .build();
1173 list.poll_until_idle().await;
1174 assert_eq!(list.rows().len(), 2);
1175 assert!(!list.is_loading());
1176 list.set_query("alp");
1177 list.poll();
1178 assert_eq!(list.visible_rows().len(), 1);
1179 }
1180
1181 #[tokio::test]
1182 async fn source_order_unfiltered_passthrough() {
1183 let src = VecSource {
1184 rows: vec![TestRow::new("a"), TestRow::new("b")],
1185 reload: true,
1186 };
1187 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1189 assert_eq!(list.visible_rows().len(), 2);
1190 assert_eq!(list.selected_row().unwrap().name, "a");
1191 }
1192
1193 #[tokio::test]
1194 async fn intercepted_combo_returns_intercepted_without_acting() {
1195 let src = VecSource {
1196 rows: vec![TestRow::new("a")],
1197 reload: true,
1198 };
1199 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1200 let mut list = SearchList::builder(src, noop_redraw())
1201 .intercept(vec![combo])
1202 .build();
1203 list.poll_until_idle().await;
1204 assert_eq!(
1206 list.handle_key(&key(KeyCode::Enter)),
1207 KeyReaction::Intercepted(combo)
1208 );
1209 }
1210
1211 #[tokio::test]
1212 async fn autocomplete_accept_rewrites_query_without_vault() {
1213 struct Mem;
1214 #[async_trait::async_trait]
1215 impl crate::components::search_list::SuggestionSource for Mem {
1216 async fn notes_by_prefix(
1217 &self,
1218 _p: &str,
1219 _n: usize,
1220 ) -> Vec<crate::components::search_list::SuggestionItem> {
1221 vec![]
1222 }
1223 async fn tags_by_prefix(
1224 &self,
1225 p: &str,
1226 _n: usize,
1227 ) -> Vec<crate::components::search_list::SuggestionItem> {
1228 if "projects".starts_with(p) {
1229 vec![crate::components::search_list::SuggestionItem::plain(
1230 "projects",
1231 )]
1232 } else {
1233 vec![]
1234 }
1235 }
1236 }
1237 let src = VecSource {
1238 rows: vec![],
1239 reload: true,
1240 };
1241 let mut list = SearchList::builder(src, noop_redraw())
1242 .autocomplete(
1243 std::sync::Arc::new(Mem),
1244 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1245 )
1246 .debounce(std::time::Duration::ZERO)
1247 .build();
1248 for c in ['#', 'p', 'r', 'o'] {
1249 let _ = list.handle_key(&key(KeyCode::Char(c)));
1250 }
1251 for _ in 0..50 {
1252 tokio::task::yield_now().await;
1253 list.poll();
1254 }
1255 let _ = list.handle_key(&key(KeyCode::Tab));
1256 assert_eq!(list.query(), "#projects");
1257 }
1258
1259 #[tokio::test]
1263 async fn accepting_saved_search_expands_query_and_exposes_name() {
1264 struct Mem;
1265 #[async_trait::async_trait]
1266 impl crate::components::search_list::SuggestionSource for Mem {
1267 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1268 vec![]
1269 }
1270 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1271 vec![]
1272 }
1273 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1274 if "todo-week".starts_with(p) {
1275 vec![SuggestionItem {
1276 display: "todo-week".into(),
1277 secondary: Some("#todo ^modified".into()),
1278 }]
1279 } else {
1280 vec![]
1281 }
1282 }
1283 }
1284 let src = VecSource {
1285 rows: vec![],
1286 reload: true,
1287 };
1288 let mut list = SearchList::builder(src, noop_redraw())
1289 .autocomplete(
1290 std::sync::Arc::new(Mem),
1291 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1292 )
1293 .debounce(std::time::Duration::ZERO)
1294 .build();
1295 for c in ['?', 't', 'o'] {
1296 let _ = list.handle_key(&key(KeyCode::Char(c)));
1297 }
1298 for _ in 0..50 {
1299 tokio::task::yield_now().await;
1300 list.poll();
1301 }
1302 let _ = list.handle_key(&key(KeyCode::Tab));
1303 assert_eq!(list.query(), "#todo ^modified");
1305 assert_eq!(
1307 list.take_accepted_saved_search().as_deref(),
1308 Some("todo-week")
1309 );
1310 assert_eq!(list.take_accepted_saved_search(), None);
1311 }
1312
1313 #[tokio::test]
1318 async fn enter_accepts_open_popup_and_reports_consumed() {
1319 struct Mem;
1320 #[async_trait::async_trait]
1321 impl crate::components::search_list::SuggestionSource for Mem {
1322 async fn notes_by_prefix(
1323 &self,
1324 _p: &str,
1325 _n: usize,
1326 ) -> Vec<crate::components::search_list::SuggestionItem> {
1327 vec![]
1328 }
1329 async fn tags_by_prefix(
1330 &self,
1331 p: &str,
1332 _n: usize,
1333 ) -> Vec<crate::components::search_list::SuggestionItem> {
1334 if "projects".starts_with(p) {
1335 vec![crate::components::search_list::SuggestionItem::plain(
1336 "projects",
1337 )]
1338 } else {
1339 vec![]
1340 }
1341 }
1342 }
1343 let src = VecSource {
1344 rows: vec![],
1345 reload: true,
1346 };
1347 let mut list = SearchList::builder(src, noop_redraw())
1348 .autocomplete(
1349 std::sync::Arc::new(Mem),
1350 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1351 )
1352 .debounce(std::time::Duration::ZERO)
1353 .build();
1354 for c in ['#', 'p', 'r', 'o'] {
1355 let _ = list.handle_key(&key(KeyCode::Char(c)));
1356 }
1357 for _ in 0..50 {
1358 tokio::task::yield_now().await;
1359 list.poll();
1360 }
1361 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1363 assert_eq!(list.query(), "#projects");
1364 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1366 }
1367
1368 #[tokio::test]
1373 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1374 let src = ScriptedStreamLeadSource {
1375 items: vec!["alpha".into(), "beta".into()],
1376 };
1377 let mut list = SearchList::builder(src, noop_redraw())
1378 .filter(Filter::Fuzzy)
1379 .initial_query("zz")
1380 .build();
1381 list.poll_until_idle().await;
1382 let vis = list.visible_rows();
1384 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1385 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1388 list.poll();
1389 let vis = list.visible_rows();
1390 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1391 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1392 assert_eq!(list.visible_len(), 2);
1393 list.set_query("");
1395 list.poll();
1396 assert!(
1397 list.visible_rows()
1398 .iter()
1399 .all(|r| matches!(r, StreamRow::Item(_)))
1400 );
1401 assert_eq!(list.visible_len(), 2);
1402 }
1403
1404 #[tokio::test]
1407 async fn oneshot_source_leading_row_still_works() {
1408 let src = VecSourceWithLead {
1409 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1410 };
1411 let mut list = SearchList::builder(src, noop_redraw())
1412 .filter(Filter::Fuzzy)
1413 .initial_query("alp")
1414 .build();
1415 list.poll_until_idle().await;
1416 let vis = list.visible_rows();
1417 assert_eq!(vis[0].name, "create:alp");
1418 assert_eq!(vis[1].name, "alpha");
1419 assert_eq!(list.visible_len(), 2);
1420 }
1421
1422 #[tokio::test]
1425 async fn selection_includes_leading_at_position_zero() {
1426 let src = VecSourceWithLead {
1427 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1428 };
1429 let mut list = SearchList::builder(src, noop_redraw())
1430 .filter(Filter::Fuzzy)
1431 .initial_query("alp")
1432 .build();
1433 list.poll_until_idle().await;
1434 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1436 list.handle_key(&key(KeyCode::Down));
1437 assert_eq!(list.selected_row().unwrap().name, "alpha");
1438 }
1439
1440 #[tokio::test]
1442 async fn no_leading_row_visible_len_matches_display() {
1443 let src = VecSource {
1444 rows: vec![TestRow::new("a"), TestRow::new("b")],
1445 reload: true,
1446 };
1447 let mut list = SearchList::builder(src, noop_redraw()).build();
1448 list.poll_until_idle().await;
1449 assert_eq!(list.visible_len(), 2);
1450 assert_eq!(list.visible_rows().len(), 2);
1451 assert_eq!(list.selected_row().unwrap().name, "a");
1452 }
1453}