Skip to main content

osp_cli/ui/
theme.rs

1use std::ops::Deref;
2use std::sync::{Arc, OnceLock};
3
4/// Palette entries used by the built-in terminal themes.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct ThemePalette {
7    /// Base foreground color for plain text.
8    pub text: String,
9    /// Secondary or de-emphasized foreground color.
10    pub muted: String,
11    /// Accent color used for keys and highlights.
12    pub accent: String,
13    /// Informational message color.
14    pub info: String,
15    /// Warning message color.
16    pub warning: String,
17    /// Success message color.
18    pub success: String,
19    /// Error message color.
20    pub error: String,
21    /// Border and chrome color.
22    pub border: String,
23    /// Title and heading color.
24    pub title: String,
25    /// Selection/highlight color.
26    pub selection: String,
27    /// Link color.
28    pub link: String,
29    /// Optional primary background color.
30    pub bg: Option<String>,
31    /// Optional alternate background color.
32    pub bg_alt: Option<String>,
33}
34
35/// Concrete theme data shared by theme handles.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ThemeData {
38    /// Stable theme identifier used for config and lookup.
39    pub id: String,
40    /// User-facing display name.
41    pub name: String,
42    /// Optional parent theme identifier.
43    pub base: Option<String>,
44    /// Core palette values for the theme.
45    pub palette: ThemePalette,
46    /// Optional overrides for derived semantic tokens.
47    pub overrides: ThemeOverrides,
48}
49
50/// Shared handle to resolved theme data.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ThemeDefinition(Arc<ThemeData>);
53
54/// Optional theme-specific overrides for derived style tokens.
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct ThemeOverrides {
57    /// Override for numeric values.
58    pub value_number: Option<String>,
59    /// Override for completion menu text.
60    pub repl_completion_text: Option<String>,
61    /// Override for completion menu backgrounds.
62    pub repl_completion_background: Option<String>,
63    /// Override for the active completion entry highlight.
64    pub repl_completion_highlight: Option<String>,
65}
66
67impl ThemeDefinition {
68    /// Builds a theme definition from palette data and optional overrides.
69    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    /// Returns the style specification used for numeric values.
86    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    /// Returns the style specification used for REPL completion text.
94    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    /// Returns the style specification used for REPL completion backgrounds.
102    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    /// Returns the style specification used for the highlighted completion entry.
110    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    /// Returns the display name shown to users.
118    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
131/// Default theme identifier used when no valid theme is configured.
132pub 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
312/// Returns the shipped theme catalog as owned theme definitions.
313pub fn builtin_themes() -> Vec<ThemeDefinition> {
314    builtin_theme_defs().to_vec()
315}
316
317/// Normalizes a theme name for lookup.
318///
319/// # Examples
320///
321/// ```
322/// use osp_cli::ui::normalize_theme_name;
323///
324/// assert_eq!(normalize_theme_name(" Rose Pine Moon "), "rose-pine-moon");
325/// assert_eq!(normalize_theme_name("tokyo_night"), "tokyo-night");
326/// ```
327pub 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
344/// Converts a theme identifier into a user-facing display name.
345///
346/// # Examples
347///
348/// ```
349/// use osp_cli::ui::display_name_from_id;
350///
351/// assert_eq!(display_name_from_id("rose-pine-moon"), "Rose Pine Moon");
352/// assert_eq!(display_name_from_id("dracula"), "Dracula");
353/// ```
354pub 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
379/// Returns all currently available built-in themes.
380///
381/// This mirrors [`builtin_themes`] but makes the intent clearer at call sites
382/// that treat the shipped theme catalog as the full available set.
383pub fn all_themes() -> Vec<ThemeDefinition> {
384    builtin_theme_defs().to_vec()
385}
386
387/// Returns the normalized identifiers of all available built-in themes.
388pub fn available_theme_names() -> Vec<String> {
389    all_themes()
390        .into_iter()
391        .map(|theme| theme.id.clone())
392        .collect()
393}
394
395/// Finds a built-in theme by name after normalization.
396pub 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
407/// Finds a theme by name after normalization.
408pub 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
419/// Resolves a theme by name, falling back to the default theme.
420///
421/// # Examples
422///
423/// ```
424/// use osp_cli::ui::{DEFAULT_THEME_NAME, resolve_theme};
425///
426/// assert_eq!(resolve_theme("dracula").id, "dracula");
427/// assert_eq!(resolve_theme("missing-theme").id, DEFAULT_THEME_NAME);
428/// ```
429pub 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
460/// Returns whether a theme name resolves to a known theme.
461pub 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}