1#[cfg(test)]
5mod adapters;
6mod host;
7mod load;
8mod resolving;
9mod seams;
10
11pub use resolving::{ResolvingRowSource, Unresolvable};
12pub use seams::{
13 Emit, Filter, Loaded, RowSource, SearchRow, SuggestionItem, SuggestionSource, VaultSuggestions,
14};
15
16use crate::components::autocomplete::{
17 AutocompleteController, AutocompleteMode, HandleKeyOutcome, TriggerOptions,
18};
19use crate::components::single_line_input::{InputOutcome, SingleLineInput};
20use crate::keys::key_combo::KeyCombo;
21use crate::settings::icons::Icons;
22use crate::settings::themes::Theme;
23use load::LoadEngine;
24use ratatui::crossterm::event::KeyEvent;
25use ratatui::{
26 Frame,
27 layout::Rect,
28 style::Style,
29 widgets::{List, ListItem, ListState},
30};
31use seams::Loaded as LoadedInner;
32use std::sync::Arc;
33
34fn fuzzy_indices<R: SearchRow>(rows: &[R], query: &str) -> Vec<usize> {
35 use nucleo::pattern::{CaseMatching, Normalization, Pattern};
36 use nucleo::{Matcher, Utf32Str};
37 let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
38 let pat = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
39 let mut scored: Vec<(usize, u32)> = rows
40 .iter()
41 .enumerate()
42 .filter_map(|(i, r)| {
43 let hay = r.match_text()?;
44 let mut buf = Vec::new();
45 let h = Utf32Str::new(hay, &mut buf);
46 pat.score(h, &mut matcher).map(|s| (i, s))
47 })
48 .collect();
49 scored.sort_by_key(|&(_, s)| std::cmp::Reverse(s));
50 scored.into_iter().map(|(i, _)| i).collect()
51}
52
53#[derive(Debug, PartialEq, Eq)]
55pub enum KeyReaction {
56 Consumed,
57 Submit,
58 Cancel,
59 Intercepted(crate::keys::key_combo::KeyCombo),
60 Unhandled,
61}
62
63pub struct SearchList<R: SearchRow> {
64 source: Arc<dyn RowSource<R>>,
65 rows: Vec<R>,
66 display: Vec<usize>,
68 leading: Option<R>,
74 selected: Option<usize>,
77 offset: usize,
82 filter: Filter<R>,
83 query: String,
84 loader: LoadEngine<R>,
85 input: SingleLineInput,
86 autocomplete: Option<AutocompleteController>,
87 intercept: Vec<KeyCombo>,
89 icons: Icons,
90 list_rect: Rect,
91 panel_rect: Rect,
97 content_rect: Rect,
106 applied_generation: u64,
111 accepted_saved_search: Option<String>,
115 last_click_pos: Option<usize>,
119 highlight_query: bool,
122}
123
124#[derive(Debug, PartialEq, Eq)]
126pub enum SearchMouse {
127 Selected(usize),
128 Activated(usize),
129 Context(usize),
132 Scrolled,
133 ContentScrollUp,
137 ContentScrollDown,
138 None,
139}
140
141pub struct SearchListBuilder<R: SearchRow> {
142 source: Arc<dyn RowSource<R>>,
143 redraw: Arc<dyn Fn() + Send + Sync>,
144 initial_query: String,
145 filter: Filter<R>,
146 autocomplete: Option<(Arc<dyn SuggestionSource>, AutocompleteMode)>,
147 intercept: Vec<KeyCombo>,
148 icons: Icons,
149 debounce: Option<std::time::Duration>,
150 highlight_query: bool,
151}
152
153impl<R: SearchRow> SearchList<R> {
154 pub fn builder(
155 source: impl RowSource<R>,
156 redraw: Arc<dyn Fn() + Send + Sync>,
157 ) -> SearchListBuilder<R> {
158 SearchListBuilder {
159 source: Arc::new(source),
160 redraw,
161 initial_query: String::new(),
162 filter: Filter::SourceOrder,
163 autocomplete: None,
164 intercept: Vec::new(),
165 icons: Icons::new(false),
166 debounce: None,
167 highlight_query: false,
168 }
169 }
170
171 fn new(b: SearchListBuilder<R>) -> Self {
172 let mut loader = LoadEngine::new(b.redraw.clone());
173 loader.start(b.source.clone(), b.initial_query.clone());
174 let input = SingleLineInput::with_value(&b.initial_query);
175 let debounce = b.debounce;
176 let autocomplete = b.autocomplete.map(|(suggestions, mode)| {
177 let mut ac =
178 AutocompleteController::new(suggestions, mode).with_trigger_opts(TriggerOptions {
179 disambiguate_header: false,
180 apply_exclusion_zone: false,
181 ..TriggerOptions::default()
184 });
185 if let Some(d) = debounce {
186 ac = ac.with_debounce(d);
187 }
188 ac.set_redraw_callback(b.redraw.clone());
189 ac
190 });
191 Self {
192 source: b.source,
193 rows: Vec::new(),
194 display: Vec::new(),
195 leading: None,
196 selected: None,
197 offset: 0,
198 filter: b.filter,
199 query: b.initial_query,
200 loader,
201 input,
202 highlight_query: b.highlight_query,
203 last_click_pos: None,
204 autocomplete,
205 intercept: b.intercept,
206 icons: b.icons,
207 list_rect: Rect::default(),
208 panel_rect: Rect::default(),
209 content_rect: Rect::default(),
210 applied_generation: 0,
211 accepted_saved_search: None,
212 }
213 }
214
215 pub fn poll(&mut self) {
216 let drained = self.loader.drain();
217 if !drained.is_empty() {
218 let current_gen = self.loader.generation();
222 if current_gen != self.applied_generation {
223 self.rows.clear();
224 self.selected = None;
225 self.offset = 0;
226 self.applied_generation = current_gen;
227 }
228 for ev in drained {
229 match ev {
230 LoadedInner::Replace(rows) => {
231 self.rows = rows;
232 }
233 LoadedInner::Push(row) => {
234 self.rows.push(row);
235 }
236 LoadedInner::Done => {}
237 }
238 }
239 self.recompute_and_seed();
240 }
241 if let Some(ac) = &mut self.autocomplete {
242 ac.poll_results();
243 }
244 }
245
246 fn recompute_and_seed(&mut self) {
251 self.recompute_display();
252 if self.selected.is_none() && self.visible_len() > 0 {
253 self.selected = Some(0);
254 }
255 }
256
257 fn autocomplete_snapshot(&self) -> host::SearchBoxHostSnapshot {
261 let value = self.input.value().to_string();
262 let cursor_byte = self.input.cursor_byte();
263 let col = value[..cursor_byte.min(value.len())].chars().count();
264 host::SearchBoxHostSnapshot {
265 lines: vec![value],
266 cursor: (0, col),
267 caret_pos: self.input.last_caret_pos(),
268 }
269 }
270
271 fn clamp_selection(&mut self) {
272 let len = self.visible_len();
273 self.selected = if len == 0 {
274 None
275 } else {
276 Some(self.selected.unwrap_or(0).min(len - 1))
277 };
278 }
279
280 fn leading_offset(&self) -> usize {
282 self.leading.is_some() as usize
283 }
284
285 pub fn visible_len(&self) -> usize {
287 self.leading_offset() + self.display.len()
288 }
289
290 pub fn match_count(&self) -> usize {
293 self.display.len()
294 }
295
296 fn visible_row(&self, pos: usize) -> Option<&R> {
298 if self.leading.is_some() && pos == 0 {
299 self.leading.as_ref()
300 } else {
301 self.rows
302 .get(*self.display.get(pos - self.leading_offset())?)
303 }
304 }
305
306 pub fn rows(&self) -> &[R] {
310 &self.rows
311 }
312
313 pub fn selected_row(&self) -> Option<&R> {
314 self.selected.and_then(|p| self.visible_row(p))
315 }
316
317 pub fn visible_rows(&self) -> Vec<&R> {
318 (0..self.visible_len())
319 .filter_map(|p| self.visible_row(p))
320 .collect()
321 }
322
323 pub fn query(&self) -> &str {
324 &self.query
325 }
326
327 pub fn take_accepted_saved_search(&mut self) -> Option<String> {
331 self.accepted_saved_search.take()
332 }
333
334 #[cfg(test)]
337 pub(crate) fn input_value(&self) -> &str {
338 self.input.value()
339 }
340 pub fn is_loading(&self) -> bool {
341 self.loader.loading
342 }
343
344 pub fn set_query(&mut self, q: impl Into<String>) {
353 let q = q.into();
354 self.input.set_value(q.clone());
355 self.query = q;
356 self.requery();
357 }
358
359 fn sync_query_from_input(&mut self) {
364 self.query = self.input.value().to_string();
365 self.requery();
366 }
367
368 fn requery(&mut self) {
371 if self.source.reload_on_query() {
372 self.loader.start(self.source.clone(), self.query.clone());
373 }
374 self.recompute_and_seed();
378 }
379
380 pub fn reload(&mut self) {
382 self.loader.start(self.source.clone(), self.query.clone());
383 }
384
385 pub fn update_rows(&mut self, mut mutate: impl FnMut(&mut R) -> bool) -> bool {
394 let mut changed = false;
395 for row in &mut self.rows {
396 if mutate(row) {
397 changed = true;
398 }
399 }
400 if changed {
401 self.recompute_display();
402 }
403 changed
404 }
405
406 pub fn select_next(&mut self) {
407 let n = self.visible_len();
408 if n == 0 {
409 return;
410 }
411 self.selected = Some(self.selected.map_or(0, |i| (i + 1).min(n - 1)));
412 }
413
414 pub fn select_prev(&mut self) {
415 if self.visible_len() == 0 {
416 return;
417 }
418 self.selected = Some(self.selected.map_or(0, |i| i.saturating_sub(1)));
419 }
420
421 fn max_scroll_offset(&self) -> usize {
426 let viewport = self.list_rect.height as usize;
427 let n = self.visible_len();
428 if viewport == 0 || n == 0 {
429 return 0;
430 }
431 let mut budget = viewport;
432 let mut first = n;
433 while first > 0 {
434 let h = self
435 .visible_row(first - 1)
436 .map(|r| r.visual_height() as usize)
437 .unwrap_or(1);
438 if h > budget {
439 break;
440 }
441 budget -= h;
442 first -= 1;
443 }
444 first.min(n - 1)
445 }
446
447 pub fn scroll_down(&mut self) {
451 let n = self.visible_len();
452 if n == 0 || self.offset >= self.max_scroll_offset() {
453 return;
454 }
455 self.offset += 1;
456 self.selected = self.selected.map(|i| (i + 1).min(n - 1));
457 }
458
459 pub fn scroll_up(&mut self) {
462 if self.offset == 0 {
463 return;
464 }
465 self.offset -= 1;
466 self.selected = self.selected.map(|i| i.saturating_sub(1));
467 }
468
469 #[cfg(test)]
472 pub(crate) fn scroll_offset(&self) -> usize {
473 self.offset
474 }
475
476 pub fn handle_key(&mut self, key: &KeyEvent) -> KeyReaction {
477 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
478
479 if let Some(combo) = crate::keys::key_event_to_combo(key)
482 && self.intercept.contains(&combo)
483 {
484 return KeyReaction::Intercepted(combo);
485 }
486
487 if self.autocomplete.as_ref().is_some_and(|ac| ac.is_open()) {
491 let snap = self.autocomplete_snapshot();
492 if let Some(ac) = &mut self.autocomplete {
493 match ac.handle_key(*key, &snap) {
494 HandleKeyOutcome::Accepted(action) => {
495 self.input.replace_range_bytes(
496 action.range.clone(),
497 &action.new_text,
498 action.new_cursor_byte,
499 );
500 self.accepted_saved_search = action.saved_search_name;
505 self.sync_query_from_input();
506 return KeyReaction::Consumed;
507 }
508 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
509 return KeyReaction::Consumed;
510 }
511 HandleKeyOutcome::NotHandled => {}
512 }
513 }
514 }
515
516 match key.code {
517 KeyCode::Up => {
518 self.select_prev();
519 return KeyReaction::Consumed;
520 }
521 KeyCode::Down => {
522 self.select_next();
523 return KeyReaction::Consumed;
524 }
525 KeyCode::Enter => return KeyReaction::Submit,
526 KeyCode::Esc => return KeyReaction::Cancel,
527 _ => {}
528 }
529 if let KeyCode::Char(_) = key.code {
531 let non_shift = key.modifiers - KeyModifiers::SHIFT;
532 if !non_shift.is_empty() {
533 return KeyReaction::Unhandled;
534 }
535 }
536 let outcome = self.input.handle_key(key);
537 let snap = self.autocomplete_snapshot();
540 match outcome {
541 InputOutcome::Changed => {
542 if let Some(ac) = &mut self.autocomplete {
543 ac.sync(&snap);
544 }
545 }
546 InputOutcome::Consumed => {
547 if let Some(ac) = &mut self.autocomplete {
548 ac.refresh_if_open(&snap);
549 }
550 }
551 InputOutcome::Cancel | InputOutcome::Submit => {
552 if let Some(ac) = &mut self.autocomplete {
553 ac.close();
554 }
555 }
556 InputOutcome::NotConsumed => {}
557 }
558 match outcome {
559 InputOutcome::Changed => {
560 self.sync_query_from_input();
561 KeyReaction::Consumed
562 }
563 InputOutcome::Consumed => KeyReaction::Consumed,
564 InputOutcome::Submit => KeyReaction::Submit,
565 InputOutcome::Cancel => KeyReaction::Cancel,
566 InputOutcome::NotConsumed => KeyReaction::Unhandled,
567 }
568 }
569
570 pub fn render_query(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
571 let base = Style::default()
572 .fg(theme.fg.to_ratatui())
573 .bg(theme.bg_panel.to_ratatui());
574 if self.highlight_query {
575 let line =
576 crate::components::query_highlight::highlight_line(self.input.value(), theme, base);
577 self.input.render_line(f, area, line, base, 0, focused);
578 } else {
579 self.input.render(f, area, base, 0, focused);
580 }
581 }
582
583 pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, focused: bool) {
584 self.poll();
585 let sel = self.selected;
586 let items: Vec<ListItem> = (0..self.visible_len())
587 .filter_map(|pos| {
588 self.visible_row(pos)
589 .map(|r| r.to_list_item(theme, &self.icons, sel == Some(pos)))
590 })
591 .collect();
592 let mut state = ListState::default().with_offset(self.offset);
593 state.select(self.selected);
594 let list =
595 List::new(items).highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
596 f.render_stateful_widget(list, area, &mut state);
597 self.offset = state.offset();
601 self.list_rect = area;
602 let _ = focused;
603 }
604
605 pub fn set_list_rect(&mut self, rect: Rect) {
614 self.list_rect = rect;
615 }
616
617 pub fn set_panel_rect(&mut self, rect: Rect) {
622 self.panel_rect = rect;
623 }
624
625 pub fn set_content_rect(&mut self, rect: Rect) {
633 self.content_rect = rect;
634 }
635
636 #[cfg(test)]
640 pub(crate) fn content_rect(&self) -> Rect {
641 self.content_rect
642 }
643
644 pub fn render_autocomplete(&mut self, f: &mut Frame, clamp: Rect, theme: &Theme) {
645 if let Some(ac) = &mut self.autocomplete {
646 ac.poll_results();
647 let caret = self.input.last_caret_pos();
648 if let (Some(state), Some(anchor)) = (ac.state_mut(), caret) {
649 state.anchor = anchor;
650 }
651 if let Some(state) = ac.state() {
652 crate::components::autocomplete::render(f, state, clamp, theme);
653 }
654 }
655 }
656
657 pub fn close_autocomplete(&mut self) {
664 if let Some(ac) = &mut self.autocomplete {
665 ac.close();
666 }
667 }
668
669 #[cfg(test)]
672 pub(crate) fn autocomplete_is_open(&self) -> bool {
673 self.autocomplete.as_ref().is_some_and(|ac| ac.is_open())
674 }
675
676 pub fn handle_mouse(&mut self, m: &ratatui::crossterm::event::MouseEvent) -> SearchMouse {
677 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
678 use ratatui::layout::Position;
679 self.close_autocomplete();
682 let pos = Position {
683 x: m.column,
684 y: m.row,
685 };
686 if matches!(
690 m.kind,
691 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
692 ) {
693 if !self.content_rect.is_empty() && self.content_rect.contains(pos) {
697 return if m.kind == MouseEventKind::ScrollUp {
698 SearchMouse::ContentScrollUp
699 } else {
700 SearchMouse::ContentScrollDown
701 };
702 }
703 let bounds = if self.panel_rect.is_empty() {
704 self.list_rect
705 } else {
706 self.panel_rect
707 };
708 if !bounds.contains(pos) {
709 return SearchMouse::None;
710 }
711 if m.kind == MouseEventKind::ScrollUp {
712 self.scroll_up();
713 } else {
714 self.scroll_down();
715 }
716 return SearchMouse::Scrolled;
717 }
718 let r = self.list_rect;
719 if !r.contains(pos) {
720 return SearchMouse::None;
721 }
722 match m.kind {
723 MouseEventKind::Down(MouseButton::Left | MouseButton::Right) if m.row >= r.y => {
724 let right_click = matches!(m.kind, MouseEventKind::Down(MouseButton::Right));
725 let target_visual = m.row - r.y; let mut acc: u16 = 0;
727 let mut hit: Option<usize> = None;
728 for pos in self.offset..self.visible_len() {
733 let h = self
734 .visible_row(pos)
735 .map(|r| r.visual_height())
736 .unwrap_or(1);
737 if target_visual < acc + h {
738 hit = Some(pos);
739 break;
740 }
741 acc += h;
742 }
743 if let Some(pos) = hit {
744 let prev = self.selected;
745 let prev_click = self.last_click_pos.replace(pos);
746 self.selected = Some(pos);
747 return if right_click {
748 SearchMouse::Context(pos)
749 } else if prev == Some(pos) && prev_click == Some(pos) {
750 SearchMouse::Activated(pos)
753 } else {
754 SearchMouse::Selected(pos)
755 };
756 }
757 SearchMouse::None
758 }
759 _ => SearchMouse::None,
760 }
761 }
762
763 fn recompute_display(&mut self) {
764 let q = self.query.trim();
765 self.leading = self.source.leading_row(q);
768 let mut idx: Vec<usize> = match &self.filter {
769 Filter::SourceOrder => (0..self.rows.len()).collect(),
770 Filter::Fuzzy if q.is_empty() => (0..self.rows.len()).collect(),
771 Filter::Fuzzy => fuzzy_indices(&self.rows, q),
772 Filter::Rank(_) if q.is_empty() => (0..self.rows.len()).collect(),
773 Filter::Rank(f) => {
774 let f = f.clone();
775 f(&self.rows, q)
776 }
777 };
778 for i in 0..self.rows.len() {
781 if self.rows[i].match_text().is_none() && !idx.contains(&i) {
782 idx.insert(0, i);
783 }
784 }
785 self.display = idx;
786 self.clamp_selection();
787 }
788
789 #[cfg(test)]
790 pub(crate) async fn poll_until_idle(&mut self) {
791 for _ in 0..600 {
797 tokio::task::yield_now().await;
798 self.poll();
799 if !self.is_loading() {
800 break;
801 }
802 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
803 }
804 self.poll();
805 }
806}
807
808impl<R: SearchRow> SearchListBuilder<R> {
809 pub fn initial_query(mut self, q: impl Into<String>) -> Self {
810 self.initial_query = q.into();
811 self
812 }
813 pub fn filter(mut self, f: Filter<R>) -> Self {
814 self.filter = f;
815 self
816 }
817 pub fn autocomplete(
818 mut self,
819 suggestions: Arc<dyn SuggestionSource>,
820 mode: AutocompleteMode,
821 ) -> Self {
822 self.autocomplete = Some((suggestions, mode));
823 self
824 }
825 pub fn intercept(mut self, v: Vec<KeyCombo>) -> Self {
826 self.intercept = v;
827 self
828 }
829 pub fn highlight_query(mut self) -> Self {
831 self.highlight_query = true;
832 self
833 }
834 pub fn icons(mut self, icons: Icons) -> Self {
835 self.icons = icons;
836 self
837 }
838 pub fn debounce(mut self, d: std::time::Duration) -> Self {
841 self.debounce = Some(d);
842 self
843 }
844 pub fn build(self) -> SearchList<R> {
845 SearchList::new(self)
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::adapters::{
852 ReloadWithLeadSource, ScriptedStreamLeadSource, ScriptedStreamSource, StreamRow, TestRow,
853 VecSource, VecSourceWithLead,
854 };
855 use super::*;
856 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
857
858 fn noop_redraw() -> std::sync::Arc<dyn Fn() + Send + Sync> {
859 std::sync::Arc::new(|| {})
860 }
861
862 fn key(c: KeyCode) -> KeyEvent {
863 KeyEvent::new(c, KeyModifiers::NONE)
864 }
865
866 fn mouse_down_at(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
867 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
868 MouseEvent {
869 kind: MouseEventKind::Down(MouseButton::Left),
870 column: col,
871 row,
872 modifiers: KeyModifiers::NONE,
873 }
874 }
875
876 #[derive(Clone, Debug, PartialEq)]
877 struct TallRow {
878 name: String,
879 height: u16,
880 }
881 impl SearchRow for TallRow {
882 fn to_list_item(
883 &self,
884 _t: &crate::settings::themes::Theme,
885 _i: &crate::settings::icons::Icons,
886 _s: bool,
887 ) -> ratatui::widgets::ListItem<'static> {
888 ratatui::widgets::ListItem::new(self.name.clone())
889 }
890 fn visual_height(&self) -> u16 {
891 self.height
892 }
893 fn match_text(&self) -> Option<&str> {
894 Some(&self.name)
895 }
896 }
897 struct TallSource(Vec<TallRow>);
898 #[async_trait::async_trait]
899 impl RowSource<TallRow> for TallSource {
900 async fn load(&self, _q: &str, emit: Emit<TallRow>) {
901 emit.replace(self.0.clone());
902 }
903 }
904
905 #[tokio::test]
909 async fn wheel_in_content_rect_routes_to_host() {
910 use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
911 let rows: Vec<TallRow> = (0..10)
912 .map(|i| TallRow {
913 name: format!("r{}", i),
914 height: 1,
915 })
916 .collect();
917 let mut list = SearchList::builder(TallSource(rows), noop_redraw()).build();
918 list.poll_until_idle().await;
919 let rect = |y: u16, h: u16| ratatui::layout::Rect {
920 x: 0,
921 y,
922 width: 20,
923 height: h,
924 };
925 list.set_panel_rect(rect(0, 10));
927 list.set_list_rect(rect(0, 4));
928 list.set_content_rect(rect(5, 5));
929 let wheel = |kind: MouseEventKind, row: u16| MouseEvent {
930 kind,
931 column: 2,
932 row,
933 modifiers: KeyModifiers::NONE,
934 };
935
936 let m = wheel(MouseEventKind::ScrollDown, 6);
938 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollDown);
939 assert_eq!(list.offset, 0, "list viewport must not move");
940 let m = wheel(MouseEventKind::ScrollUp, 6);
941 assert_eq!(list.handle_mouse(&m), SearchMouse::ContentScrollUp);
942
943 let m = wheel(MouseEventKind::ScrollDown, 2);
945 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
946
947 list.set_content_rect(ratatui::layout::Rect::default());
949 let m = wheel(MouseEventKind::ScrollDown, 6);
950 assert_eq!(list.handle_mouse(&m), SearchMouse::Scrolled);
951 }
952
953 #[tokio::test]
954 async fn mouse_maps_visual_row_to_display_index_by_height() {
955 let src = TallSource(vec![
958 TallRow {
959 name: "a".into(),
960 height: 3,
961 },
962 TallRow {
963 name: "b".into(),
964 height: 1,
965 },
966 ]);
967 let mut list = SearchList::builder(src, noop_redraw()).build();
968 list.poll_until_idle().await;
969 list.set_list_rect(ratatui::layout::Rect {
971 x: 0,
972 y: 0,
973 width: 20,
974 height: 10,
975 });
976 let m = mouse_down_at(2, 3);
978 assert!(matches!(list.handle_mouse(&m), SearchMouse::Selected(1)));
979 assert_eq!(list.selected_row().unwrap().name, "b");
980 let m = mouse_down_at(2, 1);
982 list.handle_mouse(&m);
983 assert_eq!(list.selected_row().unwrap().name, "a");
984 }
985
986 #[tokio::test]
990 async fn scroll_moves_viewport_and_keeps_selection_screen_position() {
991 let src = VecSource {
992 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
993 reload: true,
994 };
995 let mut list = SearchList::builder(src, noop_redraw()).build();
996 list.poll_until_idle().await;
997 list.set_list_rect(ratatui::layout::Rect {
999 x: 0,
1000 y: 0,
1001 width: 20,
1002 height: 4,
1003 });
1004 list.select_next();
1006 list.select_next();
1007 assert_eq!(list.selected_row().unwrap().name, "row2");
1008
1009 let scroll = |kind| ratatui::crossterm::event::MouseEvent {
1010 kind,
1011 column: 1,
1012 row: 1,
1013 modifiers: KeyModifiers::NONE,
1014 };
1015 use ratatui::crossterm::event::MouseEventKind;
1016
1017 assert_eq!(
1019 list.handle_mouse(&scroll(MouseEventKind::ScrollDown)),
1020 SearchMouse::Scrolled
1021 );
1022 assert_eq!(list.scroll_offset(), 1);
1023 assert_eq!(list.selected_row().unwrap().name, "row3");
1024
1025 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1027 assert_eq!(list.scroll_offset(), 0);
1028 assert_eq!(list.selected_row().unwrap().name, "row2");
1029
1030 list.handle_mouse(&scroll(MouseEventKind::ScrollUp));
1032 assert_eq!(list.scroll_offset(), 0);
1033 assert_eq!(list.selected_row().unwrap().name, "row2");
1034
1035 for _ in 0..20 {
1038 list.handle_mouse(&scroll(MouseEventKind::ScrollDown));
1039 }
1040 assert_eq!(list.scroll_offset(), 6);
1041 assert_eq!(list.selected_row().unwrap().name, "row8");
1042 }
1045
1046 #[tokio::test]
1050 async fn scroll_hits_panel_rect_clicks_hit_list_rect() {
1051 let src = VecSource {
1052 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1053 reload: true,
1054 };
1055 let mut list = SearchList::builder(src, noop_redraw()).build();
1056 list.poll_until_idle().await;
1057 list.set_list_rect(ratatui::layout::Rect {
1059 x: 0,
1060 y: 5,
1061 width: 20,
1062 height: 4,
1063 });
1064 let scroll_at = |row| ratatui::crossterm::event::MouseEvent {
1065 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1066 column: 1,
1067 row,
1068 modifiers: KeyModifiers::NONE,
1069 };
1070 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::None);
1072 assert_eq!(list.scroll_offset(), 0);
1073 list.set_panel_rect(ratatui::layout::Rect {
1074 x: 0,
1075 y: 0,
1076 width: 20,
1077 height: 20,
1078 });
1079 assert_eq!(list.handle_mouse(&scroll_at(1)), SearchMouse::Scrolled);
1081 assert_eq!(list.scroll_offset(), 1);
1082 let before = list.selected_row().unwrap().name.clone();
1085 assert_eq!(list.handle_mouse(&mouse_down_at(1, 1)), SearchMouse::None);
1086 assert_eq!(list.selected_row().unwrap().name, before);
1087 }
1088
1089 #[tokio::test]
1093 async fn click_after_scroll_selects_the_clicked_row() {
1094 let src = VecSource {
1095 rows: (0..10).map(|i| TestRow::new(&format!("row{i}"))).collect(),
1096 reload: true,
1097 };
1098 let mut list = SearchList::builder(src, noop_redraw()).build();
1099 list.poll_until_idle().await;
1100 list.set_list_rect(ratatui::layout::Rect {
1101 x: 0,
1102 y: 0,
1103 width: 20,
1104 height: 4,
1105 });
1106 let scroll_down = ratatui::crossterm::event::MouseEvent {
1107 kind: ratatui::crossterm::event::MouseEventKind::ScrollDown,
1108 column: 1,
1109 row: 1,
1110 modifiers: KeyModifiers::NONE,
1111 };
1112 for _ in 0..3 {
1113 list.handle_mouse(&scroll_down);
1114 }
1115 assert_eq!(list.scroll_offset(), 3);
1116 assert!(matches!(
1118 list.handle_mouse(&mouse_down_at(2, 2)),
1119 SearchMouse::Selected(5)
1120 ));
1121 assert_eq!(list.selected_row().unwrap().name, "row5");
1122 list.handle_mouse(&mouse_down_at(2, 0));
1124 assert_eq!(list.selected_row().unwrap().name, "row3");
1125 }
1126
1127 #[tokio::test]
1128 async fn initial_load_populates_rows() {
1129 let src = VecSource {
1130 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1131 reload: true,
1132 };
1133 let mut list = SearchList::builder(src, noop_redraw()).build();
1134 list.poll_until_idle().await;
1135 assert_eq!(list.rows().len(), 2);
1136 assert_eq!(list.selected_row().map(|r| r.name.as_str()), Some("alpha"));
1137 }
1138
1139 #[tokio::test]
1140 async fn requery_supersedes_and_reloads() {
1141 let src = VecSource {
1142 rows: vec![
1143 TestRow::new("alpha"),
1144 TestRow::new("alps"),
1145 TestRow::new("beta"),
1146 ],
1147 reload: true,
1148 };
1149 let mut list = SearchList::builder(src, noop_redraw()).build();
1150 list.poll_until_idle().await;
1151 assert_eq!(list.rows().len(), 3);
1152 list.set_query("alp");
1153 list.poll_until_idle().await;
1154 assert_eq!(list.rows().len(), 2); assert!(list.rows().iter().all(|r| r.name.contains("alp")));
1156 }
1157
1158 #[tokio::test]
1159 async fn arrows_navigate_and_enter_submits() {
1160 let src = VecSource {
1161 rows: vec![TestRow::new("a"), TestRow::new("b")],
1162 reload: true,
1163 };
1164 let mut list = SearchList::builder(src, noop_redraw()).build();
1165 list.poll_until_idle().await;
1166 assert_eq!(list.handle_key(&key(KeyCode::Down)), KeyReaction::Consumed);
1167 assert_eq!(list.selected_row().unwrap().name, "b");
1168 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1169 assert_eq!(list.handle_key(&key(KeyCode::Esc)), KeyReaction::Cancel);
1170 }
1171
1172 #[tokio::test]
1173 async fn typing_a_char_changes_query() {
1174 let src = VecSource {
1175 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1176 reload: true,
1177 };
1178 let mut list = SearchList::builder(src, noop_redraw()).build();
1179 list.poll_until_idle().await;
1180 assert_eq!(
1181 list.handle_key(&key(KeyCode::Char('a'))),
1182 KeyReaction::Consumed
1183 );
1184 list.poll_until_idle().await;
1185 assert_eq!(list.query(), "a");
1186 }
1187
1188 #[tokio::test]
1189 async fn rank_filter_orders_by_closure() {
1190 let src = VecSource {
1191 rows: vec![
1192 TestRow::new("todo"),
1193 TestRow::new("today"),
1194 TestRow::new("misc"),
1195 ],
1196 reload: false,
1197 };
1198 let rank = std::sync::Arc::new(|rows: &[TestRow], q: &str| -> Vec<usize> {
1199 let mut idx: Vec<usize> = (0..rows.len())
1200 .filter(|&i| rows[i].name.contains(q))
1201 .collect();
1202 idx.sort_by_key(|&i| if rows[i].name == q { 0 } else { 1 });
1203 idx
1204 });
1205 let mut list = SearchList::builder(src, noop_redraw())
1206 .filter(Filter::Rank(rank))
1207 .build();
1208 list.poll_until_idle().await;
1209 list.set_query("today");
1210 list.poll();
1211 assert_eq!(list.selected_row().unwrap().name, "today");
1212 }
1213
1214 #[tokio::test]
1215 async fn fuzzy_filter_narrows_local_set() {
1216 let src = VecSource {
1217 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1218 reload: false,
1219 };
1220 let mut list = SearchList::builder(src, noop_redraw())
1221 .filter(Filter::Fuzzy)
1222 .build();
1223 list.poll_until_idle().await;
1224 list.set_query("alp");
1225 list.poll();
1226 assert_eq!(list.visible_rows().len(), 1);
1227 assert_eq!(list.selected_row().unwrap().name, "alpha");
1228 }
1229
1230 #[tokio::test]
1231 async fn streamed_rows_arrive_then_done_and_filter_locally() {
1232 let src = ScriptedStreamSource {
1233 batches: vec![vec![TestRow::new("alpha")], vec![TestRow::new("beta")]],
1234 };
1235 let mut list = SearchList::builder(src, noop_redraw())
1236 .filter(Filter::Fuzzy)
1237 .build();
1238 list.poll_until_idle().await;
1239 assert_eq!(list.rows().len(), 2);
1240 assert!(!list.is_loading());
1241 list.set_query("alp");
1242 list.poll();
1243 assert_eq!(list.visible_rows().len(), 1);
1244 }
1245
1246 #[tokio::test]
1247 async fn source_order_unfiltered_passthrough() {
1248 let src = VecSource {
1249 rows: vec![TestRow::new("a"), TestRow::new("b")],
1250 reload: true,
1251 };
1252 let mut list = SearchList::builder(src, noop_redraw()).build(); list.poll_until_idle().await;
1254 assert_eq!(list.visible_rows().len(), 2);
1255 assert_eq!(list.selected_row().unwrap().name, "a");
1256 }
1257
1258 #[tokio::test]
1259 async fn intercepted_combo_returns_intercepted_without_acting() {
1260 let src = VecSource {
1261 rows: vec![TestRow::new("a")],
1262 reload: true,
1263 };
1264 let combo = crate::keys::key_event_to_combo(&key(KeyCode::Enter)).unwrap();
1265 let mut list = SearchList::builder(src, noop_redraw())
1266 .intercept(vec![combo])
1267 .build();
1268 list.poll_until_idle().await;
1269 assert_eq!(
1271 list.handle_key(&key(KeyCode::Enter)),
1272 KeyReaction::Intercepted(combo)
1273 );
1274 }
1275
1276 #[tokio::test]
1277 async fn autocomplete_accept_rewrites_query_without_vault() {
1278 struct Mem;
1279 #[async_trait::async_trait]
1280 impl crate::components::search_list::SuggestionSource for Mem {
1281 async fn notes_by_prefix(
1282 &self,
1283 _p: &str,
1284 _n: usize,
1285 ) -> Vec<crate::components::search_list::SuggestionItem> {
1286 vec![]
1287 }
1288 async fn tags_by_prefix(
1289 &self,
1290 p: &str,
1291 _n: usize,
1292 ) -> Vec<crate::components::search_list::SuggestionItem> {
1293 if "projects".starts_with(p) {
1294 vec![crate::components::search_list::SuggestionItem::plain(
1295 "projects",
1296 )]
1297 } else {
1298 vec![]
1299 }
1300 }
1301 }
1302 let src = VecSource {
1303 rows: vec![],
1304 reload: true,
1305 };
1306 let mut list = SearchList::builder(src, noop_redraw())
1307 .autocomplete(
1308 std::sync::Arc::new(Mem),
1309 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1310 )
1311 .debounce(std::time::Duration::ZERO)
1312 .build();
1313 for c in ['#', 'p', 'r', 'o'] {
1314 let _ = list.handle_key(&key(KeyCode::Char(c)));
1315 }
1316 for _ in 0..50 {
1317 tokio::task::yield_now().await;
1318 list.poll();
1319 }
1320 let _ = list.handle_key(&key(KeyCode::Tab));
1321 assert_eq!(list.query(), "#projects");
1322 }
1323
1324 #[tokio::test]
1328 async fn accepting_saved_search_expands_query_and_exposes_name() {
1329 struct Mem;
1330 #[async_trait::async_trait]
1331 impl crate::components::search_list::SuggestionSource for Mem {
1332 async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1333 vec![]
1334 }
1335 async fn tags_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
1336 vec![]
1337 }
1338 async fn saved_searches_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
1339 if "todo-week".starts_with(p) {
1340 vec![SuggestionItem {
1341 display: "todo-week".into(),
1342 secondary: Some("#todo ^modified".into()),
1343 }]
1344 } else {
1345 vec![]
1346 }
1347 }
1348 }
1349 let src = VecSource {
1350 rows: vec![],
1351 reload: true,
1352 };
1353 let mut list = SearchList::builder(src, noop_redraw())
1354 .autocomplete(
1355 std::sync::Arc::new(Mem),
1356 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1357 )
1358 .debounce(std::time::Duration::ZERO)
1359 .build();
1360 for c in ['?', 't', 'o'] {
1361 let _ = list.handle_key(&key(KeyCode::Char(c)));
1362 }
1363 for _ in 0..50 {
1364 tokio::task::yield_now().await;
1365 list.poll();
1366 }
1367 let _ = list.handle_key(&key(KeyCode::Tab));
1368 assert_eq!(list.query(), "#todo ^modified");
1370 assert_eq!(
1372 list.take_accepted_saved_search().as_deref(),
1373 Some("todo-week")
1374 );
1375 assert_eq!(list.take_accepted_saved_search(), None);
1376 }
1377
1378 #[tokio::test]
1383 async fn enter_accepts_open_popup_and_reports_consumed() {
1384 struct Mem;
1385 #[async_trait::async_trait]
1386 impl crate::components::search_list::SuggestionSource for Mem {
1387 async fn notes_by_prefix(
1388 &self,
1389 _p: &str,
1390 _n: usize,
1391 ) -> Vec<crate::components::search_list::SuggestionItem> {
1392 vec![]
1393 }
1394 async fn tags_by_prefix(
1395 &self,
1396 p: &str,
1397 _n: usize,
1398 ) -> Vec<crate::components::search_list::SuggestionItem> {
1399 if "projects".starts_with(p) {
1400 vec![crate::components::search_list::SuggestionItem::plain(
1401 "projects",
1402 )]
1403 } else {
1404 vec![]
1405 }
1406 }
1407 }
1408 let src = VecSource {
1409 rows: vec![],
1410 reload: true,
1411 };
1412 let mut list = SearchList::builder(src, noop_redraw())
1413 .autocomplete(
1414 std::sync::Arc::new(Mem),
1415 crate::components::autocomplete::AutocompleteMode::SearchQuery,
1416 )
1417 .debounce(std::time::Duration::ZERO)
1418 .build();
1419 for c in ['#', 'p', 'r', 'o'] {
1420 let _ = list.handle_key(&key(KeyCode::Char(c)));
1421 }
1422 for _ in 0..50 {
1423 tokio::task::yield_now().await;
1424 list.poll();
1425 }
1426 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Consumed);
1428 assert_eq!(list.query(), "#projects");
1429 assert_eq!(list.handle_key(&key(KeyCode::Enter)), KeyReaction::Submit);
1431 }
1432
1433 #[tokio::test]
1438 async fn streamed_source_leading_row_is_pinned_and_query_fresh() {
1439 let src = ScriptedStreamLeadSource {
1440 items: vec!["alpha".into(), "beta".into()],
1441 };
1442 let mut list = SearchList::builder(src, noop_redraw())
1443 .filter(Filter::Fuzzy)
1444 .initial_query("zz")
1445 .build();
1446 list.poll_until_idle().await;
1447 let vis = list.visible_rows();
1449 assert_eq!(vis[0], &StreamRow::Create("zz".into()));
1450 assert_eq!(list.visible_len(), 1); list.set_query("alp");
1453 list.poll();
1454 let vis = list.visible_rows();
1455 assert_eq!(vis[0], &StreamRow::Create("alp".into()));
1456 assert_eq!(vis[1], &StreamRow::Item("alpha".into()));
1457 assert_eq!(list.visible_len(), 2);
1458 list.set_query("");
1460 list.poll();
1461 assert!(
1462 list.visible_rows()
1463 .iter()
1464 .all(|r| matches!(r, StreamRow::Item(_)))
1465 );
1466 assert_eq!(list.visible_len(), 2);
1467 }
1468
1469 #[tokio::test]
1472 async fn oneshot_source_leading_row_still_works() {
1473 let src = VecSourceWithLead {
1474 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1475 };
1476 let mut list = SearchList::builder(src, noop_redraw())
1477 .filter(Filter::Fuzzy)
1478 .initial_query("alp")
1479 .build();
1480 list.poll_until_idle().await;
1481 let vis = list.visible_rows();
1482 assert_eq!(vis[0].name, "create:alp");
1483 assert_eq!(vis[1].name, "alpha");
1484 assert_eq!(list.visible_len(), 2);
1485 }
1486
1487 #[tokio::test]
1490 async fn selection_includes_leading_at_position_zero() {
1491 let src = VecSourceWithLead {
1492 rows: vec![TestRow::new("alpha"), TestRow::new("alps")],
1493 };
1494 let mut list = SearchList::builder(src, noop_redraw())
1495 .filter(Filter::Fuzzy)
1496 .initial_query("alp")
1497 .build();
1498 list.poll_until_idle().await;
1499 assert_eq!(list.selected_row().unwrap().name, "create:alp");
1501 list.handle_key(&key(KeyCode::Down));
1502 assert_eq!(list.selected_row().unwrap().name, "alpha");
1503 }
1504
1505 #[tokio::test]
1507 async fn no_leading_row_visible_len_matches_display() {
1508 let src = VecSource {
1509 rows: vec![TestRow::new("a"), TestRow::new("b")],
1510 reload: true,
1511 };
1512 let mut list = SearchList::builder(src, noop_redraw()).build();
1513 list.poll_until_idle().await;
1514 assert_eq!(list.visible_len(), 2);
1515 assert_eq!(list.visible_rows().len(), 2);
1516 assert_eq!(list.selected_row().unwrap().name, "a");
1517 }
1518
1519 #[tokio::test]
1522 async fn update_rows_refilters_visible_view() {
1523 let source = VecSource {
1524 rows: vec![
1525 TestRow::new("alpha"),
1526 TestRow::new("beta"),
1527 TestRow::new("gamma"),
1528 ],
1529 reload: false,
1530 };
1531 let mut list = SearchList::builder(source, noop_redraw())
1532 .filter(Filter::Fuzzy)
1533 .build();
1534 list.poll_until_idle().await;
1535
1536 list.set_query("alp");
1538 list.poll();
1539 assert_eq!(
1540 list.visible_rows()
1541 .iter()
1542 .map(|r| r.name.as_str())
1543 .collect::<Vec<_>>(),
1544 vec!["alpha"],
1545 "before update: only 'alpha' matches 'alp'"
1546 );
1547
1548 let changed = list.update_rows(|r| {
1550 if r.name == "alpha" {
1551 r.name = "renamed".to_string();
1552 true
1553 } else {
1554 false
1555 }
1556 });
1557 assert!(changed);
1558
1559 assert_eq!(
1561 list.visible_rows().len(),
1562 0,
1563 "after renaming 'alpha' -> 'renamed', nothing should match 'alp'"
1564 );
1565 }
1566
1567 #[tokio::test]
1568 async fn update_rows_mutates_in_place_and_recomputes() {
1569 let source = VecSource {
1570 rows: vec![TestRow::new("alpha"), TestRow::new("beta")],
1571 reload: false,
1572 };
1573 let mut list = SearchList::builder(source, noop_redraw()).build();
1574 list.poll_until_idle().await;
1575
1576 let changed = list.update_rows(|r| {
1578 if r.name == "alpha" {
1579 r.name = "renamed".to_string();
1580 true
1581 } else {
1582 false
1583 }
1584 });
1585 assert!(changed, "a row was changed");
1586 assert!(
1587 list.rows().iter().any(|r| r.name == "renamed"),
1588 "the mutation is visible in rows()"
1589 );
1590
1591 let changed_again = list.update_rows(|_| false);
1593 assert!(!changed_again, "no row changed");
1594 }
1595
1596 #[tokio::test]
1603 async fn reload_source_leading_row_updates_synchronously_on_set_query() {
1604 let src = ReloadWithLeadSource {
1605 rows: vec![
1606 TestRow::new("alpha"),
1607 TestRow::new("beta"),
1608 TestRow::new("gamma"),
1609 ],
1610 };
1611 let mut list = SearchList::builder(src, noop_redraw()).build();
1612 list.poll_until_idle().await;
1613 assert!(list.leading.is_none(), "no leading row for empty query");
1615
1616 list.set_query("alp");
1618
1619 let vis = list.visible_rows();
1621 assert!(
1622 !vis.is_empty(),
1623 "visible_rows must not be empty right after set_query"
1624 );
1625 assert_eq!(
1626 vis[0].name, "create:alp",
1627 "leading row must show new query synchronously, before any poll/drain"
1628 );
1629
1630 list.poll_until_idle().await;
1633 let vis = list.visible_rows();
1634 assert_eq!(
1635 vis[0].name, "create:alp",
1636 "leading row correct after drain too"
1637 );
1638 assert_eq!(vis.len(), 2, "leading + alpha");
1640 assert_eq!(vis[1].name, "alpha");
1641 }
1642
1643 #[tokio::test]
1651 async fn local_filter_reseed_after_empty_then_repopulate() {
1652 let src = VecSource {
1653 rows: vec![
1654 TestRow::new("alpha"),
1655 TestRow::new("beta"),
1656 TestRow::new("gamma"),
1657 ],
1658 reload: false,
1659 };
1660 let mut list = SearchList::builder(src, noop_redraw())
1661 .filter(Filter::Fuzzy)
1662 .build();
1663 list.poll_until_idle().await;
1664
1665 assert!(
1667 list.selected_row().is_some(),
1668 "should have a selection after initial load"
1669 );
1670
1671 list.set_query("zzznomatch");
1673 assert_eq!(list.visible_len(), 0, "no rows should match 'zzznomatch'");
1674 assert!(
1675 list.selected_row().is_none(),
1676 "selection must be None when list is empty"
1677 );
1678
1679 list.set_query("alp");
1681 assert!(
1682 list.visible_len() > 0,
1683 "at least 'alpha' should match 'alp'"
1684 );
1685 assert!(
1688 list.selected_row().is_some(),
1689 "selection must be reseeded to first visible row after repopulation"
1690 );
1691 assert_eq!(
1692 list.selected_row().unwrap().name,
1693 "alpha",
1694 "first visible row must be selected after reseeding"
1695 );
1696 }
1697}