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