Skip to main content

max / alloy_tui

7.0 KB · 206 lines History Blame Raw
1 //! The console frame: body, command-log pane, footer.
2 //!
3 //! sysop-tui split a screen into body plus a one-row footer. Alloy adds the
4 //! command-log pane between them, which docs/CONSOLE.md settled as always-on —
5 //! the "console teaches its own primitives" claim only lands if the log of
6 //! underlying CLI invocations is visible without being asked for.
7
8 use ratatui::layout::{Constraint, Layout, Rect};
9
10 /// Default height of the command-log pane, borders included: a two-row window
11 /// onto the log plus its box. Enough to show the command just run and the one
12 /// before it, which is what makes the pane read as a running transcript rather
13 /// than a status line.
14 pub const LOG_HEIGHT: u16 = 4;
15
16 /// Below this total height the log pane is dropped entirely. The body needs
17 /// room to be worth drawing; on a short terminal the log is the first thing
18 /// that should go, and it degrades to nothing rather than to a sliver of
19 /// borders with no content between them.
20 const MIN_HEIGHT_FOR_LOG: u16 = 12;
21
22 /// The three regions of a console screen. `log` is empty (zero height) when
23 /// the terminal is too short to carry it — callers can render into it
24 /// unconditionally, since ratatui clips a zero-area render.
25 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 pub struct ConsoleAreas {
27 pub body: Rect,
28 pub log: Rect,
29 pub footer: Rect,
30 }
31
32 /// Split a full-screen area into body, log pane, and footer.
33 pub fn console(area: Rect) -> ConsoleAreas {
34 console_with_log_height(area, LOG_HEIGHT)
35 }
36
37 /// As [`console`], with an explicit log-pane height.
38 pub fn console_with_log_height(area: Rect, log_height: u16) -> ConsoleAreas {
39 if area.height < MIN_HEIGHT_FOR_LOG || log_height == 0 {
40 let [body, footer] =
41 Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
42 return ConsoleAreas {
43 body,
44 log: Rect { height: 0, ..body },
45 footer,
46 };
47 }
48
49 let [body, log, footer] = Layout::vertical([
50 Constraint::Min(0),
51 Constraint::Length(log_height),
52 Constraint::Length(1),
53 ])
54 .areas(area);
55
56 ConsoleAreas { body, log, footer }
57 }
58
59 /// Width of the gutter between two linked panes. Three columns is the minimum
60 /// an elbow needs: one to leave the left pane, one to carry the vertical run,
61 /// one to enter the right pane.
62 pub const GUTTER_WIDTH: u16 = 3;
63
64 /// Center a `width` x `height` box inside `area`, for a modal drawn over a
65 /// view.
66 ///
67 /// Clamps rather than overflowing: a box larger than the area it sits in
68 /// becomes the area. A modal that renders partly offscreen is worse than a
69 /// cramped one, because the keys that dismiss it are listed on its last row.
70 pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
71 let width = width.min(area.width);
72 let height = height.min(area.height);
73 Rect {
74 x: area.x + (area.width - width) / 2,
75 y: area.y + (area.height - height) / 2,
76 width,
77 height,
78 }
79 }
80
81 /// Two panes with a connector gutter between them.
82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 pub struct PaneAreas {
84 pub left: Rect,
85 pub gutter: Rect,
86 pub right: Rect,
87 }
88
89 /// Split a body area into two panes separated by a connector gutter.
90 ///
91 /// The split is even, with any odd column going to the left pane. Below the
92 /// width needed for two usable panes the gutter collapses and the right pane
93 /// takes zero width; callers render into it unconditionally, since ratatui
94 /// clips a zero-area render, and a view that wants different narrow-terminal
95 /// behavior can check `right.width`.
96 pub fn panes(area: Rect) -> PaneAreas {
97 // Two panes of at least this width each, plus the gutter, or the split is
98 // not worth making: below it a pane is too narrow to hold a label.
99 const MIN_PANE_WIDTH: u16 = 16;
100
101 if area.width < MIN_PANE_WIDTH * 2 + GUTTER_WIDTH {
102 return PaneAreas {
103 left: area,
104 gutter: Rect { width: 0, ..area },
105 right: Rect { width: 0, ..area },
106 };
107 }
108
109 let usable = area.width - GUTTER_WIDTH;
110 let left_width = usable - usable / 2;
111
112 PaneAreas {
113 left: Rect {
114 width: left_width,
115 ..area
116 },
117 gutter: Rect {
118 x: area.x + left_width,
119 width: GUTTER_WIDTH,
120 ..area
121 },
122 right: Rect {
123 x: area.x + left_width + GUTTER_WIDTH,
124 width: usable / 2,
125 ..area
126 },
127 }
128 }
129
130 #[cfg(test)]
131 mod tests {
132 use super::*;
133
134 #[test]
135 fn panes_tile_the_area_exactly() {
136 let areas = panes(Rect::new(0, 0, 80, 20));
137 assert_eq!(areas.left.x, 0);
138 assert_eq!(areas.gutter.x, areas.left.x + areas.left.width);
139 assert_eq!(areas.right.x, areas.gutter.x + areas.gutter.width);
140 assert_eq!(
141 areas.left.width + areas.gutter.width + areas.right.width,
142 80,
143 "no column is unaccounted for"
144 );
145 assert_eq!(areas.gutter.width, GUTTER_WIDTH);
146 }
147
148 // An odd usable width cannot split evenly; the extra column has to go
149 // somewhere deterministic rather than being dropped.
150 #[test]
151 fn odd_widths_give_the_extra_column_to_the_left() {
152 let areas = panes(Rect::new(0, 0, 81, 20));
153 assert_eq!(areas.left.width, 39);
154 assert_eq!(areas.right.width, 39);
155 assert_eq!(
156 areas.left.width + areas.gutter.width + areas.right.width,
157 81
158 );
159 }
160
161 // A narrow terminal collapses to one pane rather than two unusable slivers.
162 #[test]
163 fn narrow_area_collapses_to_a_single_pane() {
164 let areas = panes(Rect::new(0, 0, 30, 20));
165 assert_eq!(areas.left.width, 30);
166 assert_eq!(areas.right.width, 0);
167 assert_eq!(areas.gutter.width, 0);
168 }
169
170 #[test]
171 fn full_height_gets_all_three_regions() {
172 let areas = console(Rect::new(0, 0, 80, 24));
173 assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1);
174 assert_eq!(areas.log.height, LOG_HEIGHT);
175 assert_eq!(areas.footer.height, 1);
176 assert_eq!(
177 areas.body.height + areas.log.height + areas.footer.height,
178 24,
179 "the three regions must tile the screen exactly, with no dead row"
180 );
181 }
182
183 // A short terminal drops the log rather than starving the body. Without this
184 // the layout hands the body 1-2 rows and spends the rest on log borders.
185 #[test]
186 fn short_terminal_drops_the_log_pane() {
187 let areas = console(Rect::new(0, 0, 80, 10));
188 assert_eq!(
189 areas.log.height, 0,
190 "log is dropped below the minimum height"
191 );
192 assert_eq!(areas.body.height, 9);
193 assert_eq!(areas.footer.height, 1);
194 }
195
196 // The degenerate case: a terminal so short there is only the footer. This
197 // must not panic or produce a negative-height body.
198 #[test]
199 fn single_row_terminal_yields_footer_only() {
200 let areas = console(Rect::new(0, 0, 80, 1));
201 assert_eq!(areas.body.height, 0);
202 assert_eq!(areas.log.height, 0);
203 assert_eq!(areas.footer.height, 1);
204 }
205 }
206