Skip to main content

max / alloy_tui

3.1.0: Hand the bevel painting to makeover-tui This module and makeover-tui were built two days apart, independently, and render the same light model. makeover-tui is the right home: it draws makeover-layout's description, so a control lights the same way in a terminal and in an egui window, and it carries the fidelity measurements this module never had. Across 31 themes a bevel loses an edge into its own face on every one of them at sixteen colours, and this module painted both tones regardless with no fallback. The rendering itself went the other way. makeover-tui 0.4.0 took this module's half-blocks and split corners, so nothing is lost by delegating and the sixteen-colour case is now handled. All six tests here pass unchanged against the delegated renderer, glyph literals included. Elevation::Sunken maps to Bevel::Inset, not Depth::Sunken. The names collide across the crates and mean opposite things: this one is an inverted bevel, a text field or a scrollbar trough, while makeover-layout's Sunken is a surface set back by colour with explicitly no edge. Mapping by name would strip the bevel off every text field and leave a flat tint. The reasoning sits on the mapping, which is where the mistake would be made. Palette.well is None rather than filled from surface_sunken. A well is derived by inverting against the theme's content colour; a sunken surface is authored and free to sit darker than raised. They are different tokens, and substituting one for the other is the fallback makeover-tui exists to delete. Adequate while only paint_bevel is called; a frame facade would need Theme to carry the real surface-well intent. Adds Bevel::fidelity. Detection stays the default, read once per process rather than per frame, but delegating meant these tests began depending on the TERM of whoever ran them: at Ansi16 the renderer correctly draws a different glyph set, so the glyph assertions would fail on a sixteen-colour terminal. It also serves a caller that already quantised its palette. AlloyButton renders through Bevel, so every button gains the sixteen-colour glyph fallback with it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 17:24 UTC
Signed with PGP, not checked
Commit: 9d780d50444e2f1a8262a8cd313995a959e4cc84
Parent: 780c71c
2 files changed, +110 insertions, -45 deletions
M Cargo.toml +6 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "alloy_tui"
3 - version = "3.0.0"
3 + version = "3.1.0"
4 4 description = "Alloy design system: makeover intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings."
5 5 edition = "2024"
6 6 # 1.88 is ratatui 0.30.2's floor, and 0.30.2 is where `Block::shadow` lands.
@@ -17,6 +17,11 @@
17 17 # crate could not be published from a clean checkout.
18 18 makeover = "2.3.0"
19 19 makeover-geometry = "0.2.0"
20 + # The family's terminal renderer, which owns the bevel painting this crate used
21 + # to do itself. 0.4.0 and not 0.3.0 because the half-block glyphs are what makes
22 + # delegating here an improvement rather than a downgrade: 0.3.0 draws
23 + # box-drawing, which is a line and not a lit edge.
24 + makeover-tui = "0.4.0"
20 25
21 26 [lints.rust]
22 27 unused = "warn"
M src/bevel.rs +104 -44
@@ -25,16 +25,44 @@
25 25 //! a half-cell column down the side are about the same number of pixels, and the
26 26 //! bevel reads as even thickness rather than as a heavy top.
27 27 //!
28 + //! # Where the painting lives
29 + //!
30 + //! Not here, since 2026-07-30. `makeover-tui` is the family's terminal renderer
31 + //! for the same light model, arrived at independently two days after this module
32 + //! and carrying two things this one never had: the fidelity measurements (across
33 + //! 31 themes, a bevel loses an edge into its face on all of them at sixteen
34 + //! colours) and a glyph fallback for that case. It also renders
35 + //! [`makeover_layout`]'s description, which is what lets a control light the same
36 + //! way in a terminal and in an egui window.
37 + //!
38 + //! So the painting moved there, taking this module's half-blocks and split
39 + //! corners with it, and what stays is the part worth keeping: a `Widget` that
40 + //! speaks [`Theme`] rather than a palette, so an Alloy caller does not assemble
41 + //! one per frame.
42 + //!
28 43 //! <!-- wiki: alloy-console -->
29 44
45 + use std::sync::OnceLock;
46 +
47 + use makeover_tui::makeover_layout::Bevel as BevelKind;
48 + use makeover_tui::{Fidelity, Palette, paint_bevel};
30 49 use ratatui::buffer::Buffer;
31 50 use ratatui::layout::Rect;
32 - use ratatui::style::{Color, Style};
33 - use ratatui::symbols::border;
34 - use ratatui::widgets::{Block, BorderType, Borders, Widget};
51 + use ratatui::widgets::Widget;
35 52
36 53 use crate::theme::Theme;
37 54
55 + /// What the terminal can show, read once per process.
56 + ///
57 + /// [`Fidelity::detect`] reads two environment variables. They cannot change
58 + /// under a running process in any way that matters, and a bevel is drawn many
59 + /// times a frame, so asking once is both cheaper and more consistent than
60 + /// asking per render.
61 + fn fidelity() -> Fidelity {
62 + static DETECTED: OnceLock<Fidelity> = OnceLock::new();
63 + *DETECTED.get_or_init(Fidelity::detect)
64 + }
65 +
38 66 /// Which way a surface is lit, which is to say what it is.
39 67 ///
40 68 /// Per docs/DESIGN-LANGUAGE.md the presence of an edge is itself the affordance:
@@ -54,17 +82,29 @@
54 82 }
55 83
56 84 impl Elevation {
57 - /// The tones for the lit-side and shaded-side passes.
85 + /// The description's name for this elevation, or `None` where there is no
86 + /// edge to draw.
58 87 ///
59 88 /// Pressed is not a variant of its own: a pressed control is a raised one
60 89 /// rendered [`Sunken`](Elevation::Sunken), which is the whole reason this
61 90 /// idiom is cheap. One swap gives every interactive widget a correct pressed
62 91 /// state, with no per-widget special case.
63 - fn edges(self, theme: &Theme) -> Option<(Color, Color)> {
92 + ///
93 + /// # Sunken is `Inset`, not `Depth::Sunken`
94 + ///
95 + /// The names collide across the two crates and mean opposite things.
96 + /// [`Elevation::Sunken`] here is an *inverted bevel* — a text field, a list
97 + /// well, a scrollbar trough — which `makeover-layout` calls
98 + /// [`Bevel::Inset`](makeover_tui::makeover_layout::Bevel::Inset) and reaches
99 + /// through `Depth::Well`. Its own `Depth::Sunken` is a surface set back by
100 + /// colour alone with explicitly no edge, which is a different claim and not
101 + /// this one. Mapping by name would silently strip the bevel off every text
102 + /// field in the console and leave a flat tint.
103 + const fn kind(self) -> Option<BevelKind> {
64 104 match self {
65 105 Elevation::Flush => None,
66 - Elevation::Raised => Some((theme.bevel_light, theme.bevel_dark)),
67 - Elevation::Sunken => Some((theme.bevel_dark, theme.bevel_light)),
106 + Elevation::Raised => Some(BevelKind::Raised),
107 + Elevation::Sunken => Some(BevelKind::Inset),
68 108 }
69 109 }
70 110 }
@@ -77,57 +117,72 @@
77 117 pub struct Bevel<'a> {
78 118 theme: &'a Theme,
79 119 elevation: Elevation,
120 + fidelity: Option<Fidelity>,
80 121 }
81 122
82 123 impl<'a> Bevel<'a> {
83 124 pub fn new(theme: &'a Theme, elevation: Elevation) -> Self {
84 - Self { theme, elevation }
125 + Self {
126 + theme,
127 + elevation,
128 + fidelity: None,
129 + }
130 + }
131 +
132 + /// Draw for a terminal of a stated colour depth instead of the detected one.
133 + ///
134 + /// Detection is right for an application, which is why it is the default.
135 + /// This exists for the two cases it cannot serve: a caller that already
136 + /// quantised its palette and knows the answer, and a test, which must not
137 + /// render differently on the machine that runs it.
138 + #[must_use]
139 + pub fn fidelity(mut self, fidelity: Fidelity) -> Self {
140 + self.fidelity = Some(fidelity);
141 + self
142 + }
143 + }
144 +
145 + impl Bevel<'_> {
146 + /// The renderer's palette for an edge-only draw.
147 + ///
148 + /// Only the two bevel tones and the fidelity are read by
149 + /// [`paint_bevel`]; the surface colours are carried because [`Palette`] is
150 + /// one struct for both halves of the renderer. `well` is `None` rather than
151 + /// guessed: [`Theme`] holds `surface_sunken`, which `makeover-layout` is
152 + /// explicit is *not* a well — a well is derived by inverting against the
153 + /// theme's own content colour, a sunken surface is authored and free to sit
154 + /// darker than raised. Substituting one for the other is exactly the
155 + /// fallback `makeover-tui` was built to delete.
156 + ///
157 + /// The moment anything here calls `frame` rather than `paint_bevel`, this
158 + /// stops being adequate and `Theme` needs the real `surface-well` intent,
159 + /// which `makeover` does derive.
160 + fn palette(&self) -> Palette {
161 + Palette {
162 + page: self.theme.surface_page,
163 + raised: self.theme.surface_raised,
164 + overlay: self.theme.surface_overlay,
165 + well: None,
166 + bevel_light: self.theme.bevel_light,
167 + bevel_dark: self.theme.bevel_dark,
168 + fidelity: self.fidelity.unwrap_or_else(fidelity),
169 + }
85 170 }
86 171 }
87 172
88 173 impl Widget for Bevel<'_> {
89 174 fn render(self, area: Rect, buf: &mut Buffer) {
90 - let Some((lit, shaded)) = self.elevation.edges(self.theme) else {
175 + let Some(kind) = self.elevation.kind() else {
91 176 return;
92 177 };
93 178 // Under two cells in either direction the two edges would land in one
94 - // cell and the light source would be a guess. Draw nothing rather than
95 - // something misleading.
179 + // cell and the light source would be a guess. `paint_bevel` declines
180 + // the same case; checking here too keeps this readable as the rule it
181 + // is rather than as a fact about somebody else's function.
96 182 if area.width < 2 || area.height < 2 {
97 183 return;
98 184 }
99 -
100 - // `Replace` so an edge never merges with a neighbouring box-drawing
101 - // character. The default strategy would try to combine them into a
102 - // junction glyph, which for a half-block is a shape from a different
103 - // alphabet.
104 - let side = |borders: Borders, color: Color| {
105 - Block::new()
106 - .borders(borders)
107 - .border_type(BorderType::QuadrantOutside)
108 - .border_style(Style::default().fg(color))
109 - .merge_borders(ratatui::symbols::merge::MergeStrategy::Replace)
110 - };
111 -
112 - // Pass one draws the lit sides and, because it owns both of them, the
113 - // corner between them. Pass two does the same for the shaded sides.
114 - side(Borders::TOP | Borders::LEFT, lit).render(area, buf);
115 - side(Borders::BOTTOM | Borders::RIGHT, shaded).render(area, buf);
116 -
117 - // The remaining two corners are where light meets shadow, and no single
118 - // side owns them: each was painted by whichever pass ran last over it.
119 - // Repaint them as half-and-half, so the transition reads as a corner
120 - // rather than as one edge overrunning the other.
121 - let right = area.x + area.width - 1;
122 - let bottom = area.y + area.height - 1;
123 - buf[(right, area.y)]
124 - .set_symbol(border::QUADRANT_TOP_HALF)
125 - .set_fg(lit)
126 - .set_bg(shaded);
127 - buf[(area.x, bottom)]
128 - .set_symbol(border::QUADRANT_BOTTOM_HALF)
129 - .set_fg(shaded)
130 - .set_bg(lit);
185 + paint_bevel(buf, area, kind, &self.palette());
131 186 }
132 187 }
133 188
@@ -160,10 +215,15 @@
160 215 }
161 216 }
162 217
218 + // Fidelity is pinned rather than detected: these assert on glyphs, and at
219 + // Ansi16 the renderer correctly draws a different set. A test that passed
220 + // or failed on the `TERM` of whoever ran it would be measuring the machine.
163 221 fn render(elevation: Elevation, w: u16, h: u16) -> Buffer {
164 222 let area = Rect::new(0, 0, w, h);
165 223 let mut buf = Buffer::empty(area);
166 - Bevel::new(&theme(), elevation).render(area, &mut buf);
224 + Bevel::new(&theme(), elevation)
225 + .fidelity(Fidelity::TrueColor)
226 + .render(area, &mut buf);
167 227 buf
168 228 }
169 229