1use std::ops::Deref;
2use std::sync::{Arc, OnceLock};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct ThemePalette {
7 pub text: String,
9 pub muted: String,
11 pub accent: String,
13 pub info: String,
15 pub warning: String,
17 pub success: String,
19 pub error: String,
21 pub border: String,
23 pub title: String,
25 pub selection: String,
27 pub link: String,
29 pub bg: Option<String>,
31 pub bg_alt: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ThemeData {
38 pub id: String,
40 pub name: String,
42 pub base: Option<String>,
44 pub palette: ThemePalette,
46 pub overrides: ThemeOverrides,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ThemeDefinition(Arc<ThemeData>);
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct ThemeOverrides {
57 pub value_number: Option<String>,
59 pub repl_completion_text: Option<String>,
61 pub repl_completion_background: Option<String>,
63 pub repl_completion_highlight: Option<String>,
65}
66
67impl ThemeDefinition {
68 pub fn new(
70 id: impl Into<String>,
71 name: impl Into<String>,
72 base: Option<String>,
73 palette: ThemePalette,
74 overrides: ThemeOverrides,
75 ) -> Self {
76 Self(Arc::new(ThemeData {
77 id: id.into(),
78 name: name.into(),
79 base,
80 palette,
81 overrides,
82 }))
83 }
84
85 pub fn value_number_spec(&self) -> &str {
87 self.overrides
88 .value_number
89 .as_deref()
90 .unwrap_or(&self.palette.success)
91 }
92
93 pub fn repl_completion_text_spec(&self) -> &str {
95 self.overrides
96 .repl_completion_text
97 .as_deref()
98 .unwrap_or("#000000")
99 }
100
101 pub fn repl_completion_background_spec(&self) -> &str {
103 self.overrides
104 .repl_completion_background
105 .as_deref()
106 .unwrap_or(&self.palette.accent)
107 }
108
109 pub fn repl_completion_highlight_spec(&self) -> &str {
111 self.overrides
112 .repl_completion_highlight
113 .as_deref()
114 .unwrap_or(&self.palette.border)
115 }
116
117 pub fn display_name(&self) -> &str {
119 self.name.as_str()
120 }
121}
122
123impl Deref for ThemeDefinition {
124 type Target = ThemeData;
125
126 fn deref(&self) -> &Self::Target {
127 self.0.as_ref()
128 }
129}
130
131pub const DEFAULT_THEME_NAME: &str = "rose-pine-moon";
133
134struct PaletteSpec<'a> {
135 text: &'a str,
136 muted: &'a str,
137 accent: &'a str,
138 info: &'a str,
139 warning: &'a str,
140 success: &'a str,
141 error: &'a str,
142 border: &'a str,
143 title: &'a str,
144}
145
146fn palette(spec: PaletteSpec<'_>) -> ThemePalette {
147 ThemePalette {
148 text: spec.text.to_string(),
149 muted: spec.muted.to_string(),
150 accent: spec.accent.to_string(),
151 info: spec.info.to_string(),
152 warning: spec.warning.to_string(),
153 success: spec.success.to_string(),
154 error: spec.error.to_string(),
155 border: spec.border.to_string(),
156 title: spec.title.to_string(),
157 selection: spec.accent.to_string(),
158 link: spec.accent.to_string(),
159 bg: None,
160 bg_alt: None,
161 }
162}
163
164fn builtin_theme(
165 id: &'static str,
166 name: &'static str,
167 palette: ThemePalette,
168 overrides: ThemeOverrides,
169) -> ThemeDefinition {
170 ThemeDefinition::new(id, name, None, palette, overrides)
171}
172
173fn builtin_theme_defs() -> &'static [ThemeDefinition] {
174 static THEMES: OnceLock<Vec<ThemeDefinition>> = OnceLock::new();
175 THEMES.get_or_init(|| {
176 vec![
177 builtin_theme(
178 "plain",
179 "Plain",
180 palette(PaletteSpec {
181 text: "",
182 muted: "",
183 accent: "",
184 info: "",
185 warning: "",
186 success: "",
187 error: "",
188 border: "",
189 title: "",
190 }),
191 ThemeOverrides::default(),
192 ),
193 builtin_theme(
194 "nord",
195 "Nord",
196 palette(PaletteSpec {
197 text: "#d8dee9",
198 muted: "#6d7688",
199 accent: "#88c0d0",
200 info: "#81a1c1",
201 warning: "#ebcb8b",
202 success: "#a3be8c",
203 error: "bold #bf616a",
204 border: "#81a1c1",
205 title: "#81a1c1",
206 }),
207 ThemeOverrides::default(),
208 ),
209 builtin_theme(
210 "dracula",
211 "Dracula",
212 palette(PaletteSpec {
213 text: "#f8f8f2",
214 muted: "#6879ad",
215 accent: "#bd93f9",
216 info: "#8be9fd",
217 warning: "#f1fa8c",
218 success: "#50fa7b",
219 error: "bold #ff5555",
220 border: "#ff79c6",
221 title: "#ff79c6",
222 }),
223 ThemeOverrides {
224 value_number: Some("#ff79c6".to_string()),
225 ..ThemeOverrides::default()
226 },
227 ),
228 builtin_theme(
229 "gruvbox",
230 "Gruvbox",
231 palette(PaletteSpec {
232 text: "#ebdbb2",
233 muted: "#a89984",
234 accent: "#8ec07c",
235 info: "#83a598",
236 warning: "#fe8019",
237 success: "#b8bb26",
238 error: "bold #fb4934",
239 border: "#fabd2f",
240 title: "#fabd2f",
241 }),
242 ThemeOverrides::default(),
243 ),
244 builtin_theme(
245 "tokyonight",
246 "Tokyo Night",
247 palette(PaletteSpec {
248 text: "#c0caf5",
249 muted: "#9aa5ce",
250 accent: "#7aa2f7",
251 info: "#7dcfff",
252 warning: "#e0af68",
253 success: "#9ece6a",
254 error: "bold #f7768e",
255 border: "#e0af68",
256 title: "#e0af68",
257 }),
258 ThemeOverrides::default(),
259 ),
260 builtin_theme(
261 "molokai",
262 "Molokai",
263 palette(PaletteSpec {
264 text: "#F8F8F2",
265 muted: "#75715E",
266 accent: "#FD971F",
267 info: "#66D9EF",
268 warning: "#E6DB74",
269 success: "#A6E22E",
270 error: "bold #F92672",
271 border: "#E6DB74",
272 title: "#E6DB74",
273 }),
274 ThemeOverrides::default(),
275 ),
276 builtin_theme(
277 "catppuccin",
278 "Catppuccin",
279 palette(PaletteSpec {
280 text: "#cdd6f4",
281 muted: "#89b4fa",
282 accent: "#fab387",
283 info: "#89dceb",
284 warning: "#f9e2af",
285 success: "#a6e3a1",
286 error: "bold #f38ba8",
287 border: "#89dceb",
288 title: "#89dceb",
289 }),
290 ThemeOverrides::default(),
291 ),
292 builtin_theme(
293 "rose-pine-moon",
294 "Rose Pine Moon",
295 palette(PaletteSpec {
296 text: "#e0def4",
297 muted: "#908caa",
298 accent: "#c4a7e7",
299 info: "#9ccfd8",
300 warning: "#f6c177",
301 success: "#8bd5ca",
302 error: "bold #eb6f92",
303 border: "#e8dff6",
304 title: "#e8dff6",
305 }),
306 ThemeOverrides::default(),
307 ),
308 ]
309 })
310}
311
312pub fn builtin_themes() -> Vec<ThemeDefinition> {
314 builtin_theme_defs().to_vec()
315}
316
317pub fn normalize_theme_name(value: &str) -> String {
328 let mut out = String::new();
329 let mut pending_dash = false;
330 for ch in value.trim().chars() {
331 if ch.is_ascii_alphanumeric() {
332 if pending_dash && !out.is_empty() {
333 out.push('-');
334 }
335 pending_dash = false;
336 out.push(ch.to_ascii_lowercase());
337 } else {
338 pending_dash = true;
339 }
340 }
341 out.trim_matches('-').to_string()
342}
343
344pub fn display_name_from_id(value: &str) -> String {
355 let trimmed = value.trim_matches('-');
356 let mut out = String::new();
357 for segment in trimmed.split(['-', '_']) {
358 if segment.is_empty() {
359 continue;
360 }
361 let mut chars = segment.chars();
362 if let Some(first) = chars.next() {
363 if !out.is_empty() {
364 out.push(' ');
365 }
366 out.push(first.to_ascii_uppercase());
367 for ch in chars {
368 out.push(ch.to_ascii_lowercase());
369 }
370 }
371 }
372 if out.is_empty() {
373 trimmed.to_string()
374 } else {
375 out
376 }
377}
378
379pub fn all_themes() -> Vec<ThemeDefinition> {
384 builtin_theme_defs().to_vec()
385}
386
387pub fn available_theme_names() -> Vec<String> {
389 all_themes()
390 .into_iter()
391 .map(|theme| theme.id.clone())
392 .collect()
393}
394
395pub fn find_builtin_theme(name: &str) -> Option<ThemeDefinition> {
397 let normalized = normalize_theme_name(name);
398 if normalized.is_empty() {
399 return None;
400 }
401 builtin_theme_defs()
402 .iter()
403 .find(|theme| theme.id == normalized)
404 .cloned()
405}
406
407pub fn find_theme(name: &str) -> Option<ThemeDefinition> {
409 let normalized = normalize_theme_name(name);
410 if normalized.is_empty() {
411 return None;
412 }
413 builtin_theme_defs()
414 .iter()
415 .find(|theme| theme.id == normalized)
416 .cloned()
417}
418
419pub fn resolve_theme(name: &str) -> ThemeDefinition {
430 find_theme(name).unwrap_or_else(default_theme_fallback)
431}
432
433fn default_theme_fallback() -> ThemeDefinition {
434 builtin_theme_defs()
435 .iter()
436 .find(|theme| theme.id == DEFAULT_THEME_NAME)
437 .cloned()
438 .or_else(|| builtin_theme_defs().first().cloned())
439 .unwrap_or_else(|| {
440 ThemeDefinition::new(
441 "plain",
442 "Plain",
443 None,
444 palette(PaletteSpec {
445 text: "",
446 muted: "",
447 accent: "",
448 info: "",
449 warning: "",
450 success: "",
451 error: "",
452 border: "",
453 title: "",
454 }),
455 ThemeOverrides::default(),
456 )
457 })
458}
459
460pub fn is_known_theme(name: &str) -> bool {
462 find_theme(name).is_some()
463}
464
465#[cfg(test)]
466mod tests {
467 use std::hint::black_box;
468
469 use super::{
470 DEFAULT_THEME_NAME, all_themes, available_theme_names, builtin_themes,
471 display_name_from_id, find_builtin_theme, find_theme, is_known_theme, resolve_theme,
472 };
473
474 #[test]
475 fn dracula_number_override_matches_python_theme_preset() {
476 let dracula = find_theme("dracula").expect("dracula theme should exist");
477 assert_eq!(dracula.value_number_spec(), "#ff79c6");
478 }
479
480 #[test]
481 fn repl_completion_defaults_follow_python_late_defaults() {
482 let theme = resolve_theme("rose-pine-moon");
483 assert_eq!(theme.repl_completion_text_spec(), "#000000");
484 assert_eq!(
485 theme.repl_completion_background_spec(),
486 theme.palette.accent
487 );
488 assert_eq!(theme.repl_completion_highlight_spec(), theme.palette.border);
489 }
490
491 #[test]
492 fn repl_completion_text_defaults_to_black_for_all_themes() {
493 for theme_id in ["rose-pine-moon", "dracula", "tokyonight", "catppuccin"] {
494 let theme = resolve_theme(theme_id);
495 assert_eq!(theme.repl_completion_text_spec(), "#000000");
496 }
497 }
498
499 #[test]
500 fn display_name_from_id_formats_title_case() {
501 assert_eq!(display_name_from_id("rose-pine-moon"), "Rose Pine Moon");
502 assert_eq!(display_name_from_id("solarized-dark"), "Solarized Dark");
503 }
504
505 #[test]
506 fn display_name_and_lookup_helpers_cover_normalization_edges() {
507 let rose = find_theme(" Rose_Pine Moon ").expect("theme lookup should normalize");
508 assert_eq!(black_box(rose.display_name()), "Rose Pine Moon");
509
510 let builtin =
511 black_box(find_builtin_theme(" TOKYONIGHT ")).expect("builtin theme should normalize");
512 assert_eq!(builtin.id, "tokyonight");
513
514 assert_eq!(black_box(display_name_from_id("--")), "");
515 assert_eq!(
516 black_box(display_name_from_id("-already-title-")),
517 "Already Title"
518 );
519 assert!(black_box(find_theme(" ")).is_none());
520 assert!(black_box(find_builtin_theme(" ")).is_none());
521 }
522
523 #[test]
524 fn theme_catalog_helpers_expose_defaults_and_fallbacks() {
525 let names = black_box(available_theme_names());
526 assert!(names.contains(&DEFAULT_THEME_NAME.to_string()));
527 assert_eq!(
528 black_box(all_themes()).len(),
529 black_box(builtin_themes()).len()
530 );
531 assert!(black_box(is_known_theme("nord")));
532 assert!(!black_box(is_known_theme("missing-theme")));
533
534 let fallback = black_box(resolve_theme("missing-theme"));
535 assert_eq!(fallback.id, DEFAULT_THEME_NAME);
536 }
537}