1use ratatui::Frame;
11use ratatui::crossterm::event::KeyCode;
12use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
13use ratatui::style::Style;
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Block, Borders, Paragraph};
16
17use crate::components::event_state::EventState;
18use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
19use crate::components::panel::panel_block;
20use crate::components::search_list::{
21 Filter, KeyReaction, RowSource, SearchList, SearchMouse, SearchRow,
22};
23use crate::settings::icons::Icons;
24use crate::settings::themes::Theme;
25
26pub trait ListPanelSpec {
29 type Row: SearchRow + Clone + Send + Sync + 'static;
30
31 const TITLE: &'static str;
33 const HAS_FILTER: bool = true;
37
38 const BORDERED_INPUT: bool = false;
46
47 const LOCAL_FILTER: bool = true;
55
56 fn submit(row: &Self::Row, tx: &AppTx);
58
59 fn context_event(_row: &Self::Row) -> Option<AppEvent> {
62 None
63 }
64
65 fn hints() -> Vec<(String, String)>;
66}
67
68pub struct QueryListPanel<S: ListPanelSpec> {
71 icons: Icons,
72 yank_combos: Vec<crate::keys::key_combo::KeyCombo>,
75 list: Option<SearchList<S::Row>>,
76}
77
78impl<S: ListPanelSpec> QueryListPanel<S> {
79 pub fn new(icons: Icons, yank_combos: Vec<crate::keys::key_combo::KeyCombo>) -> Self {
80 Self {
81 icons,
82 yank_combos,
83 list: None,
84 }
85 }
86
87 pub fn set_source(&mut self, source: impl RowSource<S::Row> + 'static, tx: &AppTx) {
90 let mut builder = SearchList::builder(source, redraw_callback(tx.clone()));
91 if S::HAS_FILTER {
92 builder = builder.filter(if S::LOCAL_FILTER {
96 Filter::Fuzzy
97 } else {
98 Filter::SourceOrder
99 });
100 }
101 self.list = Some(
102 builder
103 .yank_combos(self.yank_combos.clone())
104 .icons(self.icons.clone())
105 .build(),
106 );
107 }
108
109 pub fn is_loaded(&self) -> bool {
110 self.list.is_some()
111 }
112
113 pub fn selected_row(&self) -> Option<&S::Row> {
114 self.list.as_ref().and_then(|l| l.selected_row())
115 }
116
117 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
118 S::hints()
119 }
120
121 fn submit_selected(&self, tx: &AppTx) {
122 if let Some(row) = self.selected_row() {
123 S::submit(row, tx);
124 }
125 }
126
127 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
128 match event {
129 InputEvent::Key(key) => {
130 let Some(list) = &mut self.list else {
131 return EventState::NotConsumed;
132 };
133 if S::HAS_FILTER {
134 match list.handle_key(key) {
135 KeyReaction::Submit => {
136 self.submit_selected(tx);
137 EventState::Consumed
138 }
139 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
140 KeyReaction::Yank(target) => {
141 crate::components::yank_row(target, tx);
142 EventState::Consumed
143 }
144 KeyReaction::Intercepted(_)
145 | KeyReaction::ListVerb(_)
146 | KeyReaction::Unhandled => EventState::NotConsumed,
147 }
148 } else {
149 if list.is_yank_chord(key) {
154 let reaction = list.handle_key(key);
155 if let KeyReaction::Yank(target) = reaction {
156 crate::components::yank_row(target, tx);
157 }
158 return EventState::Consumed;
159 }
160 match key.code {
161 KeyCode::Up
162 | KeyCode::Down
163 | KeyCode::PageUp
164 | KeyCode::PageDown
165 | KeyCode::Home
166 | KeyCode::End => {
167 list.handle_key(key);
168 EventState::Consumed
169 }
170 KeyCode::Enter => {
171 self.submit_selected(tx);
172 EventState::Consumed
173 }
174 _ => EventState::NotConsumed,
175 }
176 }
177 }
178 InputEvent::Mouse(mouse) => {
179 let Some(list) = &mut self.list else {
180 return EventState::NotConsumed;
181 };
182 match list.handle_mouse(mouse) {
183 SearchMouse::Activated(_) => self.submit_selected(tx),
184 SearchMouse::Context(_) => {
185 if let Some(event) = list.selected_row().and_then(S::context_event) {
186 tx.send(event).ok();
187 }
188 }
189 _ => {}
190 }
191 EventState::Consumed
192 }
193 _ => EventState::NotConsumed,
194 }
195 }
196
197 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
199 let block = panel_block(S::TITLE, theme, focused);
200 let inner = block.inner(rect);
201 f.render_widget(block, rect);
202 self.render_in(f, inner, rect, theme, focused);
203 }
204
205 pub fn render_in(
209 &mut self,
210 f: &mut Frame,
211 body: Rect,
212 panel: Rect,
213 theme: &Theme,
214 focused: bool,
215 ) {
216 let Some(list) = &mut self.list else {
217 return;
218 };
219 if S::HAS_FILTER && S::BORDERED_INPUT {
220 list.poll();
230
231 let rows = Layout::default()
232 .direction(Direction::Vertical)
233 .constraints([Constraint::Length(3), Constraint::Min(0)])
234 .split(body);
235
236 let loading = list.is_loading();
237 let count = list.match_count();
238 let status = if loading {
239 "Searching…".to_string()
240 } else {
241 format!("{count} results")
242 };
243 let dim = Style::default().fg(theme.gray.to_ratatui());
244 let search_block = Block::default()
245 .title(" Search ")
246 .title(Line::from(Span::styled(format!(" {status} "), dim)).right_aligned())
247 .borders(Borders::ALL)
248 .border_style(theme.border_style(focused));
249 let search_inner = search_block.inner(rows[0]);
250 f.render_widget(search_block, rows[0]);
251 list.render_query(f, search_inner, theme, focused);
252
253 let query_empty = list.query().trim().is_empty();
257 if count == 0 && (loading || !query_empty) {
258 let msg = if loading {
259 "Searching…"
260 } else {
261 "No results"
262 };
263 f.render_widget(
264 Paragraph::new(Line::from(Span::styled(msg, dim))).alignment(Alignment::Center),
265 rows[1],
266 );
267 } else {
268 list.render(f, rows[1], theme, focused);
269 }
270 list.set_list_rect(rows[1]);
271 } else if S::HAS_FILTER {
272 let rows = Layout::default()
273 .direction(Direction::Vertical)
274 .constraints([Constraint::Length(1), Constraint::Min(0)])
275 .split(body);
276 list.render_query(f, rows[0], theme, focused);
277 list.render(f, rows[1], theme, focused);
278 list.set_list_rect(rows[1]);
279 } else {
280 list.render(f, body, theme, focused);
281 list.set_list_rect(body);
282 }
283 list.set_panel_rect(panel);
284 }
285
286 #[cfg(test)]
288 pub(crate) fn list_mut(&mut self) -> Option<&mut SearchList<S::Row>> {
289 self.list.as_mut()
290 }
291
292 #[cfg(test)]
293 pub(crate) fn list(&self) -> Option<&SearchList<S::Row>> {
294 self.list.as_ref()
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::components::search_list::{Emit, SearchRow};
302 use crate::settings::themes::Theme;
303 use ratatui::Terminal;
304 use ratatui::backend::TestBackend;
305 use tokio::sync::mpsc::unbounded_channel;
306
307 #[derive(Clone)]
308 struct Row(String);
309 impl SearchRow for Row {
310 fn to_list_item(
311 &self,
312 _t: &Theme,
313 _i: &Icons,
314 _s: bool,
315 ) -> ratatui::widgets::ListItem<'static> {
316 ratatui::widgets::ListItem::new(self.0.clone())
317 }
318 fn visual_height(&self) -> u16 {
319 1
320 }
321 fn match_text(&self) -> Option<&str> {
322 Some(&self.0)
323 }
324 fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
325 Some(crate::components::search_list::YankTarget::path(
326 self.0.clone(),
327 ))
328 }
329 }
330
331 struct EmptySource;
334 #[async_trait::async_trait]
335 impl RowSource<Row> for EmptySource {
336 async fn load(&self, _q: &str, emit: Emit<Row>) {
337 emit.replace(Vec::new());
338 }
339 }
340
341 struct PendingSource;
344 #[async_trait::async_trait]
345 impl RowSource<Row> for PendingSource {
346 async fn load(&self, _q: &str, _emit: Emit<Row>) {
347 std::future::pending::<()>().await;
348 }
349 }
350
351 struct BorderedSpec;
352 impl ListPanelSpec for BorderedSpec {
353 type Row = Row;
354 const TITLE: &'static str = "Semantic";
355 const BORDERED_INPUT: bool = true;
356 fn submit(_row: &Row, _tx: &AppTx) {}
357 fn hints() -> Vec<(String, String)> {
358 Vec::new()
359 }
360 }
361
362 struct ThreeSource;
365 #[async_trait::async_trait]
366 impl RowSource<Row> for ThreeSource {
367 async fn load(&self, _q: &str, emit: Emit<Row>) {
368 emit.replace(vec![
369 Row("alpha".into()),
370 Row("beta".into()),
371 Row("gamma".into()),
372 ]);
373 }
374 }
375
376 struct NoFilterSpec;
378 impl ListPanelSpec for NoFilterSpec {
379 type Row = Row;
380 const TITLE: &'static str = "Semantic";
381 const BORDERED_INPUT: bool = true;
382 const LOCAL_FILTER: bool = false;
383 fn submit(_row: &Row, _tx: &AppTx) {}
384 fn hints() -> Vec<(String, String)> {
385 Vec::new()
386 }
387 }
388
389 fn buffer_text<S: ListPanelSpec>(panel: &mut QueryListPanel<S>) -> String {
390 let theme = Theme::default();
391 let mut term = Terminal::new(TestBackend::new(40, 12)).unwrap();
392 term.draw(|f| panel.render(f, Rect::new(0, 0, 40, 12), &theme, true))
393 .unwrap();
394 let buf = term.backend().buffer().clone();
395 (0..buf.area.height)
396 .map(|y| {
397 (0..buf.area.width)
398 .map(|x| buf[(x, y)].symbol())
399 .collect::<String>()
400 })
401 .collect::<Vec<_>>()
402 .join("\n")
403 }
404
405 struct NoInputSpec;
413 impl ListPanelSpec for NoInputSpec {
414 type Row = Row;
415 const TITLE: &'static str = "Links";
416 const HAS_FILTER: bool = false;
417 fn submit(_row: &Row, _tx: &AppTx) {}
418 fn hints() -> Vec<(String, String)> {
419 Vec::new()
420 }
421 }
422
423 #[tokio::test]
427 async fn yank_chord_reaches_a_view_that_has_no_filter_input() {
428 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
429 let (tx, mut rx) = unbounded_channel();
430 let mut panel = QueryListPanel::<NoInputSpec>::new(
431 Icons::new(false),
432 vec![crate::keys::default_yank_combo()],
433 );
434 panel.set_source(ThreeSource, &tx);
435 panel.list_mut().unwrap().poll_until_idle().await;
436
437 let ctrl_y = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL);
438 let state = panel.handle_input(&InputEvent::Key(ctrl_y), &tx);
439 assert_eq!(state, EventState::Consumed);
440
441 let flashed = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
444 matches!(e, AppEvent::FlashMessage(m)
445 if m == "path copied" || m.starts_with("clipboard: "))
446 });
447 assert!(flashed, "the yank chord must reach the list and report");
448 }
449
450 #[tokio::test]
453 async fn no_filter_view_still_passes_plain_letters_to_the_host() {
454 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
455 let (tx, _rx) = unbounded_channel();
456 let mut panel = QueryListPanel::<NoInputSpec>::new(
457 Icons::new(false),
458 vec![crate::keys::default_yank_combo()],
459 );
460 panel.set_source(ThreeSource, &tx);
461 panel.list_mut().unwrap().poll_until_idle().await;
462 let b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
463 assert_eq!(
464 panel.handle_input(&InputEvent::Key(b), &tx),
465 EventState::NotConsumed,
466 "`b` is LINKS' backlinks sub-view key, not the list's"
467 );
468 }
469
470 #[tokio::test]
471 async fn no_local_filter_keeps_server_rows_that_dont_match_query() {
472 let (tx, _rx) = unbounded_channel();
473 let mut panel = QueryListPanel::<NoFilterSpec>::new(
474 Icons::new(false),
475 vec![crate::keys::default_yank_combo()],
476 );
477 panel.set_source(ThreeSource, &tx);
478 {
479 let list = panel.list_mut().unwrap();
480 list.poll_until_idle().await;
481 list.set_query("zzz-not-in-any-title");
483 list.poll_until_idle().await;
484 }
485 assert_eq!(
486 panel.list().unwrap().match_count(),
487 3,
488 "server rows must survive a non-matching query (no local filter)"
489 );
490 let text = buffer_text(&mut panel);
491 assert!(
492 text.contains("alpha") && text.contains("beta") && text.contains("gamma"),
493 "all server rows shown:\n{text}"
494 );
495 }
496
497 #[tokio::test]
501 async fn local_filter_narrows_rows_by_query() {
502 let (tx, _rx) = unbounded_channel();
503 let mut panel = QueryListPanel::<BorderedSpec>::new(
504 Icons::new(false),
505 vec![crate::keys::default_yank_combo()],
506 );
507 panel.set_source(ThreeSource, &tx);
508 {
509 let list = panel.list_mut().unwrap();
510 list.poll_until_idle().await;
511 list.set_query("alpha");
512 list.poll_until_idle().await;
513 }
514 assert_eq!(
515 panel.list().unwrap().match_count(),
516 1,
517 "local fuzzy filter keeps only the matching row"
518 );
519 }
520
521 #[tokio::test]
522 async fn bordered_input_shows_searching_indicator_while_in_flight() {
523 let (tx, _rx) = unbounded_channel();
524 let mut panel = QueryListPanel::<BorderedSpec>::new(
525 Icons::new(false),
526 vec![crate::keys::default_yank_combo()],
527 );
528 panel.set_source(PendingSource, &tx);
529 let text = buffer_text(&mut panel);
531 assert!(text.contains("Search"), "bordered search box:\n{text}");
532 assert!(text.contains("Searching"), "in-flight indicator:\n{text}");
533 }
534
535 #[tokio::test]
541 async fn render_drains_loader_in_placeholder_path() {
542 let (tx, _rx) = unbounded_channel();
543 let mut panel = QueryListPanel::<BorderedSpec>::new(
544 Icons::new(false),
545 vec![crate::keys::default_yank_combo()],
546 );
547 panel.set_source(EmptySource, &tx);
548 panel.list_mut().unwrap().set_query("x"); tokio::time::sleep(std::time::Duration::from_millis(30)).await;
552
553 let text = buffer_text(&mut panel); assert!(
555 !panel.list().unwrap().is_loading(),
556 "render must drain the loader; is_loading stuck:\n{text}"
557 );
558 assert!(text.contains("No results"), "resolved to empty:\n{text}");
559 assert!(
560 !text.contains("Searching"),
561 "must not be stuck searching:\n{text}"
562 );
563 }
564
565 #[tokio::test]
566 async fn bordered_input_shows_no_results_for_empty_completed_query() {
567 let (tx, _rx) = unbounded_channel();
568 let mut panel = QueryListPanel::<BorderedSpec>::new(
569 Icons::new(false),
570 vec![crate::keys::default_yank_combo()],
571 );
572 panel.set_source(EmptySource, &tx);
573 {
574 let list = panel.list_mut().unwrap();
575 list.poll_until_idle().await; list.set_query("nothing-matches");
577 list.poll_until_idle().await;
578 }
579 let text = buffer_text(&mut panel);
580 assert!(text.contains("Search"), "bordered search box:\n{text}");
581 assert!(text.contains("No results"), "empty-result message:\n{text}");
582 }
583}