Skip to main content

zeph_tui/
layout.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use ratatui::layout::{Constraint, Direction, Layout, Rect};
5
6/// Returns a centered `Rect` with the given percentage width and fixed height.
7#[must_use]
8pub fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
9    let vertical = Layout::vertical([
10        Constraint::Fill(1),
11        Constraint::Length(height),
12        Constraint::Fill(1),
13    ])
14    .split(area);
15
16    Layout::horizontal([
17        Constraint::Percentage((100 - percent_x) / 2),
18        Constraint::Percentage(percent_x),
19        Constraint::Percentage((100 - percent_x) / 2),
20    ])
21    .split(vertical[1])[1]
22}
23
24/// Pre-computed layout rectangles for all regions of the TUI dashboard.
25///
26/// Call [`compute`](Self::compute) once per render frame; pass the result to
27/// individual widget renderers so each widget knows its exact screen region
28/// without re-running the layout algorithm.
29///
30/// When the terminal is narrower than 80 columns or `show_side_panels` is
31/// `false`, all side-panel fields are set to [`Rect::default()`] (zero-sized)
32/// and the chat area expands to fill the full width.
33///
34/// # Examples
35///
36/// ```rust
37/// use ratatui::layout::Rect;
38/// use zeph_tui::layout::AppLayout;
39///
40/// let area = Rect::new(0, 0, 120, 40);
41/// let layout = AppLayout::compute(area, true, 3);
42/// assert_eq!(layout.header.height, 1);
43/// assert_eq!(layout.status.height, 1);
44/// assert!(layout.chat.width > layout.side_panel.width);
45/// ```
46pub struct AppLayout {
47    /// Single-row header bar (model name, session info).
48    pub header: Rect,
49    /// Main chat / transcript area.
50    pub chat: Rect,
51    /// Combined side-panel column (zero when hidden).
52    pub side_panel: Rect,
53    /// Skills mini-panel within the side column.
54    pub skills: Rect,
55    /// Memory mini-panel within the side column.
56    pub memory: Rect,
57    /// MCP resources mini-panel within the side column.
58    pub resources: Rect,
59    /// Sub-agents mini-panel within the side column.
60    pub subagents: Rect,
61    /// Single-row activity / status-spinner bar.
62    pub activity: Rect,
63    /// Multi-row text input box.
64    pub input: Rect,
65    /// Single-row bottom status bar (metrics, keybinding hints).
66    pub status: Rect,
67}
68
69impl AppLayout {
70    /// Compute the layout for the given terminal area.
71    ///
72    /// # Arguments
73    ///
74    /// * `area` — the full terminal rect (from `Frame::area()`).
75    /// * `show_side_panels` — `false` hides the side panels regardless of width.
76    /// * `input_height` — requested composer height including borders.
77    ///
78    /// # Examples
79    ///
80    /// ```rust
81    /// use ratatui::layout::Rect;
82    /// use zeph_tui::layout::AppLayout;
83    ///
84    /// // Wide terminal: side panels visible.
85    /// let layout = AppLayout::compute(Rect::new(0, 0, 120, 40), true, 3);
86    /// assert!(layout.side_panel.width > 0);
87    ///
88    /// // Narrow terminal: side panels hidden.
89    /// let layout = AppLayout::compute(Rect::new(0, 0, 60, 24), true, 3);
90    /// assert_eq!(layout.side_panel.width, 0);
91    /// ```
92    #[must_use]
93    pub fn compute(area: Rect, show_side_panels: bool, input_height: u16) -> Self {
94        let outer = Layout::default()
95            .direction(Direction::Vertical)
96            .constraints([
97                Constraint::Length(1),
98                Constraint::Min(10),
99                Constraint::Length(1),
100                Constraint::Length(input_height),
101                Constraint::Length(1),
102            ])
103            .split(area);
104
105        if !show_side_panels || area.width < 80 {
106            return Self {
107                header: outer[0],
108                chat: outer[1],
109                side_panel: Rect::default(),
110                skills: Rect::default(),
111                memory: Rect::default(),
112                resources: Rect::default(),
113                subagents: Rect::default(),
114                activity: outer[2],
115                input: outer[3],
116                status: outer[4],
117            };
118        }
119
120        let main_split = Layout::default()
121            .direction(Direction::Horizontal)
122            .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
123            .split(outer[1]);
124
125        let side_split = Layout::default()
126            .direction(Direction::Vertical)
127            .constraints([
128                Constraint::Percentage(25),
129                Constraint::Percentage(25),
130                Constraint::Percentage(25),
131                Constraint::Percentage(25),
132            ])
133            .split(main_split[1]);
134
135        Self {
136            header: outer[0],
137            chat: main_split[0],
138            side_panel: main_split[1],
139            skills: side_split[0],
140            memory: side_split[1],
141            resources: side_split[2],
142            subagents: side_split[3],
143            activity: outer[2],
144            input: outer[3],
145            status: outer[4],
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn layout_for_standard_terminal() {
156        let area = Rect::new(0, 0, 120, 40);
157        let layout = AppLayout::compute(area, true, 3);
158        assert_eq!(layout.header.height, 1);
159        assert_eq!(layout.input.height, 3);
160        assert_eq!(layout.status.height, 1);
161        assert!(layout.chat.width > layout.side_panel.width);
162    }
163
164    #[test]
165    fn layout_for_small_terminal() {
166        let area = Rect::new(0, 0, 80, 24);
167        let layout = AppLayout::compute(area, true, 3);
168        assert_eq!(layout.header.height, 1);
169        assert_eq!(layout.status.height, 1);
170        assert!(layout.chat.height >= 10);
171    }
172
173    #[test]
174    fn layout_side_panels_stack_vertically() {
175        let area = Rect::new(0, 0, 120, 40);
176        let layout = AppLayout::compute(area, true, 3);
177        assert!(layout.skills.y < layout.memory.y);
178        assert!(layout.memory.y < layout.resources.y);
179        assert!(layout.resources.y < layout.subagents.y);
180    }
181
182    #[test]
183    fn layout_input_below_chat() {
184        let area = Rect::new(0, 0, 100, 30);
185        let layout = AppLayout::compute(area, true, 3);
186        assert!(layout.input.y > layout.chat.y);
187        assert!(layout.status.y > layout.input.y);
188    }
189
190    #[test]
191    fn layout_narrow_hides_side_panels() {
192        let area = Rect::new(0, 0, 60, 24);
193        let layout = AppLayout::compute(area, true, 3);
194        assert_eq!(layout.side_panel, Rect::default());
195        assert_eq!(layout.skills, Rect::default());
196        assert_eq!(layout.memory, Rect::default());
197        assert_eq!(layout.resources, Rect::default());
198        assert_eq!(layout.subagents, Rect::default());
199        assert_eq!(layout.chat.width, area.width);
200    }
201
202    #[test]
203    fn layout_very_narrow_hides_side_panels() {
204        let area = Rect::new(0, 0, 30, 24);
205        let layout = AppLayout::compute(area, true, 3);
206        assert_eq!(layout.side_panel, Rect::default());
207        assert_eq!(layout.skills, Rect::default());
208    }
209
210    #[test]
211    fn layout_boundary_at_80_shows_side_panels() {
212        let area = Rect::new(0, 0, 80, 24);
213        let layout = AppLayout::compute(area, true, 3);
214        assert!(layout.side_panel.width > 0);
215        assert!(layout.skills.width > 0);
216    }
217
218    #[test]
219    fn layout_boundary_at_79_hides_side_panels() {
220        let area = Rect::new(0, 0, 79, 24);
221        let layout = AppLayout::compute(area, true, 3);
222        assert_eq!(layout.side_panel, Rect::default());
223    }
224
225    #[test]
226    fn layout_toggle_off_hides_side_panels() {
227        let area = Rect::new(0, 0, 120, 40);
228        let layout = AppLayout::compute(area, false, 3);
229        assert_eq!(layout.side_panel, Rect::default());
230        assert_eq!(layout.skills, Rect::default());
231        assert_eq!(layout.memory, Rect::default());
232        assert_eq!(layout.resources, Rect::default());
233        assert_eq!(layout.subagents, Rect::default());
234        assert_eq!(layout.chat.width, area.width);
235    }
236
237    #[test]
238    fn layout_toggle_on_shows_side_panels() {
239        let area = Rect::new(0, 0, 120, 40);
240        let layout = AppLayout::compute(area, true, 3);
241        assert!(layout.side_panel.width > 0);
242        assert!(layout.skills.width > 0);
243    }
244
245    #[test]
246    fn centered_rect_is_within_area() {
247        let area = Rect::new(0, 0, 100, 40);
248        let popup = centered_rect(70, 22, area);
249        assert!(popup.x >= area.x);
250        assert!(popup.y >= area.y);
251        assert!(popup.x + popup.width <= area.x + area.width);
252        assert!(popup.y + popup.height <= area.y + area.height);
253    }
254
255    #[test]
256    fn centered_rect_height_matches_requested() {
257        let area = Rect::new(0, 0, 100, 40);
258        let popup = centered_rect(70, 22, area);
259        assert_eq!(popup.height, 22);
260    }
261
262    #[test]
263    fn centered_rect_width_is_approximately_percent() {
264        let area = Rect::new(0, 0, 100, 40);
265        let popup = centered_rect(70, 10, area);
266        let expected = (100 * 70) / 100;
267        let delta = (i32::from(popup.width) - expected).unsigned_abs();
268        assert!(delta <= 2, "width={} expected~={}", popup.width, expected);
269    }
270
271    #[test]
272    fn centered_rect_is_horizontally_centered() {
273        let area = Rect::new(0, 0, 100, 40);
274        let popup = centered_rect(70, 10, area);
275        let left_margin = popup.x;
276        let right_margin = area.width - popup.width - popup.x;
277        let diff = (i32::from(left_margin) - i32::from(right_margin)).unsigned_abs();
278        assert!(diff <= 2, "left={left_margin} right={right_margin}");
279    }
280
281    mod proptest_layout {
282        use super::*;
283        use proptest::prelude::*;
284
285        fn assert_within_bounds(rect: Rect, area: Rect) {
286            assert!(
287                rect.x + rect.width <= area.x + area.width,
288                "rect {rect:?} exceeds area width {area:?}"
289            );
290            assert!(
291                rect.y + rect.height <= area.y + area.height,
292                "rect {rect:?} exceeds area height {area:?}"
293            );
294        }
295
296        proptest! {
297            #![proptest_config(ProptestConfig::with_cases(1000))]
298
299            #[test]
300            fn layout_never_panics(
301                width in 1u16..500,
302                height in 1u16..500,
303                show_side in proptest::bool::ANY,
304            ) {
305                let area = Rect::new(0, 0, width, height);
306                let layout = AppLayout::compute(area, show_side, 3);
307
308                assert_within_bounds(layout.header, area);
309                assert_within_bounds(layout.chat, area);
310                assert_within_bounds(layout.activity, area);
311                assert_within_bounds(layout.input, area);
312                assert_within_bounds(layout.status, area);
313
314                if layout.side_panel != Rect::default() {
315                    assert_within_bounds(layout.side_panel, area);
316                    assert_within_bounds(layout.skills, area);
317                    assert_within_bounds(layout.memory, area);
318                    assert_within_bounds(layout.resources, area);
319                    assert_within_bounds(layout.subagents, area);
320                }
321            }
322
323            #[test]
324            fn centered_rect_within_bounds(
325                percent_x in 10u16..100,
326                popup_h in 1u16..50,
327                area_w in 20u16..300,
328                area_h in 10u16..100,
329            ) {
330                let area = Rect::new(0, 0, area_w, area_h);
331                let popup = centered_rect(percent_x, popup_h.min(area_h), area);
332                assert_within_bounds(popup, area);
333            }
334        }
335    }
336}