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 for ev in drained {
227 match ev {
228 LoadedInner::Replace(rows) => {
229 self.rows = rows;
230 }
231 LoadedInner::Push(row) => {
232 self.rows.push(row);
233 }
234 LoadedInner::Done => {}
235 }
236 }
237 self.recompute_and_seed();
238 }
239 if let Some(ac) = &mut self.autocomplete {
240 ac.poll_results();
241 }
242 }
243
244 fn recompute_and_seed(&mut self) {
249 self.recompute_display();
250 if self.selected.is_none() && self.visible_len() > 0 {
251 self.selected = Some(0);
252 }
253 }
254
255 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
259 let value = self.input.value().to_string();
260 let cursor_byte = self.input.cursor_byte();
261 let col = value[..cursor_byte.min(value.len())].chars().count();
262 host::SearchBoxHostSnapshot {
263 lines: vec![value],
264 cursor: (0, col),
265 caret_pos: self.input.last_caret_pos(),
266 }
267 }
268
269 fn clamp_selection(&mut self) {
270 let len = self.visible_len();
271 self.selected = if len == 0 {
272 None
273 } else {
274 Some(self.selected.unwrap_or(0).min(len - 1))
275 };
276 }
277
278 fn leading_offset(&self) -> usize {
280 self.leading.is_some() as usize
281 }
282
283 pub fn visible_len(&self) -> usize {
285 self.leading_offset() + self.display.len()
286 }
287
288 pub fn match_count(&self) -> usize {
291 self.display.len()
292 }
293
294 fn visible_row(&self, pos: usize) -> Option<&R> {
296 if self.leading.is_some() && pos == 0 {
297 self.leading.as_ref()
298 } else {
299 self.rows
300 .get(*self.display.get(pos - self.leading_offset())?)
301 }
302 }
303
304 pub fn rows(&self) -> &[R] {
308 &self.rows
309 }
310
311 pub fn selected_row(&self) -> Option<&R> {
312 self.selected.and_then(|p| self.visible_row(p))
313 }
314
315 pub fn visible_rows(&self) -> Vec<&R> {
316 (0..self.visible_len())
317 .filter_map(|p| self.visible_row(p))
318 .collect()
319 }
320
321 pub fn query(&self) -> &str {
322 &self.query
323 }
324
325 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
329 self.accepted_saved_search.take()
330 }
331
332 #[cfg(test)]
335 pub(crate) fn input_value(&self) -> &str {
336 self.input.value()
337 }
338 pub fn is_loading(&self) -> bool {
339 self.loader.loading
340 }
341
342 pub fn set_query(&mut self, q: impl Into<String>) {
351 let q = q.into();
352 self.input.set_value(q.clone());
353 self.query = q;
354 self.requery();
355 }
356
357 fn sync_query_from_input(&mut self) {
362 self.query = self.input.value().to_string();
363 self.requery();
364 }
365
366 fn requery(&mut self) {
369 if self.source.reload_on_query() {
370 self.loader.start(self.source.clone(), self.query.clone());
371 }
372 self.recompute_and_seed();
376 }
377
378 pub fn reload(&mut self) {
380 self.loader.start(self.source.clone(), self.query.clone());
381 }
382
383 pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
392 let mut changed = false;
393 for row in &mut self.rows {
394 if mutate(row) {
395 changed = true;
396 }
397 }
398 if changed {
399 self.recompute_display();
400 }
401 changed
402 }
403
404 pub fn select_next(&mut self) {
405 let n = self.visible_len();
406 if n == 0 {
407 return;
408 }
409 self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
410 }
411
412 pub fn select_prev(&mut self) {
413 if self.visible_len() == 0 {
414 return;
415 }
416 self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
417 }
418
419 fn max_scroll_offset(&self) -> usize {
424 let viewport = self.list_rect.height as usize;
425 let n = self.visible_len();
426 if viewport == 0 || n == 0 {
427 return 0;
428 }
429 let mut budget = viewport;
430 let mut first = n;
431 while first > 0 {
432 let h = self
433 .visible_row(first - 1)
434 .map(|r| r.visual_height() as usize)
435 .unwrap_or(1);
436 if h > budget {
437 break;
438 }
439 budget -= h;
440 first -= 1;
441 }
442 first.min(n - 1)
443 }
444
445 pub fn scroll_down(&mut self) {
449 let n = self.visible_len();
450 if n == 0 || self.offset >= self.max_scroll_offset() {
451 return;
452 }
453 self.offset += 1;
454 self.selected = self.selected.map(|i| (i + 1).min(n - 1));
455 }
456
457 pub fn scroll_up(&mut self) {
460 if self.offset == 0 {
461 return;
462 }
463 self.offset -= 1;
464 self.selected = self.selected.map(|i| i.saturating_sub(1));
465 }
466
467 #[cfg(test)]
470 pub(crate) fn scroll_offset(&self) -> usize {
471 self.offset
472 }
473
474 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
475 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
476
477 if let Some(combo) = crate::keys::key_event_to_combo(key)
480 && self.intercept.contains(&combo)
481 {
482 return KeyReaction::Intercepted(combo);
483 }
484
485 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
489 let snap = self.autocomplete_snapshot();
490 if let Some(ac) = &mut self.autocomplete {
491 match ac.handle_key(*key, &snap) {
492 HandleKeyOutcome::Accepted(action) => {
493 self.input.replace_range_bytes(
494 action.range.clone(),
495 &action.new_text,
496 action.new_cursor_byte,
497 );
498 self.accepted_saved_search = action.saved_search_name;
503 self.sync_query_from_input();
504 return KeyReaction::Consumed;
505 }
506 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
507 return KeyReaction::Consumed;
508 }
509 HandleKeyOutcome::NotHandled => {}
510 }
511 }
512 }
513
514 match key.code {
515 KeyCode::Up => {
516 self.select_prev();
517 return KeyReaction::Consumed;
518 }
519 KeyCode::Down => {
520 self.select_next();
521 return KeyReaction::Consumed;
522 }
523 KeyCode::Enter => return KeyReaction::Submit,
524 KeyCode::Esc => return KeyReaction::Cancel,
525 _ => {}
526 }
527 if let KeyCode::Char(_) = key.code {
529 let non_shift = key.modifiers - KeyModifiers::SHIFT;
530 if !non_shift.is_empty() {
531 return KeyReaction::Unhandled;
532 }
533 }
534 let outcome = self.input.handle_key(key);
535 let snap = self.autocomplete_snapshot();
538 match outcome {
539 InputOutcome::Changed => {
540 if let Some(ac) = &mut self.autocomplete {
541 ac.sync(&snap);
542 }
543 }
544 InputOutcome::Consumed => {
545 if let Some(ac) = &mut self.autocomplete {
546 ac.refresh_if_open(&snap);
547 }
548 }
549 InputOutcome::Cancel | InputOutcome::Submit => {
550 if let Some(ac) = &mut self.autocomplete {
551 ac.close();
552 }
553 }
554 InputOutcome::NotConsumed => {}
555 }
556 match outcome {
557 InputOutcome::Changed => {
558 self.sync_query_from_input();
559 KeyReaction::Consumed
560 }
561 InputOutcome::Consumed => KeyReaction::Consumed,
562 InputOutcome::Submit => KeyReaction::Submit,
563 InputOutcome::Cancel => KeyReaction::Cancel,
564 InputOutcome::NotConsumed => KeyReaction::Unhandled,
565 }
566 }
567
568 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
569 let base = Style::default()
570 .fg(theme.fg.to_ratatui())
571 .bg(theme.bg_panel.to_ratatui());
572 if self.highlight_query {
573 let line =
574 crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
575 self.input.render_line(f, area, line, base, 0, focused);
576 } else {
577 self.input.render(f, area, base, 0, focused);
578 }
579 }
580
581 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
582 self.poll();
583 let sel = self.selected;
584 let items: Vec<ListItem> = (0..self.visible_len())
585 .filter_map(|pos| {
586 self.visible_row(pos)
587 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
588 })
589 .collect();
590 let mut state = ListState::default().with_offset(self.offset);
591 state.select(self.selected);
592 let list =
593 List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
594 f.render_stateful_widget(list, area, &mut state);
595 self.offset = state.offset();
599 self.list_rect = area;
600 let _ = focused;
601 }
602
603 pub fn set_list_rect(&mut self, rect: Rect) {
612 self.list_rect = rect;
613 }
614
615 pub fn set_panel_rect(&mut self, rect: Rect) {
620 self.panel_rect = rect;
621 }
622
623 pub fn set_content_rect(&mut self, rect: Rect) {
631 self.content_rect = rect;
632 }
633
634 #[cfg(test)]
638 pub(crate) fn content_rect(&self) -> Rect {
639 self.content_rect
640 }
641
642 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
643 if let Some(ac) = &mut self.autocomplete {
644 ac.poll_results();
645 let caret = self.input.last_caret_pos();
646 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
647 state.anchor = anchor;
648 }
649 if let Some(state) = ac.state() {
650 crate::components::autocomplete::render(f, state, clamp, theme);
651 }
652 }
653 }
654
655 pub fn close_autocomplete(&mut self) {
662 if let Some(ac) = &mut self.autocomplete {
663 ac.close();
664 }
665 }
666
667 #[cfg(test)]
670 pub(crate) fn autocomplete_is_open(&self) -> bool {
671 self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
672 }
673
674 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
675 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
676 use ratatui::layout::Position;
677 self.close_autocomplete();
680 let pos = Position {
681 x: m.column,
682 y: m.row,
683 };
684 if matches!(
688 m.kind,
689 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
690 ) {
691 if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
695 return if m.kind == MouseEventKind::ScrollUp {
696 SearchMouse::ContentScrollUp
697 } else {
698 SearchMouse::ContentScrollDown
699 };
700 }
701 let bounds = if self.panel_rect.is_empty() {
702 self.list_rect
703 } else {
704 self.panel_rect
705 };
706 if !bounds.contains(pos) {
707 return SearchMouse::None;
708 }
709 if m.kind == MouseEventKind::ScrollUp {
710 self.scroll_up();
711 } else {
712 self.scroll_down();
713 }
714 return SearchMouse::Scrolled;
715 }
716 let r = self.list_rect;
717 if !r.contains(pos) {
718 return SearchMouse::None;
719 }
720 match m.kind {
721 MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
722 let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
723 let target_visual = m.row - r.y; let mut acc: u16 = 0;
725 let mut hit: Option<usize> = None;
726 for pos in self.offset..self.visible_len() {
731 let h = self
732 .visible_row(pos)
733 .map(|r| r.visual_height())
734 .unwrap_or(1);
735 if target_visual < acc + h {
736 hit = Some(pos);
737 break;
738 }
739 acc += h;
740 }
741 if let Some(pos) = hit {
742 let prev = self.selected;
743 let prev_click = self.last_click_pos.replace(pos);
744 self.selected = Some(pos);
745 return if right_click {
746 SearchMouse::Context(pos)
747 } else if prev == Some(pos) && prev_click == Some(pos) {
748 SearchMouse::Activated(pos)
751 } else {
752 SearchMouse::Selected(pos)
753 };
754 }
755 SearchMouse::None
756 }
757 _ => SearchMouse::None,
758 }
759 }
760
761 fn recompute_display(&mut self) {
762 let q = self.query.trim();
763 self.leading = self.source.leading_row(q);
766 let mut idx: Vec<usize> = match &self.filter {
767 Filter::SourceOrder => (0..self.rows.len()).collect(),
768 Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
769 Filter::Fuzzy => fuzzy_indices(&self.rows, q),
770 Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
771 Filter::Rank(f) => {
772 let f = f.clone();
773 f(&self.rows, q)
774 }
775 };
776 for i in 0..self.rows.len() {
779 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
780 idx.insert(0, i);
781 }
782 }
783 self.display = idx;
784 self.clamp_selection();
785 }
786
787 #[cfg(test)]
788 pub(crate) async fn poll_until_idle(&mut self) {
789 for _ in 0..600 {
795 tokio::task::yield_now().await;
796 self.poll();
797 if !self.is_loading() {
798 break;
799 }
800 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
801 }
802 self.poll();
803 }
804}
805
806impl<R: SearchRow> SearchListBuilder<R> {
807 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
808 self.initial_query = q.into();
809 self
810 }
811 pub fn filter(mut self, f: Filter<R>) -> Self {
812 self.filter = f;
813 self
814 }
815 pub fn autocomplete(
816 mut self,
817 suggestions: Arc<dyn SuggestionSource>,
818 mode: AutocompleteMode,
819 ) -> Self {
820 self.autocomplete = Some((suggestions, mode));
821 self
822 }
823 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
824 self.intercept = v;
825 self
826 }
827 pub fn highlight_query(mut self) -> Self {
829 self.highlight_query = true;
830 self
831 }
832 pub fn icons(mut self, icons: Icons) -> Self {
833 self.icons = icons;
834 self
835 }
836 pub fn debounce(mut self, d: std::time::Duration) -> Self {
839 self.debounce = Some(d);
840 self
841 }
842 pub fn build(self) -> SearchList<R> {
843 SearchList::new(self)
844 }
845}
846
847#[cfg(test)]
848mod tests {
849 use super::adapters::{
850 ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
851 VecSource, VecSourceWithLead,
852 };
853 use super::*;
854 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
855
856 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
857 std::sync::Arc::new(|| {})
858 }
859
860 fn key(c: KeyCode) -> KeyEvent {
861 KeyEvent::new(c, KeyModifiers::NONE)
862 }
863
864 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
865 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
866 MouseEvent {
867 kind: MouseEventKind::Down(MouseButton::Left),
868 column: col,
869 row,
870 modifiers: KeyModifiers::NONE,
871 }
872 }
873
874 #[derive(Clone, Debug, PartialEq)]
875 struct TallRow {
876 name: String,
877 height: u16,
878 }
879 impl SearchRow for TallRow {
880 fn to_list_item(
881 &self,
882 _t: &crate::settings::themes::Theme,
883 _i: &crate::settings::icons::Icons,
884 _s: bool,
885 ) -> ratatui::widgets::ListItem<'static> {
886 ratatui::widgets::ListItem::new(self.name.clone())
887 }
888 fn visual_height(&self) -> u16 {
889 self.height
890 }
891 fn match_text(&self) -> Option<&str> {
892 Some(&self.name)
893 }
894 }
895 struct TallSource(Vec<TallRow>);
896 #[async_trait::async_trait]
897 impl RowSource<TallRow> for TallSource {
898 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
899 emit.replace(self.0.clone());
900 }
901 }
902
903 #[tokio::test]
907 async fn wheel_in_content_rect_routes_to_host() {
908 use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
909 let rows: Vec<TallRow> = (0..10)
910 .map(|i| TallRow {
911 name: format!("r{}", i),
912 height: 1,
913 })
914 .collect();
915 let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
916 list.poll_until_idle().await;
917 let rect = |y: u16, h: u16| ratatui::layout::Rect {
918 x: 0,
919 y,
920 width: 20,
921 height: h,
922 };
923 list.set_panel_rect(rect(0, 10));
925 list.set_list_rect(rect(0, 4));
926 list.set_content_rect(rect(5, 5));
927 let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
928 kind,
929 column: 2,
930 row,
931 modifiers: KeyModifiers::NONE,
932 };
933
934 let m = wheel(MouseEventKind::ScrollDown, 6);
936 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
937 assert_eq!(list.offset, 0, "list viewport must not move");
938 let m = wheel(MouseEventKind::ScrollUp, 6);
939 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
940
941 let m = wheel(MouseEventKind::ScrollDown, 2);
943 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
944
945 list.set_content_rect(ratatui::layout::Rect::default());
947 let m = wheel(MouseEventKind::ScrollDown, 6);
948 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
949 }
950
951 #[tokio::test]
952 async fn mouse_maps_visual_row_to_display_index_by_height() {
953 let src = TallSource(vec![
956 TallRow {
957 name: "a".into(),
958 height: 3,
959 },
960 TallRow {
961 name: "b".into(),
962 height: 1,
963 },
964 ]);
965 let mut list = SearchList::builder(src, noop_redraw()).build();
966 list.poll_until_idle().await;
967 list.set_list_rect(ratatui::layout::Rect {
969 x: 0,
970 y: 0,
971 width: 20,
972 height: 10,
973 });
974 let m = mouse_down_at(2, 3);
976 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
977 assert_eq!(list.selected_row().unwrap().name, "b");
978 let m = mouse_down_at(2, 1);
980 list.handle_mouse(&m);
981 assert_eq!(list.selected_row().unwrap().name, "a");
982 }
983
984 #[tokio::test]
988 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
989 let src = VecSource {
990 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
991 reload: true,
992 };
993 let mut list = SearchList::builder(src, noop_redraw()).build();
994 list.poll_until_idle().await;
995 list.set_list_rect(ratatui::layout::Rect {
997 x: 0,
998 y: 0,
999 width: 20,
1000 height: 4,
1001 });
1002 list.select_next();
1004 list.select_next();
1005 assert_eq!(list.selected_row().unwrap().name, "row2");
1006
1007 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1008 kind,
1009 column: 1,
1010 row: 1,
1011 modifiers: KeyModifiers::NONE,
1012 };
1013 use ratatui::crossterm::event::MouseEventKind;
1014
1015 assert_eq!(
1017 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1018 SearchMouse::Scrolled
1019 );
1020 assert_eq!(list.scroll_offset(), 1);
1021 assert_eq!(list.selected_row().unwrap().name, "row3");
1022
1023 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1025 assert_eq!(list.scroll_offset(), 0);
1026 assert_eq!(list.selected_row().unwrap().name, "row2");
1027
1028 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1030 assert_eq!(list.scroll_offset(), 0);
1031 assert_eq!(list.selected_row().unwrap().name, "row2");
1032
1033 for _ in 0..20 {
1036 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1037 }
1038 assert_eq!(list.scroll_offset(), 6);
1039 assert_eq!(list.selected_row().unwrap().name, "row8");
1040 }
1043
1044 #[tokio::test]
1048 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1049 let src = VecSource {
1050 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1051 reload: true,
1052 };
1053 let mut list = SearchList::builder(src, noop_redraw()).build();
1054 list.poll_until_idle().await;
1055 list.set_list_rect(ratatui::layout::Rect {
1057 x: 0,
1058 y: 5,
1059 width: 20,
1060 height: 4,
1061 });
1062 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1063 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1064 column: 1,
1065 row,
1066 modifiers: KeyModifiers::NONE,
1067 };
1068 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1070 assert_eq!(list.scroll_offset(), 0);
1071 list.set_panel_rect(ratatui::layout::Rect {
1072 x: 0,
1073 y: 0,
1074 width: 20,
1075 height: 20,
1076 });
1077 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1079 assert_eq!(list.scroll_offset(), 1);
1080 let before = list.selected_row().unwrap().name.clone();
1083 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1084 assert_eq!(list.selected_row().unwrap().name, before);
1085 }
1086
1087 #[tokio::test]
1091 async fn click_after_scroll_selects_the_clicked_row() {
1092 let src = VecSource {
1093 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1094 reload: true,
1095 };
1096 let mut list = SearchList::builder(src, noop_redraw()).build();
1097 list.poll_until_idle().await;
1098 list.set_list_rect(ratatui::layout::Rect {
1099 x: 0,
1100 y: 0,
1101 width: 20,
1102 height: 4,
1103 });
1104 let scroll_down = ratatui::crossterm::event::MouseEvent {
1105 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1106 column: 1,
1107 row: 1,
1108 modifiers: KeyModifiers::NONE,
1109 };
1110 for _ in 0..3 {
1111 list.handle_mouse(&scroll_down);
1112 }
1113 assert_eq!(list.scroll_offset(), 3);
1114 assert!(matches!(
1116 list.handle_mouse(&mouse_down_at(2, 2)),
1117 SearchMouse::Selected(5)
1118 ));
1119 assert_eq!(list.selected_row().unwrap().name, "row5");
1120 list.handle_mouse(&mouse_down_at(2, 0));
1122 assert_eq!(list.selected_row().unwrap().name, "row3");
1123 }
1124
1125 #[tokio::test]
1126 async fn initial_load_populates_rows() {
1127 let src = VecSource {
1128 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1129 reload: true,
1130 };
1131 let mut list = SearchList::builder(src, noop_redraw()).build();
1132 list.poll_until_idle().await;
1133 assert_eq!(list.rows().len(), 2);
1134 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1135 }
1136
1137 #[tokio::test]
1138 async fn requery_supersedes_and_reloads() {
1139 let src = VecSource {
1140 rows: vec![
1141 TestRow::new("alpha"),
1142 TestRow::new("alps"),
1143 TestRow::new("beta"),
1144 ],
1145 reload: true,
1146 };
1147 let mut list = SearchList::builder(src, noop_redraw()).build();
1148 list.poll_until_idle().await;
1149 assert_eq!(list.rows().len(), 3);
1150 list.set_query("alp");
1151 list.poll_until_idle().await;
1152 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1154 }
1155
1156 #[tokio::test]
1157 async fn arrows_navigate_and_enter_submits() {
1158 let src = VecSource {
1159 rows: vec![TestRow::new("a"), TestRow::new("b")],
1160 reload: true,
1161 };
1162 let mut list = SearchList::builder(src, noop_redraw()).build();
1163 list.poll_until_idle().await;
1164 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1165 assert_eq!(list.selected_row().unwrap().name, "b");
1166 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1167 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1168 }
1169
1170 #[tokio::test]
1171 async fn typing_a_char_changes_query() {
1172 let src = VecSource {
1173 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1174 reload: true,
1175 };
1176 let mut list = SearchList::builder(src, noop_redraw()).build();
1177 list.poll_until_idle().await;
1178 assert_eq!(
1179 list.handle_key(&key(KeyCode::Char('a'))),
1180 KeyReaction::Consumed
1181 );
1182 list.poll_until_idle().await;
1183 assert_eq!(list.query(), "a");
1184 }
1185
1186 #[tokio::test]
1187 async fn rank_filter_orders_by_closure() {
1188 let src = VecSource {
1189 rows: vec![
1190 TestRow::new("todo"),
1191 TestRow::new("today"),
1192 TestRow::new("misc"),
1193 ],
1194 reload: false,
1195 };
1196 let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1197 let mut idx: Vec<usize> = (0..rows.len())
1198 .filter(|&i| rows[i].name.contains(q))
1199 .collect();
1200 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1201 idx
1202 });
1203 let mut list = SearchList::builder(src, noop_redraw())
1204 .filter(Filter::Rank(rank))
1205 .build();
1206 list.poll_until_idle().await;
1207 list.set_query("today");
1208 list.poll();
1209 assert_eq!(list.selected_row().unwrap().name, "today");
1210 }
1211
1212 #[tokio::test]
1213 async fn fuzzy_filter_narrows_local_set() {
1214 let src = VecSource {
1215 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1216 reload: false,
1217 };
1218 let mut list = SearchList::builder(src, noop_redraw())
1219 .filter(Filter::Fuzzy)
1220 .build();
1221 list.poll_until_idle().await;
1222 list.set_query("alp");
1223 list.poll();
1224 assert_eq!(list.visible_rows().len(), 1);
1225 assert_eq!(list.selected_row().unwrap().name, "alpha");
1226 }
1227
1228 #[tokio::test]
1229 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1230 let src = ScriptedStreamSource {
1231 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1232 };
1233 let mut list = SearchList::builder(src, noop_redraw())
1234 .filter(Filter::Fuzzy)
1235 .build();
1236 list.poll_until_idle().await;
1237 assert_eq!(list.rows().len(), 2);
1238 assert!(!list.is_loading());
1239 list.set_query("alp");
1240 list.poll();
1241 assert_eq!(list.visible_rows().len(), 1);
1242 }
1243
1244 #[tokio::test]
1245 async fn source_order_unfiltered_passthrough() {
1246 let src = VecSource {
1247 rows: vec![TestRow::new("a"), TestRow::new("b")],
1248 reload: true,
1249 };
1250 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1252 assert_eq!(list.visible_rows().len(), 2);
1253 assert_eq!(list.selected_row().unwrap().name, "a");
1254 }
1255
1256 #[tokio::test]
1257 async fn intercepted_combo_returns_intercepted_without_acting() {
1258 let src = VecSource {
1259 rows: vec![TestRow::new("a")],
1260 reload: true,
1261 };
1262 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1263 let mut list = SearchList::builder(src, noop_redraw())
1264 .intercept(vec![combo])
1265 .build();
1266 list.poll_until_idle().await;
1267 assert_eq!(
1269 list.handle_key(&key(KeyCode::Enter)),
1270 KeyReaction::Intercepted(combo)
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn autocomplete_accept_rewrites_query_without_vault() {
1276 struct Mem;
1277 #[async_trait::async_trait]
1278 impl crate::components::search_list::SuggestionSource for Mem {
1279 async fn notes_by_prefix(
1280 &self,
1281 _p: &str,
1282 _n: usize,
1283 ) -> Vec<crate::components::search_list::SuggestionItem> {
1284 vec![]
1285 }
1286 async fn tags_by_prefix(
1287 &self,
1288 p: &str,
1289 _n: usize,
1290 ) -> Vec<crate::components::search_list::SuggestionItem> {
1291 if "projects".starts_with(p) {
1292 vec![crate::components::search_list::SuggestionItem::plain(
1293 "projects",
1294 )]
1295 } else {
1296 vec![]
1297 }
1298 }
1299 }
1300 let src = VecSource {
1301 rows: vec![],
1302 reload: true,
1303 };
1304 let mut list = SearchList::builder(src, noop_redraw())
1305 .autocomplete(
1306 std::sync::Arc::new(Mem),
1307 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1308 )
1309 .debounce(std::time::Duration::ZERO)
1310 .build();
1311 for c in ['#', 'p', 'r', 'o'] {
1312 let _ = list.handle_key(&key(KeyCode::Char(c)));
1313 }
1314 for _ in 0..50 {
1315 tokio::task::yield_now().await;
1316 list.poll();
1317 }
1318 let _ = list.handle_key(&key(KeyCode::Tab));
1319 assert_eq!(list.query(), "#projects");
1320 }
1321
1322 #[tokio::test]
1326 async fn accepting_saved_search_expands_query_and_exposes_name() {
1327 struct Mem;
1328 #[async_trait::async_trait]
1329 impl crate::components::search_list::SuggestionSource for Mem {
1330 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1331 vec![]
1332 }
1333 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1334 vec![]
1335 }
1336 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1337 if "todo-week".starts_with(p) {
1338 vec![SuggestionItem {
1339 display: "todo-week".into(),
1340 secondary: Some("#todo ^modified".into()),
1341 }]
1342 } else {
1343 vec![]
1344 }
1345 }
1346 }
1347 let src = VecSource {
1348 rows: vec![],
1349 reload: true,
1350 };
1351 let mut list = SearchList::builder(src, noop_redraw())
1352 .autocomplete(
1353 std::sync::Arc::new(Mem),
1354 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1355 )
1356 .debounce(std::time::Duration::ZERO)
1357 .build();
1358 for c in ['?', 't', 'o'] {
1359 let _ = list.handle_key(&key(KeyCode::Char(c)));
1360 }
1361 for _ in 0..50 {
1362 tokio::task::yield_now().await;
1363 list.poll();
1364 }
1365 let _ = list.handle_key(&key(KeyCode::Tab));
1366 assert_eq!(list.query(), "#todo ^modified");
1368 assert_eq!(
1370 list.take_accepted_saved_search().as_deref(),
1371 Some("todo-week")
1372 );
1373 assert_eq!(list.take_accepted_saved_search(), None);
1374 }
1375
1376 #[tokio::test]
1381 async fn enter_accepts_open_popup_and_reports_consumed() {
1382 struct Mem;
1383 #[async_trait::async_trait]
1384 impl crate::components::search_list::SuggestionSource for Mem {
1385 async fn notes_by_prefix(
1386 &self,
1387 _p: &str,
1388 _n: usize,
1389 ) -> Vec<crate::components::search_list::SuggestionItem> {
1390 vec![]
1391 }
1392 async fn tags_by_prefix(
1393 &self,
1394 p: &str,
1395 _n: usize,
1396 ) -> Vec<crate::components::search_list::SuggestionItem> {
1397 if "projects".starts_with(p) {
1398 vec![crate::components::search_list::SuggestionItem::plain(
1399 "projects",
1400 )]
1401 } else {
1402 vec![]
1403 }
1404 }
1405 }
1406 let src = VecSource {
1407 rows: vec![],
1408 reload: true,
1409 };
1410 let mut list = SearchList::builder(src, noop_redraw())
1411 .autocomplete(
1412 std::sync::Arc::new(Mem),
1413 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1414 )
1415 .debounce(std::time::Duration::ZERO)
1416 .build();
1417 for c in ['#', 'p', 'r', 'o'] {
1418 let _ = list.handle_key(&key(KeyCode::Char(c)));
1419 }
1420 for _ in 0..50 {
1421 tokio::task::yield_now().await;
1422 list.poll();
1423 }
1424 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1426 assert_eq!(list.query(), "#projects");
1427 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1429 }
1430
1431 #[tokio::test]
1436 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1437 let src = ScriptedStreamLeadSource {
1438 items: vec!["alpha".into(), "beta".into()],
1439 };
1440 let mut list = SearchList::builder(src, noop_redraw())
1441 .filter(Filter::Fuzzy)
1442 .initial_query("zz")
1443 .build();
1444 list.poll_until_idle().await;
1445 let vis = list.visible_rows();
1447 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1448 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1451 list.poll();
1452 let vis = list.visible_rows();
1453 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1454 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1455 assert_eq!(list.visible_len(), 2);
1456 list.set_query("");
1458 list.poll();
1459 assert!(
1460 list.visible_rows()
1461 .iter()
1462 .all(|r| matches!(r, StreamRow::Item(_)))
1463 );
1464 assert_eq!(list.visible_len(), 2);
1465 }
1466
1467 #[tokio::test]
1470 async fn oneshot_source_leading_row_still_works() {
1471 let src = VecSourceWithLead {
1472 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1473 };
1474 let mut list = SearchList::builder(src, noop_redraw())
1475 .filter(Filter::Fuzzy)
1476 .initial_query("alp")
1477 .build();
1478 list.poll_until_idle().await;
1479 let vis = list.visible_rows();
1480 assert_eq!(vis[0].name, "create:alp");
1481 assert_eq!(vis[1].name, "alpha");
1482 assert_eq!(list.visible_len(), 2);
1483 }
1484
1485 #[tokio::test]
1488 async fn selection_includes_leading_at_position_zero() {
1489 let src = VecSourceWithLead {
1490 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1491 };
1492 let mut list = SearchList::builder(src, noop_redraw())
1493 .filter(Filter::Fuzzy)
1494 .initial_query("alp")
1495 .build();
1496 list.poll_until_idle().await;
1497 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1499 list.handle_key(&key(KeyCode::Down));
1500 assert_eq!(list.selected_row().unwrap().name, "alpha");
1501 }
1502
1503 #[tokio::test]
1505 async fn no_leading_row_visible_len_matches_display() {
1506 let src = VecSource {
1507 rows: vec![TestRow::new("a"), TestRow::new("b")],
1508 reload: true,
1509 };
1510 let mut list = SearchList::builder(src, noop_redraw()).build();
1511 list.poll_until_idle().await;
1512 assert_eq!(list.visible_len(), 2);
1513 assert_eq!(list.visible_rows().len(), 2);
1514 assert_eq!(list.selected_row().unwrap().name, "a");
1515 }
1516
1517 #[tokio::test]
1520 async fn update_rows_refilters_visible_view() {
1521 let source = VecSource {
1522 rows: vec![
1523 TestRow::new("alpha"),
1524 TestRow::new("beta"),
1525 TestRow::new("gamma"),
1526 ],
1527 reload: false,
1528 };
1529 let mut list = SearchList::builder(source, noop_redraw())
1530 .filter(Filter::Fuzzy)
1531 .build();
1532 list.poll_until_idle().await;
1533
1534 list.set_query("alp");
1536 list.poll();
1537 assert_eq!(
1538 list.visible_rows()
1539 .iter()
1540 .map(|r| r.name.as_str())
1541 .collect::<Vec<_>>(),
1542 vec!["alpha"],
1543 "before update: only 'alpha' matches 'alp'"
1544 );
1545
1546 let changed = list.update_rows(|r| {
1548 if r.name == "alpha" {
1549 r.name = "renamed".to_string();
1550 true
1551 } else {
1552 false
1553 }
1554 });
1555 assert!(changed);
1556
1557 assert_eq!(
1559 list.visible_rows().len(),
1560 0,
1561 "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
1562 );
1563 }
1564
1565 #[tokio::test]
1566 async fn update_rows_mutates_in_place_and_recomputes() {
1567 let source = VecSource {
1568 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1569 reload: false,
1570 };
1571 let mut list = SearchList::builder(source, noop_redraw()).build();
1572 list.poll_until_idle().await;
1573
1574 let changed = list.update_rows(|r| {
1576 if r.name == "alpha" {
1577 r.name = "renamed".to_string();
1578 true
1579 } else {
1580 false
1581 }
1582 });
1583 assert!(changed, "a row was changed");
1584 assert!(
1585 list.rows().iter().any(|r| r.name == "renamed"),
1586 "the mutation is visible in rows()"
1587 );
1588
1589 let changed_again = list.update_rows(|_| false);
1591 assert!(!changed_again, "no row changed");
1592 }
1593
1594 #[tokio::test]
1601 async fn reload_source_leading_row_updates_synchronously_on_set_query() {
1602 let src = ReloadWithLeadSource {
1603 rows: vec![
1604 TestRow::new("alpha"),
1605 TestRow::new("beta"),
1606 TestRow::new("gamma"),
1607 ],
1608 };
1609 let mut list = SearchList::builder(src, noop_redraw()).build();
1610 list.poll_until_idle().await;
1611 assert!(list.leading.is_none(), "no leading row for empty query");
1613
1614 list.set_query("alp");
1616
1617 let vis = list.visible_rows();
1619 assert!(
1620 !vis.is_empty(),
1621 "visible_rows must not be empty right after set_query"
1622 );
1623 assert_eq!(
1624 vis[0].name, "create:alp",
1625 "leading row must show new query synchronously, before any poll/drain"
1626 );
1627
1628 list.poll_until_idle().await;
1631 let vis = list.visible_rows();
1632 assert_eq!(
1633 vis[0].name, "create:alp",
1634 "leading row correct after drain too"
1635 );
1636 assert_eq!(vis.len(), 2, "leading + alpha");
1638 assert_eq!(vis[1].name, "alpha");
1639 }
1640
1641 #[tokio::test]
1649 async fn local_filter_reseed_after_empty_then_repopulate() {
1650 let src = VecSource {
1651 rows: vec![
1652 TestRow::new("alpha"),
1653 TestRow::new("beta"),
1654 TestRow::new("gamma"),
1655 ],
1656 reload: false,
1657 };
1658 let mut list = SearchList::builder(src, noop_redraw())
1659 .filter(Filter::Fuzzy)
1660 .build();
1661 list.poll_until_idle().await;
1662
1663 assert!(
1665 list.selected_row().is_some(),
1666 "should have a selection after initial load"
1667 );
1668
1669 list.set_query("zzznomatch");
1671 assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
1672 assert!(
1673 list.selected_row().is_none(),
1674 "selection must be None when list is empty"
1675 );
1676
1677 list.set_query("alp");
1679 assert!(
1680 list.visible_len() > 0,
1681 "at least 'alpha' should match 'alp'"
1682 );
1683 assert!(
1686 list.selected_row().is_some(),
1687 "selection must be reseeded to first visible row after repopulation"
1688 );
1689 assert_eq!(
1690 list.selected_row().unwrap().name,
1691 "alpha",
1692 "first visible row must be selected after reseeding"
1693 );
1694 }
1695}