Skip to main content

kimun_notes/components/
command_palette.rs

1//! The **command palette** (the `›`-prefixed telescope scope): a fuzzy
2//! list of every leader-tree command. Selecting one executes its
3//! [`LeaderAction`] — the palette is a labelled door onto the same actions
4//! the leader sequences fire, never a second implementation.
5
6use ratatui::Frame;
7use ratatui::crossterm::event::KeyCode;
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::Style;
10use ratatui::widgets::{ListItem, Paragraph};
11
12use crate::components::clickable::is_press_outside;
13use crate::components::event_state::EventState;
14use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
15use crate::components::hint_row::HintRow;
16use crate::components::overlay::{Overlay, OverlayKind};
17use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
18use crate::components::rich_row::RichRow;
19use crate::components::search_list::{
20    Filter, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
21};
22use crate::keys::leader::{LeaderAction, LeaderNode};
23use crate::settings::icons::Icons;
24use crate::settings::themes::Theme;
25
26/// One palette row: a leader leaf with its full key sequence.
27#[derive(Clone)]
28pub struct CommandEntry {
29    /// `group label · leaf label`, e.g. `+find · files`.
30    pub label: String,
31    /// The key sequence, e.g. `Ctrl+G f f`.
32    pub keys: String,
33    /// `label + keys`, so the fuzzy filter matches either.
34    haystack: String,
35    pub action: LeaderAction,
36}
37
38impl SearchRow for CommandEntry {
39    fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
40        RichRow::new("›", self.label.clone())
41            .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
42            .meta(self.keys.clone())
43            .into_list_item(theme)
44    }
45
46    fn match_text(&self) -> Option<&str> {
47        Some(&self.haystack)
48    }
49
50    fn visual_height(&self) -> u16 {
51        1
52    }
53}
54
55/// Flatten a leader tree into palette entries — the single keymap source.
56pub fn command_entries(tree: &LeaderNode, gateway: &str) -> Vec<CommandEntry> {
57    fn walk(node: &LeaderNode, group: &str, keys: &str, out: &mut Vec<CommandEntry>) {
58        for (key, child) in node.children() {
59            let child_keys = format!("{keys} {key}");
60            match child {
61                // The palette never lists itself — selecting it would just
62                // close and reopen the palette.
63                LeaderNode::Leaf { action, .. } if *action == LeaderAction::Palette => {}
64                // The nine jump leaves stay out of the palette: they're a
65                // single fuzzy-searchable concept ("pinned notes"), not nine
66                // near-identical rows differing only by digit.
67                LeaderNode::Leaf {
68                    action: LeaderAction::PinnedJump(_),
69                    ..
70                } => {}
71                LeaderNode::Leaf { label, action } => {
72                    let label = if group.is_empty() {
73                        (*label).to_string()
74                    } else {
75                        format!("{group} · {label}")
76                    };
77                    out.push(CommandEntry {
78                        haystack: format!("{label} {child_keys}"),
79                        label,
80                        keys: child_keys,
81                        action: *action,
82                    });
83                }
84                LeaderNode::Group { label, .. } => walk(child, label, &child_keys, out),
85            }
86        }
87    }
88    let mut out = Vec::new();
89    walk(tree, "", gateway, &mut out);
90    out
91}
92
93/// The palette modal — same engine as the note browser, command rows.
94pub struct CommandPaletteModal {
95    list: SearchList<CommandEntry>,
96    /// Outer popup rect from the last render; a press outside closes.
97    popup_rect: Rect,
98    hints: HintRow,
99}
100
101impl CommandPaletteModal {
102    pub fn new(tree: &LeaderNode, gateway: &str, icons: Icons, tx: AppTx) -> Self {
103        // Static, in-memory rows: build synchronously over the leader-tree
104        // commands (the redraw callback is never fired on this path).
105        let list = SearchList::builder(StaticRowSource, redraw_callback(tx))
106            .filter(Filter::Fuzzy)
107            .icons(icons)
108            .build_with_rows(command_entries(tree, gateway));
109        Self {
110            list,
111            popup_rect: Rect::default(),
112            hints: HintRow::new(&[
113                (KeyCode::Null, "↑↓", "Move"),
114                (KeyCode::Enter, "⏎", "Run"),
115                (KeyCode::Esc, "Esc", "Close"),
116            ])
117            .with_indent(0),
118        }
119    }
120
121    fn execute_selected(&self, tx: &AppTx) {
122        if let Some(entry) = self.list.selected_row() {
123            let action = entry.action;
124            // Close first so the action runs with no overlay open — several
125            // actions (dialogs, pickers) no-op while one is.
126            tx.send(AppEvent::CloseOverlay).ok();
127            tx.send(AppEvent::ExecuteLeaderAction(action)).ok();
128        }
129    }
130}
131
132impl Overlay for CommandPaletteModal {
133    fn kind(&self) -> OverlayKind {
134        OverlayKind::CommandPalette
135    }
136
137    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
138        match event {
139            InputEvent::Key(key) => match self.list.handle_key(key) {
140                KeyReaction::Submit => {
141                    self.execute_selected(tx);
142                    EventState::Consumed
143                }
144                KeyReaction::Cancel => {
145                    tx.send(AppEvent::CloseOverlay).ok();
146                    EventState::Consumed
147                }
148                // Command rows declare no yank target, but the attempt still
149                // reports "nothing to copy" rather than vanishing into the
150                // catch-all below.
151                KeyReaction::Yank(target) => {
152                    crate::components::yank_row(target, tx);
153                    EventState::Consumed
154                }
155                _ => EventState::Consumed,
156            },
157            InputEvent::Mouse(mouse) => {
158                if is_press_outside(mouse, self.popup_rect) {
159                    tx.send(AppEvent::CloseOverlay).ok();
160                    return EventState::Consumed;
161                }
162                if let Some(key) = self.hints.hit(mouse) {
163                    return self.handle_input(&InputEvent::Key(key), tx);
164                }
165                if let SearchMouse::Activated(_)
166                | SearchMouse::DoubleClicked { repeat: false, .. } =
167                    self.list.handle_mouse(mouse)
168                {
169                    self.execute_selected(tx);
170                }
171                EventState::Consumed
172            }
173            _ => EventState::NotConsumed,
174        }
175    }
176
177    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
178        let popup = crate::components::centered_rect(60, 60, area);
179        self.popup_rect = popup;
180        let inner = modal_chrome(
181            f,
182            popup,
183            theme,
184            ModalSpec {
185                title: Some(" Commands "),
186                bg: ModalBg::Hard,
187                ..Default::default()
188            },
189        );
190
191        let rows = Layout::default()
192            .direction(Direction::Vertical)
193            .constraints([
194                Constraint::Length(1),
195                Constraint::Min(0),
196                Constraint::Length(1),
197            ])
198            .split(inner);
199
200        // `›` prefix + plain input (commands aren't query grammar).
201        let prefix = "› ";
202        f.render_widget(
203            Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
204            rows[0],
205        );
206        let input_rect = Rect {
207            x: rows[0].x + 2,
208            width: rows[0].width.saturating_sub(2),
209            ..rows[0]
210        };
211        self.list.render_query(f, input_rect, theme, true);
212
213        self.list.render(f, rows[1], theme, true);
214        self.list.set_list_rect(rows[1]);
215        self.list.set_panel_rect(popup);
216
217        self.hints.render(
218            f,
219            rows[2],
220            Style::default().fg(theme.gray.to_ratatui()),
221            theme,
222        );
223
224        self.list.render_autocomplete(f, popup, theme);
225    }
226
227    fn hint_shortcuts(&self) -> Vec<(String, String)> {
228        vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn palette() -> (CommandPaletteModal, AppTx) {
237        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
238        let p = CommandPaletteModal::new(
239            &crate::keys::leader::leader_tree(),
240            "Ctrl+B",
241            Icons::new(false),
242            tx.clone(),
243        );
244        (p, tx)
245    }
246
247    fn drawn(p: &mut CommandPaletteModal) -> ratatui::buffer::Buffer {
248        use ratatui::{Terminal, backend::TestBackend};
249        let theme = Theme::gruvbox_dark();
250        let mut t = Terminal::new(TestBackend::new(100, 40)).unwrap();
251        t.draw(|f| p.render(f, f.area(), &theme)).unwrap();
252        t.backend().buffer().clone()
253    }
254
255    fn click(p: &mut CommandPaletteModal, col: u16, row: u16) -> Vec<AppEvent> {
256        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
257        p.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
258        crate::test_support::drain(&mut rx).into_iter().collect()
259    }
260
261    #[test]
262    fn outside_press_and_close_chip_close_the_palette() {
263        let (mut p, _tx) = palette();
264        drawn(&mut p);
265        assert!(
266            click(&mut p, 0, 0)
267                .iter()
268                .any(|e| matches!(e, AppEvent::CloseOverlay))
269        );
270        let buf = drawn(&mut p);
271        let (col, row) = crate::test_support::find_text(&buf, "[Esc]").expect("[Esc] chip drawn");
272        assert!(
273            click(&mut p, col, row)
274                .iter()
275                .any(|e| matches!(e, AppEvent::CloseOverlay))
276        );
277    }
278
279    #[test]
280    fn entries_cover_every_leader_leaf() {
281        let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
282        // Spot-check shape and coverage.
283        assert!(entries.len() > 20);
284        assert!(
285            entries
286                .iter()
287                .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
288        );
289        assert!(
290            entries
291                .iter()
292                .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
293        );
294        assert!(
295            !entries
296                .iter()
297                .any(|e| matches!(e.action, LeaderAction::PinnedJump(_))),
298            "the nine jump leaves stay out of the palette"
299        );
300        assert!(entries.iter().any(|e| e.action == LeaderAction::FindPinned));
301        assert!(
302            entries
303                .iter()
304                .any(|e| e.action == LeaderAction::NoteTogglePin)
305        );
306    }
307
308    #[tokio::test(flavor = "multi_thread")]
309    async fn enter_closes_then_executes() {
310        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
311        let mut palette = CommandPaletteModal::new(
312            &crate::keys::leader::leader_tree(),
313            "Ctrl+G",
314            Icons::new(false),
315            tx.clone(),
316        );
317        // Let the load land.
318        for _ in 0..50 {
319            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
320            palette.list.poll();
321        }
322        assert!(palette.list.selected_row().is_some());
323
324        palette.handle_input(
325            &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
326                ratatui::crossterm::event::KeyCode::Enter,
327                ratatui::crossterm::event::KeyModifiers::NONE,
328            )),
329            &tx,
330        );
331
332        let mut order = Vec::new();
333        while let Ok(ev) = rx.try_recv() {
334            match ev {
335                AppEvent::CloseOverlay => order.push("close"),
336                AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
337                _ => {}
338            }
339        }
340        assert_eq!(order, vec!["close", "execute"]);
341    }
342}