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    /// Handed to every rebuilt list, so the drawer views honour a rebound yank
73    /// chord like the other list surfaces.
74    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    /// (Re)build the list over a fresh source — the engine-per-context
88    /// pattern every drawer view uses.
89    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            // A server-backed source (LOCAL_FILTER = false) already applied the
93            // query in `load`; keep its ranked rows as-is (SourceOrder) instead of
94            // fuzzy-filtering them again by the literal query text.
95            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                    // No filter input: only navigation keys reach the list,
150                    // so plain letters stay available to the host. The yank
151                    // chord is forwarded explicitly — it is a chord, never a
152                    // host letter, and its rows do declare yank targets.
153                    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    /// Standard rendering: panel block + (filter input row) + list.
198    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    /// Render the body into `body` (a host that drew extra chrome — LINKS'
206    /// tab bar — passes what remains). `panel` is the full panel rect, for
207    /// wheel hit-testing.
208    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            // Bordered search box (Length 3) clearly separated from the results,
221            // reusing the note finder's layout. A right-aligned status doubles as
222            // the progress indicator: "Searching…" while a query is in flight,
223            // otherwise the result count.
224            // Drain any completed load NOW. `SearchList::render` is what normally
225            // polls, but the placeholder path below renders a message instead of
226            // the list — without this the loader never drains, so `is_loading`
227            // sticks true forever ("Searching…" that never resolves) and typed
228            // results never land.
229            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            // Results: show a dimmed placeholder while a query is in flight with
254            // nothing yet on screen, or when a completed query found nothing;
255            // otherwise the (possibly stale) results stay visible.
256            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    /// Test access to the underlying list.
287    #[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    /// A source that completes immediately with no rows — the "query found
332    /// nothing" case.
333    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    /// A source whose load never resolves — stays `is_loading` forever, the
342    /// "query in flight" case.
343    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    /// A server-backed source: returns the same three rows for any query (the
363    /// server did the ranking; the query is not a local substring filter).
364    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    /// Like `BorderedSpec` but opts out of local filtering (server-backed).
377    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    /// Regression: a server-backed view (`LOCAL_FILTER = false`) must NOT drop
406    /// the server's ranked rows just because their titles don't contain the
407    /// typed query. This was the "semantic search shows one result" bug — the
408    /// local fuzzy filter discarded every conceptually-relevant note whose title
409    /// lacked the query words.
410    /// A no-filter spec (the LINKS drawer's shape), where plain letters are the
411    /// host's sub-view keys and only recognised keys reach the list.
412    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    /// A view with no filter input forwards only what it recognises, so the
424    /// yank chord has to be forwarded on purpose. Without that, LINKS rows would
425    /// declare a yank target the panel could never deliver.
426    #[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        // The flash is the observable outcome: either the copy or a clipboard
442        // error, but never silence.
443        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    /// A plain letter must still stay with the host in a no-filter view — the
451    /// yank forwarding above must not open the floodgates.
452    #[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            // A query that matches none of the row titles verbatim.
482            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    /// Contrast: a local-filter view (`LOCAL_FILTER = true`, the drawer default)
498    /// DOES narrow rows by the typed query — the behavior semantic search must
499    /// avoid but TAGS/LINKS/OUTLINE rely on.
500    #[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        // The initial load is pending → is_loading stays true.
530        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    /// Regression: the placeholder path must still drain the loader. Render is
536    /// the only thing that polls; if it renders a message *instead of* the list
537    /// without polling, `is_loading` sticks true forever and typed results never
538    /// land. Here the load completes (empty) off-thread; a single `render` — with
539    /// NO manual `poll_until_idle` — must clear loading and show "No results".
540    #[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"); // starts a load (reload_on_query)
549        // Let the spawned load run and land on the channel — but do NOT poll it
550        // in ourselves; render must be what drains it.
551        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
552
553        let text = buffer_text(&mut panel); // render → must poll
554        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; // drain the initial empty-query load
576            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}