vtcode_tui/core_tui/session/modal/
layout.rs1use 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}