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