Skip to main content

max / alloy_tui

7.5 KB · 214 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 ///
63 /// Deliberately not a [`crate::geometry::Gap`], and not a candidate to become
64 /// one. A gap is a *preference* about how far apart two things should read;
65 /// this is the width of the glyphs drawn in it. Routing it through the spacing
66 /// vocabulary would let a density preset narrow the gutter to two columns and
67 /// break the connector, which is the opposite of what naming a relationship is
68 /// supposed to buy. The same goes for `LOG_HEIGHT` and `MIN_PANE_WIDTH` above:
69 /// they are content and structural minima, not separations.
70 pub const GUTTER_WIDTH: u16 = 3;
71
72 /// Center a `width` x `height` box inside `area`, for a modal drawn over a
73 /// view.
74 ///
75 /// Clamps rather than overflowing: a box larger than the area it sits in
76 /// becomes the area. A modal that renders partly offscreen is worse than a
77 /// cramped one, because the keys that dismiss it are listed on its last row.
78 pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
79 let width = width.min(area.width);
80 let height = height.min(area.height);
81 Rect {
82 x: area.x + (area.width - width) / 2,
83 y: area.y + (area.height - height) / 2,
84 width,
85 height,
86 }
87 }
88
89 /// Two panes with a connector gutter between them.
90 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
91 pub struct PaneAreas {
92 pub left: Rect,
93 pub gutter: Rect,
94 pub right: Rect,
95 }
96
97 /// Split a body area into two panes separated by a connector gutter.
98 ///
99 /// The split is even, with any odd column going to the left pane. Below the
100 /// width needed for two usable panes the gutter collapses and the right pane
101 /// takes zero width; callers render into it unconditionally, since ratatui
102 /// clips a zero-area render, and a view that wants different narrow-terminal
103 /// behavior can check `right.width`.
104 pub fn panes(area: Rect) -> PaneAreas {
105 // Two panes of at least this width each, plus the gutter, or the split is
106 // not worth making: below it a pane is too narrow to hold a label.
107 const MIN_PANE_WIDTH: u16 = 16;
108
109 if area.width < MIN_PANE_WIDTH * 2 + GUTTER_WIDTH {
110 return PaneAreas {
111 left: area,
112 gutter: Rect { width: 0, ..area },
113 right: Rect { width: 0, ..area },
114 };
115 }
116
117 let usable = area.width - GUTTER_WIDTH;
118 let left_width = usable - usable / 2;
119
120 PaneAreas {
121 left: Rect {
122 width: left_width,
123 ..area
124 },
125 gutter: Rect {
126 x: area.x + left_width,
127 width: GUTTER_WIDTH,
128 ..area
129 },
130 right: Rect {
131 x: area.x + left_width + GUTTER_WIDTH,
132 width: usable / 2,
133 ..area
134 },
135 }
136 }
137
138 #[cfg(test)]
139 mod tests {
140 use super::*;
141
142 #[test]
143 fn panes_tile_the_area_exactly() {
144 let areas = panes(Rect::new(0, 0, 80, 20));
145 assert_eq!(areas.left.x, 0);
146 assert_eq!(areas.gutter.x, areas.left.x + areas.left.width);
147 assert_eq!(areas.right.x, areas.gutter.x + areas.gutter.width);
148 assert_eq!(
149 areas.left.width + areas.gutter.width + areas.right.width,
150 80,
151 "no column is unaccounted for"
152 );
153 assert_eq!(areas.gutter.width, GUTTER_WIDTH);
154 }
155
156 // An odd usable width cannot split evenly; the extra column has to go
157 // somewhere deterministic rather than being dropped.
158 #[test]
159 fn odd_widths_give_the_extra_column_to_the_left() {
160 let areas = panes(Rect::new(0, 0, 81, 20));
161 assert_eq!(areas.left.width, 39);
162 assert_eq!(areas.right.width, 39);
163 assert_eq!(
164 areas.left.width + areas.gutter.width + areas.right.width,
165 81
166 );
167 }
168
169 // A narrow terminal collapses to one pane rather than two unusable slivers.
170 #[test]
171 fn narrow_area_collapses_to_a_single_pane() {
172 let areas = panes(Rect::new(0, 0, 30, 20));
173 assert_eq!(areas.left.width, 30);
174 assert_eq!(areas.right.width, 0);
175 assert_eq!(areas.gutter.width, 0);
176 }
177
178 #[test]
179 fn full_height_gets_all_three_regions() {
180 let areas = console(Rect::new(0, 0, 80, 24));
181 assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1);
182 assert_eq!(areas.log.height, LOG_HEIGHT);
183 assert_eq!(areas.footer.height, 1);
184 assert_eq!(
185 areas.body.height + areas.log.height + areas.footer.height,
186 24,
187 "the three regions must tile the screen exactly, with no dead row"
188 );
189 }
190
191 // A short terminal drops the log rather than starving the body. Without this
192 // the layout hands the body 1-2 rows and spends the rest on log borders.
193 #[test]
194 fn short_terminal_drops_the_log_pane() {
195 let areas = console(Rect::new(0, 0, 80, 10));
196 assert_eq!(
197 areas.log.height, 0,
198 "log is dropped below the minimum height"
199 );
200 assert_eq!(areas.body.height, 9);
201 assert_eq!(areas.footer.height, 1);
202 }
203
204 // The degenerate case: a terminal so short there is only the footer. This
205 // must not panic or produce a negative-height body.
206 #[test]
207 fn single_row_terminal_yields_footer_only() {
208 let areas = console(Rect::new(0, 0, 80, 1));
209 assert_eq!(areas.body.height, 0);
210 assert_eq!(areas.log.height, 0);
211 assert_eq!(areas.footer.height, 1);
212 }
213 }
214