Skip to main content

vtcode_tui/core_tui/session/modal/
layout.rs

1use crate::config::constants::ui;
2use crate::ui::tui::types::SecurePromptConfig;
3use ratatui::prelude::*;
4use terminal_size::{Height, Width, terminal_size};
5use unicode_width::UnicodeWidthStr;
6
7use super::state::{ModalListState, ModalSearchState};
8use crate::ui::tui::session::measure_text_width;
9
10pub struct ModalRenderStyles {
11    pub border: Style,
12    pub highlight: Style,
13    pub badge: Style,
14    pub header: Style,
15    pub selectable: Style,
16    pub detail: Style,
17    pub search_match: Style,
18    pub title: Style,
19    pub divider: Style,
20    pub instruction_border: Style,
21    pub instruction_title: Style,
22    pub instruction_bullet: Style,
23    pub instruction_body: Style,
24    pub hint: Style,
25}
26
27pub struct ModalListLayout {
28    pub text_area: Option<Rect>,
29    pub list_area: Rect,
30}
31
32pub struct ModalBodyContext<'a, 'b> {
33    pub instructions: &'a [String],
34    pub footer_hint: Option<&'a str>,
35    pub list: Option<&'b mut ModalListState>,
36    pub styles: &'a ModalRenderStyles,
37    pub secure_prompt: Option<&'a SecurePromptConfig>,
38    pub search: Option<&'a ModalSearchState>,
39    pub input: &'a str,
40    pub cursor: usize,
41}
42
43impl ModalListLayout {
44    pub fn new(area: Rect, text_line_count: usize) -> Self {
45        if text_line_count == 0 {
46            let chunks = Layout::vertical([Constraint::Min(3)]).split(area);
47            return Self {
48                text_area: None,
49                list_area: chunks[0],
50            };
51        }
52
53        let paragraph_height = (text_line_count.min(u16::MAX as usize) as u16).saturating_add(1);
54        let chunks = Layout::vertical([Constraint::Length(paragraph_height), Constraint::Min(3)])
55            .split(area);
56
57        Self {
58            text_area: Some(chunks[0]),
59            list_area: chunks[1],
60        }
61    }
62}
63
64fn terminal_dimensions() -> Option<(u16, u16)> {
65    terminal_size().map(|(Width(width), Height(height))| (width, height))
66}
67
68pub fn compute_modal_area(
69    viewport: Rect,
70    text_lines: usize,
71    prompt_lines: usize,
72    search_lines: usize,
73    has_list: bool,
74) -> Rect {
75    if viewport.width == 0 || viewport.height == 0 {
76        return Rect::new(viewport.x, viewport.y, 0, 0);
77    }
78
79    let (term_width, term_height) = terminal_dimensions()
80        .map(|(w, h)| (w.max(1), h.max(1)))
81        .unwrap_or((viewport.width, viewport.height));
82    let available_width = viewport.width.min(term_width);
83    let available_height = viewport.height.min(term_height);
84
85    let ratio_width = ((available_width as f32) * ui::MODAL_WIDTH_RATIO).round() as u16;
86    let ratio_height = ((available_height as f32) * ui::MODAL_HEIGHT_RATIO).round() as u16;
87    let max_width = ((available_width as f32) * ui::MODAL_MAX_WIDTH_RATIO).round() as u16;
88    let max_height = ((available_height as f32) * ui::MODAL_MAX_HEIGHT_RATIO).round() as u16;
89
90    let min_width = ui::MODAL_MIN_WIDTH.min(available_width.max(1));
91    let base_min_height = ui::MODAL_MIN_HEIGHT.min(available_height.max(1));
92    let min_height = if has_list {
93        ui::MODAL_LIST_MIN_HEIGHT
94            .min(available_height.max(1))
95            .max(base_min_height)
96    } else {
97        base_min_height
98    };
99
100    let mut width = ratio_width.max(min_width);
101    width = width.min(max_width.max(min_width)).min(available_width);
102
103    let total_lines = text_lines
104        .saturating_add(prompt_lines)
105        .saturating_add(search_lines);
106    let text_height = total_lines as u16;
107    let mut height = text_height
108        .saturating_add(ui::MODAL_CONTENT_VERTICAL_PADDING)
109        .max(min_height)
110        .max(ratio_height);
111    if has_list {
112        height = height.max(ui::MODAL_LIST_MIN_HEIGHT.min(available_height));
113    }
114    height = height.min(max_height.max(min_height)).min(available_height);
115
116    let x = viewport.x + (viewport.width.saturating_sub(width)) / 2;
117    let y = viewport.y + (viewport.height.saturating_sub(height)) / 2;
118    Rect::new(x, y, width, height)
119}
120
121pub fn modal_content_width(
122    lines: &[String],
123    list: Option<&ModalListState>,
124    secure_prompt: Option<&SecurePromptConfig>,
125    search: Option<&ModalSearchState>,
126) -> u16 {
127    let mut width = lines
128        .iter()
129        .map(|line| UnicodeWidthStr::width(line.as_str()) as u16)
130        .max()
131        .unwrap_or(0);
132
133    if let Some(list_state) = list {
134        for item in &list_state.items {
135            let badge_width = item
136                .badge
137                .as_ref()
138                .map(|badge| UnicodeWidthStr::width(badge.as_str()).saturating_add(3))
139                .unwrap_or(0);
140            let title_width = UnicodeWidthStr::width(item.title.as_str());
141            let subtitle_width = item
142                .subtitle
143                .as_ref()
144                .map(|text| UnicodeWidthStr::width(text.as_str()))
145                .unwrap_or(0);
146            let indent_width = usize::from(item.indent) * 2;
147
148            let primary_width = indent_width
149                .saturating_add(badge_width)
150                .saturating_add(title_width) as u16;
151            let secondary_width = indent_width.saturating_add(subtitle_width) as u16;
152
153            width = width.max(primary_width).max(secondary_width);
154        }
155    }
156
157    if let Some(prompt) = secure_prompt {
158        let label_width = measure_text_width(prompt.label.as_str());
159        let prompt_width = label_width.saturating_add(6).max(ui::MODAL_MIN_WIDTH);
160        width = width.max(prompt_width);
161    }
162
163    if let Some(search_state) = search {
164        let label_width = measure_text_width(search_state.label.as_str());
165        let content_width = if search_state.query.is_empty() {
166            search_state
167                .placeholder
168                .as_deref()
169                .map(measure_text_width)
170                .unwrap_or(0)
171        } else {
172            measure_text_width(search_state.query.as_str())
173        };
174        let search_width = label_width
175            .saturating_add(content_width)
176            .saturating_add(ui::MODAL_CONTENT_HORIZONTAL_PADDING);
177        width = width.max(search_width.max(ui::MODAL_MIN_WIDTH));
178    }
179
180    width
181}
182
183#[derive(Clone, Copy)]
184pub enum ModalSection {
185    Search,
186    Instructions,
187    Prompt,
188    List,
189}