Skip to main content

vtcode_tui/core_tui/widgets/
modal.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Layout, Rect},
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Clear, List, ListItem, Paragraph, Widget, Wrap},
7};
8
9use crate::config::constants::ui;
10use crate::ui::tui::session::{
11    modal::{
12        ModalListLayout, ModalRenderStyles, ModalSearchState, ModalSection, WizardModalState,
13        compute_modal_area,
14    },
15    terminal_capabilities,
16};
17
18/// Generic modal widget for buffer-based overlay rendering
19///
20/// Supports multiple modal types:
21/// - List modals (file browser, prompt browser, etc.)
22/// - Wizard modals (guided workflows with tabs)
23/// - Text modals (informational messages)
24/// - Search modals (with filtering)
25/// - Secure prompt modals (password input)
26///
27/// # Example
28/// ```ignore
29/// ModalWidget::new(title, viewport)
30///     .modal_state(modal_state)
31///     .styles(styles)
32///     .render(area, buf);
33/// ```
34pub struct ModalWidget<'a> {
35    title: String,
36    viewport: Rect,
37    modal_type: ModalType<'a>,
38    styles: ModalRenderStyles,
39    input_content: Option<&'a str>,
40    cursor_position: Option<usize>,
41}
42
43/// Different types of modals that can be rendered
44pub enum ModalType<'a> {
45    /// Simple text modal with instructions
46    Text { lines: &'a [String] },
47    /// List modal with selectable items
48    List {
49        lines: &'a [String],
50        list_state: &'a mut crate::ui::tui::session::modal::ModalListState,
51    },
52    /// Wizard modal with tabs and steps
53    Wizard { wizard_state: &'a WizardModalState },
54    /// Search modal with input field
55    Search {
56        lines: &'a [String],
57        search_state: &'a ModalSearchState,
58        list_state: Option<&'a mut crate::ui::tui::session::modal::ModalListState>,
59    },
60    /// Secure prompt modal for password input
61    SecurePrompt {
62        lines: &'a [String],
63        prompt_config: &'a crate::ui::tui::types::SecurePromptConfig,
64    },
65}
66
67impl<'a> ModalWidget<'a> {
68    /// Create a new ModalWidget with required parameters
69    pub fn new(title: String, viewport: Rect) -> Self {
70        Self {
71            title,
72            viewport,
73            modal_type: ModalType::Text { lines: &[] },
74            styles: ModalRenderStyles {
75                border: Style::default(),
76                highlight: Style::default(),
77                badge: Style::default(),
78                header: Style::default(),
79                selectable: Style::default(),
80                detail: Style::default(),
81                search_match: Style::default(),
82                title: Style::default().add_modifier(Modifier::BOLD),
83                divider: Style::default(),
84                instruction_border: Style::default(),
85                instruction_title: Style::default(),
86                instruction_bullet: Style::default(),
87                instruction_body: Style::default(),
88                hint: Style::default(),
89            },
90            input_content: None,
91            cursor_position: None,
92        }
93    }
94
95    /// Set the modal type and content
96    #[must_use]
97    pub fn modal_type(mut self, modal_type: ModalType<'a>) -> Self {
98        self.modal_type = modal_type;
99        self
100    }
101
102    /// Set the render styles
103    #[must_use]
104    pub fn styles(mut self, styles: ModalRenderStyles) -> Self {
105        self.styles = styles;
106        self
107    }
108
109    /// Set input content for secure prompts
110    #[must_use]
111    pub fn input_content(mut self, content: &'a str) -> Self {
112        self.input_content = Some(content);
113        self
114    }
115
116    /// Set cursor position for secure prompts
117    #[must_use]
118    pub fn cursor_position(mut self, position: usize) -> Self {
119        self.cursor_position = Some(position);
120        self
121    }
122
123    /// Calculate the modal area based on content
124    fn calculate_modal_area(&self) -> Rect {
125        let (text_lines, prompt_lines, search_lines, has_list) = match &self.modal_type {
126            ModalType::Text { lines } => (lines.len(), 0, 0, false),
127            ModalType::List { lines, .. } => (lines.len(), 0, 0, true),
128            ModalType::Wizard { wizard_state: _ } => {
129                let height = 10; // Default height for wizard modals
130                (height, 0, 0, true)
131            }
132            ModalType::Search {
133                lines, list_state, ..
134            } => (lines.len(), 0, 3, list_state.is_some()),
135            ModalType::SecurePrompt { lines, .. } => (lines.len(), 2, 0, false),
136        };
137
138        compute_modal_area(
139            self.viewport,
140            text_lines,
141            prompt_lines,
142            search_lines,
143            has_list,
144        )
145    }
146}
147
148impl<'a> Widget for ModalWidget<'a> {
149    fn render(self, _area: Rect, buf: &mut Buffer) {
150        if self.viewport.height == 0 || self.viewport.width == 0 {
151            return;
152        }
153
154        let area = self.calculate_modal_area();
155
156        // Render clear background
157        Clear.render(area, buf);
158
159        // Render border block
160        let block = Block::bordered()
161            .title(Line::styled(self.title.clone(), self.styles.title))
162            .border_type(terminal_capabilities::get_border_type())
163            .border_style(self.styles.border);
164        let inner = block.inner(area);
165        block.render(area, buf);
166
167        if inner.width == 0 || inner.height == 0 {
168            return;
169        }
170
171        // Render modal content based on type
172        match self.modal_type {
173            ModalType::Text { lines } => {
174                self.render_text_modal(inner, buf, lines);
175            }
176            ModalType::List {
177                lines,
178                ref list_state,
179            } => {
180                self.render_list_modal(inner, buf, lines, list_state);
181            }
182            ModalType::Wizard { wizard_state } => {
183                self.render_wizard_modal(inner, buf, wizard_state);
184            }
185            ModalType::Search {
186                lines,
187                search_state,
188                list_state: ref list_state_opt,
189            } => {
190                // Handle the Option<&mut T> by creating a new Option with the reference
191                let list_state_ref = list_state_opt.as_ref().map(|s| &**s);
192                self.render_search_modal(inner, buf, lines, search_state, list_state_ref);
193            }
194            ModalType::SecurePrompt {
195                lines,
196                prompt_config,
197            } => {
198                self.render_secure_prompt_modal(inner, buf, lines, prompt_config);
199            }
200        }
201    }
202}
203
204impl<'a> ModalWidget<'a> {
205    fn render_text_modal(&self, area: Rect, buf: &mut Buffer, lines: &[String]) {
206        if lines.is_empty() {
207            return;
208        }
209
210        let paragraph = Paragraph::new(
211            lines
212                .iter()
213                .map(|line| Line::from(line.as_str()))
214                .collect::<Vec<_>>(),
215        )
216        .wrap(Wrap { trim: true });
217        paragraph.render(area, buf);
218    }
219
220    fn render_list_modal(
221        &self,
222        area: Rect,
223        buf: &mut Buffer,
224        lines: &[String],
225        list_state: &crate::ui::tui::session::modal::ModalListState,
226    ) {
227        let layout = ModalListLayout::new(area, lines.len());
228
229        // Render instructions if present
230        if let Some(text_area) = layout.text_area
231            && !lines.is_empty()
232        {
233            self.render_instructions(text_area, buf, lines);
234        }
235
236        // Render list
237        self.render_modal_list(layout.list_area, buf, list_state);
238    }
239
240    fn render_wizard_modal(&self, area: Rect, buf: &mut Buffer, wizard_state: &WizardModalState) {
241        // Layout: [Tabs Header (1 row)] [Question text] [List]
242        let chunks = Layout::vertical([
243            Constraint::Length(1), // Tabs
244            Constraint::Length(2), // Question with padding
245            Constraint::Min(3),    // List
246        ])
247        .split(area);
248
249        // Render tabs
250        self.render_wizard_tabs(
251            chunks[0],
252            buf,
253            &wizard_state.steps,
254            wizard_state.current_step,
255        );
256
257        // Render question for current step
258        if let Some(step) = wizard_state.steps.get(wizard_state.current_step) {
259            let question = Paragraph::new(Line::from(Span::styled(
260                step.question.clone(),
261                self.styles.header,
262            )));
263            question.render(chunks[1], buf);
264
265            // Note: We can't render the list here because we don't have mutable access
266            // This is a limitation of the current design - wizard modals should be
267            // handled differently or the list state should be passed separately
268        }
269    }
270
271    fn render_search_modal(
272        &self,
273        area: Rect,
274        buf: &mut Buffer,
275        lines: &[String],
276        search_state: &ModalSearchState,
277        list_state: Option<&crate::ui::tui::session::modal::ModalListState>,
278    ) {
279        let mut sections = Vec::new();
280        let has_instructions = lines.iter().any(|line| !line.trim().is_empty());
281
282        if has_instructions {
283            sections.push(ModalSection::Instructions);
284        }
285        sections.push(ModalSection::Search);
286        if list_state.is_some() {
287            sections.push(ModalSection::List);
288        }
289
290        let mut constraints = Vec::new();
291        for section in &sections {
292            match section {
293                ModalSection::Instructions => {
294                    let visible_rows = lines.len().max(1) as u16;
295                    let height = visible_rows.saturating_add(2);
296                    constraints.push(Constraint::Length(height.min(area.height)));
297                }
298                ModalSection::Search => {
299                    constraints.push(Constraint::Length(3.min(area.height)));
300                }
301                ModalSection::List => {
302                    constraints.push(Constraint::Min(3));
303                }
304                _ => {}
305            }
306        }
307
308        let chunks = Layout::vertical(constraints).split(area);
309        let mut chunk_iter = chunks.iter();
310
311        for section in &sections {
312            if let Some(chunk) = chunk_iter.next() {
313                match section {
314                    ModalSection::Instructions => {
315                        if chunk.height > 0 && has_instructions {
316                            self.render_instructions(*chunk, buf, lines);
317                        }
318                    }
319                    ModalSection::Search => {
320                        self.render_modal_search(*chunk, buf, search_state);
321                    }
322                    ModalSection::List => {
323                        if let Some(list_state) = list_state {
324                            self.render_modal_list(*chunk, buf, list_state);
325                        }
326                    }
327                    _ => {}
328                }
329            }
330        }
331    }
332
333    fn render_secure_prompt_modal(
334        &self,
335        area: Rect,
336        buf: &mut Buffer,
337        lines: &[String],
338        prompt_config: &crate::ui::tui::types::SecurePromptConfig,
339    ) {
340        let mut sections = Vec::new();
341        let has_instructions = lines.iter().any(|line| !line.trim().is_empty());
342
343        if has_instructions {
344            sections.push(ModalSection::Instructions);
345        }
346        sections.push(ModalSection::Prompt);
347
348        let mut constraints = Vec::new();
349        for section in &sections {
350            match section {
351                ModalSection::Instructions => {
352                    let visible_rows = lines.len().max(1) as u16;
353                    let height = visible_rows.saturating_add(2);
354                    constraints.push(Constraint::Length(height.min(area.height)));
355                }
356                ModalSection::Prompt => {
357                    constraints.push(Constraint::Length(3.min(area.height)));
358                }
359                _ => {}
360            }
361        }
362
363        let chunks = Layout::vertical(constraints).split(area);
364        let mut chunk_iter = chunks.iter();
365
366        for section in &sections {
367            if let Some(chunk) = chunk_iter.next() {
368                match section {
369                    ModalSection::Instructions => {
370                        if chunk.height > 0 && has_instructions {
371                            self.render_instructions(*chunk, buf, lines);
372                        }
373                    }
374                    ModalSection::Prompt => {
375                        self.render_secure_prompt(
376                            *chunk,
377                            buf,
378                            prompt_config,
379                            self.input_content.unwrap_or(""),
380                            self.cursor_position.unwrap_or(0),
381                        );
382                    }
383                    _ => {}
384                }
385            }
386        }
387    }
388
389    fn render_instructions(&self, area: Rect, buf: &mut Buffer, instructions: &[String]) {
390        let items: Vec<ListItem> = instructions
391            .iter()
392            .enumerate()
393            .map(|(i, line)| {
394                let trimmed = line.trim();
395                if trimmed.is_empty() {
396                    ListItem::new(Line::default())
397                } else if i == 0 {
398                    // First line gets header style
399                    ListItem::new(Line::from(Span::styled(
400                        trimmed.to_owned(),
401                        self.styles.header,
402                    )))
403                } else {
404                    // Subsequent lines get bullet points
405                    let bullet_prefix = format!("{} ", ui::MODAL_INSTRUCTIONS_BULLET);
406                    ListItem::new(Line::from(vec![
407                        Span::styled(bullet_prefix, self.styles.instruction_bullet),
408                        Span::styled(trimmed.to_owned(), self.styles.instruction_body),
409                    ]))
410                }
411            })
412            .collect();
413
414        let block = Block::bordered()
415            .title(Span::styled(
416                ui::MODAL_INSTRUCTIONS_TITLE.to_owned(),
417                self.styles.instruction_title,
418            ))
419            .border_type(terminal_capabilities::get_border_type())
420            .border_style(self.styles.instruction_border);
421
422        let widget = List::new(items)
423            .block(block)
424            .style(self.styles.instruction_body)
425            .highlight_symbol("")
426            .repeat_highlight_symbol(false);
427        widget.render(area, buf);
428    }
429
430    fn render_modal_list(
431        &self,
432        area: Rect,
433        buf: &mut Buffer,
434        list_state: &crate::ui::tui::session::modal::ModalListState,
435    ) {
436        use crate::ui::tui::session::modal::modal_list_items;
437
438        // Note: Since we're working with Buffer (not Frame), we can't do stateful rendering
439        // This is a simplified version that just renders the current state
440        if list_state.visible_indices.is_empty() {
441            let message = Paragraph::new(Line::from(Span::styled(
442                ui::MODAL_LIST_NO_RESULTS_MESSAGE.to_owned(),
443                self.styles.detail,
444            )))
445            .wrap(Wrap { trim: true });
446            message.render(area, buf);
447            return;
448        }
449
450        let content_width = area.width.saturating_sub(4) as usize;
451        let items = modal_list_items(list_state, &self.styles, content_width);
452        let widget = List::new(items)
453            .highlight_style(self.styles.highlight)
454            .highlight_symbol(ui::MODAL_LIST_HIGHLIGHT_FULL)
455            .repeat_highlight_symbol(true);
456
457        widget.render(area, buf);
458    }
459
460    fn render_wizard_tabs(
461        &self,
462        area: Rect,
463        buf: &mut Buffer,
464        steps: &[crate::ui::tui::session::modal::WizardStepState],
465        current_step: usize,
466    ) {
467        // Simple tab rendering - just show the current step title
468        if let Some(step) = steps.get(current_step) {
469            let icon = if step.completed { "✔" } else { "☐" };
470            let text = format!("{} {}", icon, step.title);
471            let tabs = Paragraph::new(Line::from(text).style(self.styles.highlight));
472            tabs.render(area, buf);
473        }
474    }
475
476    fn render_modal_search(&self, area: Rect, buf: &mut Buffer, search_state: &ModalSearchState) {
477        let mut spans = Vec::new();
478        if search_state.query.is_empty() {
479            if let Some(placeholder) = &search_state.placeholder {
480                spans.push(Span::styled(placeholder.clone(), self.styles.detail));
481            }
482        } else {
483            spans.push(Span::styled(
484                search_state.query.clone(),
485                self.styles.selectable,
486            ));
487        }
488        spans.push(Span::styled("▌".to_owned(), self.styles.highlight));
489
490        let block = Block::bordered()
491            .title(Span::styled(search_state.label.clone(), self.styles.header))
492            .border_type(terminal_capabilities::get_border_type())
493            .border_style(self.styles.border);
494
495        let paragraph = Paragraph::new(Line::from(spans))
496            .block(block)
497            .wrap(Wrap { trim: true });
498        paragraph.render(area, buf);
499    }
500
501    fn render_secure_prompt(
502        &self,
503        area: Rect,
504        buf: &mut Buffer,
505        config: &crate::ui::tui::types::SecurePromptConfig,
506        input: &str,
507        _cursor: usize,
508    ) {
509        // For buffer-based rendering, we'll render a simple password field
510        // The full tui-prompts integration requires a Frame, not just a Buffer
511        let grapheme_count = input.chars().count();
512        let sanitized: String = std::iter::repeat_n('•', grapheme_count).collect();
513
514        let mut spans = vec![Span::styled(config.label.clone(), self.styles.header)];
515        spans.push(Span::raw(" "));
516        spans.push(Span::styled(sanitized, self.styles.selectable));
517        spans.push(Span::styled("▌".to_owned(), self.styles.highlight));
518
519        let block = Block::bordered()
520            .border_type(terminal_capabilities::get_border_type())
521            .border_style(self.styles.border);
522
523        let paragraph = Paragraph::new(Line::from(spans))
524            .block(block)
525            .wrap(Wrap { trim: true });
526        paragraph.render(area, buf);
527    }
528}