//! UI theming: a set of selectable colour palettes. //! //! The UI uses 13 semantic colour roles (background tiers + accents). Each //! [`AppTheme`] maps to a [`Palette`] of those roles via the pure //! [`AppTheme::palette`] fn — that's the testable seam: palettes are plain data //! and the legibility guarantees below are unit-tested. `view()` reads the //! active palette instead of hardcoded literals, so every widget (incl. the //! canvas widgets that already take colours as data) re-themes automatically. //! //! `AppTheme` also maps to an `iced::Theme` ([`AppTheme::base_theme`]) so iced's //! built-in widget chrome (scrollbars, text-input selection, dropdown menus) //! matches the chosen palette. use iced::Color; use serde::{Deserialize, Serialize}; /// 13 semantic colour roles the UI draws with. Names follow the Catppuccin /// vocabulary (the original palette), but each theme supplies its own values. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Palette { /// Darkest background (window backdrop behind everything). pub crust: Color, /// Slightly raised background. pub mantle: Color, /// Main content background. pub base: Color, /// Raised panel / card surface. pub surface: Color, /// Muted surface — placeholders, disabled, faint dividers. pub overlay: Color, /// Primary foreground text. pub text: Color, /// Secondary / hint text. pub subtext: Color, /// Primary accent (titles, primary buttons, selection borders). pub blue: Color, /// Secondary accent. pub lavender: Color, /// Danger / record / error. pub red: Color, /// Red variant (a warmer alternative to `red`). pub maroon: Color, /// Success / "transmitting" / speaking. pub green: Color, /// Warning / "reconnecting". pub yellow: Color, } /// A selectable UI theme. The default is `Mocha` (the project's original look). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum AppTheme { #[default] Mocha, Macchiato, Frappe, Latte, Dracula, Nord, TokyoNight, GruvboxDark, SolarizedLight, GruvboxLight, AyuDark, AyuMirage, AyuLight, } /// Build a `Color` from a packed `0xRRGGBB` literal — keeps the palette tables /// compact and greppable against each theme's canonical hex values. fn hex(c: u32) -> Color { Color::from_rgb8((c >> 16) as u8, (c >> 8) as u8, c as u8) } impl AppTheme { /// Every theme, in picker order. pub const ALL: [AppTheme; 13] = [ AppTheme::Mocha, AppTheme::Macchiato, AppTheme::Frappe, AppTheme::Latte, AppTheme::Dracula, AppTheme::Nord, AppTheme::TokyoNight, AppTheme::GruvboxDark, AppTheme::SolarizedLight, AppTheme::GruvboxLight, AppTheme::AyuDark, AppTheme::AyuMirage, AppTheme::AyuLight, ]; /// Human-readable name for the picker. pub fn label(self) -> &'static str { match self { AppTheme::Mocha => "Mocha", AppTheme::Macchiato => "Macchiato", AppTheme::Frappe => "Frappé", AppTheme::Latte => "Latte", AppTheme::Dracula => "Dracula", AppTheme::Nord => "Nord", AppTheme::TokyoNight => "Tokyo Night", AppTheme::GruvboxDark => "Gruvbox Dark", AppTheme::SolarizedLight => "Solarized Light", AppTheme::GruvboxLight => "Gruvbox Light", AppTheme::AyuDark => "Ayu Dark", AppTheme::AyuMirage => "Ayu Mirage", AppTheme::AyuLight => "Ayu Light", } } /// True for dark themes (dark background, light text). pub fn is_dark(self) -> bool { !matches!( self, AppTheme::Latte | AppTheme::SolarizedLight | AppTheme::GruvboxLight | AppTheme::AyuLight ) } /// The matching iced built-in theme, for built-in widget chrome. pub fn base_theme(self) -> iced::Theme { match self { AppTheme::Mocha => iced::Theme::CatppuccinMocha, AppTheme::Macchiato => iced::Theme::CatppuccinMacchiato, AppTheme::Frappe => iced::Theme::CatppuccinFrappe, AppTheme::Latte => iced::Theme::CatppuccinLatte, AppTheme::Dracula => iced::Theme::Dracula, AppTheme::Nord => iced::Theme::Nord, AppTheme::TokyoNight => iced::Theme::TokyoNight, AppTheme::GruvboxDark => iced::Theme::GruvboxDark, AppTheme::SolarizedLight => iced::Theme::SolarizedLight, AppTheme::GruvboxLight => iced::Theme::GruvboxLight, AppTheme::AyuDark | AppTheme::AyuMirage => iced::Theme::TokyoNight, AppTheme::AyuLight => iced::Theme::Light, } } /// The 13-role colour palette for this theme. Pure data — colours are each /// theme's canonical hex values mapped onto our semantic roles. pub fn palette(self) -> Palette { match self { // Catppuccin Mocha — the project's original palette. AppTheme::Mocha => Palette { crust: hex(0x11111b), mantle: hex(0x181825), base: hex(0x1e1e2e), surface: hex(0x313244), overlay: hex(0x6c7086), text: hex(0xcdd6f4), subtext: hex(0xa6adc8), blue: hex(0x89b4fa), lavender: hex(0xb4befe), red: hex(0xf38ba8), maroon: hex(0xeba0ac), green: hex(0xa6e3a1), yellow: hex(0xf9e2af), }, AppTheme::Macchiato => Palette { crust: hex(0x181926), mantle: hex(0x1e2030), base: hex(0x24273a), surface: hex(0x363a4f), overlay: hex(0x6e738d), text: hex(0xcad3f5), subtext: hex(0xa5adcb), blue: hex(0x8aadf4), lavender: hex(0xb7bdf8), red: hex(0xed8796), maroon: hex(0xee99a0), green: hex(0xa6da95), yellow: hex(0xeed49f), }, AppTheme::Frappe => Palette { crust: hex(0x232634), mantle: hex(0x292c3c), base: hex(0x303446), surface: hex(0x414559), overlay: hex(0x737994), text: hex(0xc6d0f5), subtext: hex(0xa5adce), blue: hex(0x8caaee), lavender: hex(0xbabbf1), red: hex(0xe78284), maroon: hex(0xea999c), green: hex(0xa6d189), yellow: hex(0xe5c890), }, // Catppuccin Latte — light. AppTheme::Latte => Palette { crust: hex(0xdce0e8), mantle: hex(0xe6e9ef), base: hex(0xeff1f5), surface: hex(0xccd0da), overlay: hex(0x9ca0b0), text: hex(0x4c4f69), subtext: hex(0x6c6f85), blue: hex(0x1e66f5), lavender: hex(0x7287fd), red: hex(0xd20f39), maroon: hex(0xe64553), green: hex(0x40a02b), yellow: hex(0xdf8e1d), }, AppTheme::Dracula => Palette { crust: hex(0x191a21), mantle: hex(0x21222c), base: hex(0x282a36), surface: hex(0x44475a), overlay: hex(0x6272a4), text: hex(0xf8f8f2), subtext: hex(0xbdc0d4), blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent lavender: hex(0x8be9fd), // cyan red: hex(0xff5555), maroon: hex(0xff79c6), // pink green: hex(0x50fa7b), yellow: hex(0xf1fa8c), }, AppTheme::Nord => Palette { crust: hex(0x272c36), mantle: hex(0x2b313c), base: hex(0x2e3440), surface: hex(0x3b4252), overlay: hex(0x4c566a), text: hex(0xeceff4), subtext: hex(0xd8dee9), blue: hex(0x88c0d0), lavender: hex(0xb48ead), red: hex(0xbf616a), maroon: hex(0xd08770), green: hex(0xa3be8c), yellow: hex(0xebcb8b), }, AppTheme::TokyoNight => Palette { crust: hex(0x16161e), mantle: hex(0x1b1c29), base: hex(0x1a1b26), surface: hex(0x24283b), overlay: hex(0x565f89), text: hex(0xc0caf5), subtext: hex(0x9aa5ce), blue: hex(0x7aa2f7), lavender: hex(0xbb9af7), red: hex(0xf7768e), maroon: hex(0xff9e64), green: hex(0x9ece6a), yellow: hex(0xe0af68), }, AppTheme::GruvboxDark => Palette { crust: hex(0x1d2021), mantle: hex(0x242424), base: hex(0x282828), surface: hex(0x3c3836), overlay: hex(0x665c54), text: hex(0xebdbb2), subtext: hex(0xbdae93), blue: hex(0x83a598), lavender: hex(0xd3869b), red: hex(0xfb4934), maroon: hex(0xfe8019), green: hex(0xb8bb26), yellow: hex(0xfabd2f), }, // Solarized Light — light. AppTheme::SolarizedLight => Palette { crust: hex(0xe6dfc8), mantle: hex(0xf3ecd6), base: hex(0xfdf6e3), surface: hex(0xeee8d5), overlay: hex(0x93a1a1), text: hex(0x586e75), subtext: hex(0x657b83), blue: hex(0x268bd2), lavender: hex(0x6c71c4), red: hex(0xdc322f), maroon: hex(0xcb4b16), green: hex(0x859900), yellow: hex(0xb58900), }, // Gruvbox Light — light. AppTheme::GruvboxLight => Palette { crust: hex(0xf2e5bc), mantle: hex(0xf9f5d7), base: hex(0xfbf1c7), surface: hex(0xebdbb2), overlay: hex(0xbdae93), text: hex(0x3c3836), subtext: hex(0x504945), blue: hex(0x076678), lavender: hex(0x8f3f71), red: hex(0x9d0006), maroon: hex(0xaf3a03), green: hex(0x79740e), yellow: hex(0xb57614), }, AppTheme::AyuDark => Palette { crust: hex(0x06080a), mantle: hex(0x0b0e14), base: hex(0x0d1017), surface: hex(0x1c222b), overlay: hex(0x565b66), text: hex(0xbfbdb6), subtext: hex(0x9da1a6), blue: hex(0xe6b450), lavender: hex(0x59c2ff), red: hex(0xf07178), maroon: hex(0xff8f40), green: hex(0xaad94c), yellow: hex(0xffb454), }, AppTheme::AyuMirage => Palette { crust: hex(0x171b24), mantle: hex(0x1a1f29), base: hex(0x1f2430), surface: hex(0x232834), overlay: hex(0x707a8c), text: hex(0xcccac2), subtext: hex(0xa6abb4), blue: hex(0xffcc66), lavender: hex(0x73d0ff), red: hex(0xf28779), maroon: hex(0xffa759), green: hex(0xd5ff80), yellow: hex(0xffd173), }, // Ayu Light's canonical orange is deepened for legibility on white. AppTheme::AyuLight => Palette { crust: hex(0xe6e9ec), mantle: hex(0xf3f4f5), base: hex(0xfcfcfc), surface: hex(0xe8eaed), overlay: hex(0x8a9199), text: hex(0x5c6166), subtext: hex(0x737980), blue: hex(0xc7500e), lavender: hex(0x399ee6), red: hex(0xf07171), maroon: hex(0xfa8d3e), green: hex(0x86b300), yellow: hex(0xff9940), }, } } } /// WCAG relative luminance of a colour (sRGB → linear, 0.0..=1.0). pub fn relative_luminance(c: Color) -> f32 { fn lin(ch: f32) -> f32 { if ch <= 0.03928 { ch / 12.92 } else { ((ch + 0.055) / 1.055).powf(2.4) } } 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b) } /// WCAG contrast ratio between two colours (1.0..=21.0). Order-independent. pub fn contrast_ratio(a: Color, b: Color) -> f32 { let (la, lb) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } #[cfg(test)] mod tests { use super::*; #[test] fn contrast_ratio_known_values() { // Black on white is the maximum 21:1; a colour against itself is 1:1. assert!((contrast_ratio(Color::BLACK, Color::WHITE) - 21.0).abs() < 0.1); assert!((contrast_ratio(Color::WHITE, Color::WHITE) - 1.0).abs() < 0.001); // Symmetric. let a = hex(0x89b4fa); let b = hex(0x1e1e2e); assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < 0.001); } #[test] fn every_theme_has_legible_body_text() { // Primary text on the main background must clear WCAG AA (4.5:1). for theme in AppTheme::ALL { let p = theme.palette(); let ratio = contrast_ratio(p.text, p.base); assert!( ratio >= 4.5, "{}: text-on-base contrast {ratio:.2} < 4.5 (AA)", theme.label() ); } } #[test] fn every_theme_has_readable_subtext_and_accents() { // Secondary text clears AA-large (3:1); the title/primary accent must be // readable on the background too (it's used for headings + button text). for theme in AppTheme::ALL { let p = theme.palette(); let sub = contrast_ratio(p.subtext, p.base); assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label()); let accent = contrast_ratio(p.blue, p.base); assert!( accent >= 3.0, "{}: primary-accent contrast {accent:.2} < 3.0", theme.label() ); } } #[test] fn is_dark_matches_luminance_direction() { // Dark themes: text brighter than base. Light themes: text darker. for theme in AppTheme::ALL { let p = theme.palette(); let text_brighter = relative_luminance(p.text) > relative_luminance(p.base); assert_eq!( text_brighter, theme.is_dark(), "{}: is_dark={} but text_brighter={}", theme.label(), theme.is_dark(), text_brighter ); } } #[test] fn all_themes_distinct_and_labeled() { // ALL covers exactly the variants once, each with a unique non-empty label // and a distinct base colour (so swatches don't look identical). assert_eq!(AppTheme::ALL.len(), 13); let mut labels: Vec<&str> = AppTheme::ALL.iter().map(|t| t.label()).collect(); labels.sort_unstable(); labels.dedup(); assert_eq!(labels.len(), 13, "labels must be unique + non-empty"); assert!(labels.iter().all(|l| !l.is_empty())); } #[test] fn serde_round_trips_every_variant() { for theme in AppTheme::ALL { let json = serde_json::to_string(&theme).unwrap(); let back: AppTheme = serde_json::from_str(&json).unwrap(); assert_eq!(theme, back); } } #[test] fn default_is_mocha() { assert_eq!(AppTheme::default(), AppTheme::Mocha); // Default palette matches the project's original literals. let p = AppTheme::default().palette(); assert_eq!(p.base, Color::from_rgb8(30, 30, 46)); assert_eq!(p.text, Color::from_rgb8(205, 214, 244)); assert_eq!(p.blue, Color::from_rgb8(137, 180, 250)); } }