Skip to main content

nightshade_api/web/
theme.rs

1//! Named color themes: the [`Theme`] palette type, a set of built-in themes,
2//! and helpers for storing, previewing, and applying the active theme via
3//! `data-theme` and injected CSS custom properties.
4
5use leptos::prelude::*;
6
7/// A named color palette. Each field maps to a `--nightshade-*` CSS custom property
8/// emitted by [`Theme::to_css`] and scoped to `:root[data-theme="{id}"]`.
9#[derive(Clone)]
10pub struct Theme {
11    /// Stable identifier used in the `data-theme` attribute and storage.
12    pub id: String,
13    /// Human-readable name shown in theme pickers.
14    pub label: String,
15    /// Whether this is a light theme (sets `color-scheme: light`).
16    pub light: bool,
17    /// Page background color.
18    pub bg: String,
19    /// Primary panel/surface color.
20    pub panel: String,
21    /// Secondary/recessed panel color.
22    pub panel_2: String,
23    /// Panel border color.
24    pub panel_border: String,
25    /// Primary foreground text color.
26    pub text: String,
27    /// Dimmed/secondary text color.
28    pub text_dim: String,
29    /// Accent/highlight color.
30    pub accent: String,
31    /// Background color for input fields.
32    pub input_bg: String,
33    /// Danger/error color.
34    pub danger: String,
35    /// Syntax token color for keywords.
36    pub keyword: String,
37    /// Syntax token color for strings.
38    pub string: String,
39    /// Syntax token color for numbers.
40    pub number: String,
41    /// Syntax token color for comments.
42    pub comment: String,
43    /// Syntax token color for commands.
44    pub command: String,
45}
46
47impl Theme {
48    /// Render this palette as a CSS rule declaring its `--nightshade-*` custom
49    /// properties on `:root[data-theme="{id}"]`.
50    pub fn to_css(&self) -> String {
51        let scheme = if self.light {
52            "\n  color-scheme: light;"
53        } else {
54            ""
55        };
56        format!(
57            ":root[data-theme=\"{id}\"] {{{scheme}\n  \
58             --nightshade-bg: {bg};\n  \
59             --nightshade-panel: {panel};\n  \
60             --nightshade-panel-2: {panel_2};\n  \
61             --nightshade-panel-border: {panel_border};\n  \
62             --nightshade-text: {text};\n  \
63             --nightshade-text-dim: {text_dim};\n  \
64             --nightshade-accent: {accent};\n  \
65             --nightshade-input-bg: {input_bg};\n  \
66             --nightshade-danger: {danger};\n  \
67             --nightshade-tok-keyword: {keyword};\n  \
68             --nightshade-tok-string: {string};\n  \
69             --nightshade-tok-number: {number};\n  \
70             --nightshade-tok-comment: {comment};\n  \
71             --nightshade-tok-command: {command};\n}}\n",
72            id = self.id,
73            bg = self.bg,
74            panel = self.panel,
75            panel_2 = self.panel_2,
76            panel_border = self.panel_border,
77            text = self.text,
78            text_dim = self.text_dim,
79            accent = self.accent,
80            input_bg = self.input_bg,
81            danger = self.danger,
82            keyword = self.keyword,
83            string = self.string,
84            number = self.number,
85            comment = self.comment,
86            command = self.command,
87        )
88    }
89}
90
91macro_rules! theme {
92    (
93        $id:literal, $label:literal, $light:literal,
94        $bg:literal, $panel:literal, $panel_2:literal, $panel_border:literal,
95        $text:literal, $text_dim:literal, $accent:literal, $input_bg:literal, $danger:literal,
96        $keyword:literal, $string:literal, $number:literal, $comment:literal, $command:literal
97    ) => {
98        Theme {
99            id: $id.to_string(),
100            label: $label.to_string(),
101            light: $light,
102            bg: $bg.to_string(),
103            panel: $panel.to_string(),
104            panel_2: $panel_2.to_string(),
105            panel_border: $panel_border.to_string(),
106            text: $text.to_string(),
107            text_dim: $text_dim.to_string(),
108            accent: $accent.to_string(),
109            input_bg: $input_bg.to_string(),
110            danger: $danger.to_string(),
111            keyword: $keyword.to_string(),
112            string: $string.to_string(),
113            number: $number.to_string(),
114            comment: $comment.to_string(),
115            command: $command.to_string(),
116        }
117    };
118}
119
120/// The set of themes shipped with the library, in display order.
121pub fn builtin_themes() -> Vec<Theme> {
122    vec![
123        theme!(
124            "nightshade",
125            "Nightshade",
126            false,
127            "#0c0d12",
128            "#15171d",
129            "#11131a",
130            "#2a2d36",
131            "rgba(255, 255, 255, 0.86)",
132            "rgba(255, 255, 255, 0.52)",
133            "#fb923c",
134            "rgba(255, 255, 255, 0.06)",
135            "#f87171",
136            "#c792ea",
137            "#c3e88d",
138            "#f78c6c",
139            "#5c6370",
140            "#82aaff"
141        ),
142        theme!(
143            "nightshade-light",
144            "Nightshade Light",
145            true,
146            "#f4f5f8",
147            "#ffffff",
148            "#eceef3",
149            "#d4d7e0",
150            "rgba(20, 22, 28, 0.9)",
151            "rgba(20, 22, 28, 0.55)",
152            "#d9700f",
153            "rgba(0, 0, 0, 0.04)",
154            "#d23b3b",
155            "#8b32c9",
156            "#3a8a2f",
157            "#b5500a",
158            "#9098a6",
159            "#1f5fd0"
160        ),
161        theme!(
162            "dracula",
163            "Dracula",
164            false,
165            "#21222c",
166            "#282a36",
167            "#1e1f29",
168            "#3a3c4e",
169            "#f8f8f2",
170            "rgba(248, 248, 242, 0.55)",
171            "#bd93f9",
172            "rgba(255, 255, 255, 0.06)",
173            "#ff5555",
174            "#ff79c6",
175            "#f1fa8c",
176            "#bd93f9",
177            "#6272a4",
178            "#8be9fd"
179        ),
180        theme!(
181            "nord",
182            "Nord",
183            false,
184            "#2e3440",
185            "#3b4252",
186            "#353c4a",
187            "#4c566a",
188            "#eceff4",
189            "rgba(236, 239, 244, 0.55)",
190            "#88c0d0",
191            "rgba(255, 255, 255, 0.06)",
192            "#bf616a",
193            "#81a1c1",
194            "#a3be8c",
195            "#b48ead",
196            "#616e88",
197            "#8fbcbb"
198        ),
199        theme!(
200            "gruvbox",
201            "Gruvbox Dark",
202            false,
203            "#282828",
204            "#32302f",
205            "#1d2021",
206            "#504945",
207            "#ebdbb2",
208            "rgba(235, 219, 178, 0.55)",
209            "#fe8019",
210            "rgba(255, 255, 255, 0.06)",
211            "#fb4934",
212            "#fb4934",
213            "#b8bb26",
214            "#d3869b",
215            "#928374",
216            "#83a598"
217        ),
218        theme!(
219            "one-dark",
220            "One Dark",
221            false,
222            "#21252b",
223            "#282c34",
224            "#1e2228",
225            "#3b4048",
226            "#abb2bf",
227            "rgba(171, 178, 191, 0.55)",
228            "#61afef",
229            "rgba(255, 255, 255, 0.05)",
230            "#e06c75",
231            "#c678dd",
232            "#98c379",
233            "#d19a66",
234            "#5c6370",
235            "#61afef"
236        ),
237        theme!(
238            "catppuccin",
239            "Catppuccin Mocha",
240            false,
241            "#181825",
242            "#1e1e2e",
243            "#161623",
244            "#313244",
245            "#cdd6f4",
246            "rgba(205, 214, 244, 0.55)",
247            "#f5c2e7",
248            "rgba(255, 255, 255, 0.05)",
249            "#f38ba8",
250            "#cba6f7",
251            "#a6e3a1",
252            "#fab387",
253            "#6c7086",
254            "#89b4fa"
255        ),
256        theme!(
257            "tokyo-night",
258            "Tokyo Night",
259            false,
260            "#1a1b26",
261            "#1f2335",
262            "#16161e",
263            "#2f334d",
264            "#c0caf5",
265            "rgba(192, 202, 245, 0.55)",
266            "#7aa2f7",
267            "rgba(255, 255, 255, 0.05)",
268            "#f7768e",
269            "#bb9af7",
270            "#9ece6a",
271            "#ff9e64",
272            "#565f89",
273            "#7dcfff"
274        ),
275        theme!(
276            "solarized-light",
277            "Solarized Light",
278            true,
279            "#fdf6e3",
280            "#eee8d5",
281            "#e6dfc8",
282            "#d6cfb8",
283            "#4c5b61",
284            "rgba(76, 91, 97, 0.6)",
285            "#b58900",
286            "rgba(0, 0, 0, 0.04)",
287            "#dc322f",
288            "#859900",
289            "#2aa198",
290            "#d33682",
291            "#93a1a1",
292            "#268bd2"
293        ),
294    ]
295}
296
297/// The built-in themes as `(id, label)` pairs; the first entry is the default.
298pub const THEMES: &[(&str, &str)] = &[
299    ("nightshade", "Nightshade"),
300    ("nightshade-light", "Nightshade Light"),
301    ("dracula", "Dracula"),
302    ("nord", "Nord"),
303    ("gruvbox", "Gruvbox Dark"),
304    ("one-dark", "One Dark"),
305    ("catppuccin", "Catppuccin Mocha"),
306    ("tokyo-night", "Tokyo Night"),
307    ("solarized-light", "Solarized Light"),
308];
309
310const THEME_KEY: &str = "nightshade-theme";
311const THEME_STYLE_ID: &str = "nightshade-theme-styles";
312
313fn resolve_theme(stored: Option<String>) -> String {
314    stored
315        .filter(|stored| THEMES.iter().any(|(id, _)| id == stored))
316        .unwrap_or_else(|| THEMES[0].0.to_string())
317}
318
319/// Read the persisted theme id from local storage, falling back to the default
320/// when it is missing or unrecognized.
321pub fn stored_theme() -> String {
322    resolve_theme(
323        web_sys::window()
324            .and_then(|window| window.local_storage().ok().flatten())
325            .and_then(|storage| storage.get_item(THEME_KEY).ok().flatten()),
326    )
327}
328
329/// Set the `data-theme` attribute on the document root without persisting it,
330/// useful for transient hover previews.
331pub fn preview_theme(id: &str) {
332    if let Some(element) = web_sys::window()
333        .and_then(|window| window.document())
334        .and_then(|document| document.document_element())
335    {
336        let _ = element.set_attribute("data-theme", id);
337    }
338}
339
340/// Apply a theme (via [`preview_theme`]) and persist its id to local storage.
341pub fn apply_theme(id: &str) {
342    preview_theme(id);
343    if let Some(storage) =
344        web_sys::window().and_then(|window| window.local_storage().ok().flatten())
345    {
346        let _ = storage.set_item(THEME_KEY, id);
347    }
348}
349
350fn inject_theme_css(css: &str) {
351    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
352        return;
353    };
354    let element = document.get_element_by_id(THEME_STYLE_ID).or_else(|| {
355        let head = document.head()?;
356        let element = document.create_element("style").ok()?;
357        let _ = element.set_attribute("id", THEME_STYLE_ID);
358        head.append_child(&element).ok()?;
359        Some(element)
360    });
361    if let Some(element) = element {
362        element.set_text_content(Some(css));
363    }
364}
365
366#[derive(Clone, Copy)]
367struct ThemeContext(RwSignal<String>);
368
369#[derive(Clone, Copy)]
370struct ThemeRegistry(RwSignal<Vec<Theme>>);
371
372/// Get the active theme-id signal from context (provided by [`ThemeProvider`]),
373/// or a standalone signal seeded from storage when no provider is present.
374pub fn use_theme() -> RwSignal<String> {
375    use_context::<ThemeContext>()
376        .map(|context| context.0)
377        .unwrap_or_else(|| RwSignal::new(stored_theme()))
378}
379
380/// Get the registered themes from context (provided by [`ThemeProvider`]),
381/// falling back to [`builtin_themes`] when no provider is present.
382pub fn use_themes() -> Vec<Theme> {
383    use_context::<ThemeRegistry>()
384        .map(|registry| registry.0.get())
385        .unwrap_or_else(builtin_themes)
386}
387
388/// Add a custom theme to the enclosing [`ThemeProvider`]'s registry, replacing
389/// any existing theme with the same id. No-op without a provider.
390pub fn register_theme(theme: Theme) {
391    if let Some(registry) = use_context::<ThemeRegistry>() {
392        registry.0.update(|themes| {
393            if let Some(slot) = themes.iter_mut().find(|existing| existing.id == theme.id) {
394                *slot = theme;
395            } else {
396                themes.push(theme);
397            }
398        });
399    }
400}
401
402/// Provides the active-theme signal and theme registry to `children`, injects
403/// the registered themes' CSS, and keeps the document's `data-theme` in sync
404/// with the active theme. Wrap your app in this to enable theming.
405#[component]
406pub fn ThemeProvider(children: Children) -> impl IntoView {
407    let theme = RwSignal::new(stored_theme());
408    let registry = RwSignal::new(builtin_themes());
409    provide_context(ThemeContext(theme));
410    provide_context(ThemeRegistry(registry));
411
412    Effect::new(move |_| {
413        let css = registry.get().iter().map(Theme::to_css).collect::<String>();
414        inject_theme_css(&css);
415    });
416    Effect::new(move |_| apply_theme(&theme.get()));
417
418    children()
419}
420
421/// A `<select>` dropdown for choosing among the registered themes, bound to the
422/// active-theme signal.
423#[component]
424pub fn ThemePicker() -> impl IntoView {
425    let theme = use_theme();
426    view! {
427        <select
428            class="nightshade-theme-picker"
429            prop:value=move || theme.get()
430            on:change=move |event| theme.set(event_target_value(&event))
431        >
432            {move || {
433                use_themes()
434                    .into_iter()
435                    .map(|entry| view! { <option value=entry.id.clone()>{entry.label}</option> })
436                    .collect_view()
437            }}
438        </select>
439    }
440}
441
442/// A button-triggered theme menu that live-previews each theme on hover and
443/// commits the selection on click.
444#[component]
445pub fn ThemeMenu() -> impl IntoView {
446    let theme = use_theme();
447    let open = RwSignal::new(false);
448    let revert = move || preview_theme(&theme.get_untracked());
449    view! {
450        <div class="nightshade-theme-menu">
451            <button
452                class="nightshade-button"
453                aria-haspopup="menu"
454                aria-expanded=move || open.get().to_string()
455                on:click=move |_| open.update(|value| *value = !*value)
456            >
457                "Theme"
458            </button>
459            <Show when=move || open.get() fallback=|| ()>
460                <div class="nightshade-theme-menu-list" role="menu" on:pointerleave=move |_| revert()>
461                    {move || {
462                        use_themes()
463                            .into_iter()
464                            .map(|entry| {
465                                let id_hover = entry.id.clone();
466                                let id_click = entry.id.clone();
467                                let id_active = entry.id.clone();
468                                view! {
469                                    <button
470                                        class="nightshade-theme-menu-item"
471                                        class:active=move || theme.get() == id_active
472                                        on:pointerenter=move |_| preview_theme(&id_hover)
473                                        on:click=move |_| {
474                                            theme.set(id_click.clone());
475                                            open.set(false);
476                                        }
477                                    >
478                                        {entry.label}
479                                    </button>
480                                }
481                            })
482                            .collect_view()
483                    }}
484                </div>
485            </Show>
486        </div>
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::{THEMES, builtin_themes, resolve_theme};
493
494    #[test]
495    fn known_theme_is_preserved() {
496        assert_eq!(resolve_theme(Some("dracula".to_string())), "dracula");
497    }
498
499    #[test]
500    fn unknown_or_missing_theme_falls_back_to_default() {
501        let default = THEMES[0].0;
502        assert_eq!(resolve_theme(None), default);
503        assert_eq!(resolve_theme(Some("does-not-exist".to_string())), default);
504    }
505
506    #[test]
507    fn builtins_cover_the_theme_list_and_emit_tokens() {
508        let themes = builtin_themes();
509        assert_eq!(themes.len(), THEMES.len());
510        for (theme, (id, _)) in themes.iter().zip(THEMES) {
511            assert_eq!(&theme.id, id);
512            assert!(theme.to_css().contains("--nightshade-accent"));
513        }
514    }
515}