返回 CodeWhale
ids.rs
根目录 / crates / palette / src / ids.rs
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
279 lines RUST