code_mesh_tui/components/
command_palette.rs1use ratatui::{
2 layout::Rect,
3 widgets::{Block, Borders, List, ListItem, Paragraph},
4 style::Style,
5 text::{Line, Span},
6};
7use anyhow::Result;
8use crossterm::event::{KeyCode, KeyEvent};
9
10use crate::{
11 renderer::Renderer,
12 theme::Theme,
13};
14
15pub struct CommandPalette {
17 theme: Box<dyn Theme + Send + Sync>,
18 input: String,
19 cursor_position: usize,
20 commands: Vec<Command>,
21 filtered_commands: Vec<usize>,
22 selected_index: usize,
23 is_open: bool,
24}
25
26#[derive(Debug, Clone)]
27struct Command {
28 name: String,
29 description: String,
30 action: String,
31 keywords: Vec<String>,
32}
33
34impl CommandPalette {
35 pub fn new(theme: &dyn Theme) -> Self {
37 let commands = vec![
38 Command {
39 name: "Open File".to_string(),
40 description: "Open a file in the editor".to_string(),
41 action: "open-file".to_string(),
42 keywords: vec!["open".to_string(), "file".to_string()],
43 },
44 Command {
45 name: "Toggle Theme".to_string(),
46 description: "Switch between available themes".to_string(),
47 action: "toggle-theme".to_string(),
48 keywords: vec!["theme".to_string(), "color".to_string(), "appearance".to_string()],
49 },
50 Command {
51 name: "Clear Chat".to_string(),
52 description: "Clear the chat history".to_string(),
53 action: "clear-chat".to_string(),
54 keywords: vec!["clear".to_string(), "chat".to_string(), "history".to_string()],
55 },
56 Command {
57 name: "Show Help".to_string(),
58 description: "Show help information".to_string(),
59 action: "show-help".to_string(),
60 keywords: vec!["help".to_string(), "info".to_string(), "about".to_string()],
61 },
62 Command {
63 name: "Quit".to_string(),
64 description: "Exit the application".to_string(),
65 action: "quit".to_string(),
66 keywords: vec!["quit".to_string(), "exit".to_string(), "close".to_string()],
67 },
68 ];
69
70 let filtered_commands: Vec<usize> = (0..commands.len()).collect();
71
72 Self {
73 theme: Box::new(crate::theme::DefaultTheme), input: String::new(),
75 cursor_position: 0,
76 commands,
77 filtered_commands,
78 selected_index: 0,
79 is_open: false,
80 }
81 }
82
83 pub fn open(&mut self) {
85 self.is_open = true;
86 self.input.clear();
87 self.cursor_position = 0;
88 self.selected_index = 0;
89 self.update_filter();
90 }
91
92 pub fn close(&mut self) {
94 self.is_open = false;
95 }
96
97 pub fn is_open(&self) -> bool {
99 self.is_open
100 }
101
102 pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<String>> {
104 if !self.is_open {
105 return Ok(None);
106 }
107
108 match key.code {
109 KeyCode::Esc => {
110 self.close();
111 Ok(None)
112 }
113 KeyCode::Enter => {
114 if let Some(command_index) = self.filtered_commands.get(self.selected_index) {
115 let action = self.commands[*command_index].action.clone();
116 self.close();
117 Ok(Some(action))
118 } else {
119 Ok(None)
120 }
121 }
122 KeyCode::Up => {
123 if self.selected_index > 0 {
124 self.selected_index -= 1;
125 }
126 Ok(None)
127 }
128 KeyCode::Down => {
129 if self.selected_index < self.filtered_commands.len().saturating_sub(1) {
130 self.selected_index += 1;
131 }
132 Ok(None)
133 }
134 KeyCode::Backspace => {
135 if self.cursor_position > 0 {
136 self.input.remove(self.cursor_position - 1);
137 self.cursor_position -= 1;
138 self.update_filter();
139 }
140 Ok(None)
141 }
142 KeyCode::Delete => {
143 if self.cursor_position < self.input.len() {
144 self.input.remove(self.cursor_position);
145 self.update_filter();
146 }
147 Ok(None)
148 }
149 KeyCode::Left => {
150 if self.cursor_position > 0 {
151 self.cursor_position -= 1;
152 }
153 Ok(None)
154 }
155 KeyCode::Right => {
156 if self.cursor_position < self.input.len() {
157 self.cursor_position += 1;
158 }
159 Ok(None)
160 }
161 KeyCode::Home => {
162 self.cursor_position = 0;
163 Ok(None)
164 }
165 KeyCode::End => {
166 self.cursor_position = self.input.len();
167 Ok(None)
168 }
169 KeyCode::Char(c) => {
170 self.input.insert(self.cursor_position, c);
171 self.cursor_position += 1;
172 self.update_filter();
173 Ok(None)
174 }
175 _ => Ok(None),
176 }
177 }
178
179 fn update_filter(&mut self) {
181 let query = self.input.to_lowercase();
182
183 if query.is_empty() {
184 self.filtered_commands = (0..self.commands.len()).collect();
185 } else {
186 self.filtered_commands = self.commands
187 .iter()
188 .enumerate()
189 .filter(|(_, command)| {
190 command.name.to_lowercase().contains(&query) ||
191 command.description.to_lowercase().contains(&query) ||
192 command.keywords.iter().any(|keyword| keyword.to_lowercase().contains(&query))
193 })
194 .map(|(index, _)| index)
195 .collect();
196 }
197
198 if self.selected_index >= self.filtered_commands.len() {
200 self.selected_index = 0;
201 }
202 }
203
204 pub fn update_theme(&mut self, theme: &dyn Theme) {
206 }
209
210 pub fn render(&self, renderer: &Renderer, area: Rect) {
212 if !self.is_open {
213 return;
214 }
215
216 let block = Block::default()
218 .title("Command Palette")
219 .borders(Borders::ALL)
220 .border_style(Style::default().fg(self.theme.border_active()));
221
222 let inner_area = block.inner(area);
224 let chunks = ratatui::layout::Layout::default()
225 .direction(ratatui::layout::Direction::Vertical)
226 .constraints([
227 ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(1), ])
230 .split(inner_area);
231
232 renderer.render_widget(block, area);
234
235 let input_style = Style::default()
237 .fg(self.theme.text())
238 .bg(self.theme.background_element());
239
240 let input_line = Line::from(vec![
241 Span::raw("> "),
242 Span::styled(&self.input, input_style),
243 ]);
244
245 let input_paragraph = Paragraph::new(vec![input_line])
246 .style(Style::default().bg(self.theme.background_element()));
247
248 renderer.render_widget(input_paragraph, chunks[0]);
249
250 let list_items: Vec<ListItem> = self.filtered_commands
252 .iter()
253 .enumerate()
254 .map(|(index, &command_index)| {
255 let command = &self.commands[command_index];
256 let style = if index == self.selected_index {
257 Style::default()
258 .fg(self.theme.background())
259 .bg(self.theme.primary())
260 } else {
261 Style::default().fg(self.theme.text())
262 };
263
264 let content = Line::from(vec![
265 Span::styled(&command.name, style),
266 Span::raw(" - "),
267 Span::styled(&command.description, Style::default().fg(self.theme.text_muted())),
268 ]);
269
270 ListItem::new(content)
271 })
272 .collect();
273
274 let list = List::new(list_items)
275 .style(Style::default().bg(self.theme.background_element()));
276
277 renderer.render_widget(list, chunks[1]);
278 }
279}