1use std::ops::Deref;
2use std::sync::{Arc, OnceLock};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct ThemePalette {
6 pub text: String,
7 pub muted: String,
8 pub accent: String,
9 pub info: String,
10 pub warning: String,
11 pub success: String,
12 pub error: String,
13 pub border: String,
14 pub title: String,
15 pub selection: String,
16 pub link: String,
17 pub bg: Option<String>,
18 pub bg_alt: Option<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ThemeData {
23 pub id: String,
24 pub name: String,
25 pub base: Option<String>,
26 pub palette: ThemePalette,
27 pub overrides: ThemeOverrides,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ThemeDefinition(Arc<ThemeData>);
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct ThemeOverrides {
35 pub value_number: Option<String>,
36 pub repl_completion_text: Option<String>,
37 pub repl_completion_background: Option<String>,
38 pub repl_completion_highlight: Option<String>,
39}
40
41impl ThemeDefinition {
42 pub fn new(
43 id: impl Into<String>,
44 name: impl Into<String>,
45 base: Option<String>,
46 palette: ThemePalette,
47 overrides: ThemeOverrides,
48 ) -> Self {
49 Self(Arc::new(ThemeData {
50 id: id.into(),
51 name: name.into(),
52 base,
53 palette,
54 overrides,
55 }))
56 }
57
58 pub fn value_number_spec(&self) -> &str {
59 self.overrides
60 .value_number
61 .as_deref()
62 .unwrap_or(&self.palette.success)
63 }
64
65 pub fn repl_completion_text_spec(&self) -> &str {
66 self.overrides
67 .repl_completion_text
68 .as_deref()
69 .unwrap_or("#000000")
70 }
71
72 pub fn repl_completion_background_spec(&self) -> &str {
73 self.overrides
74 .repl_completion_background
75 .as_deref()
76 .unwrap_or(&self.palette.accent)
77 }
78
79 pub fn repl_completion_highlight_spec(&self) -> &str {
80 self.overrides
81 .repl_completion_highlight
82 .as_deref()
83 .unwrap_or(&self.palette.border)
84 }
85
86 pub fn display_name(&self) -> &str {
87 self.name.as_str()
88 }
89}
90
91impl Deref for ThemeDefinition {
92 type Target = ThemeData;
93
94 fn deref(&self) -> &Self::Target {
95 self.0.as_ref()
96 }
97}
98
99pub const DEFAULT_THEME_NAME: &str = "rose-pine-moon";
100
101struct PaletteSpec<'a> {
102 text: &'a str,
103 muted: &'a str,
104 accent: &'a str,
105 info: &'a str,
106 warning: &'a str,
107 success: &'a str,
108 error: &'a str,
109 border: &'a str,
110 title: &'a str,
111}
112
113fn palette(spec: PaletteSpec<'_>) -> ThemePalette {
114 ThemePalette {
115 text: spec.text.to_string(),
116 muted: spec.muted.to_string(),
117 accent: spec.accent.to_string(),
118 info: spec.info.to_string(),
119 warning: spec.warning.to_string(),
120 success: spec.success.to_string(),
121 error: spec.error.to_string(),
122 border: spec.border.to_string(),
123 title: spec.title.to_string(),
124 selection: spec.accent.to_string(),
125 link: spec.accent.to_string(),
126 bg: None,
127 bg_alt: None,
128 }
129}
130
131fn builtin_theme(
132 id: &'static str,
133 name: &'static str,
134 palette: ThemePalette,
135 overrides: ThemeOverrides,
136) -> ThemeDefinition {
137 ThemeDefinition::new(id, name, None, palette, overrides)
138}
139
140fn builtin_theme_defs() -> &'static [ThemeDefinition] {
141 static THEMES: OnceLock<Vec<ThemeDefinition>> = OnceLock::new();
142 THEMES.get_or_init(|| {
143 vec![
144 builtin_theme(
145 "plain",
146 "Plain",
147 palette(PaletteSpec {
148 text: "",
149 muted: "",
150 accent: "",
151 info: "",
152 warning: "",
153 success: "",
154 error: "",
155 border: "",
156 title: "",
157 }),
158 ThemeOverrides::default(),
159 ),
160 builtin_theme(
161 "nord",
162 "Nord",
163 palette(PaletteSpec {
164 text: "#d8dee9",
165 muted: "#6d7688",
166 accent: "#88c0d0",
167 info: "#81a1c1",
168 warning: "#ebcb8b",
169 success: "#a3be8c",
170 error: "bold #bf616a",
171 border: "#81a1c1",
172 title: "#81a1c1",
173 }),
174 ThemeOverrides::default(),
175 ),
176 builtin_theme(
177 "dracula",
178 "Dracula",
179 palette(PaletteSpec {
180 text: "#f8f8f2",
181 muted: "#6879ad",
182 accent: "#bd93f9",
183 info: "#8be9fd",
184 warning: "#f1fa8c",
185 success: "#50fa7b",
186 error: "bold #ff5555",
187 border: "#ff79c6",
188 title: "#ff79c6",
189 }),
190 ThemeOverrides {
191 value_number: Some("#ff79c6".to_string()),
192 ..ThemeOverrides::default()
193 },
194 ),
195 builtin_theme(
196 "gruvbox",
197 "Gruvbox",
198 palette(PaletteSpec {
199 text: "#ebdbb2",
200 muted: "#a89984",
201 accent: "#8ec07c",
202 info: "#83a598",
203 warning: "#fe8019",
204 success: "#b8bb26",
205 error: "bold #fb4934",
206 border: "#fabd2f",
207 title: "#fabd2f",
208 }),
209 ThemeOverrides::default(),
210 ),
211 builtin_theme(
212 "tokyonight",
213 "Tokyo Night",
214 palette(PaletteSpec {
215 text: "#c0caf5",
216 muted: "#9aa5ce",
217 accent: "#7aa2f7",
218 info: "#7dcfff",
219 warning: "#e0af68",
220 success: "#9ece6a",
221 error: "bold #f7768e",
222 border: "#e0af68",
223 title: "#e0af68",
224 }),
225 ThemeOverrides::default(),
226 ),
227 builtin_theme(
228 "molokai",
229 "Molokai",
230 palette(PaletteSpec {
231 text: "#F8F8F2",
232 muted: "#75715E",
233 accent: "#FD971F",
234 info: "#66D9EF",
235 warning: "#E6DB74",
236 success: "#A6E22E",
237 error: "bold #F92672",
238 border: "#E6DB74",
239 title: "#E6DB74",
240 }),
241 ThemeOverrides::default(),
242 ),
243 builtin_theme(
244 "catppuccin",
245 "Catppuccin",
246 palette(PaletteSpec {
247 text: "#cdd6f4",
248 muted: "#89b4fa",
249 accent: "#fab387",
250 info: "#89dceb",
251 warning: "#f9e2af",
252 success: "#a6e3a1",
253 error: "bold #f38ba8",
254 border: "#89dceb",
255 title: "#89dceb",
256 }),
257 ThemeOverrides::default(),
258 ),
259 builtin_theme(
260 "rose-pine-moon",
261 "Rose Pine Moon",
262 palette(PaletteSpec {
263 text: "#e0def4",
264 muted: "#908caa",
265 accent: "#c4a7e7",
266 info: "#9ccfd8",
267 warning: "#f6c177",
268 success: "#8bd5ca",
269 error: "bold #eb6f92",
270 border: "#e8dff6",
271 title: "#e8dff6",
272 }),
273 ThemeOverrides::default(),
274 ),
275 ]
276 })
277}
278
279pub fn builtin_themes() -> Vec<ThemeDefinition> {
280 builtin_theme_defs().to_vec()
281}
282
283pub fn normalize_theme_name(value: &str) -> String {
284 let mut out = String::new();
285 let mut pending_dash = false;
286 for ch in value.trim().chars() {
287 if ch.is_ascii_alphanumeric() {
288 if pending_dash && !out.is_empty() {
289 out.push('-');
290 }
291 pending_dash = false;
292 out.push(ch.to_ascii_lowercase());
293 } else {
294 pending_dash = true;
295 }
296 }
297 out.trim_matches('-').to_string()
298}
299
300pub fn display_name_from_id(value: &str) -> String {
301 let trimmed = value.trim_matches('-');
302 let mut out = String::new();
303 for segment in trimmed.split(['-', '_']) {
304 if segment.is_empty() {
305 continue;
306 }
307 let mut chars = segment.chars();
308 if let Some(first) = chars.next() {
309 if !out.is_empty() {
310 out.push(' ');
311 }
312 out.push(first.to_ascii_uppercase());
313 for ch in chars {
314 out.push(ch.to_ascii_lowercase());
315 }
316 }
317 }
318 if out.is_empty() {
319 trimmed.to_string()
320 } else {
321 out
322 }
323}
324
325pub fn all_themes() -> Vec<ThemeDefinition> {
326 builtin_theme_defs().to_vec()
327}
328
329pub fn available_theme_names() -> Vec<String> {
330 all_themes()
331 .into_iter()
332 .map(|theme| theme.id.clone())
333 .collect()
334}
335
336pub fn find_builtin_theme(name: &str) -> Option<ThemeDefinition> {
337 let normalized = normalize_theme_name(name);
338 if normalized.is_empty() {
339 return None;
340 }
341 builtin_theme_defs()
342 .iter()
343 .find(|theme| theme.id == normalized)
344 .cloned()
345}
346
347pub fn find_theme(name: &str) -> Option<ThemeDefinition> {
348 let normalized = normalize_theme_name(name);
349 if normalized.is_empty() {
350 return None;
351 }
352 builtin_theme_defs()
353 .iter()
354 .find(|theme| theme.id == normalized)
355 .cloned()
356}
357
358pub fn resolve_theme(name: &str) -> ThemeDefinition {
359 find_theme(name).unwrap_or_else(|| {
360 builtin_theme_defs()
361 .iter()
362 .find(|theme| theme.id == DEFAULT_THEME_NAME)
363 .expect("default theme must exist")
364 .clone()
365 })
366}
367
368pub fn is_known_theme(name: &str) -> bool {
369 find_theme(name).is_some()
370}
371
372#[cfg(test)]
373mod tests {
374 use std::hint::black_box;
375
376 use super::{
377 DEFAULT_THEME_NAME, all_themes, available_theme_names, builtin_themes,
378 display_name_from_id, find_builtin_theme, find_theme, is_known_theme, resolve_theme,
379 };
380
381 #[test]
382 fn dracula_number_override_matches_python_theme_preset() {
383 let dracula = find_theme("dracula").expect("dracula theme should exist");
384 assert_eq!(dracula.value_number_spec(), "#ff79c6");
385 }
386
387 #[test]
388 fn repl_completion_defaults_follow_python_late_defaults() {
389 let theme = resolve_theme("rose-pine-moon");
390 assert_eq!(theme.repl_completion_text_spec(), "#000000");
391 assert_eq!(
392 theme.repl_completion_background_spec(),
393 theme.palette.accent
394 );
395 assert_eq!(theme.repl_completion_highlight_spec(), theme.palette.border);
396 }
397
398 #[test]
399 fn display_name_from_id_formats_title_case() {
400 assert_eq!(display_name_from_id("rose-pine-moon"), "Rose Pine Moon");
401 assert_eq!(display_name_from_id("solarized-dark"), "Solarized Dark");
402 }
403
404 #[test]
405 fn display_name_and_lookup_helpers_cover_normalization_edges() {
406 let rose = find_theme(" Rose_Pine Moon ").expect("theme lookup should normalize");
407 assert_eq!(black_box(rose.display_name()), "Rose Pine Moon");
408
409 let builtin =
410 black_box(find_builtin_theme(" TOKYONIGHT ")).expect("builtin theme should normalize");
411 assert_eq!(builtin.id, "tokyonight");
412
413 assert_eq!(black_box(display_name_from_id("--")), "");
414 assert_eq!(
415 black_box(display_name_from_id("-already-title-")),
416 "Already Title"
417 );
418 assert!(black_box(find_theme(" ")).is_none());
419 assert!(black_box(find_builtin_theme(" ")).is_none());
420 }
421
422 #[test]
423 fn theme_catalog_helpers_expose_defaults_and_fallbacks() {
424 let names = black_box(available_theme_names());
425 assert!(names.contains(&DEFAULT_THEME_NAME.to_string()));
426 assert_eq!(
427 black_box(all_themes()).len(),
428 black_box(builtin_themes()).len()
429 );
430 assert!(black_box(is_known_theme("nord")));
431 assert!(!black_box(is_known_theme("missing-theme")));
432
433 let fallback = black_box(resolve_theme("missing-theme"));
434 assert_eq!(fallback.id, DEFAULT_THEME_NAME);
435 }
436}