Skip to main content

kimun_notes/components/
command_palette.rs

1//! The **command palette** (spec §6: `›`-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::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::Style;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{ListItem, Paragraph};
11
12use crate::components::event_state::EventState;
13use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
14use crate::components::overlay::{Overlay, OverlayKind};
15use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
16use crate::components::rich_row::RichRow;
17use crate::components::search_list::{
18    Filter, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
19};
20use crate::keys::leader::{LeaderAction, LeaderNode};
21use crate::settings::icons::Icons;
22use crate::settings::themes::Theme;
23
24/// One palette row: a leader leaf with its full key sequence.
25#[derive(Clone)]
26pub struct CommandEntry {
27    /// `group label · leaf label`, e.g. `+find · files`.
28    pub label: String,
29    /// The key sequence, e.g. `Ctrl+G f f`.
30    pub keys: String,
31    /// `label + keys`, so the fuzzy filter matches either.
32    haystack: String,
33    pub action: LeaderAction,
34}
35
36impl SearchRow for CommandEntry {
37    fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
38        RichRow::new("›", self.label.clone())
39            .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
40            .meta(self.keys.clone())
41            .into_list_item(theme)
42    }
43
44    fn match_text(&self) -> Option<&str> {
45        Some(&self.haystack)
46    }
47
48    fn visual_height(&self) -> u16 {
49        1
50    }
51}
52
53/// Flatten a leader tree into palette entries — the single keymap source.
54pub fn command_entries(tree: &LeaderNode, gateway: &str) -> Vec<CommandEntry> {
55    fn walk(node: &LeaderNode, group: &str, keys: &str, out: &mut Vec<CommandEntry>) {
56        for (key, child) in node.children() {
57            let child_keys = format!("{keys} {key}");
58            match child {
59                // The palette never lists itself — selecting it would just
60                // close and reopen the palette.
61                LeaderNode::Leaf { action, .. } if *action == LeaderAction::Palette => {}
62                LeaderNode::Leaf { label, action } => {
63                    let label = if group.is_empty() {
64                        (*label).to_string()
65                    } else {
66                        format!("{group} · {label}")
67                    };
68                    out.push(CommandEntry {
69                        haystack: format!("{label} {child_keys}"),
70                        label,
71                        keys: child_keys,
72                        action: *action,
73                    });
74                }
75                LeaderNode::Group { label, .. } => walk(child, label, &child_keys, out),
76            }
77        }
78    }
79    let mut out = Vec::new();
80    walk(tree, "", gateway, &mut out);
81    out
82}
83
84/// The palette modal — same engine as the note browser, command rows.
85pub struct CommandPaletteModal {
86    list: SearchList<CommandEntry>,
87}
88
89impl CommandPaletteModal {
90    pub fn new(tree: &LeaderNode, gateway: &str, icons: Icons, tx: AppTx) -> Self {
91        // Static, in-memory rows: build synchronously over the leader-tree
92        // commands (the redraw callback is never fired on this path).
93        let list = SearchList::builder(StaticRowSource, redraw_callback(tx))
94            .filter(Filter::Fuzzy)
95            .icons(icons)
96            .build_with_rows(command_entries(tree, gateway));
97        Self { list }
98    }
99
100    fn execute_selected(&self, tx: &AppTx) {
101        if let Some(entry) = self.list.selected_row() {
102            let action = entry.action;
103            // Close first so the action runs with no overlay open — several
104            // actions (dialogs, pickers) no-op while one is.
105            tx.send(AppEvent::CloseOverlay).ok();
106            tx.send(AppEvent::ExecuteLeaderAction(action)).ok();
107        }
108    }
109}
110
111impl Overlay for CommandPaletteModal {
112    fn kind(&self) -> OverlayKind {
113        OverlayKind::CommandPalette
114    }
115
116    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
117        match event {
118            InputEvent::Key(key) => match self.list.handle_key(key) {
119                KeyReaction::Submit => {
120                    self.execute_selected(tx);
121                    EventState::Consumed
122                }
123                KeyReaction::Cancel => {
124                    tx.send(AppEvent::CloseOverlay).ok();
125                    EventState::Consumed
126                }
127                // Command rows declare no yank target, but the attempt still
128                // reports "nothing to copy" rather than vanishing into the
129                // catch-all below.
130                KeyReaction::Yank(target) => {
131                    crate::components::yank_row(target, tx);
132                    EventState::Consumed
133                }
134                _ => EventState::Consumed,
135            },
136            InputEvent::Mouse(mouse) => {
137                if let SearchMouse::Activated(_) = self.list.handle_mouse(mouse) {
138                    self.execute_selected(tx);
139                }
140                EventState::Consumed
141            }
142            _ => EventState::NotConsumed,
143        }
144    }
145
146    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
147        let popup = crate::components::centered_rect(60, 60, area);
148        let inner = modal_chrome(
149            f,
150            popup,
151            theme,
152            ModalSpec {
153                title: Some(" Commands "),
154                bg: ModalBg::Hard,
155                ..Default::default()
156            },
157        );
158
159        let rows = Layout::default()
160            .direction(Direction::Vertical)
161            .constraints([
162                Constraint::Length(1),
163                Constraint::Min(0),
164                Constraint::Length(1),
165            ])
166            .split(inner);
167
168        // `›` prefix + plain input (commands aren't query grammar).
169        let prefix = "› ";
170        f.render_widget(
171            Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
172            rows[0],
173        );
174        let input_rect = Rect {
175            x: rows[0].x + 2,
176            width: rows[0].width.saturating_sub(2),
177            ..rows[0]
178        };
179        self.list.render_query(f, input_rect, theme, true);
180
181        self.list.render(f, rows[1], theme, true);
182        self.list.set_list_rect(rows[1]);
183        self.list.set_panel_rect(popup);
184
185        f.render_widget(
186            Paragraph::new(Line::from(Span::styled(
187                "↑↓ move · ⏎ run · Esc close",
188                Style::default().fg(theme.gray.to_ratatui()),
189            ))),
190            rows[2],
191        );
192
193        self.list.render_autocomplete(f, popup, theme);
194    }
195
196    fn hint_shortcuts(&self) -> Vec<(String, String)> {
197        vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn entries_cover_every_leader_leaf() {
207        let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
208        // Spot-check shape and coverage.
209        assert!(entries.len() > 20);
210        assert!(
211            entries
212                .iter()
213                .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
214        );
215        assert!(
216            entries
217                .iter()
218                .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
219        );
220    }
221
222    #[tokio::test(flavor = "multi_thread")]
223    async fn enter_closes_then_executes() {
224        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
225        let mut palette = CommandPaletteModal::new(
226            &crate::keys::leader::leader_tree(),
227            "Ctrl+G",
228            Icons::new(false),
229            tx.clone(),
230        );
231        // Let the load land.
232        for _ in 0..50 {
233            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
234            palette.list.poll();
235        }
236        assert!(palette.list.selected_row().is_some());
237
238        palette.handle_input(
239            &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
240                ratatui::crossterm::event::KeyCode::Enter,
241                ratatui::crossterm::event::KeyModifiers::NONE,
242            )),
243            &tx,
244        );
245
246        let mut order = Vec::new();
247        while let Ok(ev) = rx.try_recv() {
248            match ev {
249                AppEvent::CloseOverlay => order.push("close"),
250                AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
251                _ => {}
252            }
253        }
254        assert_eq!(order, vec!["close", "execute"]);
255    }
256}