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, SearchListBuilder, SearchMouse, SearchRow,
22    StaticRowSource,
23};
24use crate::settings::icons::Icons;
25use crate::settings::themes::Theme;
26
27/// What varies between list-shaped drawer views. The panel is the depth;
28/// each view is a thin adapter of this seam.
29pub trait ListPanelSpec {
30    type Row: SearchRow + Clone + Send + Sync + 'static;
31
32    /// Panel-block title.
33    const TITLE: &'static str;
34    /// Whether the top row is a typed filter input (`true`: every key goes
35    /// to the list engine; `false`: only navigation keys reach the list —
36    /// plain letters stay free for the host, e.g. LINKS' `b/o/u`).
37    const HAS_FILTER: bool = true;
38
39    /// When `true`, the filter is drawn as a bordered search box (like the note
40    /// finder) with a right-aligned result/loading status, and the results area
41    /// shows a dimmed "Searching…/No results" message. Suits server-backed views
42    /// (semantic search) where the query round-trips over the network and the
43    /// input deserves visual separation from the results. Default `false`: a
44    /// bare one-row filter for the compact local drawer views (TAGS/LINKS/
45    /// OUTLINE), whose behavior is unchanged.
46    const BORDERED_INPUT: bool = false;
47
48    /// Whether the typed query ALSO fuzzy-filters the loaded rows locally
49    /// (`true`, default). Set `false` for server-backed sources whose `load`
50    /// already applies the query (semantic search): the server returns ranked,
51    /// conceptually-relevant notes that rarely contain the query words verbatim,
52    /// so a local fuzzy pass over their titles would wrongly discard nearly all
53    /// of them. `false` keeps the input (it drives the server reload) but shows
54    /// every returned row in server rank order.
55    const LOCAL_FILTER: bool = true;
56
57    /// What Enter / click-activate does with the selected row.
58    fn submit(row: &Self::Row, tx: &AppTx);
59
60    /// The event a right-click on a row fires (rows that are real notes
61    /// open the file-ops menu). `None` = right-click selects only.
62    fn context_event(_row: &Self::Row) -> Option<AppEvent> {
63        None
64    }
65
66    fn hints() -> Vec<(String, String)>;
67}
68
69/// The shared panel body. Hosts that need extra chrome (LINKS' tab bar) draw
70/// it themselves and hand the remaining body rect to [`Self::render_in`].
71pub struct QueryListPanel<S: ListPanelSpec> {
72    icons: Icons,
73    /// Handed to every rebuilt list, so the drawer views honour a rebound yank
74    /// chord like the other list surfaces.
75    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    /// (Re)build the list over a fresh source — the engine-per-context
89    /// pattern every drawer view uses.
90    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    /// (Re)build the list over rows already in hand — applied at once, no
95    /// async load.
96    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            // A server-backed source (LOCAL_FILTER = false) already applied the
108            // query in `load`; keep its ranked rows as-is (SourceOrder) instead of
109            // fuzzy-filtering them again by the literal query text.
110            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                    // No filter input: only navigation keys reach the list,
162                    // so plain letters stay available to the host. The yank
163                    // chord is forwarded explicitly — it is a chord, never a
164                    // host letter, and its rows do declare yank targets.
165                    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    /// Standard rendering: panel block + (filter input row) + list.
211    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    /// Render the body into `body` (a host that drew extra chrome — LINKS'
219    /// tab bar — passes what remains). `panel` is the full panel rect, for
220    /// wheel hit-testing.
221    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            // Bordered search box (Length 3) clearly separated from the results,
234            // reusing the note finder's layout. A right-aligned status doubles as
235            // the progress indicator: "Searching…" while a query is in flight,
236            // otherwise the result count.
237            // Drain any completed load NOW. `SearchList::render` is what normally
238            // polls, but the placeholder path below renders a message instead of
239            // the list — without this the loader never drains, so `is_loading`
240            // sticks true forever ("Searching…" that never resolves) and typed
241            // results never land.
242            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            // Results: show a dimmed placeholder while a query is in flight with
267            // nothing yet on screen, or when a completed query found nothing;
268            // otherwise the (possibly stale) results stay visible.
269            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    /// Test access to the underlying list.
300    #[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    /// A source that completes immediately with no rows — the "query found
345    /// nothing" case.
346    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    /// A source whose load never resolves — stays `is_loading` forever, the
355    /// "query in flight" case.
356    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    /// A server-backed source: returns the same three rows for any query (the
376    /// server did the ranking; the query is not a local substring filter).
377    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    /// Like `BorderedSpec` but opts out of local filtering (server-backed).
390    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    /// Regression: a server-backed view (`LOCAL_FILTER = false`) must NOT drop
419    /// the server's ranked rows just because their titles don't contain the
420    /// typed query. This was the "semantic search shows one result" bug — the
421    /// local fuzzy filter discarded every conceptually-relevant note whose title
422    /// lacked the query words.
423    /// A no-filter spec (the LINKS drawer's shape), where plain letters are the
424    /// host's sub-view keys and only recognised keys reach the list.
425    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    /// A view with no filter input forwards only what it recognises, so the
437    /// yank chord has to be forwarded on purpose. Without that, LINKS rows would
438    /// declare a yank target the panel could never deliver.
439    #[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        // The flash is the observable outcome: either the copy or a clipboard
455        // error, but never silence.
456        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    /// A plain letter must still stay with the host in a no-filter view — the
464    /// yank forwarding above must not open the floodgates.
465    #[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            // A query that matches none of the row titles verbatim.
495            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    /// Contrast: a local-filter view (`LOCAL_FILTER = true`, the drawer default)
511    /// DOES narrow rows by the typed query — the behavior semantic search must
512    /// avoid but TAGS/LINKS/OUTLINE rely on.
513    #[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        // The initial load is pending → is_loading stays true.
543        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    /// Regression: the placeholder path must still drain the loader. Render is
549    /// the only thing that polls; if it renders a message *instead of* the list
550    /// without polling, `is_loading` sticks true forever and typed results never
551    /// land. Here the load completes (empty) off-thread; a single `render` — with
552    /// NO manual `poll_until_idle` — must clear loading and show "No results".
553    #[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"); // starts a load (reload_on_query)
562        // Let the spawned load run and land on the channel — but do NOT poll it
563        // in ourselves; render must be what drains it.
564        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
565
566        let text = buffer_text(&mut panel); // render → must poll
567        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; // drain the initial empty-query load
589            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}