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#[derive(Debug, Clone)]
17pub enum DialogResult {
18 Confirmed(String),
19 Cancelled,
20}
21
22#[derive(Debug, Clone)]
24pub enum DialogType {
25 Confirmation {
27 title: String,
28 message: String,
29 },
30 Input {
32 title: String,
33 prompt: String,
34 default_value: String,
35 },
36 Selection {
38 title: String,
39 message: String,
40 options: Vec<String>,
41 },
42 FilePicker {
44 title: String,
45 current_path: String,
46 filter: Option<String>,
47 },
48}
49
50pub 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 pub fn confirmation(title: String, message: String, theme: &dyn Theme) -> Self {
64 Self {
65 theme: Box::new(crate::theme::DefaultTheme), 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 pub fn input(title: String, prompt: String, default_value: String, theme: &dyn Theme) -> Self {
77 Self {
78 theme: Box::new(crate::theme::DefaultTheme), 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 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), 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 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), 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 pub fn width(&self) -> u16 {
117 self.width
118 }
119
120 pub fn height(&self) -> u16 {
122 self.height
123 }
124
125 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 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 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 }
180 _ => {}
181 }
182 }
183 }
184
185 Ok(None)
186 }
187
188 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 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 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 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), ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), ])
284 .split(area);
285
286 let title_paragraph = Paragraph::new(title)
288 .style(Style::default().fg(self.theme.primary()));
289 renderer.render_widget(title_paragraph, chunks[0]);
290
291 let message_paragraph = Paragraph::new(message)
293 .style(Style::default().fg(self.theme.text()));
294 renderer.render_widget(message_paragraph, chunks[1]);
295
296 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 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), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), ])
317 .split(area);
318
319 let title_paragraph = Paragraph::new(title)
321 .style(Style::default().fg(self.theme.primary()));
322 renderer.render_widget(title_paragraph, chunks[0]);
323
324 let prompt_paragraph = Paragraph::new(prompt)
326 .style(Style::default().fg(self.theme.text()));
327 renderer.render_widget(prompt_paragraph, chunks[1]);
328
329 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 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 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), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), ])
360 .split(area);
361
362 let title_paragraph = Paragraph::new(title)
364 .style(Style::default().fg(self.theme.primary()));
365 renderer.render_widget(title_paragraph, chunks[0]);
366
367 let message_paragraph = Paragraph::new(message)
369 .style(Style::default().fg(self.theme.text()));
370 renderer.render_widget(message_paragraph, chunks[1]);
371
372 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 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 fn render_file_picker(&self, renderer: &Renderer, area: Rect, title: &str, current_path: &str) {
409 let chunks = ratatui::layout::Layout::default()
412 .direction(ratatui::layout::Direction::Vertical)
413 .constraints([
414 ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), ])
419 .split(area);
420
421 let title_paragraph = Paragraph::new(title)
423 .style(Style::default().fg(self.theme.primary()));
424 renderer.render_widget(title_paragraph, chunks[0]);
425
426 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 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 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}