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 leptos::prelude::*;
18
19use crate::components::ui::{
20    IconName, SegmentedControl, SegmentedItem, SegmentedKind, SegmentedSize,
21};
22
23/// localStorage 中存储主题值的键名。
24#[cfg(any(target_arch = "wasm32", test))]
25const THEME_KEY: &str = "yggdrasil-theme";
26
27/// 实际生效的明暗主题(用户选择经 `resolve()` 解析后的结果)。
28///
29/// 下游消费者(CodeMirror 主题、图标判定)应读此类型而非 `Theme`:
30/// System 模式下系统偏好变化时,`ResolvedTheme` 会随之更新,而 `Theme` 不变。
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ResolvedTheme {
33    /// 浅色。
34    Light,
35    /// 深色。
36    Dark,
37}
38
39/// 用户选择的主题意图(三态)。
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Theme {
42    /// 浅色主题。
43    Light,
44    /// 深色主题。
45    Dark,
46    /// 跟随系统:移除 localStorage 持久化,运行时监听系统偏好。
47    System,
48}
49
50impl Theme {
51    /// 解析为实际生效的明暗主题。
52    ///
53    /// `Light`/`Dark` 直接返回;`System` 根据 `system_dark`(当前系统是否深色)
54    /// 决定。
55    pub fn resolve(&self, system_dark: bool) -> ResolvedTheme {
56        match self {
57            Theme::Light => ResolvedTheme::Light,
58            Theme::Dark => ResolvedTheme::Dark,
59            Theme::System => {
60                if system_dark {
61                    ResolvedTheme::Dark
62                } else {
63                    ResolvedTheme::Light
64                }
65            }
66        }
67    }
68}
69
70/// 读取当前系统是否为深色偏好。
71///
72/// WASM 端通过 `matchMedia('(prefers-color-scheme: dark)')` 读取;SSR 端拿不到
73/// 客户端系统偏好,返回 `false`(首屏由 `ThemePreload` 脚本客户端纠正)。
74fn read_system_dark() -> bool {
75    #[cfg(target_arch = "wasm32")]
76    {
77        if let Some(window) = web_sys::window() {
78            if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
79                return media.matches();
80            }
81        }
82    }
83    false
84}
85
86/// 检测初始主题意图。
87///
88/// 在 WASM 客户端优先读取 localStorage(`"light"`/`"dark"`),无值时回退到
89/// `Theme::System`(不再直接固化系统偏好)。仅在 hydration 完成后调用。
90#[cfg(target_arch = "wasm32")]
91fn detect_initial_theme() -> Theme {
92    if let Some(window) = web_sys::window() {
93        if let Ok(Some(storage)) = window.local_storage() {
94            if let Ok(Some(value)) = storage.get_item(THEME_KEY) {
95                return match value.as_str() {
96                    "dark" => Theme::Dark,
97                    "light" => Theme::Light,
98                    _ => Theme::System,
99                };
100            }
101        }
102    }
103    Theme::System
104}
105
106/// 提供主题上下文,在 `App` 根部调用一次。
107///
108/// 同时提供两个上下文:
109/// - `RwSignal<Theme>`:用户选择的主题意图(Light/Dark/System)。
110/// - `Memo<ResolvedTheme>`:实际生效明暗(System 模式下会随系统偏好变化)。
111///
112/// 持久化策略:`Light`/`Dark` 写入 localStorage;`System` **移除** localStorage,
113/// 使首屏防闪烁脚本回退到 `prefers-color-scheme`。
114///
115/// WASM 端注册 `matchMedia('(prefers-color-scheme: dark)')` 的 `change` 监听,
116/// 系统偏好变化时更新 `system_dark`,派生的 `resolved` memo 自动重算,下游
117/// (CodeMirror 等)通过读取 `resolved` 即可实时跟随系统。
118///
119/// `<html>` 的 `dark` class 不在此处管理。WASM 端由 `ThemeToggle` 的 `on_change`
120/// 通过 `yggdrasil-core.js` 的圆形展开动画在 View Transition 回调里同步 toggle。
121/// 初始 class 由 `ThemePreload` 首屏脚本设置,避免闪烁。
122pub fn provide_theme() -> RwSignal<Theme> {
123    // hydration 必须与 SSR 输出一致,初始模式统一为 System。
124    let theme = RwSignal::new(Theme::System);
125    let system_dark = RwSignal::new(read_system_dark());
126    // resolved 是 theme 与 system_dark 的派生态:任一变化都自动重算。
127    let resolved = Memo::new(move |_| theme.get().resolve(system_dark.get()));
128
129    #[cfg(target_arch = "wasm32")]
130    {
131        let initialized = RwSignal::new(false);
132        // Effect 只在浏览器 hydration 之后运行:恢复 localStorage 中的选择。
133        Effect::new(move |_| {
134            theme.set(detect_initial_theme());
135            initialized.set(true);
136        });
137
138        // 恢复选择后才允许持久化,避免初始 System 清掉已保存的 Light/Dark。
139        Effect::new(move |_| {
140            if !initialized.get() {
141                return;
142            }
143            let current = theme.get();
144            let mode = match current {
145                Theme::Light => "light",
146                Theme::Dark => "dark",
147                Theme::System => "system",
148            };
149            if let Some(window) = web_sys::window() {
150                if let Some(html) = window.document().and_then(|doc| doc.document_element()) {
151                    let _ = html.set_attribute("data-theme-mode", mode);
152                }
153                if let Ok(Some(storage)) = window.local_storage() {
154                    if current == Theme::System {
155                        let _ = storage.remove_item(THEME_KEY);
156                    } else {
157                        let _ = storage.set_item(THEME_KEY, mode);
158                    }
159                }
160            }
161        });
162    }
163
164    // WASM 端:System 模式下系统颜色偏好变化时,把新明暗同步到 <html> 的 dark class。
165    //
166    // 仅在 System 模式(theme == System)且 resolved 实际翻转时触发:Light/Dark 显式
167    // 模式下 resolved 由 theme 决定,DOM 已由 ThemeToggle 的 on_change 全权处理,effect
168    // 不介入,避免与手动点击的 VT 动画重复触发。
169    //
170    // 采用无动画的瞬切(__applyResolvedTheme,设置语义)而非 VT 圆形展开:系统偏好
171    // 变化是后台事件,View Transitions 在此上下文下动画不显示,故跟随系统走瞬切;
172    // 手动点击仍走 __startThemeTransition 保留圆形展开动画。
173    //
174    // 首次挂载也对齐 DOM:系统偏好可能在 ThemePreload 执行后、hydration 前变化。
175    #[cfg(target_arch = "wasm32")]
176    {
177        // 上次同步的明暗只作比较,不需要订阅,用 StoredValue。
178        let prev_resolved = StoredValue::new(None::<ResolvedTheme>);
179        Effect::new(move |_| {
180            // 仅 System 模式需要自动同步;Light/Dark 由 ThemeToggle 负责。
181            if theme.get() != Theme::System {
182                return;
183            }
184            let current = resolved.get();
185            // 未翻转直接跳过。
186            if prev_resolved.get_value() == Some(current) {
187                return;
188            }
189            prev_resolved.set_value(Some(current));
190            // resolved 翻转 → 瞬切同步 dark class(设置语义,非翻转)。
191            let is_dark = current == ResolvedTheme::Dark;
192            crate::bridges::globals::apply_resolved_theme(is_dark);
193        });
194    }
195
196    // WASM 端监听系统颜色偏好变化(无论何种模式都更新 system_dark;resolved memo
197    // 决定是否真正改变 ResolvedTheme)。主题上下文挂在 App 根部、与页面同寿,
198    // 监听器无需卸载,闭包直接 forget 交给浏览器持有。
199    #[cfg(target_arch = "wasm32")]
200    {
201        Effect::new(move |_| {
202            use wasm_bindgen::JsCast;
203
204            let Some(window) = web_sys::window() else {
205                return;
206            };
207            let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") else {
208                return;
209            };
210            // 补齐首轮渲染到监听器挂载之间发生的系统配色变化。
211            system_dark.set(media.matches());
212            let handler = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
213                // MediaQueryList 的 change 回调这里不取事件参数,重新查询当前 matches。
214                if let Some(window) = web_sys::window() {
215                    if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
216                        system_dark.set(media.matches());
217                    }
218                }
219            });
220            let _ =
221                media.add_event_listener_with_callback("change", handler.as_ref().unchecked_ref());
222            handler.forget();
223        });
224    }
225
226    provide_context(theme);
227    provide_context(resolved);
228    theme
229}
230
231/// 读取当前主题意图 Signal。
232///
233/// 需在 `provide_theme` 之后的组件树中使用。
234pub fn use_theme() -> RwSignal<Theme> {
235    expect_context::<RwSignal<Theme>>()
236}
237
238/// 读取实际生效明暗主题。
239///
240/// 下游消费者(CodeMirror、图标判定)应优先用此函数:System 模式下系统
241/// 偏好变化时,返回值会自动更新。
242pub fn use_resolved_theme() -> Memo<ResolvedTheme> {
243    expect_context::<Memo<ResolvedTheme>>()
244}
245
246const THEME_PRELOAD_SCRIPT: &str = r#"
247(function() {
248    var theme = 'system';
249    try {
250        var saved = localStorage.getItem('yggdrasil-theme');
251        if (saved === 'dark' || saved === 'light') theme = saved;
252    } catch (e) {}
253    document.documentElement.setAttribute('data-theme-mode', theme);
254    document.documentElement.classList.toggle('dark',
255        theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches));
256})();
257"#;
258
259// 主题分段控件的首屏选中态是必需样式,随 SSR 输出:由预载脚本写入的
260// data-theme-mode 决定,不等 WASM,也不受外部 CSS 旧缓存影响。它不进 layer,
261// 优先级高于 ui.css 里「未测量时当前项浮起」的兜底规则:先取消 SSR 默认选中项
262// (System)的浮起,再按 data-theme-mode 浮起对应位置的按钮。
263// 按钮顺序与 ThemeToggle 的 items 顺序耦合:1 浅色、2 深色、3 跟随系统。
264const THEME_SEGMENT_STYLE: &str = r#"
265.theme-seg .segmented:not([data-ready]) > .segmented-item[data-active] {
266    background-color: transparent; box-shadow: none; color: var(--color-fg-2); font-weight: 400;
267}
268html[data-theme-mode="light"] .theme-seg .segmented:not([data-ready]) > button:nth-of-type(1),
269html[data-theme-mode="dark"] .theme-seg .segmented:not([data-ready]) > button:nth-of-type(2),
270html[data-theme-mode="system"] .theme-seg .segmented:not([data-ready]) > button:nth-of-type(3),
271html:not([data-theme-mode]) .theme-seg .segmented:not([data-ready]) > button:nth-of-type(3) {
272    background-color: var(--color-surface-2); color: var(--color-fg); font-weight: 500;
273    box-shadow: 0 0 0 var(--hairline) var(--color-hairline), var(--shadow-sm);
274}
275html.dark[data-theme-mode="light"] .theme-seg .segmented:not([data-ready]) > button:nth-of-type(1),
276html.dark[data-theme-mode="dark"] .theme-seg .segmented:not([data-ready]) > button:nth-of-type(2),
277html.dark[data-theme-mode="system"] .theme-seg .segmented:not([data-ready]) > button:nth-of-type(3),
278html.dark:not([data-theme-mode]) .theme-seg .segmented:not([data-ready]) > button:nth-of-type(3) {
279    background-color: var(--color-surface-3);
280}
281"#;
282
283/// 首屏主题预加载脚本组件(放在外壳 `<head>` 里,只由 SSR 输出)。
284///
285/// 在页面渲染前读取 localStorage / 系统偏好,设置 `dark` class 和控制分段
286/// 控件选中态的 `data-theme-mode`,防止配色和选中项在 hydration 前后错位。
287#[component]
288pub fn ThemePreload() -> impl IntoView {
289    view! {
290        <style inner_html=THEME_SEGMENT_STYLE></style>
291        <script inner_html=THEME_PRELOAD_SCRIPT></script>
292    }
293}
294
295/// 视口中心:键盘切换外观时,圆形扩散以此为圆心。
296fn viewport_center() -> (f64, f64) {
297    #[cfg(target_arch = "wasm32")]
298    {
299        web_sys::window()
300            .map(|w| {
301                let width = w.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
302                let height = w
303                    .inner_height()
304                    .ok()
305                    .and_then(|v| v.as_f64())
306                    .unwrap_or(0.0);
307                (width / 2.0, height / 2.0)
308            })
309            .unwrap_or((0.0, 0.0))
310    }
311    #[cfg(not(target_arch = "wasm32"))]
312    (0.0, 0.0)
313}
314
315/// 切换到指定外观:只有**实际明暗**翻转时才以 `origin`(视口坐标)为圆心做圆形扩散。
316#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
317pub fn choose_theme(theme: RwSignal<Theme>, mode: Theme, origin: (f64, f64)) {
318    let current = theme.get_untracked();
319    if current == mode {
320        return;
321    }
322    #[cfg(target_arch = "wasm32")]
323    {
324        let prev_resolved = current.resolve(read_system_dark());
325        let new_resolved = mode.resolve(read_system_dark());
326        if prev_resolved != new_resolved {
327            // 实际明暗翻转 → 圆形扩散 VT。JS 从 DOM 现状推导目标主题。
328            crate::bridges::globals::start_theme_transition(origin.0, origin.1);
329        }
330    }
331    theme.set(mode);
332}
333
334/// 主题切换:浅色 / 深色 / 跟随系统三段式分段控件,基于 [`SegmentedControl`](Radio)。
335///
336/// 首屏选中态由 `<html data-theme-mode>` 经 [`THEME_SEGMENT_STYLE`] 决定,首屏无需
337/// 等待 WASM。点选后只有**实际明暗**(ResolvedTheme)翻转时才以点击点为圆心
338/// 做圆形扩散;明暗不变(如切到跟随系统且结果相同)、或由键盘切换时取视口中心。
339///
340/// Props:
341/// - `icon_only`:只显示图标(页头固定窄位);缺省由容器宽度决定是否显示文字
342#[component]
343pub fn ThemeToggle(#[prop(optional)] icon_only: bool) -> impl IntoView {
344    let theme = use_theme();
345    // 指针按下的位置:点击切换时作为圆形扩散的圆心;键盘切换没有,退回视口中心。
346    // 只在回调里读写,不参与渲染,用 StoredValue。
347    let pointer = StoredValue::new(None::<(f64, f64)>);
348    let items = vec![
349        SegmentedItem::new(Theme::Light, mode_label(Theme::Light)).icon(IconName::Sun),
350        SegmentedItem::new(Theme::Dark, mode_label(Theme::Dark)).icon(IconName::Moon),
351        SegmentedItem::new(Theme::System, mode_label(Theme::System)).icon(IconName::Monitor),
352    ];
353    let on_change = move |mode: Theme| {
354        let origin = pointer.get_value().unwrap_or_else(viewport_center);
355        choose_theme(theme, mode, origin);
356        pointer.set_value(None);
357    };
358    view! {
359        <div
360            class="theme-seg"
361            on:pointerdown=move |ev| {
362                pointer.set_value(Some((f64::from(ev.client_x()), f64::from(ev.client_y()))));
363            }
364        >
365            <SegmentedControl
366                items=items
367                value=theme
368                kind=SegmentedKind::Radio
369                size=SegmentedSize::Sm
370                aria_label="外观"
371                icon_only=icon_only
372                on_change=on_change
373            />
374        </div>
375    }
376}
377
378/// 主题意图的中文标签,用于 aria-label / title。
379fn mode_label(theme: Theme) -> &'static str {
380    match theme {
381        Theme::Light => "浅色",
382        Theme::Dark => "深色",
383        Theme::System => "跟随系统",
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn resolve_light_and_dark_ignore_system() {
393        // Light / Dark 的 resolve 与系统偏好无关。
394        assert_eq!(Theme::Light.resolve(true), ResolvedTheme::Light);
395        assert_eq!(Theme::Light.resolve(false), ResolvedTheme::Light);
396        assert_eq!(Theme::Dark.resolve(true), ResolvedTheme::Dark);
397        assert_eq!(Theme::Dark.resolve(false), ResolvedTheme::Dark);
398    }
399
400    #[test]
401    fn resolve_system_follows_system_dark() {
402        // System 的 resolve 由 system_dark 决定。
403        assert_eq!(Theme::System.resolve(true), ResolvedTheme::Dark);
404        assert_eq!(Theme::System.resolve(false), ResolvedTheme::Light);
405    }
406
407    #[test]
408    fn theme_derives_equality() {
409        // Theme 派生了 PartialEq,相同变体必须相等。
410        assert_eq!(Theme::Light, Theme::Light);
411        assert_eq!(Theme::Dark, Theme::Dark);
412        assert_eq!(Theme::System, Theme::System);
413        assert_ne!(Theme::Light, Theme::Dark);
414        assert_ne!(Theme::Dark, Theme::System);
415        assert_ne!(Theme::System, Theme::Light);
416    }
417
418    #[test]
419    fn resolved_theme_derives_equality() {
420        assert_eq!(ResolvedTheme::Light, ResolvedTheme::Light);
421        assert_eq!(ResolvedTheme::Dark, ResolvedTheme::Dark);
422        assert_ne!(ResolvedTheme::Light, ResolvedTheme::Dark);
423    }
424
425    #[test]
426    fn mode_label_covers_all_variants() {
427        assert_eq!(mode_label(Theme::Light), "浅色");
428        assert_eq!(mode_label(Theme::Dark), "深色");
429        assert_eq!(mode_label(Theme::System), "跟随系统");
430    }
431
432    #[test]
433    fn theme_preload_script_reads_local_storage() {
434        // 预加载脚本必须读取 yggdrasil-theme 键,与 THEME_KEY 保持一致。
435        assert!(THEME_PRELOAD_SCRIPT.contains("localStorage.getItem('yggdrasil-theme')"));
436        assert_eq!(THEME_KEY, "yggdrasil-theme");
437    }
438
439    #[test]
440    fn theme_preload_script_sets_dark_class_with_fallback() {
441        // 设置 dark class;无保存值时回退系统偏好;禁用 localStorage 时不抛错。
442        assert!(THEME_PRELOAD_SCRIPT.contains("classList.toggle('dark',"));
443        assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
444        assert!(THEME_PRELOAD_SCRIPT.contains("try"));
445        assert!(THEME_PRELOAD_SCRIPT.contains("catch"));
446    }
447}