Skip to main content

max / alloy_tui

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