code_mesh_tui/components/
command_palette.rs

1use 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
15/// Command palette for quick actions
16pub 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    /// Create a new command palette
36    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), // Temporary
74            input: String::new(),
75            cursor_position: 0,
76            commands,
77            filtered_commands,
78            selected_index: 0,
79            is_open: false,
80        }
81    }
82    
83    /// Open the command palette
84    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    /// Close the command palette
93    pub fn close(&mut self) {
94        self.is_open = false;
95    }
96    
97    /// Check if the command palette is open
98    pub fn is_open(&self) -> bool {
99        self.is_open
100    }
101    
102    /// Handle key events, returning the selected command if one was executed
103    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    /// Update the filtered commands based on input
180    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        // Reset selection if it's out of bounds
199        if self.selected_index >= self.filtered_commands.len() {
200            self.selected_index = 0;
201        }
202    }
203    
204    /// Update theme
205    pub fn update_theme(&mut self, theme: &dyn Theme) {
206        // In a real implementation, we'd clone or recreate the theme
207        // For now, this is a placeholder
208    }
209    
210    /// Render the command palette
211    pub fn render(&self, renderer: &Renderer, area: Rect) {
212        if !self.is_open {
213            return;
214        }
215        
216        // Create a block for the command palette
217        let block = Block::default()
218            .title("Command Palette")
219            .borders(Borders::ALL)
220            .border_style(Style::default().fg(self.theme.border_active()));
221        
222        // Split area for input and list
223        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), // Input
228                ratatui::layout::Constraint::Min(1),    // Command list
229            ])
230            .split(inner_area);
231        
232        // Render the block first
233        renderer.render_widget(block, area);
234        
235        // Render input field
236        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        // Render command list
251        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}