| 1 | //! Theme identifiers, setting normalizers and hex parsing. |
| 2 | //! |
| 3 | //! Everything here compiles without the `ratatui` feature: a settings |
| 4 | //! layer (the headless runtime) validates `theme = "..."` and colour |
| 5 | //! strings without linking a renderer. Resolving an id to a `UiTheme` |
| 6 | //! lives in `themes` behind the feature. |
| 7 | |
| 8 | /// Stable identifiers for the named themes the user can select. `System` |
| 9 | /// defers to `PaletteMode::detect()` (terminal-driven dark/light). Each |
| 10 | /// dark/light id resolves to a single fixed `UiTheme`. |
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 12 | pub enum ThemeId { |
| 13 | System, |
| 14 | Terminal, |
| 15 | Shoreline, |
| 16 | ShorelineLight, |
| 17 | Underwater, |
| 18 | UnderwaterRetro, |
| 19 | Whale, |
| 20 | WhaleLight, |
| 21 | Grayscale, |
| 22 | CatppuccinMocha, |
| 23 | TokyoNight, |
| 24 | Dracula, |
| 25 | GruvboxDark, |
| 26 | Claude, |
| 27 | Matrix, |
| 28 | SolarizedLight, |
| 29 | Uwu, |
| 30 | } |
| 31 | |
| 32 | impl ThemeId { |
| 33 | /// Parse a settings string (`"system"`, `"dark"`, `"catppuccin-mocha"`, …). |
| 34 | /// Accepts a few aliases (`"whale"` for dark, `"light"` for whale-light) |
| 35 | /// so existing config files keep working. Case-insensitive. |
| 36 | #[must_use] |
| 37 | pub fn from_name(value: &str) -> Option<Self> { |
| 38 | match normalize_theme_name(value)? { |
| 39 | "system" => Some(Self::System), |
| 40 | "terminal" => Some(Self::Terminal), |
| 41 | "underwater" | "deepsea" => Some(Self::Underwater), |
| 42 | "underwater-retro" | "retro" => Some(Self::UnderwaterRetro), |
| 43 | "shoreline" => Some(Self::Shoreline), |
| 44 | "shoreline-light" => Some(Self::ShorelineLight), |
| 45 | "dark" => Some(Self::Whale), |
| 46 | "light" => Some(Self::WhaleLight), |
| 47 | "grayscale" => Some(Self::Grayscale), |
| 48 | "catppuccin-mocha" => Some(Self::CatppuccinMocha), |
| 49 | "tokyo-night" => Some(Self::TokyoNight), |
| 50 | "dracula" => Some(Self::Dracula), |
| 51 | "gruvbox-dark" => Some(Self::GruvboxDark), |
| 52 | "claude" => Some(Self::Claude), |
| 53 | "matrix" => Some(Self::Matrix), |
| 54 | "solarized-light" => Some(Self::SolarizedLight), |
| 55 | "uwu" => Some(Self::Uwu), |
| 56 | _ => None, |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// Canonical settings string (lowercase, dash-separated). Round-trips |
| 61 | /// through `from_name`. |
| 62 | #[must_use] |
| 63 | pub const fn name(self) -> &'static str { |
| 64 | match self { |
| 65 | Self::System => "system", |
| 66 | Self::Terminal => "terminal", |
| 67 | Self::Shoreline => "shoreline", |
| 68 | Self::ShorelineLight => "shoreline-light", |
| 69 | Self::Underwater => "underwater", |
| 70 | Self::UnderwaterRetro => "underwater-retro", |
| 71 | Self::Whale => "dark", |
| 72 | Self::WhaleLight => "light", |
| 73 | Self::Grayscale => "grayscale", |
| 74 | Self::CatppuccinMocha => "catppuccin-mocha", |
| 75 | Self::TokyoNight => "tokyo-night", |
| 76 | Self::Dracula => "dracula", |
| 77 | Self::GruvboxDark => "gruvbox-dark", |
| 78 | Self::Claude => "claude", |
| 79 | Self::Matrix => "matrix", |
| 80 | Self::SolarizedLight => "solarized-light", |
| 81 | Self::Uwu => "uwu", |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// Human-readable label for picker rows. |
| 86 | #[must_use] |
| 87 | pub const fn display_name(self) -> &'static str { |
| 88 | match self { |
| 89 | Self::System => "System", |
| 90 | Self::Terminal => "Terminal", |
| 91 | Self::Shoreline => "Shoreline", |
| 92 | Self::ShorelineLight => "Shoreline Light", |
| 93 | Self::Underwater => "Underwater", |
| 94 | Self::UnderwaterRetro => "Underwater Retro", |
| 95 | Self::Whale => "Blue Stage", |
| 96 | Self::WhaleLight => "Blue Stage Light", |
| 97 | Self::Grayscale => "Grayscale", |
| 98 | Self::CatppuccinMocha => "Catppuccin Mocha", |
| 99 | Self::TokyoNight => "Tokyo Night", |
| 100 | Self::Dracula => "Dracula", |
| 101 | Self::GruvboxDark => "Gruvbox Dark", |
| 102 | Self::Claude => "Claude", |
| 103 | Self::Matrix => "Matrix", |
| 104 | Self::SolarizedLight => "Solarized Light", |
| 105 | Self::Uwu => "Uwu", |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// Short tagline for picker rows. |
| 110 | #[must_use] |
| 111 | pub const fn tagline(self) -> &'static str { |
| 112 | match self { |
| 113 | Self::System => "Follow terminal background (COLORFGBG / macOS appearance)", |
| 114 | Self::Terminal => "Inherit terminal colors fully (transparent surfaces, ANSI accents)", |
| 115 | Self::Shoreline => "Warm charcoal, one restrained blue — the desktop client's palette", |
| 116 | Self::ShorelineLight => "Shoreline on warm paper — the desktop client's light mode", |
| 117 | Self::Underwater => "The painted ocean field: ombre water, ambient life, the whale", |
| 118 | Self::UnderwaterRetro => "Flat phosphor-teal ocean: the legacy deepsea look, no ombre", |
| 119 | Self::Whale => "Stage black, action blue, and one Signal Gold human beacon", |
| 120 | Self::WhaleLight => "Paper, cobalt action, and one Signal Gold human beacon", |
| 121 | Self::Grayscale => "Color-minimal high contrast", |
| 122 | Self::CatppuccinMocha => "Soft pastels on warm dark", |
| 123 | Self::TokyoNight => "Deep blue/violet night palette", |
| 124 | Self::Dracula => "Classic high-contrast purple", |
| 125 | Self::GruvboxDark => "Vintage warm earth tones", |
| 126 | Self::Claude => "Warm navy & coral", |
| 127 | Self::Matrix => "The Matrix films inspired theme", |
| 128 | Self::SolarizedLight => { |
| 129 | "Solarized light — Light, calming palette on warm ivory — easy on the eyes" |
| 130 | } |
| 131 | Self::Uwu => "Soft kawaii night — sakura, mint, and peach", |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /// Themes shown in the `/theme` picker, in display order. |
| 137 | pub const SELECTABLE_THEMES: &[ThemeId] = &[ |
| 138 | ThemeId::System, |
| 139 | ThemeId::Terminal, |
| 140 | ThemeId::Shoreline, |
| 141 | ThemeId::ShorelineLight, |
| 142 | ThemeId::Underwater, |
| 143 | ThemeId::UnderwaterRetro, |
| 144 | ThemeId::Whale, |
| 145 | ThemeId::WhaleLight, |
| 146 | ThemeId::Grayscale, |
| 147 | ThemeId::CatppuccinMocha, |
| 148 | ThemeId::TokyoNight, |
| 149 | ThemeId::Dracula, |
| 150 | ThemeId::GruvboxDark, |
| 151 | ThemeId::Claude, |
| 152 | ThemeId::Matrix, |
| 153 | ThemeId::SolarizedLight, |
| 154 | ThemeId::Uwu, |
| 155 | ]; |
| 156 | |
| 157 | #[must_use] |
| 158 | pub fn normalize_theme_name(value: &str) -> Option<&'static str> { |
| 159 | match value.trim().to_ascii_lowercase().as_str() { |
| 160 | "" | "auto" | "system" | "default" => Some("system"), |
| 161 | "terminal" | "term" | "transparent" | "follow-terminal" | "inherit" => Some("terminal"), |
| 162 | "underwater" | "deepsea" | "deep-sea" | "ocean" | "ombre" => Some("underwater"), |
| 163 | "underwater-retro" | "retro" | "uw-retro" => Some("underwater-retro"), |
| 164 | "shoreline" | "warm" | "charcoal" => Some("shoreline"), |
| 165 | "shoreline-light" | "paper" => Some("shoreline-light"), |
| 166 | "dark" | "whale" | "whale-dark" => Some("dark"), |
| 167 | "light" | "whale-light" => Some("light"), |
| 168 | "grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white" |
| 169 | | "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"), |
| 170 | "catppuccin-mocha" | "catppuccin" | "mocha" => Some("catppuccin-mocha"), |
| 171 | "tokyo-night" | "tokyonight" | "tokyo" => Some("tokyo-night"), |
| 172 | "dracula" => Some("dracula"), |
| 173 | "gruvbox-dark" | "gruvbox" => Some("gruvbox-dark"), |
| 174 | "claude" => Some("claude"), |
| 175 | "matrix" | "hacker" => Some("matrix"), |
| 176 | "solarized-light" | "solarized" => Some("solarized-light"), |
| 177 | "uwu" | "owo" | "kawaii" => Some("uwu"), |
| 178 | _ => None, |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | pub const USER_THEME_PREFIX: &str = "custom:"; |
| 183 | |
| 184 | pub fn normalize_user_theme_selector(value: &str) -> Result<Option<String>, String> { |
| 185 | let trimmed = value.trim(); |
| 186 | let Some(slug) = trimmed.strip_prefix(USER_THEME_PREFIX) else { |
| 187 | return Ok(None); |
| 188 | }; |
| 189 | let slug = slug.trim().to_ascii_lowercase(); |
| 190 | if slug.is_empty() |
| 191 | || slug.len() > 64 |
| 192 | || !slug |
| 193 | .chars() |
| 194 | .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) |
| 195 | { |
| 196 | return Err( |
| 197 | "custom theme names must be 1-64 ASCII letters, digits, '-' or '_'".to_string(), |
| 198 | ); |
| 199 | } |
| 200 | Ok(Some(format!("{USER_THEME_PREFIX}{slug}"))) |
| 201 | } |
| 202 | |
| 203 | pub fn normalize_theme_setting(value: &str) -> Result<String, String> { |
| 204 | if let Some(id) = ThemeId::from_name(value) { |
| 205 | return Ok(id.name().to_string()); |
| 206 | } |
| 207 | normalize_user_theme_selector(value)?.ok_or_else(|| { |
| 208 | format!("invalid theme '{value}'; use a compiled theme name or custom:<name>") |
| 209 | }) |
| 210 | } |
| 211 | |
| 212 | /// Parse `#rrggbb` (or `rrggbb`) into an RGB tuple. |
| 213 | #[must_use] |
| 214 | pub fn parse_hex_rgb(value: &str) -> Option<(u8, u8, u8)> { |
| 215 | let hex = value.trim().strip_prefix('#').unwrap_or(value.trim()); |
| 216 | if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) { |
| 217 | return None; |
| 218 | } |
| 219 | |
| 220 | let r = u8::from_str_radix(&hex[0..2], 16).ok()?; |
| 221 | let g = u8::from_str_radix(&hex[2..4], 16).ok()?; |
| 222 | let b = u8::from_str_radix(&hex[4..6], 16).ok()?; |
| 223 | Some((r, g, b)) |
| 224 | } |
| 225 | |
| 226 | /// Canonical lowercase `#rrggbb` form of a hex colour string. |
| 227 | #[must_use] |
| 228 | pub fn normalize_hex_rgb_color(value: &str) -> Option<String> { |
| 229 | let (r, g, b) = parse_hex_rgb(value)?; |
| 230 | Some(format!("#{r:02x}{g:02x}{b:02x}")) |
| 231 | } |
| 232 | |
| 233 | // Theme ids and hex parsing are what the runtime links (without `ratatui`), |
| 234 | // so their tests must not sit behind the `ratatui` gate that `tests.rs` needs. |
| 235 | #[cfg(test)] |
| 236 | mod tests { |
| 237 | use super::*; |
| 238 | |
| 239 | #[test] |
| 240 | fn hex_rgb_parses_with_or_without_hash_and_rejects_malformed_input() { |
| 241 | assert_eq!(parse_hex_rgb("#1a1B26"), Some((26, 27, 38))); |
| 242 | assert_eq!(parse_hex_rgb(" 1a1b26 "), Some((26, 27, 38))); |
| 243 | assert_eq!( |
| 244 | normalize_hex_rgb_color("#1A1B26").as_deref(), |
| 245 | Some("#1a1b26") |
| 246 | ); |
| 247 | for bad in ["#123", "#zzzzzz", "", "#1a1b2", "#1a1b267", "#+1a1b2"] { |
| 248 | assert_eq!(parse_hex_rgb(bad), None, "{bad:?}"); |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | #[test] |
| 253 | fn theme_names_normalize_aliases_and_reject_unknown_names() { |
| 254 | assert_eq!(normalize_theme_name(" Default "), Some("system")); |
| 255 | assert_eq!(normalize_theme_name("whale"), Some("dark")); |
| 256 | assert_eq!(normalize_theme_name("b&w"), Some("grayscale")); |
| 257 | assert_eq!(normalize_theme_name("not-a-theme"), None); |
| 258 | for id in SELECTABLE_THEMES { |
| 259 | assert_eq!(ThemeId::from_name(id.name()), Some(*id), "{}", id.name()); |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | #[test] |
| 264 | fn custom_theme_selector_accepts_only_bounded_ascii_slugs() { |
| 265 | assert_eq!( |
| 266 | normalize_user_theme_selector("custom: My_Theme-2 "), |
| 267 | Ok(Some("custom:my_theme-2".to_string())) |
| 268 | ); |
| 269 | assert_eq!(normalize_user_theme_selector("dark"), Ok(None)); |
| 270 | for bad in ["custom:", "custom:a/b", "custom:../x", "custom:ünïcode"] { |
| 271 | assert!(normalize_user_theme_selector(bad).is_err(), "{bad:?}"); |
| 272 | } |
| 273 | assert!(normalize_user_theme_selector(&format!("custom:{}", "a".repeat(65))).is_err()); |
| 274 | assert_eq!(normalize_theme_setting("whale").as_deref(), Ok("dark")); |
| 275 | assert!(normalize_theme_setting("custom:../x").is_err()); |
| 276 | assert!(normalize_theme_setting("nope").is_err()); |
| 277 | } |
| 278 | } |
| 279 |