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 filter: Filter<R>,
76 query: String,
77 loader: LoadEngine<R>,
78 input: SingleLineInput,
79 autocomplete: Option<AutocompleteController>,
80 intercept: Vec<KeyCombo>,
82 icons: Icons,
83 list_rect: Rect,
84 applied_generation: u64,
89 accepted_saved_search: Option<String>,
94}
95
96#[derive(Debug, PartialEq, Eq)]
98pub enum SearchMouse {
99 Selected(usize),
100 Activated(usize),
101 Scrolled,
102 None,
103}
104
105pub struct SearchListBuilder<R: SearchRow> {
106 source: Arc<dyn RowSource<R>>,
107 redraw: Arc<dyn Fn() + Send + Sync>,
108 initial_query: String,
109 filter: Filter<R>,
110 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
111 intercept: Vec<KeyCombo>,
112 icons: Icons,
113 debounce: Option<std::time::Duration>,
114}
115
116impl<R: SearchRow> SearchList<R> {
117 pub fn builder(
118 source: impl RowSource<R>,
119 redraw: Arc<dyn Fn() + Send + Sync>,
120 ) -> SearchListBuilder<R> {
121 SearchListBuilder {
122 source: Arc::new(source),
123 redraw,
124 initial_query: String::new(),
125 filter: Filter::SourceOrder,
126 autocomplete: None,
127 intercept: Vec::new(),
128 icons: Icons::new(false),
129 debounce: None,
130 }
131 }
132
133 fn new(b: SearchListBuilder<R>) -> Self {
134 let mut loader = LoadEngine::new(b.redraw.clone());
135 loader.start(b.source.clone(), b.initial_query.clone());
136 let input = SingleLineInput::with_value(&b.initial_query);
137 let debounce = b.debounce;
138 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
139 let mut ac =
140 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
141 disambiguate_header: false,
142 apply_exclusion_zone: false,
143 ..TriggerOptions::default()
146 });
147 if let Some(d) = debounce {
148 ac = ac.with_debounce(d);
149 }
150 ac.set_redraw_callback(b.redraw.clone());
151 ac
152 });
153 Self {
154 source: b.source,
155 rows: Vec::new(),
156 display: Vec::new(),
157 leading: None,
158 selected: None,
159 filter: b.filter,
160 query: b.initial_query,
161 loader,
162 input,
163 autocomplete,
164 intercept: b.intercept,
165 icons: b.icons,
166 list_rect: Rect::default(),
167 applied_generation: 0,
168 accepted_saved_search: None,
169 }
170 }
171
172 pub fn poll(&mut self) {
173 let drained = self.loader.drain();
174 if !drained.is_empty() {
175 let current_gen = self.loader.generation();
179 if current_gen != self.applied_generation {
180 self.rows.clear();
181 self.selected = None;
182 self.applied_generation = current_gen;
183 }
184 }
185 for ev in drained {
186 match ev {
187 LoadedInner::Replace(rows) => {
188 self.rows = rows;
189 }
190 LoadedInner::Push(row) => {
191 self.rows.push(row);
192 }
193 LoadedInner::Done => {}
194 }
195 }
196 self.recompute_display();
197 if self.selected.is_none() && self.visible_len() > 0 {
198 self.selected = Some(0);
199 }
200 if let Some(ac) = &mut self.autocomplete {
201 ac.poll_results();
202 }
203 }
204
205 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
209 let value = self.input.value().to_string();
210 let cursor_byte = self.input.cursor_byte();
211 let col = value[..cursor_byte.min(value.len())].chars().count();
212 host::SearchBoxHostSnapshot {
213 lines: vec![value],
214 cursor: (0, col),
215 caret_pos: self.input.last_caret_pos(),
216 }
217 }
218
219 fn clamp_selection(&mut self) {
220 let len = self.visible_len();
221 self.selected = if len == 0 {
222 None
223 } else {
224 Some(self.selected.unwrap_or(0).min(len - 1))
225 };
226 }
227
228 fn leading_offset(&self) -> usize {
230 self.leading.is_some() as usize
231 }
232
233 pub fn visible_len(&self) -> usize {
235 self.leading_offset() + self.display.len()
236 }
237
238 fn visible_row(&self, pos: usize) -> Option<&R> {
240 if self.leading.is_some() && pos == 0 {
241 self.leading.as_ref()
242 } else {
243 self.rows
244 .get(*self.display.get(pos - self.leading_offset())?)
245 }
246 }
247
248 pub fn rows(&self) -> &[R] {
252 &self.rows
253 }
254
255 pub fn selected_row(&self) -> Option<&R> {
256 self.selected.and_then(|p| self.visible_row(p))
257 }
258
259 pub fn visible_rows(&self) -> Vec<&R> {
260 (0..self.visible_len())
261 .filter_map(|p| self.visible_row(p))
262 .collect()
263 }
264
265 pub fn query(&self) -> &str {
266 &self.query
267 }
268
269 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
273 self.accepted_saved_search.take()
274 }
275
276 #[cfg(test)]
279 pub(crate) fn input_value(&self) -> &str {
280 self.input.value()
281 }
282 pub fn is_loading(&self) -> bool {
283 self.loader.loading
284 }
285
286 pub fn set_query(&mut self, q: impl Into<String>) {
295 let q = q.into();
296 self.input.set_value(q.clone());
297 self.query = q;
298 self.requery();
299 }
300
301 fn sync_query_from_input(&mut self) {
306 self.query = self.input.value().to_string();
307 self.requery();
308 }
309
310 fn requery(&mut self) {
313 if self.source.reload_on_query() {
314 self.loader.start(self.source.clone(), self.query.clone());
315 } else {
316 self.recompute_display();
317 }
318 }
319
320 pub fn reload(&mut self) {
322 self.loader.start(self.source.clone(), self.query.clone());
323 }
324
325 pub fn select_next(&mut self) {
326 let n = self.visible_len();
327 if n == 0 {
328 return;
329 }
330 self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
331 }
332
333 pub fn select_prev(&mut self) {
334 if self.visible_len() == 0 {
335 return;
336 }
337 self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
338 }
339
340 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
341 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
342
343 if let Some(combo) = crate::keys::key_event_to_combo(key)
346 && self.intercept.contains(&combo)
347 {
348 return KeyReaction::Intercepted(combo);
349 }
350
351 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
355 let snap = self.autocomplete_snapshot();
356 if let Some(ac) = &mut self.autocomplete {
357 match ac.handle_key(*key, &snap) {
358 HandleKeyOutcome::Accepted(action) => {
359 self.input.replace_range_bytes(
360 action.range.clone(),
361 &action.new_text,
362 action.new_cursor_byte,
363 );
364 self.accepted_saved_search = action.saved_search_name;
369 self.sync_query_from_input();
370 return KeyReaction::Consumed;
371 }
372 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
373 return KeyReaction::Consumed;
374 }
375 HandleKeyOutcome::NotHandled => {}
376 }
377 }
378 }
379
380 match key.code {
381 KeyCode::Up => {
382 self.select_prev();
383 return KeyReaction::Consumed;
384 }
385 KeyCode::Down => {
386 self.select_next();
387 return KeyReaction::Consumed;
388 }
389 KeyCode::Enter => return KeyReaction::Submit,
390 KeyCode::Esc => return KeyReaction::Cancel,
391 _ => {}
392 }
393 if let KeyCode::Char(_) = key.code {
395 let non_shift = key.modifiers - KeyModifiers::SHIFT;
396 if !non_shift.is_empty() {
397 return KeyReaction::Unhandled;
398 }
399 }
400 let outcome = self.input.handle_key(key);
401 let snap = self.autocomplete_snapshot();
404 match outcome {
405 InputOutcome::Changed => {
406 if let Some(ac) = &mut self.autocomplete {
407 ac.sync(&snap);
408 }
409 }
410 InputOutcome::Consumed => {
411 if let Some(ac) = &mut self.autocomplete {
412 ac.refresh_if_open(&snap);
413 }
414 }
415 InputOutcome::Cancel | InputOutcome::Submit => {
416 if let Some(ac) = &mut self.autocomplete {
417 ac.close();
418 }
419 }
420 InputOutcome::NotConsumed => {}
421 }
422 match outcome {
423 InputOutcome::Changed => {
424 self.sync_query_from_input();
425 KeyReaction::Consumed
426 }
427 InputOutcome::Consumed => KeyReaction::Consumed,
428 InputOutcome::Submit => KeyReaction::Submit,
429 InputOutcome::Cancel => KeyReaction::Cancel,
430 InputOutcome::NotConsumed => KeyReaction::Unhandled,
431 }
432 }
433
434 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
435 self.input.render(
436 f,
437 area,
438 Style::default()
439 .fg(theme.fg.to_ratatui())
440 .bg(theme.bg_panel.to_ratatui()),
441 0,
442 focused,
443 );
444 }
445
446 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
447 self.poll();
448 let sel = self.selected;
449 let items: Vec<ListItem> = (0..self.visible_len())
450 .filter_map(|pos| {
451 self.visible_row(pos)
452 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
453 })
454 .collect();
455 let mut state = ListState::default();
456 state.select(self.selected);
457 let list =
458 List::new(items).highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
459 f.render_stateful_widget(list, area, &mut state);
460 self.list_rect = area;
461 let _ = focused;
462 }
463
464 pub fn set_list_rect(&mut self, rect: Rect) {
473 self.list_rect = rect;
474 }
475
476 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
477 if let Some(ac) = &mut self.autocomplete {
478 ac.poll_results();
479 let caret = self.input.last_caret_pos();
480 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
481 state.anchor = anchor;
482 }
483 if let Some(state) = ac.state() {
484 crate::components::autocomplete::render(f, state, clamp, theme);
485 }
486 }
487 }
488
489 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
490 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
491 use ratatui::layout::Position;
492 if let Some(ac) = &mut self.autocomplete {
495 ac.close();
496 }
497 let r = self.list_rect;
498 if !r.contains(Position {
499 x: m.column,
500 y: m.row,
501 }) {
502 return SearchMouse::None;
503 }
504 match m.kind {
505 MouseEventKind::Down(MouseButton::Left) if m.row >= r.y => {
506 let target_visual = m.row - r.y; let mut acc: u16 = 0;
508 let mut hit: Option<usize> = None;
509 for pos in 0..self.visible_len() {
512 let h = self
513 .visible_row(pos)
514 .map(|r| r.visual_height())
515 .unwrap_or(1);
516 if target_visual < acc + h {
517 hit = Some(pos);
518 break;
519 }
520 acc += h;
521 }
522 if let Some(pos) = hit {
523 let prev = self.selected;
524 self.selected = Some(pos);
525 return if prev == Some(pos) {
526 SearchMouse::Activated(pos)
527 } else {
528 SearchMouse::Selected(pos)
529 };
530 }
531 SearchMouse::None
532 }
533 MouseEventKind::ScrollUp => {
534 self.select_prev();
535 SearchMouse::Scrolled
536 }
537 MouseEventKind::ScrollDown => {
538 self.select_next();
539 SearchMouse::Scrolled
540 }
541 _ => SearchMouse::None,
542 }
543 }
544
545 fn recompute_display(&mut self) {
546 let q = self.query.trim();
547 self.leading = self.source.leading_row(q);
550 let mut idx: Vec<usize> = match &self.filter {
551 Filter::SourceOrder => (0..self.rows.len()).collect(),
552 Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
553 Filter::Fuzzy => fuzzy_indices(&self.rows, q),
554 Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
555 Filter::Rank(f) => {
556 let f = f.clone();
557 f(&self.rows, q)
558 }
559 };
560 for i in 0..self.rows.len() {
563 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
564 idx.insert(0, i);
565 }
566 }
567 self.display = idx;
568 self.clamp_selection();
569 }
570
571 #[cfg(test)]
572 pub(crate) async fn poll_until_idle(&mut self) {
573 for _ in 0..600 {
579 tokio::task::yield_now().await;
580 self.poll();
581 if !self.is_loading() {
582 break;
583 }
584 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
585 }
586 self.poll();
587 }
588}
589
590impl<R: SearchRow> SearchListBuilder<R> {
591 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
592 self.initial_query = q.into();
593 self
594 }
595 pub fn filter(mut self, f: Filter<R>) -> Self {
596 self.filter = f;
597 self
598 }
599 pub fn autocomplete(
600 mut self,
601 suggestions: Arc<dyn SuggestionSource>,
602 mode: AutocompleteMode,
603 ) -> Self {
604 self.autocomplete = Some((suggestions, mode));
605 self
606 }
607 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
608 self.intercept = v;
609 self
610 }
611 pub fn icons(mut self, icons: Icons) -> Self {
612 self.icons = icons;
613 self
614 }
615 pub fn debounce(mut self, d: std::time::Duration) -> Self {
618 self.debounce = Some(d);
619 self
620 }
621 pub fn build(self) -> SearchList<R> {
622 SearchList::new(self)
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::adapters::{
629 ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow, VecSource,
630 VecSourceWithLead,
631 };
632 use super::*;
633 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
634
635 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
636 std::sync::Arc::new(|| {})
637 }
638
639 fn key(c: KeyCode) -> KeyEvent {
640 KeyEvent::new(c, KeyModifiers::NONE)
641 }
642
643 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
644 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
645 MouseEvent {
646 kind: MouseEventKind::Down(MouseButton::Left),
647 column: col,
648 row,
649 modifiers: KeyModifiers::NONE,
650 }
651 }
652
653 #[derive(Clone, Debug, PartialEq)]
654 struct TallRow {
655 name: String,
656 height: u16,
657 }
658 impl SearchRow for TallRow {
659 fn to_list_item(
660 &self,
661 _t: &crate::settings::themes::Theme,
662 _i: &crate::settings::icons::Icons,
663 _s: bool,
664 ) -> ratatui::widgets::ListItem<'static> {
665 ratatui::widgets::ListItem::new(self.name.clone())
666 }
667 fn visual_height(&self) -> u16 {
668 self.height
669 }
670 fn match_text(&self) -> Option<&str> {
671 Some(&self.name)
672 }
673 }
674 struct TallSource(Vec<TallRow>);
675 #[async_trait::async_trait]
676 impl RowSource<TallRow> for TallSource {
677 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
678 emit.replace(self.0.clone());
679 }
680 }
681
682 #[tokio::test]
683 async fn mouse_maps_visual_row_to_display_index_by_height() {
684 let src = TallSource(vec![
687 TallRow {
688 name: "a".into(),
689 height: 3,
690 },
691 TallRow {
692 name: "b".into(),
693 height: 1,
694 },
695 ]);
696 let mut list = SearchList::builder(src, noop_redraw()).build();
697 list.poll_until_idle().await;
698 list.set_list_rect(ratatui::layout::Rect {
700 x: 0,
701 y: 0,
702 width: 20,
703 height: 10,
704 });
705 let m = mouse_down_at(2, 3);
707 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
708 assert_eq!(list.selected_row().unwrap().name, "b");
709 let m = mouse_down_at(2, 1);
711 list.handle_mouse(&m);
712 assert_eq!(list.selected_row().unwrap().name, "a");
713 }
714
715 #[tokio::test]
716 async fn initial_load_populates_rows() {
717 let src = VecSource {
718 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
719 reload: true,
720 };
721 let mut list = SearchList::builder(src, noop_redraw()).build();
722 list.poll_until_idle().await;
723 assert_eq!(list.rows().len(), 2);
724 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
725 }
726
727 #[tokio::test]
728 async fn requery_supersedes_and_reloads() {
729 let src = VecSource {
730 rows: vec![
731 TestRow::new("alpha"),
732 TestRow::new("alps"),
733 TestRow::new("beta"),
734 ],
735 reload: true,
736 };
737 let mut list = SearchList::builder(src, noop_redraw()).build();
738 list.poll_until_idle().await;
739 assert_eq!(list.rows().len(), 3);
740 list.set_query("alp");
741 list.poll_until_idle().await;
742 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
744 }
745
746 #[tokio::test]
747 async fn arrows_navigate_and_enter_submits() {
748 let src = VecSource {
749 rows: vec![TestRow::new("a"), TestRow::new("b")],
750 reload: true,
751 };
752 let mut list = SearchList::builder(src, noop_redraw()).build();
753 list.poll_until_idle().await;
754 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
755 assert_eq!(list.selected_row().unwrap().name, "b");
756 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
757 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
758 }
759
760 #[tokio::test]
761 async fn typing_a_char_changes_query() {
762 let src = VecSource {
763 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
764 reload: true,
765 };
766 let mut list = SearchList::builder(src, noop_redraw()).build();
767 list.poll_until_idle().await;
768 assert_eq!(
769 list.handle_key(&key(KeyCode::Char('a'))),
770 KeyReaction::Consumed
771 );
772 list.poll_until_idle().await;
773 assert_eq!(list.query(), "a");
774 }
775
776 #[tokio::test]
777 async fn rank_filter_orders_by_closure() {
778 let src = VecSource {
779 rows: vec![
780 TestRow::new("todo"),
781 TestRow::new("today"),
782 TestRow::new("misc"),
783 ],
784 reload: false,
785 };
786 let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
787 let mut idx: Vec<usize> = (0..rows.len())
788 .filter(|&i| rows[i].name.contains(q))
789 .collect();
790 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
791 idx
792 });
793 let mut list = SearchList::builder(src, noop_redraw())
794 .filter(Filter::Rank(rank))
795 .build();
796 list.poll_until_idle().await;
797 list.set_query("today");
798 list.poll();
799 assert_eq!(list.selected_row().unwrap().name, "today");
800 }
801
802 #[tokio::test]
803 async fn fuzzy_filter_narrows_local_set() {
804 let src = VecSource {
805 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
806 reload: false,
807 };
808 let mut list = SearchList::builder(src, noop_redraw())
809 .filter(Filter::Fuzzy)
810 .build();
811 list.poll_until_idle().await;
812 list.set_query("alp");
813 list.poll();
814 assert_eq!(list.visible_rows().len(), 1);
815 assert_eq!(list.selected_row().unwrap().name, "alpha");
816 }
817
818 #[tokio::test]
819 async fn streamed_rows_arrive_then_done_and_filter_locally() {
820 let src = ScriptedStreamSource {
821 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
822 };
823 let mut list = SearchList::builder(src, noop_redraw())
824 .filter(Filter::Fuzzy)
825 .build();
826 list.poll_until_idle().await;
827 assert_eq!(list.rows().len(), 2);
828 assert!(!list.is_loading());
829 list.set_query("alp");
830 list.poll();
831 assert_eq!(list.visible_rows().len(), 1);
832 }
833
834 #[tokio::test]
835 async fn source_order_unfiltered_passthrough() {
836 let src = VecSource {
837 rows: vec![TestRow::new("a"), TestRow::new("b")],
838 reload: true,
839 };
840 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
842 assert_eq!(list.visible_rows().len(), 2);
843 assert_eq!(list.selected_row().unwrap().name, "a");
844 }
845
846 #[tokio::test]
847 async fn intercepted_combo_returns_intercepted_without_acting() {
848 let src = VecSource {
849 rows: vec![TestRow::new("a")],
850 reload: true,
851 };
852 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
853 let mut list = SearchList::builder(src, noop_redraw())
854 .intercept(vec![combo])
855 .build();
856 list.poll_until_idle().await;
857 assert_eq!(
859 list.handle_key(&key(KeyCode::Enter)),
860 KeyReaction::Intercepted(combo)
861 );
862 }
863
864 #[tokio::test]
865 async fn autocomplete_accept_rewrites_query_without_vault() {
866 struct Mem;
867 #[async_trait::async_trait]
868 impl crate::components::search_list::SuggestionSource for Mem {
869 async fn notes_by_prefix(
870 &self,
871 _p: &str,
872 _n: usize,
873 ) -> Vec<crate::components::search_list::SuggestionItem> {
874 vec![]
875 }
876 async fn tags_by_prefix(
877 &self,
878 p: &str,
879 _n: usize,
880 ) -> Vec<crate::components::search_list::SuggestionItem> {
881 if "projects".starts_with(p) {
882 vec![crate::components::search_list::SuggestionItem::plain(
883 "projects",
884 )]
885 } else {
886 vec![]
887 }
888 }
889 }
890 let src = VecSource {
891 rows: vec![],
892 reload: true,
893 };
894 let mut list = SearchList::builder(src, noop_redraw())
895 .autocomplete(
896 std::sync::Arc::new(Mem),
897 crate::components::autocomplete::AutocompleteMode::SearchQuery,
898 )
899 .debounce(std::time::Duration::ZERO)
900 .build();
901 for c in ['#', 'p', 'r', 'o'] {
902 let _ = list.handle_key(&key(KeyCode::Char(c)));
903 }
904 for _ in 0..50 {
905 tokio::task::yield_now().await;
906 list.poll();
907 }
908 let _ = list.handle_key(&key(KeyCode::Tab));
909 assert_eq!(list.query(), "#projects");
910 }
911
912 #[tokio::test]
916 async fn accepting_saved_search_expands_query_and_exposes_name() {
917 struct Mem;
918 #[async_trait::async_trait]
919 impl crate::components::search_list::SuggestionSource for Mem {
920 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
921 vec![]
922 }
923 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
924 vec![]
925 }
926 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
927 if "todo-week".starts_with(p) {
928 vec![SuggestionItem {
929 display: "todo-week".into(),
930 secondary: Some("#todo ^modified".into()),
931 }]
932 } else {
933 vec![]
934 }
935 }
936 }
937 let src = VecSource {
938 rows: vec![],
939 reload: true,
940 };
941 let mut list = SearchList::builder(src, noop_redraw())
942 .autocomplete(
943 std::sync::Arc::new(Mem),
944 crate::components::autocomplete::AutocompleteMode::SearchQuery,
945 )
946 .debounce(std::time::Duration::ZERO)
947 .build();
948 for c in ['?', 't', 'o'] {
949 let _ = list.handle_key(&key(KeyCode::Char(c)));
950 }
951 for _ in 0..50 {
952 tokio::task::yield_now().await;
953 list.poll();
954 }
955 let _ = list.handle_key(&key(KeyCode::Tab));
956 assert_eq!(list.query(), "#todo ^modified");
958 assert_eq!(
960 list.take_accepted_saved_search().as_deref(),
961 Some("todo-week")
962 );
963 assert_eq!(list.take_accepted_saved_search(), None);
964 }
965
966 #[tokio::test]
971 async fn enter_accepts_open_popup_and_reports_consumed() {
972 struct Mem;
973 #[async_trait::async_trait]
974 impl crate::components::search_list::SuggestionSource for Mem {
975 async fn notes_by_prefix(
976 &self,
977 _p: &str,
978 _n: usize,
979 ) -> Vec<crate::components::search_list::SuggestionItem> {
980 vec![]
981 }
982 async fn tags_by_prefix(
983 &self,
984 p: &str,
985 _n: usize,
986 ) -> Vec<crate::components::search_list::SuggestionItem> {
987 if "projects".starts_with(p) {
988 vec![crate::components::search_list::SuggestionItem::plain(
989 "projects",
990 )]
991 } else {
992 vec![]
993 }
994 }
995 }
996 let src = VecSource {
997 rows: vec![],
998 reload: true,
999 };
1000 let mut list = SearchList::builder(src, noop_redraw())
1001 .autocomplete(
1002 std::sync::Arc::new(Mem),
1003 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1004 )
1005 .debounce(std::time::Duration::ZERO)
1006 .build();
1007 for c in ['#', 'p', 'r', 'o'] {
1008 let _ = list.handle_key(&key(KeyCode::Char(c)));
1009 }
1010 for _ in 0..50 {
1011 tokio::task::yield_now().await;
1012 list.poll();
1013 }
1014 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1016 assert_eq!(list.query(), "#projects");
1017 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1019 }
1020
1021 #[tokio::test]
1026 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1027 let src = ScriptedStreamLeadSource {
1028 items: vec!["alpha".into(), "beta".into()],
1029 };
1030 let mut list = SearchList::builder(src, noop_redraw())
1031 .filter(Filter::Fuzzy)
1032 .initial_query("zz")
1033 .build();
1034 list.poll_until_idle().await;
1035 let vis = list.visible_rows();
1037 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1038 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1041 list.poll();
1042 let vis = list.visible_rows();
1043 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1044 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1045 assert_eq!(list.visible_len(), 2);
1046 list.set_query("");
1048 list.poll();
1049 assert!(
1050 list.visible_rows()
1051 .iter()
1052 .all(|r| matches!(r, StreamRow::Item(_)))
1053 );
1054 assert_eq!(list.visible_len(), 2);
1055 }
1056
1057 #[tokio::test]
1060 async fn oneshot_source_leading_row_still_works() {
1061 let src = VecSourceWithLead {
1062 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1063 };
1064 let mut list = SearchList::builder(src, noop_redraw())
1065 .filter(Filter::Fuzzy)
1066 .initial_query("alp")
1067 .build();
1068 list.poll_until_idle().await;
1069 let vis = list.visible_rows();
1070 assert_eq!(vis[0].name, "create:alp");
1071 assert_eq!(vis[1].name, "alpha");
1072 assert_eq!(list.visible_len(), 2);
1073 }
1074
1075 #[tokio::test]
1078 async fn selection_includes_leading_at_position_zero() {
1079 let src = VecSourceWithLead {
1080 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1081 };
1082 let mut list = SearchList::builder(src, noop_redraw())
1083 .filter(Filter::Fuzzy)
1084 .initial_query("alp")
1085 .build();
1086 list.poll_until_idle().await;
1087 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1089 list.handle_key(&key(KeyCode::Down));
1090 assert_eq!(list.selected_row().unwrap().name, "alpha");
1091 }
1092
1093 #[tokio::test]
1095 async fn no_leading_row_visible_len_matches_display() {
1096 let src = VecSource {
1097 rows: vec![TestRow::new("a"), TestRow::new("b")],
1098 reload: true,
1099 };
1100 let mut list = SearchList::builder(src, noop_redraw()).build();
1101 list.poll_until_idle().await;
1102 assert_eq!(list.visible_len(), 2);
1103 assert_eq!(list.visible_rows().len(), 2);
1104 assert_eq!(list.selected_row().unwrap().name, "a");
1105 }
1106}