code_mesh_tui/components/
dialog.rs

1use ratatui::{
2    layout::Rect,
3    widgets::{Block, Borders, Paragraph, List, ListItem},
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/// Dialog result enumeration
16#[derive(Debug, Clone)]
17pub enum DialogResult {
18    Confirmed(String),
19    Cancelled,
20}
21
22/// Dialog types
23#[derive(Debug, Clone)]
24pub enum DialogType {
25    /// Simple confirmation dialog
26    Confirmation {
27        title: String,
28        message: String,
29    },
30    /// Input dialog with text field
31    Input {
32        title: String,
33        prompt: String,
34        default_value: String,
35    },
36    /// Selection dialog with list of options
37    Selection {
38        title: String,
39        message: String,
40        options: Vec<String>,
41    },
42    /// File picker dialog
43    FilePicker {
44        title: String,
45        current_path: String,
46        filter: Option<String>,
47    },
48}
49
50/// Modal dialog component
51pub struct Dialog {
52    theme: Box<dyn Theme + Send + Sync>,
53    dialog_type: DialogType,
54    input_text: String,
55    cursor_position: usize,
56    selected_index: usize,
57    width: u16,
58    height: u16,
59}
60
61impl Dialog {
62    /// Create a new confirmation dialog
63    pub fn confirmation(title: String, message: String, theme: &dyn Theme) -> Self {
64        Self {
65            theme: Box::new(crate::theme::DefaultTheme), // Temporary
66            dialog_type: DialogType::Confirmation { title, message },
67            input_text: String::new(),
68            cursor_position: 0,
69            selected_index: 0,
70            width: 50,
71            height: 8,
72        }
73    }
74    
75    /// Create a new input dialog
76    pub fn input(title: String, prompt: String, default_value: String, theme: &dyn Theme) -> Self {
77        Self {
78            theme: Box::new(crate::theme::DefaultTheme), // Temporary
79            dialog_type: DialogType::Input { title, prompt, default_value: default_value.clone() },
80            input_text: default_value,
81            cursor_position: 0,
82            selected_index: 0,
83            width: 60,
84            height: 10,
85        }
86    }
87    
88    /// Create a new selection dialog
89    pub fn selection(title: String, message: String, options: Vec<String>, theme: &dyn Theme) -> Self {
90        let height = 8 + options.len().min(10) as u16;
91        Self {
92            theme: Box::new(crate::theme::DefaultTheme), // Temporary
93            dialog_type: DialogType::Selection { title, message, options },
94            input_text: String::new(),
95            cursor_position: 0,
96            selected_index: 0,
97            width: 60,
98            height,
99        }
100    }
101    
102    /// Create a new file picker dialog
103    pub fn file_picker(title: String, current_path: String, filter: Option<String>, theme: &dyn Theme) -> Self {
104        Self {
105            theme: Box::new(crate::theme::DefaultTheme), // Temporary
106            dialog_type: DialogType::FilePicker { title, current_path, filter },
107            input_text: String::new(),
108            cursor_position: 0,
109            selected_index: 0,
110            width: 80,
111            height: 20,
112        }
113    }
114    
115    /// Get dialog width
116    pub fn width(&self) -> u16 {
117        self.width
118    }
119    
120    /// Get dialog height
121    pub fn height(&self) -> u16 {
122        self.height
123    }
124    
125    /// Handle key events, returning the result if dialog is completed
126    pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<DialogResult>> {
127        match key.code {
128            KeyCode::Esc => {
129                return Ok(Some(DialogResult::Cancelled));
130            }
131            KeyCode::Enter => {
132                return Ok(Some(self.get_result()));
133            }
134            _ => {}
135        }
136        
137        match &self.dialog_type {
138            DialogType::Confirmation { .. } => {
139                // Handle Y/N for confirmation
140                match key.code {
141                    KeyCode::Char('y') | KeyCode::Char('Y') => {
142                        return Ok(Some(DialogResult::Confirmed("yes".to_string())));
143                    }
144                    KeyCode::Char('n') | KeyCode::Char('N') => {
145                        return Ok(Some(DialogResult::Cancelled));
146                    }
147                    _ => {}
148                }
149            }
150            DialogType::Input { .. } => {
151                self.handle_input_key(key);
152            }
153            DialogType::Selection { options, .. } => {
154                match key.code {
155                    KeyCode::Up => {
156                        if self.selected_index > 0 {
157                            self.selected_index -= 1;
158                        }
159                    }
160                    KeyCode::Down => {
161                        if self.selected_index < options.len().saturating_sub(1) {
162                            self.selected_index += 1;
163                        }
164                    }
165                    _ => {}
166                }
167            }
168            DialogType::FilePicker { .. } => {
169                // Handle file picker navigation
170                match key.code {
171                    KeyCode::Up => {
172                        if self.selected_index > 0 {
173                            self.selected_index -= 1;
174                        }
175                    }
176                    KeyCode::Down => {
177                        self.selected_index += 1;
178                        // Would be bounded by actual file list length
179                    }
180                    _ => {}
181                }
182            }
183        }
184        
185        Ok(None)
186    }
187    
188    /// Handle input key events for text input
189    fn handle_input_key(&mut self, key: KeyEvent) {
190        match key.code {
191            KeyCode::Backspace => {
192                if self.cursor_position > 0 {
193                    self.input_text.remove(self.cursor_position - 1);
194                    self.cursor_position -= 1;
195                }
196            }
197            KeyCode::Delete => {
198                if self.cursor_position < self.input_text.len() {
199                    self.input_text.remove(self.cursor_position);
200                }
201            }
202            KeyCode::Left => {
203                if self.cursor_position > 0 {
204                    self.cursor_position -= 1;
205                }
206            }
207            KeyCode::Right => {
208                if self.cursor_position < self.input_text.len() {
209                    self.cursor_position += 1;
210                }
211            }
212            KeyCode::Home => {
213                self.cursor_position = 0;
214            }
215            KeyCode::End => {
216                self.cursor_position = self.input_text.len();
217            }
218            KeyCode::Char(c) => {
219                self.input_text.insert(self.cursor_position, c);
220                self.cursor_position += 1;
221            }
222            _ => {}
223        }
224    }
225    
226    /// Get the current result
227    fn get_result(&self) -> DialogResult {
228        match &self.dialog_type {
229            DialogType::Confirmation { .. } => {
230                DialogResult::Confirmed("yes".to_string())
231            }
232            DialogType::Input { .. } => {
233                DialogResult::Confirmed(self.input_text.clone())
234            }
235            DialogType::Selection { options, .. } => {
236                if let Some(option) = options.get(self.selected_index) {
237                    DialogResult::Confirmed(option.clone())
238                } else {
239                    DialogResult::Cancelled
240                }
241            }
242            DialogType::FilePicker { current_path, .. } => {
243                DialogResult::Confirmed(current_path.clone())
244            }
245        }
246    }
247    
248    /// Render the dialog
249    pub fn render(&self, renderer: &Renderer, area: Rect) {
250        let block = Block::default()
251            .borders(Borders::ALL)
252            .border_style(Style::default().fg(self.theme.border_active()))
253            .style(Style::default().bg(self.theme.background_panel()));
254        
255        renderer.render_widget(block.clone(), area);
256        
257        let inner_area = block.inner(area);
258        
259        match &self.dialog_type {
260            DialogType::Confirmation { title, message } => {
261                self.render_confirmation(renderer, inner_area, title, message);
262            }
263            DialogType::Input { title, prompt, .. } => {
264                self.render_input(renderer, inner_area, title, prompt);
265            }
266            DialogType::Selection { title, message, options } => {
267                self.render_selection(renderer, inner_area, title, message, options);
268            }
269            DialogType::FilePicker { title, current_path, .. } => {
270                self.render_file_picker(renderer, inner_area, title, current_path);
271            }
272        }
273    }
274    
275    /// Render confirmation dialog
276    fn render_confirmation(&self, renderer: &Renderer, area: Rect, title: &str, message: &str) {
277        let chunks = ratatui::layout::Layout::default()
278            .direction(ratatui::layout::Direction::Vertical)
279            .constraints([
280                ratatui::layout::Constraint::Length(1), // Title
281                ratatui::layout::Constraint::Min(1),    // Message
282                ratatui::layout::Constraint::Length(1), // Buttons
283            ])
284            .split(area);
285        
286        // Title
287        let title_paragraph = Paragraph::new(title)
288            .style(Style::default().fg(self.theme.primary()));
289        renderer.render_widget(title_paragraph, chunks[0]);
290        
291        // Message
292        let message_paragraph = Paragraph::new(message)
293            .style(Style::default().fg(self.theme.text()));
294        renderer.render_widget(message_paragraph, chunks[1]);
295        
296        // Buttons
297        let buttons_line = Line::from(vec![
298            Span::styled("[Y]es", Style::default().fg(self.theme.success())),
299            Span::raw(" / "),
300            Span::styled("[N]o", Style::default().fg(self.theme.error())),
301        ]);
302        let buttons_paragraph = Paragraph::new(vec![buttons_line]);
303        renderer.render_widget(buttons_paragraph, chunks[2]);
304    }
305    
306    /// Render input dialog
307    fn render_input(&self, renderer: &Renderer, area: Rect, title: &str, prompt: &str) {
308        let chunks = ratatui::layout::Layout::default()
309            .direction(ratatui::layout::Direction::Vertical)
310            .constraints([
311                ratatui::layout::Constraint::Length(1), // Title
312                ratatui::layout::Constraint::Length(1), // Prompt
313                ratatui::layout::Constraint::Length(1), // Input
314                ratatui::layout::Constraint::Min(1),    // Spacer
315                ratatui::layout::Constraint::Length(1), // Help
316            ])
317            .split(area);
318        
319        // Title
320        let title_paragraph = Paragraph::new(title)
321            .style(Style::default().fg(self.theme.primary()));
322        renderer.render_widget(title_paragraph, chunks[0]);
323        
324        // Prompt
325        let prompt_paragraph = Paragraph::new(prompt)
326            .style(Style::default().fg(self.theme.text()));
327        renderer.render_widget(prompt_paragraph, chunks[1]);
328        
329        // Input field
330        let input_line = Line::from(vec![
331            Span::raw("> "),
332            Span::styled(&self.input_text, Style::default().fg(self.theme.text())),
333        ]);
334        let input_paragraph = Paragraph::new(vec![input_line])
335            .style(Style::default().bg(self.theme.background_element()));
336        renderer.render_widget(input_paragraph, chunks[2]);
337        
338        // Help
339        let help_line = Line::from(vec![
340            Span::styled("Enter", Style::default().fg(self.theme.success())),
341            Span::raw(" to confirm, "),
342            Span::styled("Esc", Style::default().fg(self.theme.error())),
343            Span::raw(" to cancel"),
344        ]);
345        let help_paragraph = Paragraph::new(vec![help_line])
346            .style(Style::default().fg(self.theme.text_muted()));
347        renderer.render_widget(help_paragraph, chunks[4]);
348    }
349    
350    /// Render selection dialog
351    fn render_selection(&self, renderer: &Renderer, area: Rect, title: &str, message: &str, options: &[String]) {
352        let chunks = ratatui::layout::Layout::default()
353            .direction(ratatui::layout::Direction::Vertical)
354            .constraints([
355                ratatui::layout::Constraint::Length(1), // Title
356                ratatui::layout::Constraint::Length(1), // Message
357                ratatui::layout::Constraint::Min(1),    // Options
358                ratatui::layout::Constraint::Length(1), // Help
359            ])
360            .split(area);
361        
362        // Title
363        let title_paragraph = Paragraph::new(title)
364            .style(Style::default().fg(self.theme.primary()));
365        renderer.render_widget(title_paragraph, chunks[0]);
366        
367        // Message
368        let message_paragraph = Paragraph::new(message)
369            .style(Style::default().fg(self.theme.text()));
370        renderer.render_widget(message_paragraph, chunks[1]);
371        
372        // Options
373        let list_items: Vec<ListItem> = options
374            .iter()
375            .enumerate()
376            .map(|(index, option)| {
377                let style = if index == self.selected_index {
378                    Style::default()
379                        .fg(self.theme.background())
380                        .bg(self.theme.primary())
381                } else {
382                    Style::default().fg(self.theme.text())
383                };
384                
385                ListItem::new(Line::from(Span::styled(option, style)))
386            })
387            .collect();
388        
389        let list = List::new(list_items)
390            .style(Style::default().bg(self.theme.background_element()));
391        renderer.render_widget(list, chunks[2]);
392        
393        // Help
394        let help_line = Line::from(vec![
395            Span::styled("↑↓", Style::default().fg(self.theme.accent())),
396            Span::raw(" to navigate, "),
397            Span::styled("Enter", Style::default().fg(self.theme.success())),
398            Span::raw(" to select, "),
399            Span::styled("Esc", Style::default().fg(self.theme.error())),
400            Span::raw(" to cancel"),
401        ]);
402        let help_paragraph = Paragraph::new(vec![help_line])
403            .style(Style::default().fg(self.theme.text_muted()));
404        renderer.render_widget(help_paragraph, chunks[3]);
405    }
406    
407    /// Render file picker dialog
408    fn render_file_picker(&self, renderer: &Renderer, area: Rect, title: &str, current_path: &str) {
409        // This is a simplified file picker
410        // A full implementation would show directory contents
411        let chunks = ratatui::layout::Layout::default()
412            .direction(ratatui::layout::Direction::Vertical)
413            .constraints([
414                ratatui::layout::Constraint::Length(1), // Title
415                ratatui::layout::Constraint::Length(1), // Current path
416                ratatui::layout::Constraint::Min(1),    // File list
417                ratatui::layout::Constraint::Length(1), // Help
418            ])
419            .split(area);
420        
421        // Title
422        let title_paragraph = Paragraph::new(title)
423            .style(Style::default().fg(self.theme.primary()));
424        renderer.render_widget(title_paragraph, chunks[0]);
425        
426        // Current path
427        let path_paragraph = Paragraph::new(current_path)
428            .style(Style::default().fg(self.theme.accent()));
429        renderer.render_widget(path_paragraph, chunks[1]);
430        
431        // Placeholder file list
432        let placeholder = Paragraph::new("File picker implementation pending...")
433            .style(Style::default().fg(self.theme.text_muted()));
434        renderer.render_widget(placeholder, chunks[2]);
435        
436        // Help
437        let help_line = Line::from(vec![
438            Span::styled("Enter", Style::default().fg(self.theme.success())),
439            Span::raw(" to select, "),
440            Span::styled("Esc", Style::default().fg(self.theme.error())),
441            Span::raw(" to cancel"),
442        ]);
443        let help_paragraph = Paragraph::new(vec![help_line])
444            .style(Style::default().fg(self.theme.text_muted()));
445        renderer.render_widget(help_paragraph, chunks[3]);
446    }
447}