1use ratatui::layout::{Constraint, Direction, Layout, Rect};
5
6#[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
24pub struct AppLayout {
47 pub header: Rect,
49 pub chat: Rect,
51 pub side_panel: Rect,
53 pub skills: Rect,
55 pub memory: Rect,
57 pub resources: Rect,
59 pub subagents: Rect,
61 pub activity: Rect,
63 pub input: Rect,
65 pub status: Rect,
67}
68
69impl AppLayout {
70 #[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}