max / alloy_tui
6 files changed,
+243 insertions,
-9 deletions
| @@ -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 |
| @@ -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::*; |
| @@ -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 | + | } |