1use dioxus::prelude::*;
18#[cfg(target_arch = "wasm32")]
22use dioxus::html::InteractionLocation;
23
24#[cfg(any(target_arch = "wasm32", test))]
26const THEME_KEY: &str = "yggdrasil-theme";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ResolvedTheme {
34 Light,
36 Dark,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Theme {
43 Light,
45 Dark,
47 System,
49}
50
51impl Theme {
52 pub fn cycle(&self) -> Self {
54 match self {
55 Theme::Dark => Theme::Light,
56 Theme::Light => Theme::System,
57 Theme::System => Theme::Dark,
58 }
59 }
60
61 pub fn resolve(&self, system_dark: bool) -> ResolvedTheme {
66 match self {
67 Theme::Light => ResolvedTheme::Light,
68 Theme::Dark => ResolvedTheme::Dark,
69 Theme::System => {
70 if system_dark {
71 ResolvedTheme::Dark
72 } else {
73 ResolvedTheme::Light
74 }
75 }
76 }
77 }
78}
79
80fn read_system_dark() -> bool {
85 #[cfg(target_arch = "wasm32")]
86 {
87 if let Some(window) = web_sys::window() {
88 if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
89 return media.matches();
90 }
91 }
92 }
93 false
94}
95
96#[cfg(target_arch = "wasm32")]
101fn detect_initial_theme() -> Theme {
102 if let Some(window) = web_sys::window() {
103 if let Ok(Some(storage)) = window.local_storage() {
104 if let Ok(Some(value)) = storage.get_item(THEME_KEY) {
105 return match value.as_str() {
106 "dark" => Theme::Dark,
107 "light" => Theme::Light,
108 _ => Theme::System,
109 };
110 }
111 }
112 }
113 Theme::System
114}
115
116pub fn use_theme_provider() -> Signal<Theme> {
133 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
135 let mut theme = use_signal(|| Theme::System);
136 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
140 let mut system_dark = use_signal(read_system_dark);
141 let resolved = use_memo(move || theme().resolve(system_dark()));
143
144 #[cfg(target_arch = "wasm32")]
145 {
146 let mut initialized = use_signal(|| false);
147 use_effect(move || {
148 theme.set(detect_initial_theme());
149 initialized.set(true);
150 });
151
152 use_effect(move || {
154 if !initialized() {
155 return;
156 }
157 let current = theme();
158 let mode = match current {
159 Theme::Light => "light",
160 Theme::Dark => "dark",
161 Theme::System => "system",
162 };
163 if let Some(window) = web_sys::window() {
164 if let Some(html) = window.document().and_then(|doc| doc.document_element()) {
165 let _ = html.set_attribute("data-theme-mode", mode);
166 }
167 if let Ok(Some(storage)) = window.local_storage() {
168 if current == Theme::System {
169 let _ = storage.remove_item(THEME_KEY);
170 } else {
171 let _ = storage.set_item(THEME_KEY, mode);
172 }
173 }
174 }
175 });
176 }
177
178 #[cfg(target_arch = "wasm32")]
196 {
197 let mut prev_resolved: Signal<Option<ResolvedTheme>> = use_signal(|| None);
198 use_effect(move || {
199 if theme() != Theme::System {
201 return;
202 }
203 let current = resolved();
204 if prev_resolved() == Some(current) {
206 return;
207 }
208 prev_resolved.set(Some(current));
209 let Some(window) = web_sys::window() else {
211 return;
212 };
213 let key = "__applyResolvedTheme".into();
214 if let Ok(fn_val) = js_sys::Reflect::get(&window, &key) {
215 if !fn_val.is_undefined() && !fn_val.is_null() {
216 use wasm_bindgen::JsCast;
217 let fn_obj = fn_val.unchecked_into::<js_sys::Function>();
218 let is_dark = current == ResolvedTheme::Dark;
219 let _ = fn_obj.call1(&window, &is_dark.into());
220 }
221 }
222 });
223 }
224
225 #[cfg(target_arch = "wasm32")]
230 {
231 use crate::hooks::event_listener::use_event_listener;
232
233 use_event_listener(
234 move || {
235 let window = web_sys::window()?;
236 let media = window
237 .match_media("(prefers-color-scheme: dark)")
238 .ok()
239 .flatten()?;
240 system_dark.set(media.matches());
242 Some(media)
243 },
244 "change",
245 move || {
246 if let Some(window) = web_sys::window() {
249 if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
250 system_dark.set(media.matches());
251 }
252 }
253 },
254 );
255 }
256
257 use_context_provider(|| theme);
258 use_context_provider(|| resolved);
259 theme
260}
261
262pub fn use_theme() -> Signal<Theme> {
266 use_context::<Signal<Theme>>()
267}
268
269pub fn use_resolved_theme() -> Memo<ResolvedTheme> {
274 use_context::<Memo<ResolvedTheme>>()
275}
276
277const THEME_PRELOAD_SCRIPT: &str = r#"
278(function() {
279 var theme = 'system';
280 try {
281 var saved = localStorage.getItem('yggdrasil-theme');
282 if (saved === 'dark' || saved === 'light') theme = saved;
283 } catch (e) {}
284 document.documentElement.setAttribute('data-theme-mode', theme);
285 document.documentElement.classList.toggle('dark',
286 theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches));
287})();
288"#;
289
290const THEME_ICON_STYLE: &str = r#"
292.theme-toggle svg { display: none; }
293html[data-theme-mode="light"] .theme-toggle .theme-icon-light,
294html[data-theme-mode="dark"] .theme-toggle .theme-icon-dark,
295html[data-theme-mode="system"] .theme-toggle .theme-icon-system,
296html:not([data-theme-mode]) .theme-toggle .theme-icon-system { display: block; }
297"#;
298
299#[component]
304pub fn ThemePreload() -> Element {
305 rsx! {
306 style { dangerous_inner_html: "{THEME_ICON_STYLE}" }
307 script { dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}" }
308 }
309}
310
311#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
321#[component]
322pub fn ThemeToggle() -> Element {
323 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
325 let mut theme = use_theme();
326 #[cfg(target_arch = "wasm32")]
328 let resolved = use_resolved_theme();
329
330 rsx! {
331 button {
332 class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary",
333 r#type: "button",
334 onclick: move |evt| {
335 let next = theme().cycle();
336 #[cfg(target_arch = "wasm32")]
337 {
338 let prev_resolved = resolved();
339 let new_resolved = next.resolve(read_system_dark());
340 if prev_resolved != new_resolved {
341 use wasm_bindgen::JsCast;
342 let coords = evt.client_coordinates();
347 let x = coords.x;
348 let y = coords.y;
349 let window = web_sys::window()
350 .expect(
351 "主题切换回调仅在 WASM 浏览器上下文执行:无 window",
352 );
353 let key = "__startThemeTransition".into();
354 if let Ok(fn_val) = js_sys::Reflect::get(&window, &key) {
355 if !fn_val.is_undefined() && !fn_val.is_null() {
356 let fn_obj = fn_val.unchecked_into::<js_sys::Function>();
357 let _ = fn_obj.call2(&window, &x.into(), &y.into());
358 }
359 }
360 }
361 theme.set(next);
362 }
363 #[cfg(not(target_arch = "wasm32"))]
364 {
365 theme.set(next);
366 }
367 },
368 svg {
370 class: "theme-icon-dark",
371 role: "img",
372 "aria-label": "主题切换(当前:{mode_label(Theme::Dark)})",
373 xmlns: "http://www.w3.org/2000/svg",
374 height: "24px",
375 view_box: "0 -960 960 960",
376 width: "24px",
377 fill: "currentColor",
378 title { "当前:{mode_label(Theme::Dark)}(点击切换)" }
379 path { d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z" }
380 }
381 svg {
382 class: "theme-icon-light",
383 role: "img",
384 "aria-label": "主题切换(当前:{mode_label(Theme::Light)})",
385 xmlns: "http://www.w3.org/2000/svg",
386 height: "24px",
387 view_box: "0 -960 960 960",
388 width: "24px",
389 fill: "currentColor",
390 title { "当前:{mode_label(Theme::Light)}(点击切换)" }
391 path { d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z" }
392 }
393 svg {
394 class: "theme-icon-system",
395 role: "img",
396 "aria-label": "主题切换(当前:{mode_label(Theme::System)})",
397 xmlns: "http://www.w3.org/2000/svg",
398 height: "24px",
399 view_box: "0 -960 960 960",
400 width: "24px",
401 fill: "currentColor",
402 title { "当前:{mode_label(Theme::System)}(点击切换)" }
403 path { d: "M40-120v-80h880v80H40Zm120-120q-33 0-56.5-23.5T80-320v-440q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v440q0 33-23.5 56.5T800-240H160Zm0-80h640v-440H160v440Zm0 0v-440 440Z" }
404 }
405 }
406 }
407}
408
409fn mode_label(theme: Theme) -> &'static str {
411 match theme {
412 Theme::Light => "浅色",
413 Theme::Dark => "深色",
414 Theme::System => "跟随系统",
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn cycle_rotates_three_states() {
424 assert_eq!(Theme::Dark.cycle(), Theme::Light);
426 assert_eq!(Theme::Light.cycle(), Theme::System);
427 assert_eq!(Theme::System.cycle(), Theme::Dark);
428 }
429
430 #[test]
431 fn cycle_returns_to_origin_after_full_loop() {
432 assert_eq!(Theme::Light.cycle().cycle().cycle(), Theme::Light);
434 assert_eq!(Theme::Dark.cycle().cycle().cycle(), Theme::Dark);
435 assert_eq!(Theme::System.cycle().cycle().cycle(), Theme::System);
436 }
437
438 #[test]
439 fn resolve_light_and_dark_ignore_system() {
440 assert_eq!(Theme::Light.resolve(true), ResolvedTheme::Light);
442 assert_eq!(Theme::Light.resolve(false), ResolvedTheme::Light);
443 assert_eq!(Theme::Dark.resolve(true), ResolvedTheme::Dark);
444 assert_eq!(Theme::Dark.resolve(false), ResolvedTheme::Dark);
445 }
446
447 #[test]
448 fn resolve_system_follows_system_dark() {
449 assert_eq!(Theme::System.resolve(true), ResolvedTheme::Dark);
451 assert_eq!(Theme::System.resolve(false), ResolvedTheme::Light);
452 }
453
454 #[test]
455 fn theme_derives_equality() {
456 assert_eq!(Theme::Light, Theme::Light);
458 assert_eq!(Theme::Dark, Theme::Dark);
459 assert_eq!(Theme::System, Theme::System);
460 assert_ne!(Theme::Light, Theme::Dark);
461 assert_ne!(Theme::Dark, Theme::System);
462 assert_ne!(Theme::System, Theme::Light);
463 }
464
465 #[test]
466 fn resolved_theme_derives_equality() {
467 assert_eq!(ResolvedTheme::Light, ResolvedTheme::Light);
468 assert_eq!(ResolvedTheme::Dark, ResolvedTheme::Dark);
469 assert_ne!(ResolvedTheme::Light, ResolvedTheme::Dark);
470 }
471
472 #[test]
473 fn mode_label_covers_all_variants() {
474 assert_eq!(mode_label(Theme::Light), "浅色");
475 assert_eq!(mode_label(Theme::Dark), "深色");
476 assert_eq!(mode_label(Theme::System), "跟随系统");
477 }
478
479 #[test]
480 fn theme_preload_script_sets_dark_class() {
481 assert!(THEME_PRELOAD_SCRIPT.contains("classList.toggle('dark',"));
483 }
484
485 #[test]
486 fn theme_preload_script_reads_local_storage() {
487 assert!(THEME_PRELOAD_SCRIPT.contains("localStorage.getItem('yggdrasil-theme')"));
489 assert_eq!(THEME_KEY, "yggdrasil-theme");
490 }
491
492 #[test]
493 fn theme_preload_script_falls_back_to_prefers_color_scheme() {
494 assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
497 }
498
499 #[test]
500 fn theme_preload_script_swallows_errors() {
501 assert!(THEME_PRELOAD_SCRIPT.contains("try"));
503 assert!(THEME_PRELOAD_SCRIPT.contains("catch"));
504 }
505}