Skip to main content

zeph_tui/widgets/
command_palette.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use ratatui::Frame;
5use ratatui::layout::{Alignment, Rect};
6use ratatui::style::Style;
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
9
10use crate::command::{CommandEntry, filter_commands};
11use crate::layout::centered_rect;
12use crate::theme::Theme;
13
14pub struct CommandPaletteState {
15    pub query: String,
16    pub cursor: usize,
17    pub selected: usize,
18    pub filtered: Vec<&'static CommandEntry>,
19}
20
21impl CommandPaletteState {
22    #[must_use]
23    pub fn new() -> Self {
24        Self {
25            query: String::new(),
26            cursor: 0,
27            selected: 0,
28            filtered: filter_commands(""),
29        }
30    }
31
32    pub fn push_char(&mut self, c: char) {
33        let byte_offset = self
34            .query
35            .char_indices()
36            .nth(self.cursor)
37            .map_or(self.query.len(), |(i, _)| i);
38        self.query.insert(byte_offset, c);
39        self.cursor += 1;
40        self.refilter();
41    }
42
43    pub fn pop_char(&mut self) {
44        if self.cursor > 0 {
45            let byte_offset = self
46                .query
47                .char_indices()
48                .nth(self.cursor - 1)
49                .map_or(self.query.len(), |(i, _)| i);
50            self.query.remove(byte_offset);
51            self.cursor -= 1;
52            self.refilter();
53        }
54    }
55
56    pub fn move_up(&mut self) {
57        self.selected = self.selected.saturating_sub(1);
58    }
59
60    pub fn move_down(&mut self) {
61        if !self.filtered.is_empty() {
62            self.selected = (self.selected + 1).min(self.filtered.len() - 1);
63        }
64    }
65
66    #[must_use]
67    pub fn selected_entry(&self) -> Option<&'static CommandEntry> {
68        self.filtered.get(self.selected).copied()
69    }
70
71    fn refilter(&mut self) {
72        self.filtered = filter_commands(&self.query);
73        if self.filtered.is_empty() {
74            self.selected = 0;
75        } else {
76            self.selected = self.selected.min(self.filtered.len() - 1);
77        }
78    }
79}
80
81impl Default for CommandPaletteState {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87pub fn render(state: &CommandPaletteState, frame: &mut Frame, area: Rect) {
88    let theme = Theme::default();
89
90    #[allow(clippy::cast_possible_truncation)]
91    let height = (state.filtered.len() as u16 + 4).clamp(6, 20);
92    let popup = centered_rect(60, height, area);
93
94    frame.render_widget(Clear, popup);
95
96    let block = Block::default()
97        .borders(Borders::ALL)
98        .border_style(theme.panel_border)
99        .title(" Command Palette ")
100        .title_alignment(Alignment::Center);
101
102    frame.render_widget(block, popup);
103
104    let inner = popup.inner(ratatui::layout::Margin {
105        horizontal: 1,
106        vertical: 1,
107    });
108
109    if inner.height < 2 {
110        return;
111    }
112
113    let query_area = Rect {
114        x: inner.x,
115        y: inner.y,
116        width: inner.width,
117        height: 1,
118    };
119
120    let query_line = Line::from(vec![
121        Span::styled(": ", theme.highlight),
122        Span::raw(&state.query),
123    ]);
124    frame.render_widget(Paragraph::new(query_line), query_area);
125
126    if inner.height < 3 {
127        return;
128    }
129
130    let list_area = Rect {
131        x: inner.x,
132        y: inner.y + 2,
133        width: inner.width,
134        height: inner.height - 2,
135    };
136
137    let items: Vec<ListItem> = state
138        .filtered
139        .iter()
140        .enumerate()
141        .map(|(i, entry)| {
142            let style = if i == state.selected {
143                Style::default().bg(theme.highlight.fg.unwrap_or(ratatui::style::Color::Blue))
144            } else {
145                Style::default()
146            };
147            let shortcut_str = entry.shortcut.map_or(String::new(), |s| format!(" [{s}]"));
148            let shortcut_style = style.patch(Style::default().fg(ratatui::style::Color::DarkGray));
149            ListItem::new(Line::from(vec![
150                Span::styled(format!("{:<20}", entry.id), style.patch(theme.panel_title)),
151                Span::styled(format!("  {}", entry.label), style),
152                Span::styled(shortcut_str, shortcut_style),
153            ]))
154        })
155        .collect();
156
157    let mut list_state = ListState::default();
158    list_state.select(Some(state.selected));
159
160    frame.render_stateful_widget(List::new(items), list_area, &mut list_state);
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::test_utils::render_to_string;
167
168    #[test]
169    fn new_state_has_all_commands() {
170        let state = CommandPaletteState::new();
171        assert!(state.filtered.len() >= 12);
172        assert_eq!(state.selected, 0);
173        assert!(state.query.is_empty());
174        assert_eq!(state.cursor, 0);
175    }
176
177    #[test]
178    fn push_char_updates_query_and_filters() {
179        let mut state = CommandPaletteState::new();
180        state.push_char('s');
181        state.push_char('k');
182        assert_eq!(state.query, "sk");
183        assert_eq!(state.cursor, 2);
184        assert!(!state.filtered.is_empty());
185        assert_eq!(state.filtered[0].id, "skill:list");
186    }
187
188    #[test]
189    fn pop_char_removes_last_char() {
190        let mut state = CommandPaletteState::new();
191        state.push_char('s');
192        state.push_char('k');
193        state.pop_char();
194        assert_eq!(state.query, "s");
195        assert_eq!(state.cursor, 1);
196    }
197
198    #[test]
199    fn pop_char_on_empty_is_noop() {
200        let mut state = CommandPaletteState::new();
201        state.pop_char();
202        assert!(state.query.is_empty());
203        assert_eq!(state.cursor, 0);
204    }
205
206    #[test]
207    fn move_down_increments_selection() {
208        let mut state = CommandPaletteState::new();
209        assert_eq!(state.selected, 0);
210        state.move_down();
211        assert_eq!(state.selected, 1);
212    }
213
214    #[test]
215    fn move_down_clamps_at_last() {
216        let mut state = CommandPaletteState::new();
217        let last = state.filtered.len() - 1;
218        state.selected = last;
219        state.move_down();
220        assert_eq!(state.selected, last);
221    }
222
223    #[test]
224    fn move_up_decrements_selection() {
225        let mut state = CommandPaletteState::new();
226        state.selected = 3;
227        state.move_up();
228        assert_eq!(state.selected, 2);
229    }
230
231    #[test]
232    fn move_up_clamps_at_zero() {
233        let mut state = CommandPaletteState::new();
234        state.selected = 0;
235        state.move_up();
236        assert_eq!(state.selected, 0);
237    }
238
239    #[test]
240    fn selected_entry_returns_correct_command() {
241        let state = CommandPaletteState::new();
242        let entry = state.selected_entry().unwrap();
243        assert_eq!(entry.id, "skill:list");
244    }
245
246    #[test]
247    fn selected_entry_returns_none_when_empty_filter() {
248        let mut state = CommandPaletteState::new();
249        for c in "xxxxxxxxxx".chars() {
250            state.push_char(c);
251        }
252        assert!(state.selected_entry().is_none());
253    }
254
255    #[test]
256    fn refilter_clamps_selection_to_new_len() {
257        let mut state = CommandPaletteState::new();
258        state.selected = 5;
259        state.push_char('s');
260        state.push_char('k');
261        assert!(state.selected < state.filtered.len().max(1));
262    }
263
264    #[test]
265    fn render_command_palette_snapshot() {
266        let state = CommandPaletteState::new();
267        let output = render_to_string(80, 24, |frame, area| {
268            render(&state, frame, area);
269        });
270        assert!(output.contains("Command Palette"));
271        assert!(output.contains("skill:list"));
272        assert!(output.contains("mcp:list"));
273    }
274
275    #[test]
276    fn render_with_query() {
277        let mut state = CommandPaletteState::new();
278        state.push_char('v');
279        state.push_char('i');
280        state.push_char('e');
281        state.push_char('w');
282        let output = render_to_string(80, 24, |frame, area| {
283            render(&state, frame, area);
284        });
285        assert!(
286            output.contains("view:cost")
287                || output.contains("view:config")
288                || output.contains("view:tools")
289        );
290    }
291}