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                _ => EventState::Consumed,
128            },
129            InputEvent::Mouse(mouse) => {
130                if let SearchMouse::Activated(_) = self.list.handle_mouse(mouse) {
131                    self.execute_selected(tx);
132                }
133                EventState::Consumed
134            }
135            _ => EventState::NotConsumed,
136        }
137    }
138
139    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
140        let popup = crate::components::centered_rect(60, 60, area);
141        let inner = modal_chrome(
142            f,
143            popup,
144            theme,
145            ModalSpec {
146                title: Some(" Commands "),
147                bg: ModalBg::Hard,
148                ..Default::default()
149            },
150        );
151
152        let rows = Layout::default()
153            .direction(Direction::Vertical)
154            .constraints([
155                Constraint::Length(1),
156                Constraint::Min(0),
157                Constraint::Length(1),
158            ])
159            .split(inner);
160
161        // `›` prefix + plain input (commands aren't query grammar).
162        let prefix = "› ";
163        f.render_widget(
164            Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
165            rows[0],
166        );
167        let input_rect = Rect {
168            x: rows[0].x + 2,
169            width: rows[0].width.saturating_sub(2),
170            ..rows[0]
171        };
172        self.list.render_query(f, input_rect, theme, true);
173
174        self.list.render(f, rows[1], theme, true);
175        self.list.set_list_rect(rows[1]);
176        self.list.set_panel_rect(popup);
177
178        f.render_widget(
179            Paragraph::new(Line::from(Span::styled(
180                "↑↓ move · ⏎ run · Esc close",
181                Style::default().fg(theme.gray.to_ratatui()),
182            ))),
183            rows[2],
184        );
185
186        self.list.render_autocomplete(f, popup, theme);
187    }
188
189    fn hint_shortcuts(&self) -> Vec<(String, String)> {
190        vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn entries_cover_every_leader_leaf() {
200        let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
201        // Spot-check shape and coverage.
202        assert!(entries.len() > 20);
203        assert!(
204            entries
205                .iter()
206                .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
207        );
208        assert!(
209            entries
210                .iter()
211                .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
212        );
213    }
214
215    #[tokio::test(flavor = "multi_thread")]
216    async fn enter_closes_then_executes() {
217        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
218        let mut palette = CommandPaletteModal::new(
219            &crate::keys::leader::leader_tree(),
220            "Ctrl+G",
221            Icons::new(false),
222            tx.clone(),
223        );
224        // Let the load land.
225        for _ in 0..50 {
226            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
227            palette.list.poll();
228        }
229        assert!(palette.list.selected_row().is_some());
230
231        palette.handle_input(
232            &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
233                ratatui::crossterm::event::KeyCode::Enter,
234                ratatui::crossterm::event::KeyModifiers::NONE,
235            )),
236            &tx,
237        );
238
239        let mut order = Vec::new();
240        while let Ok(ev) = rx.try_recv() {
241            match ev {
242                AppEvent::CloseOverlay => order.push("close"),
243                AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
244                _ => {}
245            }
246        }
247        assert_eq!(order, vec!["close", "execute"]);
248    }
249}