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 list: Option<SearchList<S::Row>>,
73}
74
75impl<S: ListPanelSpec> QueryListPanel<S> {
76 pub fn new(icons: Icons) -> Self {
77 Self { icons, list: None }
78 }
79
80 pub fn set_source(&mut self, source: impl RowSource<S::Row> + 'static, tx: &AppTx) {
83 let mut builder = SearchList::builder(source, redraw_callback(tx.clone()));
84 if S::HAS_FILTER {
85 builder = builder.filter(if S::LOCAL_FILTER {
89 Filter::Fuzzy
90 } else {
91 Filter::SourceOrder
92 });
93 }
94 self.list = Some(builder.icons(self.icons.clone()).build());
95 }
96
97 pub fn is_loaded(&self) -> bool {
98 self.list.is_some()
99 }
100
101 pub fn selected_row(&self) -> Option<&S::Row> {
102 self.list.as_ref().and_then(|l| l.selected_row())
103 }
104
105 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
106 S::hints()
107 }
108
109 fn submit_selected(&self, tx: &AppTx) {
110 if let Some(row) = self.selected_row() {
111 S::submit(row, tx);
112 }
113 }
114
115 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
116 match event {
117 InputEvent::Key(key) => {
118 let Some(list) = &mut self.list else {
119 return EventState::NotConsumed;
120 };
121 if S::HAS_FILTER {
122 match list.handle_key(key) {
123 KeyReaction::Submit => {
124 self.submit_selected(tx);
125 EventState::Consumed
126 }
127 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
128 KeyReaction::Intercepted(_) | KeyReaction::Unhandled => {
129 EventState::NotConsumed
130 }
131 }
132 } else {
133 match key.code {
136 KeyCode::Up
137 | KeyCode::Down
138 | KeyCode::PageUp
139 | KeyCode::PageDown
140 | KeyCode::Home
141 | KeyCode::End => {
142 list.handle_key(key);
143 EventState::Consumed
144 }
145 KeyCode::Enter => {
146 self.submit_selected(tx);
147 EventState::Consumed
148 }
149 _ => EventState::NotConsumed,
150 }
151 }
152 }
153 InputEvent::Mouse(mouse) => {
154 let Some(list) = &mut self.list else {
155 return EventState::NotConsumed;
156 };
157 match list.handle_mouse(mouse) {
158 SearchMouse::Activated(_) => self.submit_selected(tx),
159 SearchMouse::Context(_) => {
160 if let Some(event) = list.selected_row().and_then(S::context_event) {
161 tx.send(event).ok();
162 }
163 }
164 _ => {}
165 }
166 EventState::Consumed
167 }
168 _ => EventState::NotConsumed,
169 }
170 }
171
172 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
174 let block = panel_block(S::TITLE, theme, focused);
175 let inner = block.inner(rect);
176 f.render_widget(block, rect);
177 self.render_in(f, inner, rect, theme, focused);
178 }
179
180 pub fn render_in(
184 &mut self,
185 f: &mut Frame,
186 body: Rect,
187 panel: Rect,
188 theme: &Theme,
189 focused: bool,
190 ) {
191 let Some(list) = &mut self.list else {
192 return;
193 };
194 if S::HAS_FILTER && S::BORDERED_INPUT {
195 list.poll();
205
206 let rows = Layout::default()
207 .direction(Direction::Vertical)
208 .constraints([Constraint::Length(3), Constraint::Min(0)])
209 .split(body);
210
211 let loading = list.is_loading();
212 let count = list.match_count();
213 let status = if loading {
214 "Searching…".to_string()
215 } else {
216 format!("{count} results")
217 };
218 let dim = Style::default().fg(theme.gray.to_ratatui());
219 let search_block = Block::default()
220 .title(" Search ")
221 .title(Line::from(Span::styled(format!(" {status} "), dim)).right_aligned())
222 .borders(Borders::ALL)
223 .border_style(theme.border_style(focused));
224 let search_inner = search_block.inner(rows[0]);
225 f.render_widget(search_block, rows[0]);
226 list.render_query(f, search_inner, theme, focused);
227
228 let query_empty = list.query().trim().is_empty();
232 if count == 0 && (loading || !query_empty) {
233 let msg = if loading {
234 "Searching…"
235 } else {
236 "No results"
237 };
238 f.render_widget(
239 Paragraph::new(Line::from(Span::styled(msg, dim))).alignment(Alignment::Center),
240 rows[1],
241 );
242 } else {
243 list.render(f, rows[1], theme, focused);
244 }
245 list.set_list_rect(rows[1]);
246 } else if S::HAS_FILTER {
247 let rows = Layout::default()
248 .direction(Direction::Vertical)
249 .constraints([Constraint::Length(1), Constraint::Min(0)])
250 .split(body);
251 list.render_query(f, rows[0], theme, focused);
252 list.render(f, rows[1], theme, focused);
253 list.set_list_rect(rows[1]);
254 } else {
255 list.render(f, body, theme, focused);
256 list.set_list_rect(body);
257 }
258 list.set_panel_rect(panel);
259 }
260
261 #[cfg(test)]
263 pub(crate) fn list_mut(&mut self) -> Option<&mut SearchList<S::Row>> {
264 self.list.as_mut()
265 }
266
267 #[cfg(test)]
268 pub(crate) fn list(&self) -> Option<&SearchList<S::Row>> {
269 self.list.as_ref()
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::components::search_list::{Emit, SearchRow};
277 use crate::settings::themes::Theme;
278 use ratatui::Terminal;
279 use ratatui::backend::TestBackend;
280 use tokio::sync::mpsc::unbounded_channel;
281
282 #[derive(Clone)]
283 struct Row(String);
284 impl SearchRow for Row {
285 fn to_list_item(
286 &self,
287 _t: &Theme,
288 _i: &Icons,
289 _s: bool,
290 ) -> ratatui::widgets::ListItem<'static> {
291 ratatui::widgets::ListItem::new(self.0.clone())
292 }
293 fn visual_height(&self) -> u16 {
294 1
295 }
296 fn match_text(&self) -> Option<&str> {
297 Some(&self.0)
298 }
299 }
300
301 struct EmptySource;
304 #[async_trait::async_trait]
305 impl RowSource<Row> for EmptySource {
306 async fn load(&self, _q: &str, emit: Emit<Row>) {
307 emit.replace(Vec::new());
308 }
309 }
310
311 struct PendingSource;
314 #[async_trait::async_trait]
315 impl RowSource<Row> for PendingSource {
316 async fn load(&self, _q: &str, _emit: Emit<Row>) {
317 std::future::pending::<()>().await;
318 }
319 }
320
321 struct BorderedSpec;
322 impl ListPanelSpec for BorderedSpec {
323 type Row = Row;
324 const TITLE: &'static str = "Semantic";
325 const BORDERED_INPUT: bool = true;
326 fn submit(_row: &Row, _tx: &AppTx) {}
327 fn hints() -> Vec<(String, String)> {
328 Vec::new()
329 }
330 }
331
332 struct ThreeSource;
335 #[async_trait::async_trait]
336 impl RowSource<Row> for ThreeSource {
337 async fn load(&self, _q: &str, emit: Emit<Row>) {
338 emit.replace(vec![
339 Row("alpha".into()),
340 Row("beta".into()),
341 Row("gamma".into()),
342 ]);
343 }
344 }
345
346 struct NoFilterSpec;
348 impl ListPanelSpec for NoFilterSpec {
349 type Row = Row;
350 const TITLE: &'static str = "Semantic";
351 const BORDERED_INPUT: bool = true;
352 const LOCAL_FILTER: bool = false;
353 fn submit(_row: &Row, _tx: &AppTx) {}
354 fn hints() -> Vec<(String, String)> {
355 Vec::new()
356 }
357 }
358
359 fn buffer_text<S: ListPanelSpec>(panel: &mut QueryListPanel<S>) -> String {
360 let theme = Theme::default();
361 let mut term = Terminal::new(TestBackend::new(40, 12)).unwrap();
362 term.draw(|f| panel.render(f, Rect::new(0, 0, 40, 12), &theme, true))
363 .unwrap();
364 let buf = term.backend().buffer().clone();
365 (0..buf.area.height)
366 .map(|y| {
367 (0..buf.area.width)
368 .map(|x| buf[(x, y)].symbol())
369 .collect::<String>()
370 })
371 .collect::<Vec<_>>()
372 .join("\n")
373 }
374
375 #[tokio::test]
381 async fn no_local_filter_keeps_server_rows_that_dont_match_query() {
382 let (tx, _rx) = unbounded_channel();
383 let mut panel = QueryListPanel::<NoFilterSpec>::new(Icons::new(false));
384 panel.set_source(ThreeSource, &tx);
385 {
386 let list = panel.list_mut().unwrap();
387 list.poll_until_idle().await;
388 list.set_query("zzz-not-in-any-title");
390 list.poll_until_idle().await;
391 }
392 assert_eq!(
393 panel.list().unwrap().match_count(),
394 3,
395 "server rows must survive a non-matching query (no local filter)"
396 );
397 let text = buffer_text(&mut panel);
398 assert!(
399 text.contains("alpha") && text.contains("beta") && text.contains("gamma"),
400 "all server rows shown:\n{text}"
401 );
402 }
403
404 #[tokio::test]
408 async fn local_filter_narrows_rows_by_query() {
409 let (tx, _rx) = unbounded_channel();
410 let mut panel = QueryListPanel::<BorderedSpec>::new(Icons::new(false));
411 panel.set_source(ThreeSource, &tx);
412 {
413 let list = panel.list_mut().unwrap();
414 list.poll_until_idle().await;
415 list.set_query("alpha");
416 list.poll_until_idle().await;
417 }
418 assert_eq!(
419 panel.list().unwrap().match_count(),
420 1,
421 "local fuzzy filter keeps only the matching row"
422 );
423 }
424
425 #[tokio::test]
426 async fn bordered_input_shows_searching_indicator_while_in_flight() {
427 let (tx, _rx) = unbounded_channel();
428 let mut panel = QueryListPanel::<BorderedSpec>::new(Icons::new(false));
429 panel.set_source(PendingSource, &tx);
430 let text = buffer_text(&mut panel);
432 assert!(text.contains("Search"), "bordered search box:\n{text}");
433 assert!(text.contains("Searching"), "in-flight indicator:\n{text}");
434 }
435
436 #[tokio::test]
442 async fn render_drains_loader_in_placeholder_path() {
443 let (tx, _rx) = unbounded_channel();
444 let mut panel = QueryListPanel::<BorderedSpec>::new(Icons::new(false));
445 panel.set_source(EmptySource, &tx);
446 panel.list_mut().unwrap().set_query("x"); tokio::time::sleep(std::time::Duration::from_millis(30)).await;
450
451 let text = buffer_text(&mut panel); assert!(
453 !panel.list().unwrap().is_loading(),
454 "render must drain the loader; is_loading stuck:\n{text}"
455 );
456 assert!(text.contains("No results"), "resolved to empty:\n{text}");
457 assert!(
458 !text.contains("Searching"),
459 "must not be stuck searching:\n{text}"
460 );
461 }
462
463 #[tokio::test]
464 async fn bordered_input_shows_no_results_for_empty_completed_query() {
465 let (tx, _rx) = unbounded_channel();
466 let mut panel = QueryListPanel::<BorderedSpec>::new(Icons::new(false));
467 panel.set_source(EmptySource, &tx);
468 {
469 let list = panel.list_mut().unwrap();
470 list.poll_until_idle().await; list.set_query("nothing-matches");
472 list.poll_until_idle().await;
473 }
474 let text = buffer_text(&mut panel);
475 assert!(text.contains("Search"), "bordered search box:\n{text}");
476 assert!(text.contains("No results"), "empty-result message:\n{text}");
477 }
478}