Skip to main content

max / alloy_tui

Draw in palette indices on terminals that only have sixteen colors The Linux console has sixteen colors and approximates any 24-bit color it is sent. Its approximation is not the theme's: a page and the border drawn on it can land on one entry, and the border stops existing. Alloy's console frame was invisible for exactly this reason, on every view, and would have been on fw12's tty after install as well as on the installer. So resolve the theme to what the terminal can draw instead of leaving the terminal to guess. detect_color_depth reads the environment; for_terminal quantizes to makeover's ANSI_16, and quantizes anything that must be seen against the page against the page, so a border stays a border. Additive: from_theme is unchanged, and a capable terminal gets the theme as authored.
Author: Max Johnson <me@maxj.phd> · 2026-07-21 21:55 UTC
Commit: 4b077f94a222d9f2f186f97ca991fcd793c67f83
Parent: bc2720c
3 files changed, +161 insertions, -3 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "alloy_tui"
3 - version = "1.0.0"
3 + version = "1.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 rust-version = "1.86"
@@ -10,4 +10,4 @@ authors = ["Max Johnson <me@maxj.phd>"]
10 10
11 11 [dependencies]
12 12 ratatui = "0.30"
13 - makeover = "1.0.0"
13 + makeover = "1.1.0"
M src/lib.rs +1 -1
@@ -32,5 +32,5 @@ pub use focus::FocusRing;
32 32 pub use keys::{Action, classify};
33 33 pub use layout::{ConsoleAreas, PaneAreas, console, panes};
34 34 pub use selection::{MARKER, selected_style};
35 - pub use theme::{Mode, Theme, ThemeError};
35 + pub use theme::{ColorDepth, Mode, Theme, ThemeError, detect_color_depth};
36 36 pub use widgets::*;
M src/theme.rs +158
@@ -136,6 +136,107 @@ fn rgb(c: Rgb) -> Color {
136 136 Color::Rgb(c.r, c.g, c.b)
137 137 }
138 138
139 + /// How much color the terminal being drawn to can actually show.
140 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
141 + pub enum ColorDepth {
142 + /// 24-bit. Theme colors are sent as authored.
143 + Full,
144 + /// The sixteen ANSI colors, addressed by index.
145 + Ansi16,
146 + }
147 +
148 + /// What the environment says the terminal can show.
149 + ///
150 + /// `COLORTERM` is the only positive signal a terminal gives for 24-bit color,
151 + /// and `TERM=linux` is the case this exists for: the Linux virtual console,
152 + /// which is what an installer and a machine with no desktop draw on. Anything
153 + /// else is assumed to manage 24-bit, which is the safer wrong answer. Guessing
154 + /// [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity;
155 + /// guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color
156 + /// the user paid for.
157 + pub fn detect_color_depth() -> ColorDepth {
158 + let colorterm = std::env::var("COLORTERM").unwrap_or_default();
159 + if colorterm == "truecolor" || colorterm == "24bit" {
160 + return ColorDepth::Full;
161 + }
162 + match std::env::var("TERM").unwrap_or_default().as_str() {
163 + "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16,
164 + _ => ColorDepth::Full,
165 + }
166 + }
167 +
168 + /// The palette entry for `c`, as an index the terminal will not reinterpret.
169 + fn indexed(c: Color) -> Color {
170 + match c {
171 + Color::Rgb(r, g, b) => {
172 + Color::Indexed(makeover::quantize(Rgb { r, g, b }, &makeover::ANSI_16) as u8)
173 + }
174 + other => other,
175 + }
176 + }
177 +
178 + /// As [`indexed`], but guaranteed to stay legible against `on`.
179 + fn indexed_against(c: Color, on: Color) -> Color {
180 + match (c, on) {
181 + (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(makeover::quantize_against(
182 + Rgb { r, g, b },
183 + Rgb { r: br, g: bg, b: bb },
184 + &makeover::ANSI_16,
185 + ) as u8),
186 + _ => indexed(c),
187 + }
188 + }
189 +
190 + impl Theme {
191 + /// This theme as the terminal can actually draw it.
192 + ///
193 + /// At [`ColorDepth::Full`] the theme is returned untouched. At
194 + /// [`ColorDepth::Ansi16`] every color becomes a palette index, which is the
195 + /// point: left as 24-bit, the terminal approximates them itself, and its
196 + /// approximation collapses tones that the theme keeps apart. Alloy's
197 + /// console lost its frame that way, drawing a border in a color the Linux
198 + /// console could not distinguish from the page behind it.
199 + ///
200 + /// Anything that has to be seen against the page is quantized against it
201 + /// rather than on its own, so a border stays a border and text stays
202 + /// readable. The surfaces themselves are quantized plainly: they are what
203 + /// the others are measured against.
204 + pub fn for_terminal(self, depth: ColorDepth) -> Theme {
205 + if depth == ColorDepth::Full {
206 + return self;
207 + }
208 +
209 + let page = indexed(self.surface_page);
210 + let on_page = |c: Color| indexed_against(c, self.surface_page);
211 +
212 + Theme {
213 + mode: self.mode,
214 +
215 + surface_page: page,
216 + surface_raised: indexed(self.surface_raised),
217 + surface_sunken: indexed(self.surface_sunken),
218 + surface_overlay: indexed(self.surface_overlay),
219 +
220 + content_primary: on_page(self.content_primary),
221 + content_secondary: on_page(self.content_secondary),
222 + content_muted: on_page(self.content_muted),
223 +
224 + action_primary: on_page(self.action_primary),
225 +
226 + status_danger: on_page(self.status_danger),
227 + status_success: on_page(self.status_success),
228 + status_warning: on_page(self.status_warning),
229 + status_info: on_page(self.status_info),
230 +
231 + line_border: on_page(self.line_border),
232 + border_subtle: on_page(self.border_subtle),
233 + border_strong: on_page(self.border_strong),
234 +
235 + category: self.category.map(on_page),
236 + }
237 + }
238 + }
239 +
139 240 // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
140 241 // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
141 242 // less uniform than OKLab but keeps the derived hex reproducible against the
@@ -193,4 +294,61 @@ mod tests {
193 294 got.r, got.g, got.b
194 295 );
195 296 }
297 +
298 + // Akari Dawn as far as this matters: the page, the text on it, and the
299 + // strong border derived above.
300 + fn akari_dawn() -> Theme {
301 + let page = Color::Rgb(0xe4, 0xde, 0xd6);
302 + Theme {
303 + mode: Mode::Light,
304 + surface_page: page,
305 + surface_raised: page,
306 + surface_sunken: page,
307 + surface_overlay: page,
308 + content_primary: Color::Rgb(0x1a, 0x18, 0x16),
309 + content_secondary: Color::Rgb(0x1a, 0x18, 0x16),
310 + content_muted: Color::Rgb(0x7f, 0x78, 0x6d),
311 + action_primary: Color::Rgb(0x8a, 0x45, 0x30),
312 + status_danger: Color::Rgb(0x8a, 0x45, 0x30),
313 + status_success: Color::Rgb(0x8a, 0x45, 0x30),
314 + status_warning: Color::Rgb(0x8a, 0x45, 0x30),
315 + status_info: Color::Rgb(0x8a, 0x45, 0x30),
316 + line_border: Color::Rgb(0xca, 0xbe, 0xae),
317 + border_subtle: Color::Rgb(0xda, 0xd2, 0xc7),
318 + border_strong: Color::Rgb(0x7f, 0x78, 0x6d),
319 + category: [Color::Rgb(0x8a, 0x45, 0x30); 6],
320 + }
321 + }
322 +
323 + #[test]
324 + fn a_capable_terminal_gets_the_theme_as_authored() {
325 + let theme = akari_dawn().for_terminal(ColorDepth::Full);
326 + assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d));
327 + }
328 +
329 + // Indices, not RGB. Sending RGB to a terminal that cannot show it leaves
330 + // the approximating to the terminal, which is where the collapse happened.
331 + #[test]
332 + fn a_sixteen_color_terminal_gets_indices() {
333 + let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
334 + for color in [
335 + theme.surface_page,
336 + theme.content_primary,
337 + theme.border_strong,
338 + theme.border_subtle,
339 + theme.line_border,
340 + ] {
341 + assert!(matches!(color, Color::Indexed(_)), "{color:?}");
342 + }
343 + }
344 +
345 + // The bug, as a test: the installer's frame drew in border_strong on
346 + // surface_page and could not be seen.
347 + #[test]
348 + fn the_frame_stays_visible_against_the_page() {
349 + let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
350 + assert_ne!(theme.border_strong, theme.surface_page);
351 + assert_ne!(theme.border_subtle, theme.surface_page);
352 + assert_ne!(theme.content_primary, theme.surface_page);
353 + }
196 354 }