Skip to main content

kimun_notes/components/
query_list_panel.rs

1//! **QueryListPanel** — the one body shared by every list-shaped drawer view
2//! (TAGS, LINKS, OUTLINE): an optional filter input over a [`SearchList`],
3//! with submit / right-click behavior injected through [`ListPanelSpec`].
4//!
5//! The views vary only in their row type, what Enter does, and whether rows
6//! are real notes (right-click context menu); everything about key routing,
7//! mouse hit-testing, and layout is identical — so it lives exactly once
8//! here, and a new drawer view is a spec + a source, not a copied panel.
9
10use 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
26/// What varies between list-shaped drawer views. The panel is the depth;
27/// each view is a thin adapter of this seam.
28pub trait ListPanelSpec {
29    type Row: SearchRow + Clone + Send + Sync + 'static;
30
31    /// Panel-block title.
32    const TITLE: &'static str;
33    /// Whether the top row is a typed filter input (`true`: every key goes
34    /// to the list engine; `false`: only navigation keys reach the list —
35    /// plain letters stay free for the host, e.g. LINKS' `b/o/u`).
36    const HAS_FILTER: bool = true;
37
38    /// When `true`, the filter is drawn as a bordered search box (like the note
39    /// finder) with a right-aligned result/loading status, and the results area
40    /// shows a dimmed "Searching…/No results" message. Suits server-backed views
41    /// (semantic search) where the query round-trips over the network and the
42    /// input deserves visual separation from the results. Default `false`: a
43    /// bare one-row filter for the compact local drawer views (TAGS/LINKS/
44    /// OUTLINE), whose behavior is unchanged.
45    const BORDERED_INPUT: bool = false;
46
47    /// Whether the typed query ALSO fuzzy-filters the loaded rows locally
48    /// (`true`, default). Set `false` for server-backed sources whose `load`
49    /// already applies the query (semantic search): the server returns ranked,
50    /// conceptually-relevant notes that rarely contain the query words verbatim,
51    /// so a local fuzzy pass over their titles would wrongly discard nearly all
52    /// of them. `false` keeps the input (it drives the server reload) but shows
53    /// every returned row in server rank order.
54    const LOCAL_FILTER: bool = true;
55
56    /// What Enter / click-activate does with the selected row.
57    fn submit(row: &Self::Row, tx: &AppTx);
58
59    /// The event a right-click on a row fires (rows that are real notes
60    /// open the file-ops menu). `None` = right-click selects only.
61    fn context_event(_row: &Self::Row) -> Option<AppEvent> {
62        None
63    }
64
65    fn hints() -> Vec<(String, String)>;
66}
67
68/// The shared panel body. Hosts that need extra chrome (LINKS' tab bar) draw
69/// it themselves and hand the remaining body rect to [`Self::render_in`].
70pub 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    /// (Re)build the list over a fresh source — the engine-per-context
81    /// pattern every drawer view uses.
82    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            // A server-backed source (LOCAL_FILTER = false) already applied the
86            // query in `load`; keep its ranked rows as-is (SourceOrder) instead of
87            // fuzzy-filtering them again by the literal query text.
88            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                    // No filter input: only navigation keys reach the list,
134                    // so plain letters stay available to the host.
135                    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    /// Standard rendering: panel block + (filter input row) + list.
173    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    /// Render the body into `body` (a host that drew extra chrome — LINKS'
181    /// tab bar — passes what remains). `panel` is the full panel rect, for
182    /// wheel hit-testing.
183    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            // Bordered search box (Length 3) clearly separated from the results,
196            // reusing the note finder's layout. A right-aligned status doubles as
197            // the progress indicator: "Searching…" while a query is in flight,
198            // otherwise the result count.
199            // Drain any completed load NOW. `SearchList::render` is what normally
200            // polls, but the placeholder path below renders a message instead of
201            // the list — without this the loader never drains, so `is_loading`
202            // sticks true forever ("Searching…" that never resolves) and typed
203            // results never land.
204            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            // Results: show a dimmed placeholder while a query is in flight with
229            // nothing yet on screen, or when a completed query found nothing;
230            // otherwise the (possibly stale) results stay visible.
231            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    /// Test access to the underlying list.
262    #[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    /// A source that completes immediately with no rows — the "query found
302    /// nothing" case.
303    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    /// A source whose load never resolves — stays `is_loading` forever, the
312    /// "query in flight" case.
313    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    /// A server-backed source: returns the same three rows for any query (the
333    /// server did the ranking; the query is not a local substring filter).
334    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    /// Like `BorderedSpec` but opts out of local filtering (server-backed).
347    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    /// Regression: a server-backed view (`LOCAL_FILTER = false`) must NOT drop
376    /// the server's ranked rows just because their titles don't contain the
377    /// typed query. This was the "semantic search shows one result" bug — the
378    /// local fuzzy filter discarded every conceptually-relevant note whose title
379    /// lacked the query words.
380    #[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            // A query that matches none of the row titles verbatim.
389            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    /// Contrast: a local-filter view (`LOCAL_FILTER = true`, the drawer default)
405    /// DOES narrow rows by the typed query — the behavior semantic search must
406    /// avoid but TAGS/LINKS/OUTLINE rely on.
407    #[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        // The initial load is pending → is_loading stays true.
431        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    /// Regression: the placeholder path must still drain the loader. Render is
437    /// the only thing that polls; if it renders a message *instead of* the list
438    /// without polling, `is_loading` sticks true forever and typed results never
439    /// land. Here the load completes (empty) off-thread; a single `render` — with
440    /// NO manual `poll_until_idle` — must clear loading and show "No results".
441    #[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"); // starts a load (reload_on_query)
447        // Let the spawned load run and land on the channel — but do NOT poll it
448        // in ourselves; render must be what drains it.
449        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
450
451        let text = buffer_text(&mut panel); // render → must poll
452        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; // drain the initial empty-query load
471            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}