Skip to main content

oxicode_vtui/design/layout/
welcome.rs

1//! Welcome screen layout — vertically centered logo + menu + prompt.
2//!
3//! Ported from grok-build's `views/welcome/mod.rs` (`WelcomeLayout`).
4//!
5//! Two layout variants:
6//! - **Stacked** (narrow): logo → gap → menu → gap → prompt → version
7//! - **Hero box** (wide ≥ `HERO_BOX_MIN_WIDTH`): side-by-side logo + menu
8
9use ratatui::layout::{Constraint, Layout, Rect};
10use ratatui::widgets::{Block, Padding};
11
12use super::config::LayoutConfig;
13
14/// Minimum terminal width for the hero-box layout.
15pub const HERO_BOX_MIN_WIDTH: u16 = 90;
16
17/// Prompt input height (shared across both variants).
18pub 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/// Which focus state the welcome prompt is in.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum WelcomePromptFocus {
31    /// Prompt is focused (accepting text input).
32    #[default]
33    Prompt,
34    /// Session picker is focused.
35    SessionPicker,
36}
37
38/// Computed areas for the welcome screen layout.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct WelcomeLayout {
41    /// Logo area.
42    pub logo: Rect,
43    /// Error / warning row.
44    pub error: Rect,
45    /// Menu items area.
46    pub menu: Rect,
47    /// Info slot below the menu (changelog / announcement).
48    pub changelog: Rect,
49    /// Tip row.
50    pub tip: Rect,
51    /// Prompt input widget.
52    pub prompt: Rect,
53    /// Version badge row.
54    pub version: Rect,
55    // Hero box sub-rects (all zero when inactive).
56    /// Outer hero box border.
57    pub hero_box: Rect,
58    /// Logo inside the hero box.
59    pub hero_logo: Rect,
60    /// Version inside the hero box.
61    pub hero_version: Rect,
62    /// Subtitle row inside the hero box.
63    pub hero_subtitle: Rect,
64    /// Info slot inside the hero box.
65    pub hero_info: Rect,
66    /// Menu inside the hero box.
67    pub hero_menu: Rect,
68}
69
70impl WelcomeLayout {
71    /// Whether the hero box is active.
72    #[must_use]
73    pub fn has_hero_box(&self) -> bool {
74        self.hero_box.width > 0 && self.hero_box.height > 0
75    }
76
77    /// Fixed rows below the menu+tip+prompt+version block.
78    #[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    /// Minimum content height for the hero-box layout.
85    #[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    /// Compute the welcome screen layout.
95    #[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    /// Horizontal margin for the given compact flag.
271    #[must_use]
272    pub fn h_margin(compact: bool) -> u16 {
273        if compact { H_MARGIN_COMPACT } else { H_MARGIN }
274    }
275}
276
277/// Outer padding for the welcome screen.
278#[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}