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