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::{Constraint, Direction, Layout, Rect};
13
14use crate::components::event_state::EventState;
15use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
16use crate::components::panel::panel_block;
17use crate::components::search_list::{
18    Filter, KeyReaction, RowSource, SearchList, SearchMouse, SearchRow,
19};
20use crate::settings::icons::Icons;
21use crate::settings::themes::Theme;
22
23/// What varies between list-shaped drawer views. The panel is the depth;
24/// each view is a thin adapter of this seam.
25pub trait ListPanelSpec {
26    type Row: SearchRow + Clone + Send + Sync + 'static;
27
28    /// Panel-block title.
29    const TITLE: &'static str;
30    /// Whether the top row is a typed filter input (`true`: every key goes
31    /// to the list engine; `false`: only navigation keys reach the list —
32    /// plain letters stay free for the host, e.g. LINKS' `b/o/u`).
33    const HAS_FILTER: bool = true;
34
35    /// What Enter / click-activate does with the selected row.
36    fn submit(row: &Self::Row, tx: &AppTx);
37
38    /// The event a right-click on a row fires (rows that are real notes
39    /// open the file-ops menu). `None` = right-click selects only.
40    fn context_event(_row: &Self::Row) -> Option<AppEvent> {
41        None
42    }
43
44    fn hints() -> Vec<(String, String)>;
45}
46
47/// The shared panel body. Hosts that need extra chrome (LINKS' tab bar) draw
48/// it themselves and hand the remaining body rect to [`Self::render_in`].
49pub struct QueryListPanel<S: ListPanelSpec> {
50    icons: Icons,
51    list: Option<SearchList<S::Row>>,
52}
53
54impl<S: ListPanelSpec> QueryListPanel<S> {
55    pub fn new(icons: Icons) -> Self {
56        Self { icons, list: None }
57    }
58
59    /// (Re)build the list over a fresh source — the engine-per-context
60    /// pattern every drawer view uses.
61    pub fn set_source(&mut self, source: impl RowSource<S::Row> + 'static, tx: &AppTx) {
62        let mut builder = SearchList::builder(source, redraw_callback(tx.clone()));
63        if S::HAS_FILTER {
64            builder = builder.filter(Filter::Fuzzy);
65        }
66        self.list = Some(builder.icons(self.icons.clone()).build());
67    }
68
69    pub fn is_loaded(&self) -> bool {
70        self.list.is_some()
71    }
72
73    pub fn selected_row(&self) -> Option<&S::Row> {
74        self.list.as_ref().and_then(|l| l.selected_row())
75    }
76
77    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
78        S::hints()
79    }
80
81    fn submit_selected(&self, tx: &AppTx) {
82        if let Some(row) = self.selected_row() {
83            S::submit(row, tx);
84        }
85    }
86
87    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
88        match event {
89            InputEvent::Key(key) => {
90                let Some(list) = &mut self.list else {
91                    return EventState::NotConsumed;
92                };
93                if S::HAS_FILTER {
94                    match list.handle_key(key) {
95                        KeyReaction::Submit => {
96                            self.submit_selected(tx);
97                            EventState::Consumed
98                        }
99                        KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
100                        KeyReaction::Intercepted(_) | KeyReaction::Unhandled => {
101                            EventState::NotConsumed
102                        }
103                    }
104                } else {
105                    // No filter input: only navigation keys reach the list,
106                    // so plain letters stay available to the host.
107                    match key.code {
108                        KeyCode::Up
109                        | KeyCode::Down
110                        | KeyCode::PageUp
111                        | KeyCode::PageDown
112                        | KeyCode::Home
113                        | KeyCode::End => {
114                            list.handle_key(key);
115                            EventState::Consumed
116                        }
117                        KeyCode::Enter => {
118                            self.submit_selected(tx);
119                            EventState::Consumed
120                        }
121                        _ => EventState::NotConsumed,
122                    }
123                }
124            }
125            InputEvent::Mouse(mouse) => {
126                let Some(list) = &mut self.list else {
127                    return EventState::NotConsumed;
128                };
129                match list.handle_mouse(mouse) {
130                    SearchMouse::Activated(_) => self.submit_selected(tx),
131                    SearchMouse::Context(_) => {
132                        if let Some(event) = list.selected_row().and_then(S::context_event) {
133                            tx.send(event).ok();
134                        }
135                    }
136                    _ => {}
137                }
138                EventState::Consumed
139            }
140            _ => EventState::NotConsumed,
141        }
142    }
143
144    /// Standard rendering: panel block + (filter input row) + list.
145    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
146        let block = panel_block(S::TITLE, theme, focused);
147        let inner = block.inner(rect);
148        f.render_widget(block, rect);
149        self.render_in(f, inner, rect, theme, focused);
150    }
151
152    /// Render the body into `body` (a host that drew extra chrome — LINKS'
153    /// tab bar — passes what remains). `panel` is the full panel rect, for
154    /// wheel hit-testing.
155    pub fn render_in(
156        &mut self,
157        f: &mut Frame,
158        body: Rect,
159        panel: Rect,
160        theme: &Theme,
161        focused: bool,
162    ) {
163        let Some(list) = &mut self.list else {
164            return;
165        };
166        if S::HAS_FILTER {
167            let rows = Layout::default()
168                .direction(Direction::Vertical)
169                .constraints([Constraint::Length(1), Constraint::Min(0)])
170                .split(body);
171            list.render_query(f, rows[0], theme, focused);
172            list.render(f, rows[1], theme, focused);
173            list.set_list_rect(rows[1]);
174        } else {
175            list.render(f, body, theme, focused);
176            list.set_list_rect(body);
177        }
178        list.set_panel_rect(panel);
179    }
180
181    /// Test access to the underlying list.
182    #[cfg(test)]
183    pub(crate) fn list_mut(&mut self) -> Option<&mut SearchList<S::Row>> {
184        self.list.as_mut()
185    }
186
187    #[cfg(test)]
188    pub(crate) fn list(&self) -> Option<&SearchList<S::Row>> {
189        self.list.as_ref()
190    }
191}