Skip to main content

max / alloy_tui

4.0.0: Take fidelity, the well, and the button's fill from makeover-tui Three duplications, one direction. ColorDepth is gone. It answered the same question as Fidelity and read the same two environment variables to do it, and the two answers have to agree or the quantisation here and the glyph fallback over there disagree about what the terminal is. Fidelity is re-exported instead, and `fidelity()` caches the detection for the two places an application needs it. Theme gains surface_well, makeover's derived inset surface. Palette assembly moves to Theme::palette, which bevel.rs had been doing by hand every frame with well: None. AlloyButton draws through makeover_tui::frame. That fixes a real bug: the pressed face was surface_sunken, and a theme is free to author sunken darker than raised while a well always inverts away from the text, so on goingson's theme a pressed button dropped further into the page instead of reading as recessed. The Ansi16 glyph fallback and the check against painting an invisible fill come with it. Breaking: ColorDepth and detect_color_depth are removed, and for_terminal takes a Fidelity. Callers want `alloy_tui::fidelity()`. Not converted: AlloyBlock and the two modals. They return or carry titles, shadows and padding, which frame has no notion of, and they draw Alloy's own border tokens rather than the bevel pair. Moving them is a design decision, not this.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 02:41 UTC
Signed with PGP, not checked
Commit: f9b6423f3e954663d4e8253f8c92c72973068c34
Parent: 9d780d5
7 files changed, +271 insertions, -162 deletions
M Cargo.toml +9 -6
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "alloy_tui"
3 - version = "3.1.0"
3 + version = "4.0.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,11 +17,14 @@
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 + # The family's terminal renderer, which owns the bevel painting, the fill, and
21 + # the reading of what the terminal can show. 0.4.0 and not 0.3.0 because the
22 + # half-block glyphs are what makes delegating here an improvement rather than a
23 + # downgrade: 0.3.0 draws box-drawing, which is a line and not a lit edge. 0.4.1
24 + # because this crate quantises a whole theme off `Fidelity::detect`, and before
25 + # that release an unrecognised TERM answered Ansi16, which cost a heavier frame
26 + # when the renderer was the only consumer and costs a flattened theme now.
27 + makeover-tui = "0.4.1"
25 28
26 29 [lints.rust]
27 30 unused = "warn"
M src/bevel.rs +5 -44
@@ -42,26 +42,13 @@
42 42 //!
43 43 //! <!-- wiki: alloy-console -->
44 44
45 - use std::sync::OnceLock;
46 -
47 45 use makeover_tui::makeover_layout::Bevel as BevelKind;
48 - use makeover_tui::{Fidelity, Palette, paint_bevel};
46 + use makeover_tui::{Fidelity, paint_bevel};
49 47 use ratatui::buffer::Buffer;
50 48 use ratatui::layout::Rect;
51 49 use ratatui::widgets::Widget;
52 50
53 - use crate::theme::Theme;
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 - }
51 + use crate::theme::{Theme, fidelity};
65 52
66 53 /// Which way a surface is lit, which is to say what it is.
67 54 ///
@@ -142,34 +129,6 @@
142 129 }
143 130 }
144 131
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 - }
170 - }
171 - }
172 -
173 132 impl Widget for Bevel<'_> {
174 133 fn render(self, area: Rect, buf: &mut Buffer) {
175 134 let Some(kind) = self.elevation.kind() else {
@@ -182,7 +141,8 @@
182 141 if area.width < 2 || area.height < 2 {
183 142 return;
184 143 }
185 - paint_bevel(buf, area, kind, &self.palette());
144 + let palette = self.theme.palette(self.fidelity.unwrap_or_else(fidelity));
145 + paint_bevel(buf, area, kind, &palette);
186 146 }
187 147 }
188 148
@@ -198,6 +158,7 @@
198 158 surface_raised: Color::Rgb(1, 1, 1),
199 159 surface_sunken: Color::Rgb(2, 2, 2),
200 160 surface_overlay: Color::Rgb(3, 3, 3),
161 + surface_well: Some(Color::Rgb(9, 9, 9)),
201 162 content_primary: Color::Rgb(4, 4, 4),
202 163 content_secondary: Color::Rgb(5, 5, 5),
203 164 content_muted: Color::Rgb(6, 6, 6),
@@ -109,6 +109,7 @@
109 109 surface_raised: Color::Rgb(1, 1, 1),
110 110 surface_sunken: Color::Rgb(2, 2, 2),
111 111 surface_overlay: Color::Rgb(3, 3, 3),
112 + surface_well: Some(Color::Rgb(9, 9, 9)),
112 113 content_primary: Color::Rgb(4, 4, 4),
113 114 content_secondary: Color::Rgb(5, 5, 5),
114 115 content_muted: Color::Rgb(6, 6, 6),
@@ -252,6 +252,7 @@
252 252 surface_raised: Color::Rgb(1, 1, 1),
253 253 surface_sunken: Color::Rgb(2, 2, 2),
254 254 surface_overlay: Color::Rgb(3, 3, 3),
255 + surface_well: Some(Color::Rgb(9, 9, 9)),
255 256 content_primary: Color::Rgb(4, 4, 4),
256 257 content_secondary: Color::Rgb(5, 5, 5),
257 258 content_muted: Color::Rgb(6, 6, 6),
M src/lib.rs +1 -4
@@ -40,8 +40,5 @@
40 40 pub use keys::{Action, classify};
41 41 pub use layout::{ConsoleAreas, PaneAreas, console, panes};
42 42 pub use selection::{MARKER, selected_style};
43 - pub use theme::{
44 - ColorDepth, Mode, Theme, ThemeError, border_strong, border_subtle, detect_color_depth,
45 - mix_linear_srgb,
46 - };
43 + pub use theme::{Fidelity, Mode, Theme, ThemeError, border_strong, border_subtle, mix_linear_srgb};
47 44 pub use widgets::*;
M src/theme.rs +175 -83
@@ -15,9 +15,21 @@
15 15 //! and pull colors from it. Apps build one `Theme` per theme load (via
16 16 //! `makeover::load_theme` + `Theme::from_theme`) and thread it through.
17 17
18 + use std::sync::OnceLock;
19 +
18 20 use makeover::{Rgb, ThemeColors};
21 + use makeover_tui::Palette;
19 22 use ratatui::style::Color;
20 23
24 + /// What the terminal can show, from the family's renderer.
25 + ///
26 + /// Re-exported rather than restated. Alloy had its own three-valued `ColorDepth`
27 + /// with its own `COLORTERM`/`TERM` reading, which is one answer too many now
28 + /// that the rendering goes through `makeover-tui`: the quantization below and
29 + /// the glyph fallback over there have to agree about what the terminal is, and
30 + /// two enums agreeing by convention is how they stop agreeing.
31 + pub use makeover_tui::Fidelity;
32 +
21 33 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 34 pub enum Mode {
23 35 Light,
@@ -49,6 +61,22 @@
49 61 pub surface_sunken: Color,
50 62 pub surface_overlay: Color,
51 63
64 + /// makeover's inset content surface: the surface inside a raised container,
65 + /// so a list reads as content in a container rather than as bands on a panel.
66 + ///
67 + /// Not [`surface_sunken`](Theme::surface_sunken), and the distinction is the
68 + /// reason this field exists rather than being aliased onto that one. A theme
69 + /// is free to author sunken *darker* than raised (goingson does) while a well
70 + /// always inverts away from the text, so substituting one for the other lands
71 + /// a well on the wrong side of its face on exactly the themes where it
72 + /// matters. `makeover-tui` deleted that substitution from the description on
73 + /// purpose; reintroducing it here would put it back a layer down.
74 + ///
75 + /// `None` where makeover derived nothing, which is a theme that authors no
76 + /// raised surface or no content color. Left missing rather than guessed, per
77 + /// the same rule: [`Palette::fill`] answers a missing well with structure.
78 + pub surface_well: Option<Color>,
79 +
52 80 pub content_primary: Color,
53 81 pub content_secondary: Color,
54 82 pub content_muted: Color,
@@ -151,6 +179,15 @@
151 179 surface_sunken: rgb(get("surface.sunken")?),
152 180 surface_overlay: rgb(get("surface.overlay")?),
153 181
182 + // Optional where the others are required, because it is derived
183 + // rather than authored: makeover emits it only when the theme gave
184 + // it both a raised surface and a content color to read the direction
185 + // off. Demanding it would reject a theme that is otherwise complete.
186 + surface_well: resolved
187 + .hex("surface-well")
188 + .and_then(Rgb::from_hex)
189 + .map(rgb),
190 +
154 191 content_primary: rgb(content_primary),
155 192 content_secondary: rgb(get("content.secondary")?),
156 193 content_muted: rgb(get("content.muted")?),
@@ -185,71 +222,39 @@
185 222 Color::Rgb(c.r, c.g, c.b)
186 223 }
187 224
188 - /// How much color the terminal being drawn to can actually show.
189 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
190 - pub enum ColorDepth {
191 - /// 24-bit. Theme colors are sent as authored.
192 - Full,
193 - /// The xterm 256-color table, addressed by index.
194 - ///
195 - /// Enough to keep a two-tone bevel: both Akari themes put the two edges and
196 - /// the face they surround on three separate entries here, where sixteen
197 - /// colors has nothing between a face and its neighbour and one edge lands
198 - /// back on the face.
199 - Ansi256,
200 - /// The sixteen ANSI colors, addressed by index.
201 - Ansi16,
225 + /// What the terminal can show, read once per process.
226 + ///
227 + /// The answer an application wants in both places it is needed: passed to
228 + /// [`Theme::for_terminal`] to quantize the theme, and to [`Theme::palette`] so
229 + /// the renderer knows what the colors it was handed were quantized *to*. Asking
230 + /// once and threading the same value through is what keeps those two consistent;
231 + /// calling [`Fidelity::detect`] twice would too, but nothing enforces that it is
232 + /// the same call, and this is cheaper besides.
233 + ///
234 + /// [`Fidelity::detect`] reads two environment variables. They cannot change under
235 + /// a running process in any way that matters, and a bevel is drawn many times a
236 + /// frame, so asking once is both cheaper and more consistent than asking per
237 + /// render.
238 + ///
239 + /// Replaces `detect_color_depth`, which answered the same question in Alloy's own
240 + /// vocabulary.
241 + #[must_use]
242 + pub fn fidelity() -> Fidelity {
243 + static DETECTED: OnceLock<Fidelity> = OnceLock::new();
244 + *DETECTED.get_or_init(Fidelity::detect)
202 245 }
203 246
204 - impl ColorDepth {
205 - /// The palette to quantize into, and what to add to an index in it to get
206 - /// the number the terminal wants.
207 - ///
208 - /// 256 resolves to makeover's fixed region rather than the whole table: the
209 - /// low sixteen are repaintable in every emulator, so a match landing there
210 - /// is a match against a color the user may have moved out from under it.
211 - fn palette(self) -> Option<(&'static [makeover::Rgb], usize)> {
212 - match self {
213 - ColorDepth::Full => None,
214 - ColorDepth::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)),
215 - ColorDepth::Ansi16 => Some((&makeover::ANSI_16, 0)),
216 - }
217 - }
218 - }
219 -
220 - /// What the environment says the terminal can show.
247 + /// The palette to quantize into, and what to add to an index in it to get the
248 + /// number the terminal wants.
221 249 ///
222 - /// `COLORTERM` is the only positive signal a terminal gives for 24-bit color,
223 - /// and `TERM=linux` is the case this exists for: the Linux virtual console,
224 - /// which is what an installer and a machine with no desktop draw on.
225 - ///
226 - /// A `TERM` ending in `-256color` and no `COLORTERM` is the terminal saying what
227 - /// it has. Taking it at its word beats the old behavior of calling it
228 - /// [`Full`](ColorDepth::Full) and sending 24-bit for it to approximate, because
229 - /// its approximation is per-color and collapses tones the theme keeps apart,
230 - /// which is the same failure that cost the console its frame on the VT.
231 - ///
232 - /// Everything else is assumed to manage 24-bit, which is the safer wrong answer:
233 - /// guessing [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity,
234 - /// and guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color
235 - /// the user paid for.
236 - pub fn detect_color_depth() -> ColorDepth {
237 - depth_from_env(
238 - &std::env::var("COLORTERM").unwrap_or_default(),
239 - &std::env::var("TERM").unwrap_or_default(),
240 - )
241 - }
242 -
243 - /// [`detect_color_depth`] with the environment passed in, so the decision can be
244 - /// tested without mutating a process-wide variable from a parallel test.
245 - fn depth_from_env(colorterm: &str, term: &str) -> ColorDepth {
246 - if colorterm == "truecolor" || colorterm == "24bit" {
247 - return ColorDepth::Full;
248 - }
249 - match term {
250 - "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16,
251 - _ if term.ends_with("-256color") => ColorDepth::Ansi256,
252 - _ => ColorDepth::Full,
250 + /// 256 resolves to makeover's fixed region rather than the whole table: the low
251 + /// sixteen are repaintable in every emulator, so a match landing there is a
252 + /// match against a color the user may have moved out from under it.
253 + fn palette_for(fidelity: Fidelity) -> Option<(&'static [makeover::Rgb], usize)> {
254 + match fidelity {
255 + Fidelity::TrueColor => None,
256 + Fidelity::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)),
257 + Fidelity::Ansi16 => Some((&makeover::ANSI_16, 0)),
253 258 }
254 259 }
255 260
@@ -291,7 +296,7 @@
291 296 impl Theme {
292 297 /// This theme as the terminal can actually draw it.
293 298 ///
294 - /// At [`ColorDepth::Full`] the theme is returned untouched. Otherwise every
299 + /// At [`Fidelity::TrueColor`] the theme is returned untouched. Otherwise every
295 300 /// color becomes a palette index, which is the point: left as 24-bit, the
296 301 /// terminal approximates them itself, and its approximation collapses tones
297 302 /// that the theme keeps apart. Alloy's console lost its frame that way,
@@ -307,13 +312,14 @@
307 312 /// are measured against the raised surface they surround rather than against
308 313 /// the page, and running them through [`indexed_against`] would push both
309 314 /// onto the same entry and invert the bevel on one side. At
310 - /// [`ColorDepth::Ansi16`] the palette cannot hold the pair at all and one
315 + /// [`Fidelity::Ansi16`] the palette cannot hold the pair at all and one
311 316 /// edge lands back on its face, which is a property of sixteen colors rather
312 - /// than something this can fix: a caller drawing there should spend the edge
313 - /// that survives on a single-tone shadow.
317 + /// than something this can fix. A caller drawing there does not have to
318 + /// handle that itself: [`Theme::palette`] carries the fidelity through to
319 + /// `makeover-tui`, which answers it with glyphs instead of tones.
314 320 #[must_use]
315 - pub fn for_terminal(self, depth: ColorDepth) -> Theme {
316 - let Some((palette, offset)) = depth.palette() else {
321 + pub fn for_terminal(self, fidelity: Fidelity) -> Theme {
322 + let Some((palette, offset)) = palette_for(fidelity) else {
317 323 return self;
318 324 };
319 325
@@ -327,6 +333,11 @@
327 333 surface_raised: plain(self.surface_raised),
328 334 surface_sunken: plain(self.surface_sunken),
329 335 surface_overlay: plain(self.surface_overlay),
336 + // Plainly, like the other surfaces and for the same reason as the
337 + // bevel pair: a well is measured against the raised face it is cut
338 + // into, not against the page, so quantizing it against the page
339 + // would push it toward contrast it is not supposed to have.
340 + surface_well: self.surface_well.map(plain),
330 341
331 342 content_primary: on_page(self.content_primary),
332 343 content_secondary: on_page(self.content_secondary),
@@ -351,6 +362,35 @@
351 362 }
352 363 }
353 364
365 + impl Theme {
366 + /// This theme as `makeover-tui`'s renderer wants it.
367 + ///
368 + /// The one place a [`Palette`] is assembled. Every widget that draws through
369 + /// the family renderer asks here rather than filling the struct itself,
370 + /// because two of the fields are decisions rather than lookups — which token
371 + /// serves as the well, and whether `fidelity` matches what the colors were
372 + /// actually quantized to — and a per-widget copy is a per-widget chance to
373 + /// answer them differently.
374 + ///
375 + /// `fidelity` has to be the same value passed to [`Theme::for_terminal`].
376 + /// The renderer takes its colors already quantized and cannot recover the
377 + /// depth from them afterwards, so it is told; telling it something else is
378 + /// how a frame ends up drawing a glyph fallback over colors that did not
379 + /// need one, or skipping the fallback over colors that did.
380 + #[must_use]
381 + pub fn palette(&self, fidelity: Fidelity) -> Palette {
382 + Palette {
383 + page: self.surface_page,
384 + raised: self.surface_raised,
385 + overlay: self.surface_overlay,
386 + well: self.surface_well,
387 + bevel_light: self.bevel_light,
388 + bevel_dark: self.bevel_dark,
389 + fidelity,
390 + }
391 + }
392 + }
393 +
354 394 /// Alloy's decorative divider: the authored border pulled toward the page.
355 395 ///
356 396 /// Public because the console is not the only thing that renders this token.
@@ -475,6 +515,10 @@
475 515 surface_raised: page,
476 516 surface_sunken: page,
477 517 surface_overlay: page,
518 + // As makeover derives it from Akari Dawn's real raised surface,
519 + // #ede7de, which this fixture flattens onto the page: the theme's
520 + // text is dark, so the well goes the other way and darkens.
521 + surface_well: Some(Color::Rgb(0xd6, 0xd0, 0xc7)),
478 522 content_primary: Color::Rgb(0x1a, 0x18, 0x16),
479 523 content_secondary: Color::Rgb(0x1a, 0x18, 0x16),
480 524 content_muted: Color::Rgb(0x7f, 0x78, 0x6d),
@@ -497,7 +541,7 @@
497 541
498 542 #[test]
499 543 fn a_capable_terminal_gets_the_theme_as_authored() {
500 - let theme = akari_dawn().for_terminal(ColorDepth::Full);
544 + let theme = akari_dawn().for_terminal(Fidelity::TrueColor);
501 545 assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d));
502 546 }
503 547
@@ -505,7 +549,7 @@
505 549 // the approximating to the terminal, which is where the collapse happened.
506 550 #[test]
507 551 fn a_sixteen_color_terminal_gets_indices() {
508 - let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
552 + let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
509 553 for color in [
510 554 theme.surface_page,
511 555 theme.content_primary,
@@ -521,7 +565,7 @@
521 565 // and the face they surround have to reach three separate entries.
522 566 #[test]
523 567 fn a_256_color_terminal_keeps_both_bevel_edges() {
524 - let theme = akari_dawn().for_terminal(ColorDepth::Ansi256);
568 + let theme = akari_dawn().for_terminal(Fidelity::Ansi256);
525 569 assert_ne!(theme.bevel_light, theme.surface_raised);
526 570 assert_ne!(theme.bevel_dark, theme.surface_raised);
527 571 assert_ne!(theme.bevel_light, theme.bevel_dark);
@@ -532,7 +576,7 @@
532 576 // instead of drawing a bevel that resolves on two sides.
533 577 #[test]
534 578 fn a_sixteen_color_terminal_loses_one_bevel_edge() {
535 - let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
579 + let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
536 580 let light_survives = theme.bevel_light != theme.surface_raised;
537 581 let dark_survives = theme.bevel_dark != theme.surface_raised;
538 582 assert!(
@@ -546,7 +590,7 @@
546 590 // Forgetting the offset would silently address the repaintable low sixteen.
547 591 #[test]
548 592 fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
549 - let theme = akari_dawn().for_terminal(ColorDepth::Ansi256);
593 + let theme = akari_dawn().for_terminal(Fidelity::Ansi256);
550 594 for color in [
551 595 theme.surface_page,
552 596 theme.content_primary,
@@ -561,23 +605,71 @@
561 605 }
562 606 }
563 607
608 + // Detection itself is `makeover-tui`'s and tested there. What is still this
609 + // crate's problem is that the answer it gives is the one this quantization
610 + // was built for, so the two cases with a bug behind them are pinned here as
611 + // well: the VT, whose approximation cost the console its frame, and a
612 + // terminal that named itself nothing in particular, which must not be
613 + // flattened to sixteen colors on no evidence.
564 614 #[test]
565 - fn a_256_color_term_is_detected_from_its_name() {
566 - let depth = depth_from_env;
567 - assert_eq!(depth("", "xterm-256color"), ColorDepth::Ansi256);
568 - assert_eq!(depth("", "screen-256color"), ColorDepth::Ansi256);
569 - // A terminal claiming 24-bit is believed over its name.
570 - assert_eq!(depth("truecolor", "xterm-256color"), ColorDepth::Full);
571 - // The VT is still the VT.
572 - assert_eq!(depth("", "linux"), ColorDepth::Ansi16);
573 - assert_eq!(depth("", "foot"), ColorDepth::Full);
615 + fn the_fidelity_this_quantizes_for_is_the_one_the_family_detects() {
616 + assert_eq!(Fidelity::from_env("", "linux"), Fidelity::Ansi16);
617 + assert_eq!(Fidelity::from_env("", "foot"), Fidelity::TrueColor);
618 + assert_eq!(Fidelity::from_env("", "xterm-256color"), Fidelity::Ansi256);
619 + }
620 +
621 + // Every intent the renderer asks for has to be carried across, or a widget
622 + // drawing through `frame` gets a hole where a surface should be. `well` is
623 + // the one that can legitimately be absent, and it is absent as `None` rather
624 + // than as some other surface standing in for it.
625 + #[test]
626 + fn the_renderer_palette_carries_the_theme_across_unsubstituted() {
627 + let theme = akari_dawn();
628 + let p = theme.palette(Fidelity::Ansi256);
629 + assert_eq!(p.page, theme.surface_page);
630 + assert_eq!(p.raised, theme.surface_raised);
631 + assert_eq!(p.overlay, theme.surface_overlay);
632 + assert_eq!(p.bevel_light, theme.bevel_light);
633 + assert_eq!(p.bevel_dark, theme.bevel_dark);
634 + assert_eq!(p.fidelity, Fidelity::Ansi256);
635 + assert_eq!(p.well, theme.surface_well);
636 +
637 + let no_well = Theme {
638 + surface_well: None,
639 + ..theme
640 + };
641 + assert_eq!(no_well.palette(Fidelity::TrueColor).well, None);
642 + assert_ne!(
643 + no_well.palette(Fidelity::TrueColor).well,
644 + Some(no_well.surface_sunken)
645 + );
646 + }
647 +
648 + // A well is measured against the face it is cut into, so it quantizes
649 + // plainly like the other surfaces. Run through `indexed_against` it would be
650 + // pushed toward contrast with the page, which is the one thing a well is not
651 + // supposed to have.
652 + #[test]
653 + fn a_well_survives_quantization_as_a_surface() {
654 + let theme = Theme {
655 + surface_raised: Color::Rgb(0xed, 0xe7, 0xde),
656 + surface_well: Some(Color::Rgb(0xd6, 0xd0, 0xc7)),
657 + ..akari_dawn()
658 + };
659 + let quantized = theme.for_terminal(Fidelity::Ansi256);
660 + let well = quantized.surface_well.expect("a well went missing");
661 + assert!(matches!(well, Color::Indexed(_)), "{well:?}");
662 + assert_ne!(
663 + well, quantized.surface_raised,
664 + "the well collapsed onto its face"
665 + );
574 666 }
575 667
576 668 // The bug, as a test: the installer's frame drew in border_strong on
577 669 // surface_page and could not be seen.
578 670 #[test]
579 671 fn the_frame_stays_visible_against_the_page() {
580 - let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
672 + let theme = akari_dawn().for_terminal(Fidelity::Ansi16);
581 673 assert_ne!(theme.border_strong, theme.surface_page);
582 674 assert_ne!(theme.border_subtle, theme.surface_page);
583 675 assert_ne!(theme.content_primary, theme.surface_page);
M src/widgets.rs +79 -25
@@ -14,11 +14,15 @@
14 14 use ratatui::text::{Line, Span};
15 15 use ratatui::widgets::{Block, Borders, Paragraph, Shadow, Widget};
16 16
17 + use makeover_tui::frame;
18 + use makeover_tui::makeover_layout::Depth;
19 +
17 20 use crate::bevel::{Bevel, Elevation};
18 21 use crate::input::TextField;
19 22 use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style};
20 23 use crate::text;
21 24 use crate::theme::Theme;
25 + use crate::theme::fidelity;
22 26
23 27 /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block`
24 28 /// so downstream code composes with it directly (`AlloyBlock::new(theme).build()`
@@ -432,34 +436,57 @@
432 436 return;
433 437 }
434 438
435 - let elevation = if self.pressed {
436 - Elevation::Sunken
437 - } else {
438 - Elevation::Raised
439 - };
440 -
441 439 // A pressed control sits on the recessed surface, so the fill moves with
442 440 // the light rather than staying put under an inverted bevel.
443 - let face = if self.pressed {
444 - self.theme.surface_sunken
441 + let depth = if self.pressed {
442 + Depth::Well
445 443 } else {
446 - self.theme.surface_raised
447 - };
448 - let (bg, fg) = if self.primary {
449 - (self.theme.content_primary, self.theme.surface_raised)
450 - } else {
451 - (face, self.theme.content_primary)
452 - };
453 - let fg = if self.disabled {
454 - self.theme.content_muted
455 - } else {
456 - fg
444 + Depth::Raised
457 445 };
458 446
459 - Paragraph::new("")
460 - .style(Style::default().bg(bg))
461 - .render(area, buf);
462 - Bevel::new(self.theme, elevation).render(area, buf);
447 + if self.primary {
448 + // The one fill the description has no name for. Per
449 + // docs/DESIGN-LANGUAGE.md a primary button is the pane's single
450 + // inverted surface, which is a weight decision rather than a depth
451 + // one: `Fill` names surfaces, and this is the text color used as
452 + // one. So the fill is painted here and only the edge is delegated —
453 + // the bevel is unchanged, because a primary button is lit like every
454 + // other and only filled differently.
455 + Paragraph::new("")
456 + .style(Style::default().bg(self.theme.content_primary))
457 + .render(area, buf);
458 + let elevation = if self.pressed {
459 + Elevation::Sunken
460 + } else {
461 + Elevation::Raised
462 + };
463 + Bevel::new(self.theme, elevation).render(area, buf);
464 + } else {
465 + // Fill and edge in one call, which is what makes the pressed state
466 + // correct rather than approximately correct. The face used to be
467 + // `surface_sunken`, and that is the substitution `makeover-tui` was
468 + // built to delete: a theme may author sunken darker than raised
469 + // (goingson does) while a well always inverts away from the text, so
470 + // on those themes a pressed button dropped further into the page
471 + // instead of reading as recessed. `Depth::Well` resolves the derived
472 + // `surface-well` intent, and where a theme has none it draws no fill
473 + // and lets the inverted edge carry the state alone.
474 + //
475 + // The Ansi16 glyph fallback comes with it. At sixteen colors the
476 + // fill collapses onto the face on most themes and one bevel edge
477 + // vanishes on all of them, so raised and pressed would otherwise be
478 + // the same box; the renderer answers that with a doubled frame for
479 + // raised and a light one for the well.
480 + frame(buf, area, depth, &self.theme.palette(fidelity()));
481 + }
482 +
483 + let fg = if self.disabled {
484 + self.theme.content_muted
485 + } else if self.primary {
486 + self.theme.surface_raised
487 + } else {
488 + self.theme.content_primary
489 + };
463 490
464 491 // The label goes on the middle row, inside the edge. On a button too
465 492 // short to have an inside, it takes the whole area and the bevel is
@@ -474,9 +501,15 @@
474 501 } else {
475 502 Rect { height: 1, ..area }
476 503 };
504 + // No background on the label. Whatever the face turned out to be is
505 + // already in the buffer, and that includes the case where the renderer
506 + // declined to paint a fill because it would not have shown against what
507 + // was behind it. Restating the color here would paint it anyway and undo
508 + // exactly the check that makes a well legible on a theme that cannot
509 + // color one.
477 510 Paragraph::new(Line::from(Span::styled(
478 511 self.label,
479 - Style::default().bg(bg).fg(fg),
512 + Style::default().fg(fg),
480 513 )))
481 514 .centered()
482 515 .render(label_area, buf);
@@ -1381,6 +1414,7 @@
1381 1414 surface_raised: Color::Rgb(1, 1, 1),
1382 1415 surface_sunken: Color::Rgb(2, 2, 2),
1383 1416 surface_overlay: Color::Rgb(3, 3, 3),
1417 + surface_well: Some(Color::Rgb(9, 9, 9)),
1384 1418 content_primary: Color::Rgb(4, 4, 4),
1385 1419 content_secondary: Color::Rgb(5, 5, 5),
1386 1420 content_muted: Color::Rgb(6, 6, 6),
@@ -2002,7 +2036,27 @@
2002 2036 assert_eq!(up[(0u16, 0u16)].fg, theme.bevel_light);
2003 2037 assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
2004 2038 assert_eq!(up[(3u16, 1u16)].bg, theme.surface_raised);
2005 - assert_eq!(down[(3u16, 1u16)].bg, theme.surface_sunken);
2039 + // The well, not `surface_sunken`. A theme may author sunken darker than
2040 + // raised while a well always inverts away from the text, so the two are
2041 + // only interchangeable on themes where the substitution happens not to
2042 + // bite. Pinned as the well so it stays that way.
2043 + assert_eq!(down[(3u16, 1u16)].bg, theme.surface_well.unwrap());
2044 + assert_ne!(down[(3u16, 1u16)].bg, theme.surface_sunken);
2045 + }
2046 +
2047 + // A theme that gave makeover nothing to derive a well from still has to
2048 + // produce a legible pressed state. No fill is painted and the inverted edge
2049 + // carries it alone, which is the whole reason the renderer declines rather
2050 + // than substituting some other surface.
2051 + #[test]
2052 + fn a_button_pressed_on_a_theme_with_no_well_keeps_its_inverted_edge() {
2053 + let theme = Theme {
2054 + surface_well: None,
2055 + ..theme()
2056 + };
2057 + let (down, area) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
2058 + assert_eq!(rows(&down, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
2059 + assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
2006 2060 }
2007 2061
2008 2062 // Dimmed and still there, so the layout keeps teaching itself.