zeph_tui/widgets/
command_palette.rs1use 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}