Skip to main content

max / alloy_tui

6.0 KB · 161 lines History Blame Raw
1 //! Spacing, as relationships rather than column counts.
2 //!
3 //! `makeover` resolves colour, which varies by theme. `makeover-geometry`
4 //! carries what does not, and this is the terminal's reading of it: a
5 //! [`Gap`] names *what is being separated* and this module answers how many
6 //! cells that is here.
7 //!
8 //! Why bother in a TUI, where the vocabulary collapses hard. A terminal cell is
9 //! coarse enough that four of the six gaps round to nothing horizontally and
10 //! five of six vertically, so most of what this module says is "no space". That
11 //! is the point rather than a disappointment: it says it *consistently*, and a
12 //! widget that asks for `Gap::Group` gets the same answer as every other widget
13 //! that asks, instead of one of them being written with a hardcoded 1 and its
14 //! neighbour with a 2.
15 //!
16 //! # Cells are not square
17 //!
18 //! A terminal cell is roughly 8x17 device pixels, so one row of space reads as
19 //! about twice the gap one column does. Treating the grid as one surface means
20 //! every gap is either visually thin horizontally or visually fat vertically.
21 //!
22 //! So there are two surfaces, one per axis, and they differ only in base. The
23 //! horizontal one is `Surface::terminal()` — a one-cell base and a one-cell
24 //! quantum, the crate's own definition. The vertical one keeps the one-cell
25 //! quantum (a row is still the smallest thing a terminal can draw) and halves
26 //! the base, which is what "a row is worth two columns" means expressed as a
27 //! measurement rather than as a special case.
28 //!
29 //! What falls out is the behaviour a terminal UI wants and would otherwise be
30 //! hand-tuned into every widget: containers get side padding and spend no rows
31 //! on it, and vertical space opens up only at pane scale, where a blank row is
32 //! carrying a real division rather than decorating one.
33 //!
34 //! | | bound | peer | group | section | pane | page |
35 //! |---|---|---|---|---|---|---|
36 //! | columns | 0 | 0 | 1 | 1 | 2 | 2 |
37 //! | rows | 0 | 0 | 0 | 0 | 1 | 1 |
38 //!
39 //! This answers the open question the crate's design note left for its first
40 //! terminal consumer. Nothing above is terminal-specific machinery: `Surface`
41 //! already takes a base and a quantum, and a second surface for the second axis
42 //! is a use of that, not an extension to it.
43
44 use makeover_geometry::Surface;
45 use ratatui::widgets::Padding;
46
47 pub use makeover_geometry::{Density, Gap, Step};
48
49 /// The console is driven by a keyboard and read at desk distance. There is no
50 /// touch terminal to be the other case, so this is the only density in play —
51 /// named rather than assumed, so the call sites read the same as a webview's.
52 pub const DENSITY: Density = Density::Pointer;
53
54 /// Columns. One cell is the base and one cell is the quantum.
55 fn horizontal() -> Surface {
56 Surface::terminal()
57 }
58
59 /// Rows. Same quantum — a row is the smallest thing a terminal draws — against
60 /// half the base, because a row of space is worth about two columns of it.
61 fn vertical() -> Surface {
62 Surface {
63 base: 0.5,
64 ..Surface::terminal()
65 }
66 }
67
68 /// How many columns of separation `gap` is worth.
69 #[must_use]
70 pub fn columns(gap: Gap) -> u16 {
71 clamp(horizontal().gap(gap, DENSITY))
72 }
73
74 /// How many rows of separation `gap` is worth.
75 #[must_use]
76 pub fn rows(gap: Gap) -> u16 {
77 clamp(vertical().gap(gap, DENSITY))
78 }
79
80 /// `gap` as ratatui padding, per axis.
81 ///
82 /// What a bordered container wants inside its frame. [`Gap::Group`] is the
83 /// usual answer, being the crate's name for a container's inner margin.
84 #[must_use]
85 pub fn padding(gap: Gap) -> Padding {
86 Padding::symmetric(columns(gap), rows(gap))
87 }
88
89 /// A terminal is addressed in `u16`, and no gap this vocabulary produces comes
90 /// near that. Saturating rather than `as` so a future base could not silently
91 /// wrap a layout.
92 fn clamp(quanta: u32) -> u16 {
93 u16::try_from(quanta).unwrap_or(u16::MAX)
94 }
95
96 #[cfg(test)]
97 mod tests {
98 use super::*;
99
100 // The table in this module's own documentation. If these move, the doc is
101 // wrong and so is every widget that trusted it.
102 #[test]
103 fn the_axes_resolve_as_documented() {
104 let want = [
105 (Gap::Bound, 0, 0),
106 (Gap::Peer, 0, 0),
107 (Gap::Group, 1, 0),
108 (Gap::Section, 1, 0),
109 (Gap::Pane, 2, 1),
110 (Gap::Page, 2, 1),
111 ];
112 for (gap, cols, rws) in want {
113 assert_eq!(columns(gap), cols, "{gap:?} columns");
114 assert_eq!(rows(gap), rws, "{gap:?} rows");
115 }
116 }
117
118 // The invariant the crate's design note calls out as the one that matters:
119 // a looser relationship may collapse onto a tighter one, because a coarse
120 // surface has fewer distinctions available, but it must never resolve
121 // *tighter*. Checked per axis, since the two surfaces differ.
122 #[test]
123 fn a_looser_gap_never_resolves_tighter_than_a_closer_one() {
124 for axis in [
125 ("columns", columns as fn(Gap) -> u16),
126 ("rows", rows as fn(Gap) -> u16),
127 ] {
128 let (name, resolve) = axis;
129 let ordered = Gap::all();
130 for pair in ordered.windows(2) {
131 let (tight, loose) = (pair[0], pair[1]);
132 assert!(
133 resolve(loose) >= resolve(tight),
134 "{name}: {loose:?} ({}) resolved tighter than {tight:?} ({})",
135 resolve(loose),
136 resolve(tight)
137 );
138 }
139 }
140 }
141
142 // A row costs about twice what a column does, so no gap should ever spend
143 // more rows than columns. This is the whole reason there are two surfaces.
144 #[test]
145 fn no_gap_spends_more_rows_than_columns() {
146 for gap in Gap::all() {
147 assert!(
148 rows(gap) <= columns(gap),
149 "{gap:?} spends {} rows against {} columns",
150 rows(gap),
151 columns(gap)
152 );
153 }
154 }
155
156 #[test]
157 fn a_group_pads_the_sides_and_costs_no_rows() {
158 assert_eq!(padding(Gap::Group), Padding::symmetric(1, 0));
159 }
160 }
161