//! Alloy's theme: the family's resolved intents plus the two border tokens //! Alloy derives for itself. //! //! The intent-to-`Color` mapping is [`makeover_tui::Theme`], and never a second //! copy here: two mappings agreeing about which intent a surface reads from is a //! convention rather than a fact. Quantisation lives there too, and //! [`makeover_tui::Theme`] is `#[non_exhaustive]`, so a copy out here could not //! rebuild a quantised version of it anyway. //! //! # The two tokens that stayed, and why they are not duplication //! //! Per the Alloy repo's docs/TOKENS.md, Alloy derives two tokens locally so //! theme files stay minimal and cross-app compatible: //! //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection //! //! Mix is in linear sRGB, matching TOKENS.md's worked audit math. //! //! makeover emits a `border-strong` too, and [`makeover_tui::Theme`] carries it: //! a flat 5% darkening of the authored border, a slightly firmer divider. Alloy's //! is pulled most of the way to the text colour because DESIGN-LANGUAGE.md makes //! it Alloy's entire focus ring and TOKENS.md holds it to WCAG AA-UI against the //! page. //! On Akari Dawn the two land at 3.27:1 and 1.63:1. They are different tokens //! wearing one name, and adopting the shared one would take focus to half the //! required floor. //! //! That is exactly why the family intents sit behind a named field rather than //! being flattened in or reached through `Deref`: at every call site, //! `theme.border_strong` is Alloy's focus ring and `theme.makeover.border_strong` //! is the divider, and neither can be mistaken for the other. //! //! ratatui is immediate-mode with per-widget styling — there is no global visuals //! object. Widgets take a `&Theme` at construction and pull colours from it. Apps //! build one per theme load (`makeover::load_theme` + [`Theme::from_theme`]) and //! thread it through. use std::sync::OnceLock; use makeover::{Rgb, ThemeColors}; use makeover_tui::{Palette, Quantize}; use ratatui::style::Color; /// What the terminal can show, from the family's renderer. /// /// Re-exported rather than restated. Alloy had its own three-valued `ColorDepth` /// with its own `COLORTERM`/`TERM` reading, which is one answer too many now /// that the rendering goes through `makeover-tui`: the quantization there and /// the glyph fallback there have to agree about what the terminal is, and two /// enums agreeing by convention is how they stop agreeing. pub use makeover_tui::Fidelity; /// A theme's polarity, and why a theme could not be resolved. Both the family's. pub use makeover_tui::{Mode, ThemeError}; /// A theme's intents as Alloy renders them. /// /// `#[non_exhaustive]` because Alloy can grow a derived token of its own without /// that being a major, the same way [`makeover_tui::Theme`] can grow an intent. #[derive(Debug, Clone, Copy)] #[non_exhaustive] pub struct Theme { /// The family's intents: surfaces, content, status, the bevel pair, and /// makeover's own `border-strong`. /// /// Named rather than flattened. See the module header: reading /// `theme.makeover.border_strong` where you meant Alloy's focus ring is a /// mistake worth being able to see. pub makeover: makeover_tui::Theme, /// Alloy's decorative divider. Not a focus ring. pub border_subtle: Color, /// Alloy's focus and selection border, held to WCAG AA-UI against the page. /// /// Not [`makeover_tui::Theme::border_strong`], which is a divider. pub border_strong: Color, } impl Theme { /// Resolve a loaded makeover `ThemeColors` into Alloy's theme. /// /// The family half is [`makeover_tui::Theme::from_theme`], which rejects a /// partial theme by naming the key it wanted. The two tokens below are then /// derived from intents that call has already proven present. pub fn from_theme(theme: &ThemeColors) -> Result { let makeover_theme = makeover_tui::Theme::from_theme(theme)?; // Derived in Rgb rather than off the resolved `Color`s: TOKENS.md's // audit math is in linear sRGB over 8-bit channels, and going through // ratatui's Color and back would be a round trip for nothing. Each key // is required, and `from_theme` above already failed if it were absent. let get = |key: &'static str| -> Result { let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?; Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { key, value: hex.clone(), }) }; let line = get("line.border")?; Ok(Self { makeover: makeover_theme, border_subtle: rgb(border_subtle(line, get("surface.page")?)), border_strong: rgb(border_strong(line, get("content.primary")?)), }) } /// This theme as the terminal can actually draw it. /// /// The family half is [`makeover_tui::Theme::for_terminal`], which owns the /// rules: quantised against the page where a colour must stay legible /// against it, plainly where it is measured against the surface it sits on. /// Alloy's two tokens are borders seen against the page, so they take the /// against-the-page path, through the same [`Quantize`] rather than a second /// implementation of it. /// /// Quantised against the page *as authored*, not as quantised. The family /// half does the same, and matching matters: measuring against an already /// indexed page would answer a different question than the one the rule asks. #[must_use] pub fn for_terminal(self, fidelity: Fidelity) -> Self { let Some(q) = Quantize::for_fidelity(fidelity) else { return self; }; let page = self.makeover.surface_page; Self { makeover: self.makeover.for_terminal(fidelity), border_subtle: q.against(self.border_subtle, page), border_strong: q.against(self.border_strong, page), } } /// This theme as `makeover-tui`'s renderer wants it. /// /// Delegated whole: neither of Alloy's tokens is a surface or a bevel edge, /// so the renderer's palette is entirely the family's. /// /// `fidelity` has to be the same value passed to [`Theme::for_terminal`]. /// The renderer takes its colours already quantised and cannot recover the /// depth from them afterwards, so it is told; telling it something else is /// how a frame ends up drawing a glyph fallback over colours that did not /// need one, or skipping it over colours that did. #[must_use] pub fn palette(&self, fidelity: Fidelity) -> Palette { self.makeover.palette(fidelity) } } fn rgb(c: Rgb) -> Color { Color::Rgb(c.r, c.g, c.b) } /// What the terminal can show, read once per process. /// /// The answer an application wants in both places it is needed: passed to /// [`Theme::for_terminal`] to quantize the theme, and to [`Theme::palette`] so /// the renderer knows what the colors it was handed were quantized *to*. Asking /// once and threading the same value through is what keeps those two consistent; /// calling [`Fidelity::detect`] twice would too, but nothing enforces that it is /// the same call, and this is cheaper besides. /// /// [`Fidelity::detect`] reads two environment variables. They cannot change under /// a running process in any way that matters, and a bevel is drawn many times a /// frame, so asking once is both cheaper and more consistent than asking per /// render. #[must_use] pub fn fidelity() -> Fidelity { static DETECTED: OnceLock = OnceLock::new(); *DETECTED.get_or_init(Fidelity::detect) } /// A theme with distinct, easily-named colours, for tests that assert on which /// token reached which cell. /// /// Built by loading a real bundled theme and overwriting every field, because /// [`makeover_tui::Theme`] is `#[non_exhaustive]`: a crate outside it cannot /// write the literal, though it may mutate the fields of one it owns. Was three /// identical literals in `bevel`, `help` and `connector`, differing only in /// `mode`. #[cfg(test)] pub(crate) fn test_theme(mode: Mode) -> Theme { let dir = makeover::bundled_themes_dir().expect("makeover ships a themes dir"); let colors = makeover::load_theme(&[(dir, false)], "goingson").expect("bundled theme loads"); let mut m = makeover_tui::Theme::from_theme(&colors).expect("bundled theme resolves"); m.mode = mode; m.surface_page = Color::Rgb(0, 0, 0); m.surface_raised = Color::Rgb(1, 1, 1); m.surface_sunken = Color::Rgb(2, 2, 2); m.surface_overlay = Color::Rgb(3, 3, 3); m.surface_well = Some(Color::Rgb(9, 9, 9)); m.content_primary = Color::Rgb(4, 4, 4); m.content_secondary = Color::Rgb(5, 5, 5); m.content_muted = Color::Rgb(6, 6, 6); m.action_primary = Color::Rgb(7, 7, 7); m.status_danger = Color::Rgb(8, 8, 8); m.status_success = Color::Rgb(9, 9, 9); m.status_warning = Color::Rgb(10, 10, 10); m.status_info = Color::Rgb(11, 11, 11); m.line_border = Color::Rgb(12, 12, 12); m.bevel_light = Color::Rgb(16, 16, 16); m.bevel_dark = Color::Rgb(17, 17, 17); m.category = [Color::Rgb(15, 15, 15); 6]; Theme { makeover: m, border_subtle: Color::Rgb(13, 13, 13), border_strong: Color::Rgb(14, 14, 14), } } /// Alloy's decorative divider: the authored border pulled toward the page. /// /// Public because the console is not the only thing that renders this token. /// The image's desktop skeleton — GTK, sway, yazi and the rest — is generated /// from the same theme file, and a second implementation of this line is a /// second answer to what `border-subtle` is. There is no built-in palette to /// fall back on (docs/TOKENS.md: no hex in Rust), so the generator asks here. pub fn border_subtle(line_border: Rgb, surface_page: Rgb) -> Rgb { mix_linear_srgb(line_border, surface_page, 0.60) } /// Alloy's focus and selection border: the authored border pulled toward text. /// /// Held to WCAG AA-UI against the page by TOKENS.md, which is why it is not /// makeover's `border-strong` — see the note in [`Theme::from_theme`]. Public /// for the same reason as [`border_subtle`]. pub fn border_strong(line_border: Rgb, content_primary: Rgb) -> Rgb { mix_linear_srgb(line_border, content_primary, 0.65) } /// Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are /// gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually /// less uniform than OKLab but keeps the derived hex reproducible against the /// contrast tables in TOKENS.md. /// /// Exposed alongside the two derivations above so a caller composing its own /// tone reaches for the same mix the tokens use rather than OKLab's, which /// would answer differently. pub fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb { let al = srgb_to_linear(a); let bl = srgb_to_linear(b); let m = ( al.0 + (bl.0 - al.0) * t, al.1 + (bl.1 - al.1) * t, al.2 + (bl.2 - al.2) * t, ); linear_to_srgb(m) } fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) { ( channel_to_linear(c.r), channel_to_linear(c.g), channel_to_linear(c.b), ) } fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb { Rgb { r: channel_to_srgb(c.0), g: channel_to_srgb(c.1), b: channel_to_srgb(c.2), } } fn channel_to_linear(c: u8) -> f32 { let c = c as f32 / 255.0; if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } fn channel_to_srgb(c: f32) -> u8 { let v = if c <= 0.003_130_8 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; (v * 255.0).round().clamp(0.0, 255.0) as u8 } #[cfg(test)] mod tests { use super::*; // TOKENS.md line 61 anchors the derivation math against Akari Dawn: // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary // must produce #7f786d (the value the contrast-audit table is calibrated on). // If this test fails, the audit table in TOKENS.md is stale, not the code. #[test] fn akari_dawn_border_strong_matches_tokens_md() { let border = Rgb::from_hex("#cabeae").unwrap(); let primary = Rgb::from_hex("#1a1816").unwrap(); let got = border_strong(border, primary); assert_eq!( (got.r, got.g, got.b), (0x7f, 0x78, 0x6d), "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d", got.r, got.g, got.b ); } // The other half of the pair, pinned for the same reason: the image's // desktop skeleton is generated against these two functions, so a drift // here silently repaints every GTK app, sway border and yazi pane. #[test] fn akari_dawn_border_subtle_matches_the_shipped_skeleton() { let border = Rgb::from_hex("#cabeae").unwrap(); let page = Rgb::from_hex("#e4ded6").unwrap(); let got = border_subtle(border, page); assert_eq!( (got.r, got.g, got.b), (0xda, 0xd2, 0xc7), "border-subtle derivation drifted; got #{:02x}{:02x}{:02x}, expected #dad2c7", got.r, got.g, got.b ); } // Akari Dawn as far as this matters: the page, the text on it, and the // strong border derived above. fn akari_dawn() -> Theme { let page = Color::Rgb(0xe4, 0xde, 0xd6); let mut m = test_theme(Mode::Light).makeover; m.surface_page = page; m.surface_raised = page; m.surface_sunken = page; m.surface_overlay = page; // As makeover derives it from Akari Dawn's real raised surface, // #ede7de, which this fixture flattens onto the page: the theme's // text is dark, so the well goes the other way and darkens. m.surface_well = Some(Color::Rgb(0xd6, 0xd0, 0xc7)); m.content_primary = Color::Rgb(0x1a, 0x18, 0x16); m.content_secondary = Color::Rgb(0x1a, 0x18, 0x16); m.content_muted = Color::Rgb(0x7f, 0x78, 0x6d); m.action_primary = Color::Rgb(0x8a, 0x45, 0x30); m.status_danger = Color::Rgb(0x8a, 0x45, 0x30); m.status_success = Color::Rgb(0x8a, 0x45, 0x30); m.status_warning = Color::Rgb(0x8a, 0x45, 0x30); m.status_info = Color::Rgb(0x8a, 0x45, 0x30); m.line_border = Color::Rgb(0xca, 0xbe, 0xae); // As makeover derives them from Akari Dawn's real raised surface, // #ede7de, which is a step above the page this fixture flattens // every surface onto. m.bevel_light = Color::Rgb(0xff, 0xfe, 0xf5); m.bevel_dark = Color::Rgb(0xb3, 0xad, 0xa5); m.category = [Color::Rgb(0x8a, 0x45, 0x30); 6]; Theme { makeover: m, border_subtle: Color::Rgb(0xda, 0xd2, 0xc7), border_strong: Color::Rgb(0x7f, 0x78, 0x6d), } } // The reason Alloy keeps deriving its own. makeover's `border-strong` is a // 5% darkening of the authored border, a firmer divider; Alloy's is pulled // most of the way to the text because TOKENS.md holds the focus ring to // WCAG AA-UI against the page. If these ever coincide, one of the two // derivations moved and a focus ring is about to be drawn as a divider. #[test] fn alloys_focus_ring_is_not_makeovers_divider() { let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); let metas = makeover::list_themes_from_dirs(&[(dir.clone(), false)]); let mut checked = 0; for meta in &metas { let colors = makeover::load_theme(&[(dir.clone(), false)], &meta.id).expect("theme loads"); let theme = Theme::from_theme(&colors).expect("theme resolves"); assert_ne!( theme.border_strong, theme.makeover.border_strong, "on `{}` Alloy's focus ring and makeover's divider are the same colour", meta.id ); checked += 1; } assert!(checked > 0, "no themes were checked"); } #[test] fn a_capable_terminal_gets_the_theme_as_authored() { let theme = akari_dawn().for_terminal(Fidelity::TrueColor); assert_eq!(theme.border_strong, Color::Rgb(0x7f, 0x78, 0x6d)); } // Indices, not RGB. Sending RGB to a terminal that cannot show it leaves // the approximating to the terminal, which is where the collapse happened. #[test] fn a_sixteen_color_terminal_gets_indices() { let theme = akari_dawn().for_terminal(Fidelity::Ansi16); for color in [ theme.makeover.surface_page, theme.makeover.content_primary, theme.border_strong, theme.border_subtle, theme.makeover.line_border, ] { assert!(matches!(color, Color::Indexed(_)), "{color:?}"); } } // 256 colors is the shallowest depth that can hold a bevel: the two edges // and the face they surround have to reach three separate entries. #[test] fn a_256_color_terminal_keeps_both_bevel_edges() { let theme = akari_dawn().for_terminal(Fidelity::Ansi256); assert_ne!(theme.makeover.bevel_light, theme.makeover.surface_raised); assert_ne!(theme.makeover.bevel_dark, theme.makeover.surface_raised); assert_ne!(theme.makeover.bevel_light, theme.makeover.bevel_dark); } // And sixteen cannot. Asserted rather than left implicit so that a caller // reading this knows to spend the surviving edge on a single-tone shadow // instead of drawing a bevel that resolves on two sides. #[test] fn a_sixteen_color_terminal_loses_one_bevel_edge() { let theme = akari_dawn().for_terminal(Fidelity::Ansi16); let light_survives = theme.makeover.bevel_light != theme.makeover.surface_raised; let dark_survives = theme.makeover.bevel_dark != theme.makeover.surface_raised; assert!( light_survives != dark_survives, "expected exactly one edge to survive, light {light_survives} dark {dark_survives}" ); } // The indices handed to a 256-color terminal have to be the ones it paints, // and quantizing against the fixed region returns an index into that region. // Forgetting the offset would silently address the repaintable low sixteen. #[test] fn the_256_indices_land_outside_the_repaintable_low_sixteen() { let theme = akari_dawn().for_terminal(Fidelity::Ansi256); for color in [ theme.makeover.surface_page, theme.makeover.content_primary, theme.border_strong, theme.makeover.bevel_light, theme.makeover.bevel_dark, ] { let Color::Indexed(i) = color else { panic!("{color:?} is not an index") }; assert!(i >= 16, "index {i} is in the repaintable range"); } } // Detection itself is `makeover-tui`'s and tested there. What is still this // crate's problem is that the answer it gives is the one this quantization // was built for, so the two cases with a bug behind them are pinned here as // well: the VT, whose approximation cost the console its frame, and a // terminal that named itself nothing in particular, which must not be // flattened to sixteen colors on no evidence. #[test] fn the_fidelity_this_quantizes_for_is_the_one_the_family_detects() { assert_eq!(Fidelity::from_env("", "linux"), Fidelity::Ansi16); assert_eq!(Fidelity::from_env("", "foot"), Fidelity::TrueColor); assert_eq!(Fidelity::from_env("", "xterm-256color"), Fidelity::Ansi256); } // Every intent the renderer asks for has to be carried across, or a widget // drawing through `frame` gets a hole where a surface should be. `well` is // the one that can legitimately be absent, and it is absent as `None` rather // than as some other surface standing in for it. #[test] fn the_renderer_palette_carries_the_theme_across_unsubstituted() { let theme = akari_dawn(); let p = theme.palette(Fidelity::Ansi256); assert_eq!(p.page, theme.makeover.surface_page); assert_eq!(p.raised, theme.makeover.surface_raised); assert_eq!(p.overlay, theme.makeover.surface_overlay); assert_eq!(p.bevel_light, theme.makeover.bevel_light); assert_eq!(p.bevel_dark, theme.makeover.bevel_dark); assert_eq!(p.fidelity, Fidelity::Ansi256); assert_eq!(p.well, theme.makeover.surface_well); let mut no_well = theme; no_well.makeover.surface_well = None; assert_eq!(no_well.palette(Fidelity::TrueColor).well, None); assert_ne!( no_well.palette(Fidelity::TrueColor).well, Some(no_well.makeover.surface_sunken) ); } // A well is measured against the face it is cut into, so it quantizes // plainly like the other surfaces. Run through `indexed_against` it would be // pushed toward contrast with the page, which is the one thing a well is not // supposed to have. #[test] fn a_well_survives_quantization_as_a_surface() { let mut theme = akari_dawn(); theme.makeover.surface_raised = Color::Rgb(0xed, 0xe7, 0xde); theme.makeover.surface_well = Some(Color::Rgb(0xd6, 0xd0, 0xc7)); let quantized = theme.for_terminal(Fidelity::Ansi256); let well = quantized .makeover .surface_well .expect("a well went missing"); assert!(matches!(well, Color::Indexed(_)), "{well:?}"); assert_ne!( well, quantized.makeover.surface_raised, "the well collapsed onto its face" ); } // The bug, as a test: the installer's frame drew in border_strong on // surface_page and could not be seen. #[test] fn the_frame_stays_visible_against_the_page() { let theme = akari_dawn().for_terminal(Fidelity::Ansi16); assert_ne!(theme.border_strong, theme.makeover.surface_page); assert_ne!(theme.border_subtle, theme.makeover.surface_page); assert_ne!(theme.makeover.content_primary, theme.makeover.surface_page); } }