1use dioxus::prelude::*;
19#[cfg(target_arch = "wasm32")]
23use dioxus::html::InteractionLocation;
24
25#[cfg(any(target_arch = "wasm32", test))]
27const THEME_KEY: &str = "yggdrasil-theme";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ResolvedTheme {
35 Light,
37 Dark,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Theme {
44 Light,
46 Dark,
48 System,
50}
51
52impl Theme {
53 pub fn cycle(&self) -> Self {
55 match self {
56 Theme::Light => Theme::Dark,
57 Theme::Dark => Theme::System,
58 Theme::System => Theme::Light,
59 }
60 }
61
62 pub fn resolve(&self, system_dark: bool) -> ResolvedTheme {
67 match self {
68 Theme::Light => ResolvedTheme::Light,
69 Theme::Dark => ResolvedTheme::Dark,
70 Theme::System => {
71 if system_dark {
72 ResolvedTheme::Dark
73 } else {
74 ResolvedTheme::Light
75 }
76 }
77 }
78 }
79}
80
81fn read_system_dark() -> bool {
86 #[cfg(target_arch = "wasm32")]
87 {
88 if let Some(window) = web_sys::window() {
89 if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
90 return media.matches();
91 }
92 }
93 }
94 false
95}
96
97fn detect_initial_theme() -> Theme {
102 #[cfg(target_arch = "wasm32")]
103 {
104 if let Some(window) = web_sys::window() {
105 if let Ok(Some(storage)) = window.local_storage() {
107 if let Ok(Some(value)) = storage.get_item(THEME_KEY) {
108 return match value.as_str() {
109 "dark" => Theme::Dark,
110 "light" => Theme::Light,
111 _ => Theme::System,
113 };
114 }
115 }
116 }
117 Theme::System
119 }
120
121 #[cfg(feature = "server")]
122 {
123 if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
125 if let Some(cookie) = ctx.parts_mut().headers.get("cookie") {
126 if let Ok(cookie_str) = cookie.to_str() {
127 for cookie_pair in cookie_str.split(';') {
129 let mut parts = cookie_pair.trim().splitn(2, '=');
130 if let (Some(name), Some(value)) = (parts.next(), parts.next()) {
131 match (name, value) {
132 ("theme", "dark") => return Theme::Dark,
133 ("theme", "light") => return Theme::Light,
134 _ => {}
135 }
136 }
137 }
138 }
139 }
140 }
141 }
142
143 #[cfg(not(target_arch = "wasm32"))]
146 Theme::System
147}
148
149pub fn use_theme_provider() -> Signal<Theme> {
166 let theme = use_signal(detect_initial_theme);
167 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
171 let mut system_dark = use_signal(read_system_dark);
172 let resolved = use_memo(move || theme().resolve(system_dark()));
174
175 use_effect(move || {
177 let current = theme();
178 #[cfg(target_arch = "wasm32")]
179 {
180 if let Some(window) = web_sys::window() {
181 if let Ok(Some(storage)) = window.local_storage() {
182 match current {
183 Theme::System => {
184 let _ = storage.remove_item(THEME_KEY);
185 }
186 Theme::Light => {
187 let _ = storage.set_item(THEME_KEY, "light");
188 }
189 Theme::Dark => {
190 let _ = storage.set_item(THEME_KEY, "dark");
191 }
192 }
193 }
194 }
195 }
196 let _ = current;
198 });
199
200 #[cfg(target_arch = "wasm32")]
219 {
220 let mut prev_resolved: Signal<Option<ResolvedTheme>> = use_signal(|| None);
221 use_effect(move || {
222 if theme() != Theme::System {
224 return;
225 }
226 let current = resolved();
227 let Some(prev) = prev_resolved() else {
229 prev_resolved.set(Some(current));
230 return;
231 };
232 if prev == current {
234 return;
235 }
236 prev_resolved.set(Some(current));
237 let Some(window) = web_sys::window() else {
239 return;
240 };
241 let key = "__applyResolvedTheme".into();
242 if let Ok(fn_val) = js_sys::Reflect::get(&window, &key) {
243 if !fn_val.is_undefined() && !fn_val.is_null() {
244 use wasm_bindgen::JsCast;
245 let fn_obj = fn_val.unchecked_into::<js_sys::Function>();
246 let is_dark = current == ResolvedTheme::Dark;
247 let _ = fn_obj.call1(&window, &is_dark.into());
248 }
249 }
250 });
251 }
252
253 #[cfg(target_arch = "wasm32")]
258 {
259 use crate::hooks::event_listener::use_event_listener;
260
261 use_event_listener(
262 || {
263 let window = web_sys::window()?;
264 window
265 .match_media("(prefers-color-scheme: dark)")
266 .ok()
267 .flatten()
268 },
269 "change",
270 move || {
271 if let Some(window) = web_sys::window() {
274 if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
275 system_dark.set(media.matches());
276 }
277 }
278 },
279 );
280 }
281
282 use_context_provider(|| theme);
283 use_context_provider(|| resolved);
284 theme
285}
286
287pub fn use_theme() -> Signal<Theme> {
291 use_context::<Signal<Theme>>()
292}
293
294pub fn use_resolved_theme() -> Memo<ResolvedTheme> {
299 use_context::<Memo<ResolvedTheme>>()
300}
301
302const THEME_PRELOAD_SCRIPT: &str = r#"
303(function() {
304 try {
305 var theme = localStorage.getItem('yggdrasil-theme');
306 if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
307 document.documentElement.classList.add('dark');
308 }
309 } catch (e) {}
310})();
311"#;
312
313#[component]
318pub fn ThemePreload() -> Element {
319 rsx! {
320 script { dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}" }
321 }
322}
323
324#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
336#[component]
337pub fn ThemeToggle() -> Element {
338 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
340 let mut theme = use_theme();
341 #[cfg(target_arch = "wasm32")]
344 let resolved = use_resolved_theme();
345 #[cfg(target_arch = "wasm32")]
346 let system_dark = use_signal(read_system_dark);
347 #[cfg(not(target_arch = "wasm32"))]
348 let (_resolved, _system_dark): (ResolvedTheme, bool) = (ResolvedTheme::Light, false);
349
350 rsx! {
351 button {
352 class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary",
353 r#type: "button",
354 aria_label: "主题切换(当前:{mode_label(theme())})",
355 title: "当前:{mode_label(theme())}(点击切换)",
356 onclick: move |evt| {
357 let next = theme().cycle();
358 #[cfg(target_arch = "wasm32")]
359 {
360 let prev_resolved = resolved();
361 let new_resolved = next.resolve(system_dark());
362 if prev_resolved != new_resolved {
363 use wasm_bindgen::JsCast;
364 let coords = evt.client_coordinates();
369 let x = coords.x;
370 let y = coords.y;
371 let window = web_sys::window()
372 .expect(
373 "主题切换回调仅在 WASM 浏览器上下文执行:无 window",
374 );
375 let key = "__startThemeTransition".into();
376 if let Ok(fn_val) = js_sys::Reflect::get(&window, &key) {
377 if !fn_val.is_undefined() && !fn_val.is_null() {
378 let fn_obj = fn_val.unchecked_into::<js_sys::Function>();
379 let _ = fn_obj.call2(&window, &x.into(), &y.into());
380 }
381 }
382 }
383 theme.set(next);
384 }
385 #[cfg(not(target_arch = "wasm32"))]
386 {
387 theme.set(next);
388 }
389 },
390 {
394 let icon_key: &str = match theme() {
395 Theme::Dark => "dark",
396 Theme::Light => "light",
397 Theme::System => "system",
398 };
399 match theme() {
400 Theme::Dark => rsx! {
401 svg {
402 key: "icon-{icon_key}",
403 xmlns: "http://www.w3.org/2000/svg",
404 height: "24px",
405 view_box: "0 -960 960 960",
406 width: "24px",
407 fill: "currentColor",
408 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" }
409 }
410 },
411 Theme::Light => rsx! {
412 svg {
413 key: "icon-{icon_key}",
414 xmlns: "http://www.w3.org/2000/svg",
415 height: "24px",
416 view_box: "0 -960 960 960",
417 width: "24px",
418 fill: "currentColor",
419 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" }
420 }
421 },
422 Theme::System => rsx! {
423 svg {
424 key: "icon-{icon_key}",
425 xmlns: "http://www.w3.org/2000/svg",
426 height: "24px",
427 view_box: "0 -960 960 960",
428 width: "24px",
429 fill: "currentColor",
430 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" }
431 }
432 },
433 }
434 }
435 }
436 }
437}
438
439fn mode_label(theme: Theme) -> &'static str {
441 match theme {
442 Theme::Light => "浅色",
443 Theme::Dark => "深色",
444 Theme::System => "跟随系统",
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn cycle_rotates_three_states() {
454 assert_eq!(Theme::Light.cycle(), Theme::Dark);
456 assert_eq!(Theme::Dark.cycle(), Theme::System);
457 assert_eq!(Theme::System.cycle(), Theme::Light);
458 }
459
460 #[test]
461 fn cycle_returns_to_origin_after_full_loop() {
462 assert_eq!(Theme::Light.cycle().cycle().cycle(), Theme::Light);
464 assert_eq!(Theme::Dark.cycle().cycle().cycle(), Theme::Dark);
465 assert_eq!(Theme::System.cycle().cycle().cycle(), Theme::System);
466 }
467
468 #[test]
469 fn resolve_light_and_dark_ignore_system() {
470 assert_eq!(Theme::Light.resolve(true), ResolvedTheme::Light);
472 assert_eq!(Theme::Light.resolve(false), ResolvedTheme::Light);
473 assert_eq!(Theme::Dark.resolve(true), ResolvedTheme::Dark);
474 assert_eq!(Theme::Dark.resolve(false), ResolvedTheme::Dark);
475 }
476
477 #[test]
478 fn resolve_system_follows_system_dark() {
479 assert_eq!(Theme::System.resolve(true), ResolvedTheme::Dark);
481 assert_eq!(Theme::System.resolve(false), ResolvedTheme::Light);
482 }
483
484 #[test]
485 fn theme_derives_equality() {
486 assert_eq!(Theme::Light, Theme::Light);
488 assert_eq!(Theme::Dark, Theme::Dark);
489 assert_eq!(Theme::System, Theme::System);
490 assert_ne!(Theme::Light, Theme::Dark);
491 assert_ne!(Theme::Dark, Theme::System);
492 assert_ne!(Theme::System, Theme::Light);
493 }
494
495 #[test]
496 fn resolved_theme_derives_equality() {
497 assert_eq!(ResolvedTheme::Light, ResolvedTheme::Light);
498 assert_eq!(ResolvedTheme::Dark, ResolvedTheme::Dark);
499 assert_ne!(ResolvedTheme::Light, ResolvedTheme::Dark);
500 }
501
502 #[test]
503 fn mode_label_covers_all_variants() {
504 assert_eq!(mode_label(Theme::Light), "浅色");
505 assert_eq!(mode_label(Theme::Dark), "深色");
506 assert_eq!(mode_label(Theme::System), "跟随系统");
507 }
508
509 #[test]
510 fn theme_preload_script_adds_dark_class() {
511 assert!(THEME_PRELOAD_SCRIPT.contains("classList.add('dark')"));
513 }
514
515 #[test]
516 fn theme_preload_script_reads_local_storage() {
517 assert!(THEME_PRELOAD_SCRIPT.contains("localStorage.getItem('yggdrasil-theme')"));
519 assert_eq!(THEME_KEY, "yggdrasil-theme");
520 }
521
522 #[test]
523 fn theme_preload_script_falls_back_to_prefers_color_scheme() {
524 assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
527 }
528
529 #[test]
530 fn theme_preload_script_swallows_errors() {
531 assert!(THEME_PRELOAD_SCRIPT.contains("try"));
533 assert!(THEME_PRELOAD_SCRIPT.contains("catch"));
534 }
535}