//! 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, } #[derive(Debug, Clone, Copy)] 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, 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(), }) }; let surface_page = get("surface.page")?; let content_primary = get("content.primary")?; let line_border = get("line.border")?; let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60); let border_strong = mix_linear_srgb(line_border, content_primary, 0.65); 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), 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 sixteen ANSI colors, addressed by index. Ansi16, } /// 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. Anything /// else is assumed to manage 24-bit, which is the safer wrong answer. Guessing /// [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity; /// guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color /// the user paid for. pub fn detect_color_depth() -> ColorDepth { let colorterm = std::env::var("COLORTERM").unwrap_or_default(); if colorterm == "truecolor" || colorterm == "24bit" { return ColorDepth::Full; } match std::env::var("TERM").unwrap_or_default().as_str() { "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16, _ => ColorDepth::Full, } } /// The palette entry for `c`, as an index the terminal will not reinterpret. fn indexed(c: Color) -> Color { match c { Color::Rgb(r, g, b) => { Color::Indexed(makeover::quantize(Rgb { r, g, b }, &makeover::ANSI_16) as u8) } other => other, } } /// As [`indexed`], but guaranteed to stay legible against `on`. fn indexed_against(c: Color, on: Color) -> 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, }, &makeover::ANSI_16, ) as u8) } _ => indexed(c), } } impl Theme { /// This theme as the terminal can actually draw it. /// /// At [`ColorDepth::Full`] the theme is returned untouched. At /// [`ColorDepth::Ansi16`] 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. #[must_use] pub fn for_terminal(self, depth: ColorDepth) -> Theme { if depth == ColorDepth::Full { return self; } let page = indexed(self.surface_page); let on_page = |c: Color| indexed_against(c, self.surface_page); Theme { mode: self.mode, surface_page: page, surface_raised: indexed(self.surface_raised), surface_sunken: indexed(self.surface_sunken), surface_overlay: indexed(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), category: self.category.map(on_page), } } } // 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. 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 = mix_linear_srgb(border, primary, 0.65); 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 ); } // 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), 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:?}"); } } // 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); } }