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                // The nine jump leaves stay out of the palette: they're a
63                // single fuzzy-searchable concept ("pinned notes"), not nine
64                // near-identical rows differing only by digit.
65                LeaderNode::Leaf {
66                    action: LeaderAction::PinnedJump(_),
67                    ..
68                } => {}
69                LeaderNode::Leaf { label, action } => {
70                    let label = if group.is_empty() {
71                        (*label).to_string()
72                    } else {
73                        format!("{group} · {label}")
74                    };
75                    out.push(CommandEntry {
76                        haystack: format!("{label} {child_keys}"),
77                        label,
78                        keys: child_keys,
79                        action: *action,
80                    });
81                }
82                LeaderNode::Group { label, .. } => walk(child, label, &child_keys, out),
83            }
84        }
85    }
86    let mut out = Vec::new();
87    walk(tree, "", gateway, &mut out);
88    out
89}
90
91/// The palette modal — same engine as the note browser, command rows.
92pub struct CommandPaletteModal {
93    list: SearchList<CommandEntry>,
94}
95
96impl CommandPaletteModal {
97    pub fn new(tree: &LeaderNode, gateway: &str, icons: Icons, tx: AppTx) -> Self {
98        // Static, in-memory rows: build synchronously over the leader-tree
99        // commands (the redraw callback is never fired on this path).
100        let list = SearchList::builder(StaticRowSource, redraw_callback(tx))
101            .filter(Filter::Fuzzy)
102            .icons(icons)
103            .build_with_rows(command_entries(tree, gateway));
104        Self { list }
105    }
106
107    fn execute_selected(&self, tx: &AppTx) {
108        if let Some(entry) = self.list.selected_row() {
109            let action = entry.action;
110            // Close first so the action runs with no overlay open — several
111            // actions (dialogs, pickers) no-op while one is.
112            tx.send(AppEvent::CloseOverlay).ok();
113            tx.send(AppEvent::ExecuteLeaderAction(action)).ok();
114        }
115    }
116}
117
118impl Overlay for CommandPaletteModal {
119    fn kind(&self) -> OverlayKind {
120        OverlayKind::CommandPalette
121    }
122
123    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
124        match event {
125            InputEvent::Key(key) => match self.list.handle_key(key) {
126                KeyReaction::Submit => {
127                    self.execute_selected(tx);
128                    EventState::Consumed
129                }
130                KeyReaction::Cancel => {
131                    tx.send(AppEvent::CloseOverlay).ok();
132                    EventState::Consumed
133                }
134                // Command rows declare no yank target, but the attempt still
135                // reports "nothing to copy" rather than vanishing into the
136                // catch-all below.
137                KeyReaction::Yank(target) => {
138                    crate::components::yank_row(target, tx);
139                    EventState::Consumed
140                }
141                _ => EventState::Consumed,
142            },
143            InputEvent::Mouse(mouse) => {
144                if let SearchMouse::Activated(_) = self.list.handle_mouse(mouse) {
145                    self.execute_selected(tx);
146                }
147                EventState::Consumed
148            }
149            _ => EventState::NotConsumed,
150        }
151    }
152
153    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
154        let popup = crate::components::centered_rect(60, 60, area);
155        let inner = modal_chrome(
156            f,
157            popup,
158            theme,
159            ModalSpec {
160                title: Some(" Commands "),
161                bg: ModalBg::Hard,
162                ..Default::default()
163            },
164        );
165
166        let rows = Layout::default()
167            .direction(Direction::Vertical)
168            .constraints([
169                Constraint::Length(1),
170                Constraint::Min(0),
171                Constraint::Length(1),
172            ])
173            .split(inner);
174
175        // `›` prefix + plain input (commands aren't query grammar).
176        let prefix = "› ";
177        f.render_widget(
178            Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
179            rows[0],
180        );
181        let input_rect = Rect {
182            x: rows[0].x + 2,
183            width: rows[0].width.saturating_sub(2),
184            ..rows[0]
185        };
186        self.list.render_query(f, input_rect, theme, true);
187
188        self.list.render(f, rows[1], theme, true);
189        self.list.set_list_rect(rows[1]);
190        self.list.set_panel_rect(popup);
191
192        f.render_widget(
193            Paragraph::new(Line::from(Span::styled(
194                "↑↓ move · ⏎ run · Esc close",
195                Style::default().fg(theme.gray.to_ratatui()),
196            ))),
197            rows[2],
198        );
199
200        self.list.render_autocomplete(f, popup, theme);
201    }
202
203    fn hint_shortcuts(&self) -> Vec<(String, String)> {
204        vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn entries_cover_every_leader_leaf() {
214        let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
215        // Spot-check shape and coverage.
216        assert!(entries.len() > 20);
217        assert!(
218            entries
219                .iter()
220                .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
221        );
222        assert!(
223            entries
224                .iter()
225                .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
226        );
227        assert!(
228            !entries
229                .iter()
230                .any(|e| matches!(e.action, LeaderAction::PinnedJump(_))),
231            "the nine jump leaves stay out of the palette"
232        );
233        assert!(entries.iter().any(|e| e.action == LeaderAction::FindPinned));
234        assert!(
235            entries
236                .iter()
237                .any(|e| e.action == LeaderAction::NoteTogglePin)
238        );
239    }
240
241    #[tokio::test(flavor = "multi_thread")]
242    async fn enter_closes_then_executes() {
243        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
244        let mut palette = CommandPaletteModal::new(
245            &crate::keys::leader::leader_tree(),
246            "Ctrl+G",
247            Icons::new(false),
248            tx.clone(),
249        );
250        // Let the load land.
251        for _ in 0..50 {
252            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
253            palette.list.poll();
254        }
255        assert!(palette.list.selected_row().is_some());
256
257        palette.handle_input(
258            &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
259                ratatui::crossterm::event::KeyCode::Enter,
260                ratatui::crossterm::event::KeyModifiers::NONE,
261            )),
262            &tx,
263        );
264
265        let mut order = Vec::new();
266        while let Ok(ev) = rx.try_recv() {
267            match ev {
268                AppEvent::CloseOverlay => order.push("close"),
269                AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
270                _ => {}
271            }
272        }
273        assert_eq!(order, vec!["close", "execute"]);
274    }
275}