1use ratatui::layout::{Constraint, Layout, Rect};
10use ratatui::widgets::{Block, Padding};
11
12use super::config::LayoutConfig;
13
14pub const HERO_BOX_MIN_WIDTH: u16 = 90;
16
17pub const PROMPT_HEIGHT: u16 = 3;
19
20const VERSION_GAP: u16 = 1;
21const H_MARGIN: u16 = 2;
22const H_MARGIN_COMPACT: u16 = 1;
23
24fn logo_height_default() -> u16 {
25 7
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum WelcomePromptFocus {
31 #[default]
33 Prompt,
34 SessionPicker,
36}
37
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct WelcomeLayout {
41 pub logo: Rect,
43 pub error: Rect,
45 pub menu: Rect,
47 pub changelog: Rect,
49 pub tip: Rect,
51 pub prompt: Rect,
53 pub version: Rect,
55 pub hero_box: Rect,
58 pub hero_logo: Rect,
60 pub hero_version: Rect,
62 pub hero_subtitle: Rect,
64 pub hero_info: Rect,
66 pub hero_menu: Rect,
68}
69
70impl WelcomeLayout {
71 #[must_use]
73 pub fn has_hero_box(&self) -> bool {
74 self.hero_box.width > 0 && self.hero_box.height > 0
75 }
76
77 #[must_use]
79 pub fn fixed_below(tip_height: u16) -> u16 {
80 let tip_gap = u16::from(tip_height > 0);
81 tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1
82 }
83
84 #[must_use]
86 pub fn min_hero_box_height(error_height: u16, menu_height: u16, tip_height: u16) -> u16 {
87 let inner = menu_height.max(logo_height_default())
88 + error_height
89 + tip_height
90 + Self::fixed_below(tip_height);
91 inner + 2
92 }
93
94 #[must_use]
96 pub fn compute(
97 content_area: Rect,
98 logo_height: u16,
99 error_height: u16,
100 menu_height: u16,
101 tip_height: u16,
102 changelog_height: u16,
103 compact: bool,
104 ) -> Self {
105 let use_hero_box = !compact
106 && content_area.width >= HERO_BOX_MIN_WIDTH
107 && menu_height > 0
108 && content_area.height
109 >= Self::min_hero_box_height(error_height, menu_height, tip_height);
110
111 if use_hero_box {
112 return Self::compute_hero_box(content_area, error_height, menu_height, tip_height);
113 }
114 Self::compute_stacked(
115 content_area,
116 logo_height,
117 error_height,
118 menu_height,
119 tip_height,
120 changelog_height,
121 compact,
122 )
123 }
124
125 fn compute_stacked(
126 content_area: Rect,
127 logo_height: u16,
128 error_height: u16,
129 menu_height: u16,
130 tip_height: u16,
131 changelog_height: u16,
132 compact: bool,
133 ) -> Self {
134 let zero = Rect::default();
135 let logo_rows = if compact { 0 } else { logo_height };
136 let gap_after_logo = u16::from(error_height > 0);
137 let tip_gap = u16::from(tip_height > 0);
138 let fixed_below = Self::fixed_below(tip_height);
139 let fixed_above = logo_rows + 1 + gap_after_logo + error_height;
140
141 let (eff_cl_h, eff_cl_gap) = if !compact && changelog_height > 0 {
142 let cg = 1u16;
143 let needed = fixed_above + menu_height + cg + changelog_height + 1 + fixed_below;
144 if content_area.height >= needed {
145 (changelog_height, cg)
146 } else {
147 (0, 0)
148 }
149 } else {
150 (0, 0)
151 };
152
153 let top_pad = if compact {
154 0
155 } else {
156 let rem = content_area.height.saturating_sub(fixed_above);
157 rem.saturating_sub(menu_height)
158 .saturating_sub(eff_cl_gap + eff_cl_h)
159 .saturating_sub(fixed_below)
160 / 2
161 };
162
163 let [
164 _,
165 logo,
166 _,
167 _,
168 error,
169 menu,
170 _,
171 changelog,
172 _,
173 tip,
174 _,
175 prompt,
176 _,
177 version,
178 ] = Layout::vertical([
179 Constraint::Length(top_pad),
180 Constraint::Length(logo_rows),
181 Constraint::Length(1),
182 Constraint::Length(gap_after_logo),
183 Constraint::Length(error_height),
184 Constraint::Length(menu_height),
185 Constraint::Length(eff_cl_gap),
186 Constraint::Length(eff_cl_h),
187 Constraint::Min(1),
188 Constraint::Length(tip_height),
189 Constraint::Length(tip_gap),
190 Constraint::Length(PROMPT_HEIGHT),
191 Constraint::Length(VERSION_GAP),
192 Constraint::Length(1),
193 ])
194 .areas(content_area);
195
196 Self {
197 logo,
198 error,
199 menu,
200 changelog,
201 tip,
202 prompt,
203 version,
204 hero_box: zero,
205 hero_logo: zero,
206 hero_version: zero,
207 hero_subtitle: zero,
208 hero_info: zero,
209 hero_menu: zero,
210 }
211 }
212
213 fn compute_hero_box(
214 content_area: Rect,
215 error_height: u16,
216 menu_height: u16,
217 tip_height: u16,
218 ) -> Self {
219 let zero = Rect::default();
220 let hero_inner_h = menu_height.max(logo_height_default()) + error_height + tip_height;
221 let hero_h = hero_inner_h + 2;
222 let hero_w = content_area.width.min(HERO_BOX_MIN_WIDTH);
223 let total_needed = hero_h + 1 + PROMPT_HEIGHT + VERSION_GAP + 1;
224 let top_pad = content_area.height.saturating_sub(total_needed) / 2;
225 let hero_y = content_area.y + top_pad.min(content_area.height.saturating_sub(hero_h));
226 let hero_x = content_area.x + (content_area.width.saturating_sub(hero_w)) / 2;
227 let hero_box = Rect::new(hero_x, hero_y, hero_w, hero_h);
228 let hero_inner = Block::default()
229 .padding(Padding::symmetric(1, 0))
230 .inner(hero_box);
231 let left_w = (hero_inner.width / 3).max(20);
232 let [hero_logo, hero_menu] =
233 Layout::horizontal([Constraint::Length(left_w), Constraint::Min(1)]).areas(hero_inner);
234 let below_y = hero_box.y + hero_box.height;
235 let prompt = Rect::new(
236 content_area.x + H_MARGIN,
237 below_y + 1,
238 content_area.width.saturating_sub(H_MARGIN * 2),
239 PROMPT_HEIGHT,
240 );
241 let version = Rect::new(
242 content_area.x + H_MARGIN,
243 prompt.y + PROMPT_HEIGHT + VERSION_GAP,
244 content_area.width.saturating_sub(H_MARGIN * 2),
245 1,
246 );
247
248 Self {
249 logo: zero,
250 error: Rect::new(hero_inner.x, hero_logo.y, hero_inner.width, error_height),
251 menu: zero,
252 changelog: zero,
253 tip: Rect::new(
254 hero_inner.x,
255 hero_logo.y + hero_logo.height.saturating_sub(tip_height),
256 hero_inner.width,
257 tip_height,
258 ),
259 prompt,
260 version,
261 hero_box,
262 hero_logo,
263 hero_version: zero,
264 hero_subtitle: zero,
265 hero_info: zero,
266 hero_menu,
267 }
268 }
269
270 #[must_use]
272 pub fn h_margin(compact: bool) -> u16 {
273 if compact { H_MARGIN_COMPACT } else { H_MARGIN }
274 }
275}
276
277#[must_use]
279pub fn welcome_outer_padding(layout_cfg: &LayoutConfig, compact: bool) -> Padding {
280 let h = if compact { 1 } else { layout_cfg.hpad_left };
281 let v = if compact { 0 } else { layout_cfg.outer_vpad };
282 Padding::new(h, h, v, v)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn screen(w: u16, h: u16) -> Rect {
290 Rect::new(0, 0, w, h)
291 }
292
293 #[test]
294 fn stacked_layout_basic() {
295 let l = WelcomeLayout::compute(screen(80, 30), 7, 0, 7, 0, 0, false);
296 assert!(l.logo.height > 0);
297 assert!(l.menu.height > 0);
298 assert_eq!(l.prompt.height, PROMPT_HEIGHT);
299 assert!(l.logo.y < l.menu.y);
300 assert!(!l.has_hero_box());
301 }
302
303 #[test]
304 fn hero_box_on_wide() {
305 let l = WelcomeLayout::compute(screen(120, 30), 7, 0, 7, 0, 0, false);
306 assert!(l.has_hero_box());
307 assert!(l.hero_logo.width > 0);
308 }
309
310 #[test]
311 fn compact_skips_logo() {
312 let l = WelcomeLayout::compute(screen(80, 20), 7, 0, 7, 0, 0, true);
313 assert_eq!(l.logo.height, 0);
314 }
315
316 #[test]
317 fn version_below_prompt() {
318 let l = WelcomeLayout::compute(screen(80, 30), 7, 0, 7, 0, 0, false);
319 assert!(l.version.y > l.prompt.y);
320 }
321}