Skip to main content

osp_cli/ui/
theme.rs

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}