Skip to main content

yggdrasil/
theme.rs

1//! 主题(浅色 / 深色 / 跟随系统)管理。
2//!
3//! 三态模型:
4//! - `Theme::Light` / `Theme::Dark`:用户显式选择,持久化到 localStorage。
5//! - `Theme::System`:跟随系统偏好。**移除 localStorage 持久化**,首屏防闪烁
6//!   脚本因此自动回退到 `prefers-color-scheme` 分支;运行时监听
7//!   `matchMedia('(prefers-color-scheme: dark)')` 的 `change` 事件,系统切换
8//!   主题时实时同步 `.dark` class 与下游(CodeMirror 等)。
9//!
10//! SSR 与客户端 hydration 都从 `Theme::System` 开始;挂载后再读取
11//! localStorage 恢复用户选择。首屏配色和图标均由 `ThemePreload` 提前设置,
12//! 不依赖 WASM 加载速度或服务端无法读取的浏览器偏好。
13//!
14//! 下游消费者(CodeMirror、SVG 图标判定)应读 `ResolvedTheme`(实际生效明暗),
15//! 而非 `Theme`(用户意图),这样 System 模式下系统偏好变化能自动传播。
16
17use dioxus::prelude::*;
18// InteractionLocation 提供 client_coordinates(),用于读取鼠标点击的视口坐标。
19// 该 trait 不在 dioxus::prelude(后者只 re-export events::*),需单独从 dioxus::html 引入。
20// 仅 WASM 前端用到(ThemeToggle 的圆形展开动画取点击坐标),服务端构建剥离。
21#[cfg(target_arch = "wasm32")]
22use dioxus::html::InteractionLocation;
23
24/// localStorage 中存储主题值的键名。
25#[cfg(any(target_arch = "wasm32", test))]
26const THEME_KEY: &str = "yggdrasil-theme";
27
28/// 实际生效的明暗主题(用户选择经 `resolve()` 解析后的结果)。
29///
30/// 下游消费者(CodeMirror 主题、图标判定)应读此类型而非 `Theme`:
31/// System 模式下系统偏好变化时,`ResolvedTheme` 会随之更新,而 `Theme` 不变。
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ResolvedTheme {
34    /// 浅色。
35    Light,
36    /// 深色。
37    Dark,
38}
39
40/// 用户选择的主题意图(三态)。
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Theme {
43    /// 浅色主题。
44    Light,
45    /// 深色主题。
46    Dark,
47    /// 跟随系统:移除 localStorage 持久化,运行时监听系统偏好。
48    System,
49}
50
51impl Theme {
52    /// 三态循环切换:Dark → Light → System → Dark。
53    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    /// 解析为实际生效的明暗主题。
62    ///
63    /// `Light`/`Dark` 直接返回;`System` 根据 `system_dark`(当前系统是否深色)
64    /// 决定。
65    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
80/// 读取当前系统是否为深色偏好。
81///
82/// WASM 端通过 `matchMedia('(prefers-color-scheme: dark)')` 读取;SSR 端拿不到
83/// 客户端系统偏好,返回 `false`(首屏由 `ThemePreload` 脚本客户端纠正)。
84fn 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/// 检测初始主题意图。
97///
98/// 在 WASM 客户端优先读取 localStorage(`"light"`/`"dark"`),无值时回退到
99/// `Theme::System`(不再直接固化系统偏好)。仅在 hydration 完成后调用。
100#[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
116/// 提供主题上下文的 Hook。
117///
118/// 同时提供两个上下文:
119/// - `Signal<Theme>`:用户选择的主题意图(Light/Dark/System)。
120/// - `Memo<ResolvedTheme>`:实际生效明暗(System 模式下会随系统偏好变化)。
121///
122/// 持久化策略:`Light`/`Dark` 写入 localStorage;`System` **移除** localStorage,
123/// 使首屏防闪烁脚本回退到 `prefers-color-scheme`。
124///
125/// WASM 端注册 `matchMedia('(prefers-color-scheme: dark)')` 的 `change` 监听,
126/// 系统偏好变化时更新 `system_dark`,派生的 `resolved` memo 自动重算,下游
127/// (CodeMirror 等)通过读取 `resolved` 即可实时跟随系统。
128///
129/// `<html>` 的 `dark` class 不在此处管理。WASM 端由 `ThemeToggle` 的 onclick
130/// 通过 `yggdrasil-core.js` 的圆形展开动画在 View Transition 回调里同步 toggle。
131/// 初始 class 由 `ThemePreload` 首屏脚本设置,避免闪烁。
132pub fn use_theme_provider() -> Signal<Theme> {
133    // hydration 不会修补首轮 VDOM 与 SSR 的差异,必须使用一致的初始模式。
134    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
135    let mut theme = use_signal(|| Theme::System);
136    // system_dark 仅在 wasm32 监听闭包里 .set();非 wasm 构建剥离该闭包,
137    // 此处统一标注 mut 以满足 wasm32 的借用检查(非 wasm 端会触发 unused_mut,
138    // 由下方 cfg_attr 抑制)。
139    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
140    let mut system_dark = use_signal(read_system_dark);
141    // resolved 是 theme 与 system_dark 的派生态:任一变化都自动重算。
142    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        // 恢复选择后才允许持久化,避免初始 System 清掉已保存的 Light/Dark。
153        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    // WASM 端:System 模式下系统颜色偏好变化时,把新明暗同步到 <html> 的 dark class。
179    //
180    // 这是原始实现的关键缺口:系统偏好变化时 system_dark signal 与 resolved memo 都
181    // 会更新,下游(CodeMirror 等)能跟随,但 <html> 的 dark class 原本只有「首屏预
182    // 加载脚本」与「手动点击 ThemeToggle」两个写入点,自动场景下无人同步,页面配色
183    // 纹丝不动。
184    //
185    // 仅在 System 模式(theme == System)且 resolved 实际翻转时触发:Light/Dark 显式
186    // 模式下 resolved 由 theme 决定,DOM 已由 ThemeToggle::onclick 全权处理,effect
187    // 不介入,避免与手动点击的 VT 动画重复触发。
188    //
189    // 采用无动画的瞬切(__applyResolvedTheme,设置语义)而非 VT 圆形展开:系统偏好
190    // 变化是后台事件,实测 View Transitions 在此上下文下动画不显示(VT 流程虽正常
191    // 完成、CSS 也正确挂上 tt-expand,但视觉上仅瞬切),故跟随系统走瞬切更可靠;
192    // 手动点击仍走 __startThemeTransition 保留圆形展开动画。
193    //
194    // 首次挂载也对齐 DOM:系统偏好可能在 ThemePreload 执行后、hydration 前变化。
195    #[cfg(target_arch = "wasm32")]
196    {
197        let mut prev_resolved: Signal<Option<ResolvedTheme>> = use_signal(|| None);
198        use_effect(move || {
199            // 仅 System 模式需要自动同步;Light/Dark 由 ThemeToggle::onclick 负责。
200            if theme() != Theme::System {
201                return;
202            }
203            let current = resolved();
204            // 未翻转(如 signal 因无关读取重算)直接跳过。
205            if prev_resolved() == Some(current) {
206                return;
207            }
208            prev_resolved.set(Some(current));
209            // resolved 翻转 → 瞬切同步 dark class(设置语义,非翻转)。
210            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    // WASM 端监听系统颜色偏好变化(仅 System 模式有意义,但无论何种模式都更新
226    // system_dark signal;resolved memo 会决定是否真正改变 ResolvedTheme)。
227    // 注册 / 卸载清理由 use_event_listener 统一负责,target 在其内部 use_effect
228    // 首次运行时通过 acquire 闭包获取(此时 DOM 一定可用)。
229    #[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                // 补齐首轮渲染到监听器挂载之间发生的系统配色变化。
241                system_dark.set(media.matches());
242                Some(media)
243            },
244            "change",
245            move || {
246                // handler 需要重新读取当前 matches 值(MediaQueryList 的事件回调
247                // 不带参,只能自行重新查询)。
248                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
262/// 读取当前主题意图 Signal 的 Hook。
263///
264/// 需在 `use_theme_provider` 之后的组件树中使用。
265pub fn use_theme() -> Signal<Theme> {
266    use_context::<Signal<Theme>>()
267}
268
269/// 读取实际生效明暗主题的 Hook。
270///
271/// 下游消费者(CodeMirror、图标判定)应优先用此 Hook:System 模式下系统
272/// 偏好变化时,返回值会自动更新。
273pub 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
290// 图标显隐是首屏必需样式,随 SSR 输出,避免外部 CSS 旧缓存影响模式显示。
291const 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/// 首屏主题预加载脚本组件。
300///
301/// 在页面渲染前读取 localStorage / 系统偏好,设置 `dark` class 和控制图标的
302/// `data-theme-mode`,防止配色和图标在 hydration 前后错位。
303#[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/// 主题切换按钮组件(三态循环:Dark → Light → System → Dark)。
312///
313/// 三个图标使用固定 SSR 结构,由 `data-theme-mode` 决定哪个可见。
314/// 首屏无需等待 WASM;切换模式时通过 CSS 显示对应图标并播放进入动画。
315///
316/// 仅当**实际生效明暗**(ResolvedTheme)因切换而翻转时,才额外触发圆形展开
317/// VT 动画(颜色过渡);明暗不变时(如 Light → System 且系统浅色)只换图标。
318///
319// evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。
320#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
321#[component]
322pub fn ThemeToggle() -> Element {
323    // theme 在 wasm 与 server 两侧都需要 mut(onclick 内 theme.set)。
324    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
325    let mut theme = use_theme();
326    // resolved 用于判断切换前后实际明暗是否翻转(决定是否触发 VT)。
327    #[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                        // 实际明暗翻转 → 触发圆形展开 VT 动画(颜色过渡)。
343                        // JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。
344                        // 用 Reflect::get 取 window.__startThemeTransition 再 call2 调用,
345                        // 替代旧版 format!-into-eval 字符串拼贴(无注入面、与 bridge 风格一致)。
346                        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 不参与无障碍名称计算,可见图标提供当前模式和提示。
369            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
409/// 主题意图的中文标签,用于 aria-label / title。
410fn 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        // 三态循环:Dark → Light → System → Dark。
425        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        // 走完一圈(三次)应回到起点。
433        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        // Light / Dark 的 resolve 与系统偏好无关。
441        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        // System 的 resolve 由 system_dark 决定。
450        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        // Theme 派生了 PartialEq,相同变体必须相等。
457        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        // 预加载脚本必须包含给 documentElement 设置 dark class 的逻辑。
482        assert!(THEME_PRELOAD_SCRIPT.contains("classList.toggle('dark',"));
483    }
484
485    #[test]
486    fn theme_preload_script_reads_local_storage() {
487        // 预加载脚本必须读取 yggdrasil-theme 键,与 THEME_KEY 保持一致。
488        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        // 当 localStorage 中无主题时,脚本应回退到系统颜色偏好。
495        // System 模式移除 localStorage 后首屏即走此分支,保证零闪烁跟随系统。
496        assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
497    }
498
499    #[test]
500    fn theme_preload_script_swallows_errors() {
501        // 预加载脚本必须包裹在 try/catch 中,避免禁用 localStorage 时抛错。
502        assert!(THEME_PRELOAD_SCRIPT.contains("try"));
503        assert!(THEME_PRELOAD_SCRIPT.contains("catch"));
504    }
505}