mockforge_tui/widgets/
command_palette.rs1use 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#[derive(Debug, Clone)]
16struct Command {
17 label: &'static str,
18 action: PaletteAction,
19}
20
21#[derive(Debug, Clone, Copy)]
23pub enum PaletteAction {
24 GoToScreen(usize),
26 Refresh,
28 ToggleHelp,
30 Quit,
32}
33
34pub 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 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 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 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 pub fn close(&mut self) {
93 self.visible = false;
94 }
95
96 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 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 let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
178
179 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 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 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 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}