Skip to main content

mockforge_tui/widgets/
command_palette.rs

1//! Command palette — fuzzy-filtered list of actions triggered by `:`.
2
3use crossterm::event::{KeyCode, KeyEvent};
4use ratatui::{
5    layout::{Constraint, Flex, Layout, Rect},
6    text::{Line, Span},
7    widgets::{Block, Borders, Clear, Paragraph},
8    Frame,
9};
10
11use crate::screens::ScreenId;
12use crate::theme::Theme;
13
14/// A command that can be executed from the palette.
15#[derive(Debug, Clone)]
16struct Command {
17    label: &'static str,
18    action: PaletteAction,
19}
20
21/// What happens when a command is selected.
22#[derive(Debug, Clone, Copy)]
23pub enum PaletteAction {
24    /// Navigate to a specific screen tab.
25    GoToScreen(usize),
26    /// Refresh the current screen.
27    Refresh,
28    /// Toggle help overlay.
29    ToggleHelp,
30    /// Quit the application.
31    Quit,
32}
33
34/// State for the command palette overlay.
35pub struct CommandPalette {
36    pub visible: bool,
37    input: String,
38    cursor: usize,
39    commands: Vec<Command>,
40    filtered: Vec<usize>,
41    selected: usize,
42}
43
44impl CommandPalette {
45    pub fn new() -> Self {
46        let mut commands = Vec::new();
47
48        // Add screen navigation commands.
49        for (i, sid) in ScreenId::ALL.iter().enumerate() {
50            commands.push(Command {
51                label: sid.label(),
52                action: PaletteAction::GoToScreen(i),
53            });
54        }
55
56        // Add utility commands.
57        commands.push(Command {
58            label: "Refresh",
59            action: PaletteAction::Refresh,
60        });
61        commands.push(Command {
62            label: "Help",
63            action: PaletteAction::ToggleHelp,
64        });
65        commands.push(Command {
66            label: "Quit",
67            action: PaletteAction::Quit,
68        });
69
70        let filtered: Vec<usize> = (0..commands.len()).collect();
71
72        Self {
73            visible: false,
74            input: String::new(),
75            cursor: 0,
76            commands,
77            filtered,
78            selected: 0,
79        }
80    }
81
82    /// Open the command palette.
83    pub fn open(&mut self) {
84        self.visible = true;
85        self.input.clear();
86        self.cursor = 0;
87        self.selected = 0;
88        self.rebuild_filtered();
89    }
90
91    /// Close the palette without executing.
92    pub fn close(&mut self) {
93        self.visible = false;
94    }
95
96    /// Handle a key event. Returns `Some(action)` if a command was selected.
97    pub fn handle_key(&mut self, key: KeyEvent) -> Option<PaletteAction> {
98        if !self.visible {
99            return None;
100        }
101
102        match key.code {
103            KeyCode::Char(c) => {
104                self.input.insert(self.cursor, c);
105                self.cursor += 1;
106                self.rebuild_filtered();
107                self.selected = 0;
108                None
109            }
110            KeyCode::Backspace => {
111                if self.cursor > 0 {
112                    self.cursor -= 1;
113                    self.input.remove(self.cursor);
114                    self.rebuild_filtered();
115                    self.selected = 0;
116                }
117                None
118            }
119            KeyCode::Up => {
120                self.selected = self.selected.saturating_sub(1);
121                None
122            }
123            KeyCode::Down => {
124                if !self.filtered.is_empty() {
125                    self.selected = (self.selected + 1).min(self.filtered.len() - 1);
126                }
127                None
128            }
129            KeyCode::Enter => {
130                let action = self.filtered.get(self.selected).map(|&idx| self.commands[idx].action);
131                self.close();
132                action
133            }
134            KeyCode::Esc => {
135                self.close();
136                None
137            }
138            _ => None,
139        }
140    }
141
142    fn rebuild_filtered(&mut self) {
143        if self.input.is_empty() {
144            self.filtered = (0..self.commands.len()).collect();
145        } else {
146            let query = self.input.to_lowercase();
147            self.filtered = self
148                .commands
149                .iter()
150                .enumerate()
151                .filter(|(_, cmd)| cmd.label.to_lowercase().contains(&query))
152                .map(|(i, _)| i)
153                .collect();
154        }
155    }
156
157    /// Render the palette overlay.
158    pub fn render(&self, frame: &mut Frame) {
159        if !self.visible {
160            return;
161        }
162
163        let area = centered_rect(50, 60, frame.area());
164        frame.render_widget(Clear, area);
165
166        let block = Block::default()
167            .title(" Command Palette ")
168            .title_style(Theme::title())
169            .borders(Borders::ALL)
170            .border_style(Theme::dim())
171            .style(Theme::surface());
172
173        let inner = block.inner(area);
174        frame.render_widget(block, area);
175
176        // Split inner into input line + results list.
177        let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
178
179        // Input line.
180        let input_line = Line::from(vec![
181            Span::styled(": ", Theme::key_hint()),
182            Span::styled(&self.input, Theme::base()),
183            Span::styled("▏", Theme::key_hint()),
184        ]);
185        frame.render_widget(Paragraph::new(input_line), chunks[0]);
186
187        // Results list (max visible items).
188        let max_visible = chunks[1].height as usize;
189        let mut lines = Vec::new();
190        for (display_idx, &cmd_idx) in self.filtered.iter().enumerate().take(max_visible) {
191            let cmd = &self.commands[cmd_idx];
192            let style = if display_idx == self.selected {
193                Theme::highlight()
194            } else {
195                Theme::base()
196            };
197            lines.push(Line::from(Span::styled(format!("  {}", cmd.label), style)));
198        }
199
200        if lines.is_empty() {
201            lines.push(Line::from(Span::styled("  No matching commands", Theme::dim())));
202        }
203
204        frame.render_widget(Paragraph::new(lines), chunks[1]);
205    }
206}
207
208fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
209    let vertical = Layout::vertical([Constraint::Percentage(percent_y)])
210        .flex(Flex::Center)
211        .split(area);
212    Layout::horizontal([Constraint::Percentage(percent_x)])
213        .flex(Flex::Center)
214        .split(vertical[0])[0]
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
221
222    fn key(code: KeyCode) -> KeyEvent {
223        KeyEvent {
224            code,
225            modifiers: KeyModifiers::NONE,
226            kind: KeyEventKind::Press,
227            state: KeyEventState::NONE,
228        }
229    }
230
231    #[test]
232    fn open_and_close() {
233        let mut palette = CommandPalette::new();
234        assert!(!palette.visible);
235        palette.open();
236        assert!(palette.visible);
237        palette.close();
238        assert!(!palette.visible);
239    }
240
241    #[test]
242    fn filter_narrows_results() {
243        let mut palette = CommandPalette::new();
244        palette.open();
245        let all_count = palette.filtered.len();
246
247        // Type "log" to filter
248        palette.handle_key(key(KeyCode::Char('l')));
249        palette.handle_key(key(KeyCode::Char('o')));
250        palette.handle_key(key(KeyCode::Char('g')));
251
252        assert!(palette.filtered.len() < all_count);
253        assert!(!palette.filtered.is_empty());
254    }
255
256    #[test]
257    fn enter_selects_command() {
258        let mut palette = CommandPalette::new();
259        palette.open();
260        // First item should be Dashboard (GoToScreen(0))
261        let action = palette.handle_key(key(KeyCode::Enter));
262        assert!(action.is_some());
263        assert!(!palette.visible);
264    }
265
266    #[test]
267    fn esc_closes_without_action() {
268        let mut palette = CommandPalette::new();
269        palette.open();
270        let action = palette.handle_key(key(KeyCode::Esc));
271        assert!(action.is_none());
272        assert!(!palette.visible);
273    }
274
275    #[test]
276    fn hidden_palette_returns_none() {
277        let mut palette = CommandPalette::new();
278        let action = palette.handle_key(key(KeyCode::Enter));
279        assert!(action.is_none());
280    }
281}