//! 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 makeover::{Rgb, ThemeColors}; use ratatui::style::Color; #[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, 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")?), 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) } /// How much color the terminal being drawn to can actually show. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorDepth { /// 24-bit. Theme colors are sent as authored. Full, /// The xterm 256-color table, addressed by index. /// /// Enough to keep a two-tone bevel: both Akari themes put the two edges and /// the face they surround on three separate entries here, where sixteen /// colors has nothing between a face and its neighbour and one edge lands /// back on the face. Ansi256, /// The sixteen ANSI colors, addressed by index. Ansi16, } impl ColorDepth { /// 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(self) -> Option<(&'static [makeover::Rgb], usize)> { match self { ColorDepth::Full => None, ColorDepth::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)), ColorDepth::Ansi16 => Some((&makeover::ANSI_16, 0)), } } } /// What the environment says the terminal can show. /// /// `COLORTERM` is the only positive signal a terminal gives for 24-bit color, /// and `TERM=linux` is the case this exists for: the Linux virtual console, /// which is what an installer and a machine with no desktop draw on. /// /// A `TERM` ending in `-256color` and no `COLORTERM` is the terminal saying what /// it has. Taking it at its word beats the old behavior of calling it /// [`Full`](ColorDepth::Full) and sending 24-bit for it to approximate, because /// its approximation is per-color and collapses tones the theme keeps apart, /// which is the same failure that cost the console its frame on the VT. /// /// Everything else is assumed to manage 24-bit, which is the safer wrong answer: /// guessing [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity, /// and guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color /// the user paid for. pub fn detect_color_depth() -> ColorDepth { depth_from_env( &std::env::var("COLORTERM").unwrap_or_default(), &std::env::var("TERM").unwrap_or_default(), ) } /// [`detect_color_depth`] with the environment passed in, so the decision can be /// tested without mutating a process-wide variable from a parallel test. fn depth_from_env(colorterm: &str, term: &str) -> ColorDepth { if colorterm == "truecolor" || colorterm == "24bit" { return ColorDepth::Full; } match term { "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16, _ if term.ends_with("-256color") => ColorDepth::Ansi256, _ => ColorDepth::Full, } } /// 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 [`ColorDepth::Full`] 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 /// [`ColorDepth::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 should spend the edge /// that survives on a single-tone shadow. #[must_use] pub fn for_terminal(self, depth: ColorDepth) -> Theme { let Some((palette, offset)) = depth.palette() 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), 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), } } } /// 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, 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(ColorDepth::Full); 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(ColorDepth::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(ColorDepth::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(ColorDepth::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(ColorDepth::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"); } } #[test] fn a_256_color_term_is_detected_from_its_name() { let depth = depth_from_env; assert_eq!(depth("", "xterm-256color"), ColorDepth::Ansi256); assert_eq!(depth("", "screen-256color"), ColorDepth::Ansi256); // A terminal claiming 24-bit is believed over its name. assert_eq!(depth("truecolor", "xterm-256color"), ColorDepth::Full); // The VT is still the VT. assert_eq!(depth("", "linux"), ColorDepth::Ansi16); assert_eq!(depth("", "foot"), ColorDepth::Full); } // 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(ColorDepth::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); } }