Skip to main content

max / alloy_tui

Give the console a spacing vocabulary, and answer the cell-shape question makeover resolves colour; makeover-geometry carries what does not. This is the terminal's reading of it. A widget asks for Gap::Group and gets an answer, instead of one widget being written with a hardcoded 1 and its neighbour with a 2 -- which was the state of things, except that the number was almost always 0: nothing in this crate had any inner padding at all, so content sat flush against every border it was inside. The crate's design note left an open question for whatever consumed it from a terminal first, since a cell is roughly 8x17 and a row of space therefore reads as about twice the gap a column does. The answer here is two surfaces, one per axis, differing only in base: columns keep Surface::terminal()'s one-cell base, rows halve it, both keep the one-cell quantum because a row is still the smallest thing a terminal draws. That needs no addition to the crate -- Surface already takes a base and a quantum, and a second surface for a second axis is a use of that rather than an extension to it. What falls out is what a TUI wants and would otherwise be hand-tuned into each widget: containers get side padding and spend no rows on it, and vertical space opens only at pane scale where a blank row carries a real division. Applied at AlloyBlock::build, which every bordered box passes through, and at the two floating panels that build their own block. GUTTER_WIDTH, LOG_HEIGHT and MIN_PANE_WIDTH stay plain constants with a note saying why: they are the width of the glyphs drawn in the gutter and structural minima, not preferences about how far apart two things should read, and routing them through a density preset would break the connector. makeover-geometry is unpublished, so the dependency carries both a version and a path: local builds resolve to the tree, and cargo publish refuses until 0.1.0 is on crates.io. That gate is deliberate.
Author: Max Johnson <me@maxj.phd> · 2026-07-27 02:19 UTC
Signed with PGP, not checked
Commit: 7debcaf5b8c3f4ddc96d28224997cf98f482bb39
Parent: b22ae16
6 files changed, +243 insertions, -9 deletions
M Cargo.toml +4
@@ -12,6 +12,10 @@
12 12 [dependencies]
13 13 ratatui = "0.30"
14 14 makeover = "2.1.0"
15 + # Unpublished. A path dep with a version means local builds resolve to the tree
16 + # and `cargo publish` refuses until 0.1.0 is actually on crates.io, which is the
17 + # gate we want: this crate cannot ship depending on something nobody can fetch.
18 + makeover-geometry = { version = "0.1.0", path = "../makeover-geometry" }
15 19
16 20 # TEMPORARY. The bevel intents and the 256-color palette are in makeover's tree
17 21 # and not yet on crates.io. Remove this the moment makeover publishes them, and
@@ -59,6 +59,14 @@
59 59 /// Width of the gutter between two linked panes. Three columns is the minimum
60 60 /// an elbow needs: one to leave the left pane, one to carry the vertical run,
61 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.
62 70 pub const GUTTER_WIDTH: u16 = 3;
63 71
64 72 /// Center a `width` x `height` box inside `area`, for a modal drawn over a
M src/lib.rs +6 -1
@@ -20,6 +20,7 @@
20 20 pub mod connector;
21 21 pub mod cursor;
22 22 pub mod focus;
23 + pub mod geometry;
23 24 pub mod help;
24 25 pub mod input;
25 26 pub mod keys;
@@ -33,10 +34,14 @@
33 34 pub use connector::AlloyConnector;
34 35 pub use cursor::Cursor;
35 36 pub use focus::FocusRing;
37 + pub use geometry::{Gap, padding};
36 38 pub use help::{AlloyKeymap, Binding, KeyGroup, binding, unavailable};
37 39 pub use input::TextField;
38 40 pub use keys::{Action, classify};
39 41 pub use layout::{ConsoleAreas, PaneAreas, console, panes};
40 42 pub use selection::{MARKER, selected_style};
41 - pub use theme::{ColorDepth, Mode, Theme, ThemeError, detect_color_depth};
43 + pub use theme::{
44 + ColorDepth, Mode, Theme, ThemeError, border_strong, border_subtle, detect_color_depth,
45 + mix_linear_srgb,
46 + };
42 47 pub use widgets::*;
M src/theme.rs +50 -8
@@ -119,8 +119,8 @@
119 119 // holds it to WCAG AA-UI against the page. On Akari Dawn the two land at
120 120 // 3.27:1 and 1.63:1, so they are different tokens wearing one name and
121 121 // adopting the shared one would take focus to half the required floor.
122 - let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60);
123 - let border_strong = mix_linear_srgb(line_border, content_primary, 0.65);
122 + let border_subtle = border_subtle(line_border, surface_page);
123 + let border_strong = border_strong(line_border, content_primary);
124 124
125 125 let mode = match theme.meta.variant.as_str() {
126 126 "dark" => Mode::Dark,
@@ -336,11 +336,35 @@
336 336 }
337 337 }
338 338
339 - // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
340 - // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
341 - // less uniform than OKLab but keeps the derived hex reproducible against the
342 - // contrast tables in TOKENS.md.
343 - fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
339 + /// Alloy's decorative divider: the authored border pulled toward the page.
340 + ///
341 + /// Public because the console is not the only thing that renders this token.
342 + /// The image's desktop skeleton — GTK, sway, yazi and the rest — is generated
343 + /// from the same theme file, and a second implementation of this line is a
344 + /// second answer to what `border-subtle` is. There is no built-in palette to
345 + /// fall back on (docs/TOKENS.md: no hex in Rust), so the generator asks here.
346 + pub fn border_subtle(line_border: Rgb, surface_page: Rgb) -> Rgb {
347 + mix_linear_srgb(line_border, surface_page, 0.60)
348 + }
349 +
350 + /// Alloy's focus and selection border: the authored border pulled toward text.
351 + ///
352 + /// Held to WCAG AA-UI against the page by TOKENS.md, which is why it is not
353 + /// makeover's `border-strong` — see the note in [`Theme::from_theme`]. Public
354 + /// for the same reason as [`border_subtle`].
355 + pub fn border_strong(line_border: Rgb, content_primary: Rgb) -> Rgb {
356 + mix_linear_srgb(line_border, content_primary, 0.65)
357 + }
358 +
359 + /// Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
360 + /// gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
361 + /// less uniform than OKLab but keeps the derived hex reproducible against the
362 + /// contrast tables in TOKENS.md.
363 + ///
364 + /// Exposed alongside the two derivations above so a caller composing its own
365 + /// tone reaches for the same mix the tokens use rather than OKLab's, which
366 + /// would answer differently.
367 + pub fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
344 368 let al = srgb_to_linear(a);
345 369 let bl = srgb_to_linear(b);
346 370 let m = (
@@ -397,7 +421,7 @@
397 421 fn akari_dawn_border_strong_matches_tokens_md() {
398 422 let border = Rgb::from_hex("#cabeae").unwrap();
399 423 let primary = Rgb::from_hex("#1a1816").unwrap();
400 - let got = mix_linear_srgb(border, primary, 0.65);
424 + let got = border_strong(border, primary);
401 425 assert_eq!(
402 426 (got.r, got.g, got.b),
403 427 (0x7f, 0x78, 0x6d),
@@ -408,6 +432,24 @@
408 432 );
409 433 }
410 434
435 + // The other half of the pair, pinned for the same reason: the image's
436 + // desktop skeleton is generated against these two functions, so a drift
437 + // here silently repaints every GTK app, sway border and yazi pane.
438 + #[test]
439 + fn akari_dawn_border_subtle_matches_the_shipped_skeleton() {
440 + let border = Rgb::from_hex("#cabeae").unwrap();
441 + let page = Rgb::from_hex("#e4ded6").unwrap();
442 + let got = border_subtle(border, page);
443 + assert_eq!(
444 + (got.r, got.g, got.b),
445 + (0xda, 0xd2, 0xc7),
446 + "border-subtle derivation drifted; got #{:02x}{:02x}{:02x}, expected #dad2c7",
447 + got.r,
448 + got.g,
449 + got.b
450 + );
451 + }
452 +
411 453 // Akari Dawn as far as this matters: the page, the text on it, and the
412 454 // strong border derived above.
413 455 fn akari_dawn() -> Theme {
@@ -57,6 +57,13 @@
57 57 Block::default()
58 58 .borders(Borders::ALL)
59 59 .border_style(Style::default().fg(border_color))
60 + // A container's inner margin is `Gap::Group` by definition, and on
61 + // this surface that is one column and no rows. Applied here rather
62 + // than at each call site because this is the one place every
63 + // bordered box in the console passes through: content used to sit
64 + // flush against the frame everywhere, which is the look of a box
65 + // drawn around text rather than a box containing it.
66 + .padding(crate::geometry::padding(crate::geometry::Gap::Group))
60 67 .style(
61 68 Style::default()
62 69 .bg(self.theme.surface_page)
@@ -525,6 +532,10 @@
525 532 .border_style(Style::default().fg(self.theme.border_strong))
526 533 .style(base)
527 534 .shadow(floating_shadow(self.theme))
535 + // The same inner margin AlloyBlock gives every other container. A
536 + // floating panel is still a container, and it is the one a user
537 + // looks straight at.
538 + .padding(crate::geometry::padding(crate::geometry::Gap::Group))
528 539 .title(format!(" {} ", self.title));
529 540 let inner = block.inner(area);
530 541 block.render(area, buf);
@@ -1167,6 +1178,10 @@
1167 1178 .border_style(Style::default().fg(self.theme.border_strong))
1168 1179 .style(base)
1169 1180 .shadow(floating_shadow(self.theme))
1181 + // The same inner margin AlloyBlock gives every other container. A
1182 + // floating panel is still a container, and it is the one a user
1183 + // looks straight at.
1184 + .padding(crate::geometry::padding(crate::geometry::Gap::Group))
1170 1185 .title(format!(" {} ", self.title));
1171 1186 let inner = block.inner(area);
1172 1187 block.render(area, buf);
@@ -1,0 +1,160 @@
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 + }