//! Theme palette: makeover intents resolved into ratatui `Color`s, plus //! the two Alloy-derived border tokens. //! //! Per docs/TOKENS.md, Alloy consumes makeover `.toml` files (the same //! schema every make-family app already reads) and derives two extra 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. //! //! ratatui is immediate-mode with per-widget styling — there is no global //! visuals object. Widgets in this crate take a `&Theme` at construction time //! and pull colors from it. Apps build one `Theme` per theme load (via //! `makeover::load_theme` + `Theme::from_theme`) and thread it through. use std::sync::OnceLock; use makeover::{Rgb, ThemeColors}; use makeover_tui::Palette; 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 below and /// the glyph fallback over 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; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Light, Dark, HighContrast, } /// A theme's intents, resolved to the colors ratatui draws with. /// /// `#[non_exhaustive]` because this struct gains a field every time makeover /// gains an intent, and without the attribute each one of those is a major here. /// 3.0.0 is itself that major, forced by the bevel pair; the attribute is what /// stops the next token from forcing another. Added in this release because it /// is the last moment it is free — nothing outside this crate builds a `Theme` /// field-by-field today, since [`Theme::from_theme`] is the only sane way to get /// one and a partial theme is an error rather than a default. /// /// The cost is real and accepted: a downstream crate can no longer construct one /// literally or match it exhaustively. For a palette that is *defined* as /// however many intents makeover currently has, neither is a thing a consumer /// should be doing. #[derive(Debug, Clone, Copy)] #[non_exhaustive] pub struct Theme { pub mode: Mode, pub surface_page: Color, pub surface_raised: Color, pub surface_sunken: Color, pub surface_overlay: Color, /// makeover's inset content surface: the surface inside a raised container, /// so a list reads as content in a container rather than as bands on a panel. /// /// Not [`surface_sunken`](Theme::surface_sunken), and the distinction is the /// reason this field exists rather than being aliased onto that one. A theme /// is free to author sunken *darker* than raised (goingson does) while a well /// always inverts away from the text, so substituting one for the other lands /// a well on the wrong side of its face on exactly the themes where it /// matters. `makeover-tui` deleted that substitution from the description on /// purpose; reintroducing it here would put it back a layer down. /// /// `None` where makeover derived nothing, which is a theme that authors no /// raised surface or no content color. Left missing rather than guessed, per /// the same rule: [`Palette::fill`] answers a missing well with structure. pub surface_well: Option, pub content_primary: Color, pub content_secondary: Color, pub content_muted: Color, pub action_primary: Color, pub status_danger: Color, pub status_success: Color, pub status_warning: Color, pub status_info: Color, pub line_border: Color, pub border_subtle: Color, pub border_strong: Color, /// The lit and shadowed edges of a raised surface, from makeover. /// /// A control is lit from the top left, so its top and left edges take /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the /// two recesses it, which is what a pressed state and a text well are. The /// light source does not flip with the theme's polarity — a dark theme is lit /// from the same corner, or the rule stops transferring between widgets, /// which is the whole reason to have one. pub bevel_light: Color, pub bevel_dark: Color, pub category: [Color; 6], } #[derive(Debug, Clone)] pub enum ThemeError { MissingKey(&'static str), InvalidHex { key: &'static str, value: String }, } impl std::fmt::Display for ThemeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"), ThemeError::InvalidHex { key, value } => { write!(f, "theme key `{key}` has invalid hex value `{value}`") } } } } impl std::error::Error for ThemeError {} impl Theme { /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`. /// Requires every intent Alloy renders — a malformed or partial theme is /// rejected explicitly rather than silently rendering with defaults. pub fn from_theme(theme: &ThemeColors) -> Result { 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(), }) }; // The bevel pair is makeover's, so that a console, a webview and an egui // app light a raised surface the same way. Read through `resolve` rather // than recomputed here, which is the point of it living in the crate. let resolved = makeover::resolve(theme); let intent = |key: &'static str| -> Result { let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?; Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { key, value: hex.to_string(), }) }; let surface_page = get("surface.page")?; let content_primary = get("content.primary")?; let line_border = get("line.border")?; // These two stay local, and deliberately, though makeover also emits a // `border-strong`. Its version is a fixed 5% darkening of the authored // border, which is a slightly firmer divider; this one is pulled most of // the way to the text color because Alloy spends it on the focus ring, // where docs/DESIGN-LANGUAGE.md makes it the entire cue 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, so they are different tokens wearing one name and // adopting the shared one would take focus to half the required floor. let border_subtle = border_subtle(line_border, surface_page); let border_strong = border_strong(line_border, content_primary); let mode = match theme.meta.variant.as_str() { "dark" => Mode::Dark, "high-contrast" => Mode::HighContrast, _ => Mode::Light, }; Ok(Self { mode, surface_page: rgb(surface_page), surface_raised: rgb(get("surface.raised")?), surface_sunken: rgb(get("surface.sunken")?), surface_overlay: rgb(get("surface.overlay")?), // Optional where the others are required, because it is derived // rather than authored: makeover emits it only when the theme gave // it both a raised surface and a content color to read the direction // off. Demanding it would reject a theme that is otherwise complete. surface_well: resolved .hex("surface-well") .and_then(Rgb::from_hex) .map(rgb), content_primary: rgb(content_primary), content_secondary: rgb(get("content.secondary")?), content_muted: rgb(get("content.muted")?), action_primary: rgb(get("action.primary")?), status_danger: rgb(get("status.danger")?), status_success: rgb(get("status.success")?), status_warning: rgb(get("status.warning")?), status_info: rgb(get("status.info")?), line_border: rgb(line_border), border_subtle: rgb(border_subtle), border_strong: rgb(border_strong), bevel_light: rgb(intent("bevel-light")?), bevel_dark: rgb(intent("bevel-dark")?), category: [ rgb(get("category.one")?), rgb(get("category.two")?), rgb(get("category.three")?), rgb(get("category.four")?), rgb(get("category.five")?), rgb(get("category.six")?), ], }) } } 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. /// /// Replaces `detect_color_depth`, which answered the same question in Alloy's own /// vocabulary. #[must_use] pub fn fidelity() -> Fidelity { static DETECTED: OnceLock = OnceLock::new(); *DETECTED.get_or_init(Fidelity::detect) } /// The palette to quantize into, and what to add to an index in it to get the /// number the terminal wants. /// /// 256 resolves to makeover's fixed region rather than the whole table: the low /// sixteen are repaintable in every emulator, so a match landing there is a /// match against a color the user may have moved out from under it. fn palette_for(fidelity: Fidelity) -> Option<(&'static [makeover::Rgb], usize)> { match fidelity { Fidelity::TrueColor => None, Fidelity::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)), Fidelity::Ansi16 => Some((&makeover::ANSI_16, 0)), } } /// The palette entry for `c`, as an index the terminal will not reinterpret. fn indexed(c: Color, palette: &[Rgb], offset: usize) -> Color { match c { Color::Rgb(r, g, b) => { Color::Indexed((makeover::quantize(Rgb { r, g, b }, palette) + offset) as u8) } other => other, } } /// As [`indexed`], but guaranteed to stay legible against `on`. /// /// Only for a color whose job is to be told apart from a known background. It /// answers "nearest entry that still contrasts with `on`" and has no notion of /// which side of `on` the answer should fall, so a pair of colors that must also /// stay apart from *each other* is the one thing it must not be used for: both /// are pushed onto the same contrasting entry. That is why the bevel edges go /// through [`indexed`]. fn indexed_against(c: Color, on: Color, palette: &[Rgb], offset: usize) -> Color { match (c, on) { (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed( (makeover::quantize_against( Rgb { r, g, b }, Rgb { r: br, g: bg, b: bb, }, palette, ) + offset) as u8, ), _ => indexed(c, palette, offset), } } impl Theme { /// This theme as the terminal can actually draw it. /// /// At [`Fidelity::TrueColor`] the theme is returned untouched. Otherwise every /// color becomes a palette index, which is the point: left as 24-bit, the /// terminal approximates them itself, and its approximation collapses tones /// that the theme keeps apart. Alloy's console lost its frame that way, /// drawing a border in a color the Linux console could not distinguish from /// the page behind it. /// /// Anything that has to be seen against the page is quantized against it /// rather than on its own, so a border stays a border and text stays /// readable. The surfaces themselves are quantized plainly: they are what /// the others are measured against. /// /// The bevel edges are quantized plainly too, for a different reason. They /// are measured against the raised surface they surround rather than against /// the page, and running them through [`indexed_against`] would push both /// onto the same entry and invert the bevel on one side. At /// [`Fidelity::Ansi16`] the palette cannot hold the pair at all and one /// edge lands back on its face, which is a property of sixteen colors rather /// than something this can fix. A caller drawing there does not have to /// handle that itself: [`Theme::palette`] carries the fidelity through to /// `makeover-tui`, which answers it with glyphs instead of tones. #[must_use] pub fn for_terminal(self, fidelity: Fidelity) -> Theme { let Some((palette, offset)) = palette_for(fidelity) else { return self; }; let plain = |c: Color| indexed(c, palette, offset); let on_page = |c: Color| indexed_against(c, self.surface_page, palette, offset); Theme { mode: self.mode, surface_page: plain(self.surface_page), surface_raised: plain(self.surface_raised), surface_sunken: plain(self.surface_sunken), surface_overlay: plain(self.surface_overlay), // Plainly, like the other surfaces and for the same reason as the // bevel pair: a well is measured against the raised face it is cut // into, not against the page, so quantizing it against the page // would push it toward contrast it is not supposed to have. surface_well: self.surface_well.map(plain), content_primary: on_page(self.content_primary), content_secondary: on_page(self.content_secondary), content_muted: on_page(self.content_muted), action_primary: on_page(self.action_primary), status_danger: on_page(self.status_danger), status_success: on_page(self.status_success), status_warning: on_page(self.status_warning), status_info: on_page(self.status_info), line_border: on_page(self.line_border), border_subtle: on_page(self.border_subtle), border_strong: on_page(self.border_strong), bevel_light: plain(self.bevel_light), bevel_dark: plain(self.bevel_dark), category: self.category.map(on_page), } } } impl Theme { /// This theme as `makeover-tui`'s renderer wants it. /// /// The one place a [`Palette`] is assembled. Every widget that draws through /// the family renderer asks here rather than filling the struct itself, /// because two of the fields are decisions rather than lookups — which token /// serves as the well, and whether `fidelity` matches what the colors were /// actually quantized to — and a per-widget copy is a per-widget chance to /// answer them differently. /// /// `fidelity` has to be the same value passed to [`Theme::for_terminal`]. /// The renderer takes its colors already quantized 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 colors that did not /// need one, or skipping the fallback over colors that did. #[must_use] pub fn palette(&self, fidelity: Fidelity) -> Palette { Palette { page: self.surface_page, raised: self.surface_raised, overlay: self.surface_overlay, well: self.surface_well, bevel_light: self.bevel_light, bevel_dark: self.bevel_dark, fidelity, } } } /// 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); Theme { mode: Mode::Light, surface_page: page, surface_raised: page, surface_sunken: page, 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. surface_well: Some(Color::Rgb(0xd6, 0xd0, 0xc7)), content_primary: Color::Rgb(0x1a, 0x18, 0x16), content_secondary: Color::Rgb(0x1a, 0x18, 0x16), content_muted: Color::Rgb(0x7f, 0x78, 0x6d), action_primary: Color::Rgb(0x8a, 0x45, 0x30), status_danger: Color::Rgb(0x8a, 0x45, 0x30), status_success: Color::Rgb(0x8a, 0x45, 0x30), status_warning: Color::Rgb(0x8a, 0x45, 0x30), status_info: Color::Rgb(0x8a, 0x45, 0x30), line_border: Color::Rgb(0xca, 0xbe, 0xae), border_subtle: Color::Rgb(0xda, 0xd2, 0xc7), border_strong: Color::Rgb(0x7f, 0x78, 0x6d), // 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. bevel_light: Color::Rgb(0xff, 0xfe, 0xf5), bevel_dark: Color::Rgb(0xb3, 0xad, 0xa5), category: [Color::Rgb(0x8a, 0x45, 0x30); 6], } } #[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.surface_page, theme.content_primary, theme.border_strong, theme.border_subtle, theme.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.bevel_light, theme.surface_raised); assert_ne!(theme.bevel_dark, theme.surface_raised); assert_ne!(theme.bevel_light, theme.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.bevel_light != theme.surface_raised; let dark_survives = theme.bevel_dark != theme.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.surface_page, theme.content_primary, theme.border_strong, theme.bevel_light, theme.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.surface_page); assert_eq!(p.raised, theme.surface_raised); assert_eq!(p.overlay, theme.surface_overlay); assert_eq!(p.bevel_light, theme.bevel_light); assert_eq!(p.bevel_dark, theme.bevel_dark); assert_eq!(p.fidelity, Fidelity::Ansi256); assert_eq!(p.well, theme.surface_well); let no_well = Theme { surface_well: None, ..theme }; assert_eq!(no_well.palette(Fidelity::TrueColor).well, None); assert_ne!( no_well.palette(Fidelity::TrueColor).well, Some(no_well.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 theme = Theme { surface_raised: Color::Rgb(0xed, 0xe7, 0xde), surface_well: Some(Color::Rgb(0xd6, 0xd0, 0xc7)), ..akari_dawn() }; let quantized = theme.for_terminal(Fidelity::Ansi256); let well = quantized.surface_well.expect("a well went missing"); assert!(matches!(well, Color::Indexed(_)), "{well:?}"); assert_ne!( well, quantized.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.surface_page); assert_ne!(theme.border_subtle, theme.surface_page); assert_ne!(theme.content_primary, theme.surface_page); } }