1use leptos::prelude::*;
6
7#[derive(Clone)]
10pub struct Theme {
11 pub id: String,
13 pub label: String,
15 pub light: bool,
17 pub bg: String,
19 pub panel: String,
21 pub panel_2: String,
23 pub panel_border: String,
25 pub text: String,
27 pub text_dim: String,
29 pub accent: String,
31 pub input_bg: String,
33 pub danger: String,
35 pub keyword: String,
37 pub string: String,
39 pub number: String,
41 pub comment: String,
43 pub command: String,
45}
46
47impl Theme {
48 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
120pub 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
297pub 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
319pub 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
329pub 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
340pub 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
372pub fn use_theme() -> RwSignal<String> {
375 use_context::<ThemeContext>()
376 .map(|context| context.0)
377 .unwrap_or_else(|| RwSignal::new(stored_theme()))
378}
379
380pub fn use_themes() -> Vec<Theme> {
383 use_context::<ThemeRegistry>()
384 .map(|registry| registry.0.get())
385 .unwrap_or_else(builtin_themes)
386}
387
388pub 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#[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#[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#[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}