feat(theme): 新增跟随系统主题模式与三态循环切换
Theme 枚举从 {Light, Dark} 扩展为 {Light, Dark, System},新增
ResolvedTheme 表示实际生效明暗。
- System 模式移除 localStorage 持久化,首屏防闪烁脚本因此自动回退到
prefers-color-scheme 分支,首屏零闪烁。
- use_theme_provider 提供 Theme + ResolvedTheme 双 context;resolved 是
theme 与 system_dark 的派生 memo。WASM 端注册 matchMedia change 监听,
系统偏好变化时实时同步 .dark class 与下游(CodeMirror 等)。
- ThemeToggle 由二态 toggle 改为三态循环(Light→Dark→System→Light),
图标按当前模式切换(太阳/月亮/显示器);仅当实际明暗翻转时才触发
圆形展开动画,避免 System 与同向 Light/Dark 间无意义动画。
- system.rs CodeMirror 传参改读 resolved,使 System 模式下系统偏好变化
时编辑器主题自动跟随。
This commit is contained in:
parent
9a218db478
commit
eacc79ed83
@ -677,11 +677,12 @@ fn SqlConsoleTab() -> Element {
|
|||||||
use crate::components::ui::ADMIN_TABLE_CLASS;
|
use crate::components::ui::ADMIN_TABLE_CLASS;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::codemirror_bridge;
|
use crate::codemirror_bridge;
|
||||||
use crate::theme::use_theme;
|
// use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。
|
||||||
|
use crate::theme::use_resolved_theme;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::theme::Theme;
|
use crate::theme::ResolvedTheme;
|
||||||
|
|
||||||
let theme = use_theme();
|
let resolved = use_resolved_theme();
|
||||||
let sql_text = use_signal(String::new);
|
let sql_text = use_signal(String::new);
|
||||||
let mut result = use_signal(|| Option::<SqlResult>::None);
|
let mut result = use_signal(|| Option::<SqlResult>::None);
|
||||||
let mut error = use_signal(|| Option::<String>::None);
|
let mut error = use_signal(|| Option::<String>::None);
|
||||||
@ -690,9 +691,9 @@ fn SqlConsoleTab() -> Element {
|
|||||||
let mut with_explain = use_signal(|| false);
|
let mut with_explain = use_signal(|| false);
|
||||||
let mut allow_multi = use_signal(|| false);
|
let mut allow_multi = use_signal(|| false);
|
||||||
let mut confirm_dangerous = use_signal(|| false);
|
let mut confirm_dangerous = use_signal(|| false);
|
||||||
// theme/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。
|
// resolved/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let _ = (&theme, &sql_text);
|
let _ = (&resolved, &sql_text);
|
||||||
|
|
||||||
// CodeMirror 实例句柄(仅 WASM)。
|
// CodeMirror 实例句柄(仅 WASM)。
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@ -712,7 +713,7 @@ fn SqlConsoleTab() -> Element {
|
|||||||
});
|
});
|
||||||
let on_ready = Closure::new(|| {});
|
let on_ready = Closure::new(|| {});
|
||||||
|
|
||||||
let theme_name = if theme() == Theme::Dark { "dark" } else { "light" };
|
let theme_name = if resolved() == ResolvedTheme::Dark { "dark" } else { "light" };
|
||||||
let opts = codemirror_bridge::EditorOptions::new();
|
let opts = codemirror_bridge::EditorOptions::new();
|
||||||
opts.set_language("sql");
|
opts.set_language("sql");
|
||||||
opts.set_theme(theme_name);
|
opts.set_theme(theme_name);
|
||||||
@ -742,11 +743,12 @@ fn SqlConsoleTab() -> Element {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 主题切换时同步编辑器主题
|
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
|
||||||
|
// resolved 是 theme + system_dark 的派生 memo,任一变化都自动触发此 effect。
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let t = theme();
|
let r = resolved();
|
||||||
if let Some(h) = editor_handle.read().as_ref() {
|
if let Some(h) = editor_handle.read().as_ref() {
|
||||||
h.instance().set_theme(if t == Theme::Dark { "dark" } else { "light" });
|
h.instance().set_theme(if r == ResolvedTheme::Dark { "dark" } else { "light" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
390
src/theme.rs
390
src/theme.rs
@ -1,9 +1,19 @@
|
|||||||
//! 主题(浅色 / 深色)管理。
|
//! 主题(浅色 / 深色 / 跟随系统)管理。
|
||||||
|
//!
|
||||||
|
//! 三态模型:
|
||||||
|
//! - `Theme::Light` / `Theme::Dark`:用户显式选择,持久化到 localStorage。
|
||||||
|
//! - `Theme::System`:跟随系统偏好。**移除 localStorage 持久化**,首屏防闪烁
|
||||||
|
//! 脚本因此自动回退到 `prefers-color-scheme` 分支;运行时监听
|
||||||
|
//! `matchMedia('(prefers-color-scheme: dark)')` 的 `change` 事件,系统切换
|
||||||
|
//! 主题时实时同步 `.dark` class 与下游(CodeMirror 等)。
|
||||||
//!
|
//!
|
||||||
//! 提供两条初始化路径:
|
//! 提供两条初始化路径:
|
||||||
//! - **SSR**:从 HTTP 请求 Cookie 中的 `theme` 字段检测主题,避免首屏闪烁。
|
//! - **SSR**:从 HTTP 请求 Cookie 中的 `theme` 字段检测主题,避免首屏闪烁。
|
||||||
//! - **WASM 客户端**:优先读取 `localStorage` 中的持久化主题;不存在时回退到
|
//! - **WASM 客户端**:优先读取 `localStorage` 中的持久化主题;不存在时回退到
|
||||||
//! `prefers-color-scheme` 媒体查询;切换时同步更新 DOM class 与 localStorage。
|
//! `Theme::System`(不再直接固化系统偏好)。
|
||||||
|
//!
|
||||||
|
//! 下游消费者(CodeMirror、SVG 图标判定)应读 `ResolvedTheme`(实际生效明暗),
|
||||||
|
//! 而非 `Theme`(用户意图),这样 System 模式下系统偏好变化能自动传播。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
// InteractionLocation 提供 client_coordinates(),用于读取鼠标点击的视口坐标。
|
// InteractionLocation 提供 client_coordinates(),用于读取鼠标点击的视口坐标。
|
||||||
@ -16,54 +26,96 @@ use dioxus::html::InteractionLocation;
|
|||||||
#[cfg(any(target_arch = "wasm32", test))]
|
#[cfg(any(target_arch = "wasm32", test))]
|
||||||
const THEME_KEY: &str = "yggdrasil-theme";
|
const THEME_KEY: &str = "yggdrasil-theme";
|
||||||
|
|
||||||
/// 应用主题枚举。
|
/// 实际生效的明暗主题(用户选择经 `resolve()` 解析后的结果)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
///
|
||||||
|
/// 下游消费者(CodeMirror 主题、图标判定)应读此类型而非 `Theme`:
|
||||||
|
/// System 模式下系统偏好变化时,`ResolvedTheme` 会随之更新,而 `Theme` 不变。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResolvedTheme {
|
||||||
|
/// 浅色。
|
||||||
|
Light,
|
||||||
|
/// 深色。
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户选择的主题意图(三态)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Theme {
|
pub enum Theme {
|
||||||
/// 浅色主题。
|
/// 浅色主题。
|
||||||
Light,
|
Light,
|
||||||
/// 深色主题。
|
/// 深色主题。
|
||||||
Dark,
|
Dark,
|
||||||
|
/// 跟随系统:移除 localStorage 持久化,运行时监听系统偏好。
|
||||||
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
/// 切换到相反主题。
|
/// 三态循环切换:Light → Dark → System → Light。
|
||||||
pub fn toggle(&self) -> Self {
|
pub fn cycle(&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Theme::Light => Theme::Dark,
|
Theme::Light => Theme::Dark,
|
||||||
Theme::Dark => Theme::Light,
|
Theme::Dark => Theme::System,
|
||||||
|
Theme::System => Theme::Light,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析为实际生效的明暗主题。
|
||||||
|
///
|
||||||
|
/// `Light`/`Dark` 直接返回;`System` 根据 `system_dark`(当前系统是否深色)
|
||||||
|
/// 决定。
|
||||||
|
pub fn resolve(&self, system_dark: bool) -> ResolvedTheme {
|
||||||
|
match self {
|
||||||
|
Theme::Light => ResolvedTheme::Light,
|
||||||
|
Theme::Dark => ResolvedTheme::Dark,
|
||||||
|
Theme::System => {
|
||||||
|
if system_dark {
|
||||||
|
ResolvedTheme::Dark
|
||||||
|
} else {
|
||||||
|
ResolvedTheme::Light
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测初始主题。
|
/// 读取当前系统是否为深色偏好。
|
||||||
///
|
///
|
||||||
/// 在 WASM 客户端优先读取 localStorage,回退到系统颜色偏好;
|
/// WASM 端通过 `matchMedia('(prefers-color-scheme: dark)')` 读取;SSR 端拿不到
|
||||||
/// 在 SSR 阶段解析请求 Cookie;否则默认浅色主题。
|
/// 客户端系统偏好,返回 `false`(首屏由 `ThemePreload` 脚本客户端纠正)。
|
||||||
|
fn read_system_dark() -> bool {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
|
||||||
|
return media.matches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测初始主题意图。
|
||||||
|
///
|
||||||
|
/// 在 WASM 客户端优先读取 localStorage(`"light"`/`"dark"`),无值时回退到
|
||||||
|
/// `Theme::System`(不再直接固化系统偏好);在 SSR 阶段解析请求 Cookie。
|
||||||
fn detect_initial_theme() -> Theme {
|
fn detect_initial_theme() -> Theme {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
let window = match web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
Some(w) => w,
|
// 优先读取 localStorage 中持久化的主题值。
|
||||||
None => return Theme::Light,
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
};
|
if let Ok(Some(value)) = storage.get_item(THEME_KEY) {
|
||||||
|
return match value.as_str() {
|
||||||
// 优先读取 localStorage 中持久化的主题值。
|
"dark" => Theme::Dark,
|
||||||
if let Ok(Some(storage)) = window.local_storage() {
|
"light" => Theme::Light,
|
||||||
if let Ok(Some(value)) = storage.get_item(THEME_KEY) {
|
// 未知值(含历史遗留)视为未选择,回退到 System。
|
||||||
return if value == "dark" {
|
_ => Theme::System,
|
||||||
Theme::Dark
|
};
|
||||||
} else {
|
}
|
||||||
Theme::Light
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没有持久化值时,根据系统颜色偏好决定。
|
|
||||||
if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") {
|
|
||||||
if media.matches() {
|
|
||||||
return Theme::Dark;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 无持久化值:跟随系统。
|
||||||
|
return Theme::System;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -76,8 +128,10 @@ fn detect_initial_theme() -> Theme {
|
|||||||
for cookie_pair in cookie_str.split(';') {
|
for cookie_pair in cookie_str.split(';') {
|
||||||
let mut parts = cookie_pair.trim().splitn(2, '=');
|
let mut parts = cookie_pair.trim().splitn(2, '=');
|
||||||
if let (Some(name), Some(value)) = (parts.next(), parts.next()) {
|
if let (Some(name), Some(value)) = (parts.next(), parts.next()) {
|
||||||
if name == "theme" && value == "dark" {
|
match (name, value) {
|
||||||
return Theme::Dark;
|
("theme", "dark") => return Theme::Dark,
|
||||||
|
("theme", "light") => return Theme::Light,
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -86,32 +140,49 @@ fn detect_initial_theme() -> Theme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Theme::Light
|
Theme::System
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提供主题上下文的 Hook。
|
/// 提供主题上下文的 Hook。
|
||||||
///
|
///
|
||||||
/// 初始化时按 SSR Cookie → WASM localStorage → 系统偏好的顺序检测主题;
|
/// 同时提供两个上下文:
|
||||||
/// 主题变化时将值持久化到 localStorage。
|
/// - `Signal<Theme>`:用户选择的主题意图(Light/Dark/System)。
|
||||||
|
/// - `Memo<ResolvedTheme>`:实际生效明暗(System 模式下会随系统偏好变化)。
|
||||||
|
///
|
||||||
|
/// 持久化策略:`Light`/`Dark` 写入 localStorage;`System` **移除** localStorage,
|
||||||
|
/// 使首屏防闪烁脚本回退到 `prefers-color-scheme`。
|
||||||
|
///
|
||||||
|
/// WASM 端注册 `matchMedia('(prefers-color-scheme: dark)')` 的 `change` 监听,
|
||||||
|
/// 系统偏好变化时更新 `system_dark`,派生的 `resolved` memo 自动重算,下游
|
||||||
|
/// (CodeMirror 等)通过读取 `resolved` 即可实时跟随系统。
|
||||||
///
|
///
|
||||||
/// `<html>` 的 `dark` class 不在此处管理。WASM 端由 `ThemeToggle` 的 onclick
|
/// `<html>` 的 `dark` class 不在此处管理。WASM 端由 `ThemeToggle` 的 onclick
|
||||||
/// 通过 `yggdrasil-core.js` 的圆形展开动画在 View Transition 回调里同步 toggle。
|
/// 通过 `yggdrasil-core.js` 的圆形展开动画在 View Transition 回调里同步 toggle。
|
||||||
/// 初始 class 由 `ThemePreload` 首屏脚本设置,避免闪烁。
|
/// 初始 class 由 `ThemePreload` 首屏脚本设置,避免闪烁。
|
||||||
pub fn use_theme_provider() -> Signal<Theme> {
|
pub fn use_theme_provider() -> Signal<Theme> {
|
||||||
let theme = use_signal(detect_initial_theme);
|
let theme = use_signal(detect_initial_theme);
|
||||||
|
let system_dark = use_signal(read_system_dark);
|
||||||
|
// resolved 是 theme 与 system_dark 的派生态:任一变化都自动重算。
|
||||||
|
let resolved = use_memo(move || theme().resolve(system_dark()));
|
||||||
|
|
||||||
|
// 持久化:System 移除 localStorage;Light/Dark 写入对应值。
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let current = theme();
|
let current = theme();
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
// 将当前主题持久化到 localStorage。
|
|
||||||
if let Ok(Some(storage)) = window.local_storage() {
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
let theme_str = match current {
|
match current {
|
||||||
Theme::Dark => "dark",
|
Theme::System => {
|
||||||
Theme::Light => "light",
|
let _ = storage.remove_item(THEME_KEY);
|
||||||
};
|
}
|
||||||
let _ = storage.set_item(THEME_KEY, theme_str);
|
Theme::Light => {
|
||||||
|
let _ = storage.set_item(THEME_KEY, "light");
|
||||||
|
}
|
||||||
|
Theme::Dark => {
|
||||||
|
let _ = storage.set_item(THEME_KEY, "dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,17 +190,72 @@ pub fn use_theme_provider() -> Signal<Theme> {
|
|||||||
let _ = current;
|
let _ = current;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WASM 端监听系统颜色偏好变化(仅 System 模式有意义,但无论何种模式都更新
|
||||||
|
// system_dark signal;resolved memo 会决定是否真正改变 ResolvedTheme)。
|
||||||
|
// 用 use_hook 持有 Closure + MediaQueryList,use_drop 时清理,防止泄漏。
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
let listener_state = use_hook(|| {
|
||||||
|
Rc::new(RefCell::new(None::<(
|
||||||
|
wasm_bindgen::prelude::Closure<dyn FnMut()>,
|
||||||
|
web_sys::MediaQueryList,
|
||||||
|
)>))
|
||||||
|
});
|
||||||
|
let listener_state_for_effect = listener_state.clone();
|
||||||
|
|
||||||
|
use_effect(move || {
|
||||||
|
let Some(window) = web_sys::window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// 初始化时同步一次(read_system_dark 已设过初值,这里保持一致即可)。
|
||||||
|
let media_for_closure = media.clone();
|
||||||
|
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
|
||||||
|
system_dark.set(media_for_closure.matches());
|
||||||
|
})
|
||||||
|
as Box<dyn FnMut()>);
|
||||||
|
let _ = media.add_event_listener_with_callback(
|
||||||
|
"change",
|
||||||
|
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
|
||||||
|
);
|
||||||
|
*listener_state_for_effect.borrow_mut() = Some((closure, media));
|
||||||
|
});
|
||||||
|
|
||||||
|
use_drop(move || {
|
||||||
|
if let Some((closure, media)) = listener_state.borrow_mut().take() {
|
||||||
|
let _ = media.remove_event_listener_with_callback(
|
||||||
|
"change",
|
||||||
|
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
use_context_provider(|| theme);
|
use_context_provider(|| theme);
|
||||||
|
use_context_provider(|| resolved);
|
||||||
theme
|
theme
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取当前主题 Signal 的 Hook。
|
/// 读取当前主题意图 Signal 的 Hook。
|
||||||
///
|
///
|
||||||
/// 需在 `use_theme_provider` 之后的组件树中使用。
|
/// 需在 `use_theme_provider` 之后的组件树中使用。
|
||||||
pub fn use_theme() -> Signal<Theme> {
|
pub fn use_theme() -> Signal<Theme> {
|
||||||
use_context::<Signal<Theme>>()
|
use_context::<Signal<Theme>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取实际生效明暗主题的 Hook。
|
||||||
|
///
|
||||||
|
/// 下游消费者(CodeMirror、图标判定)应优先用此 Hook:System 模式下系统
|
||||||
|
/// 偏好变化时,返回值会自动更新。
|
||||||
|
pub fn use_resolved_theme() -> Memo<ResolvedTheme> {
|
||||||
|
use_context::<Memo<ResolvedTheme>>()
|
||||||
|
}
|
||||||
|
|
||||||
const THEME_PRELOAD_SCRIPT: &str = r#"
|
const THEME_PRELOAD_SCRIPT: &str = r#"
|
||||||
(function() {
|
(function() {
|
||||||
try {
|
try {
|
||||||
@ -152,7 +278,12 @@ pub fn ThemePreload() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 主题切换按钮组件。
|
/// 主题切换按钮组件(三态循环:Light → Dark → System → Light)。
|
||||||
|
///
|
||||||
|
/// 点击根据当前模式取下一个 `Theme`。仅当**实际生效明暗**(ResolvedTheme)
|
||||||
|
/// 因切换而翻转时才触发圆形展开动画;若切换前后明暗不变(如 Light → System
|
||||||
|
/// 且系统当前为浅色),直接更新 Signal,无动画无延迟。
|
||||||
|
///
|
||||||
// evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。
|
// evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
|
||||||
#[component]
|
#[component]
|
||||||
@ -167,38 +298,55 @@ pub fn ThemeToggle() -> Element {
|
|||||||
// 故非 wasm 端标注 unused_mut。
|
// 故非 wasm 端标注 unused_mut。
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
|
||||||
let mut click_gen = use_signal(|| 0u32);
|
let mut click_gen = use_signal(|| 0u32);
|
||||||
|
// resolved / system_dark 用于判断切换前后实际明暗是否翻转。
|
||||||
|
// resolved 仅在 wasm 分支读取,system_dark 同理;非 wasm 端用 _ 引用避免警告。
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let resolved = use_resolved_theme();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let system_dark = use_signal(read_system_dark);
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let (_resolved, _system_dark): (ResolvedTheme, bool) = (ResolvedTheme::Light, false);
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
button {
|
button {
|
||||||
class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary",
|
class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary",
|
||||||
r#type: "button",
|
r#type: "button",
|
||||||
aria_label: "切换深色/浅色主题",
|
aria_label: "主题切换(当前:{mode_label(theme())})",
|
||||||
|
title: "当前:{mode_label(theme())}(点击切换)",
|
||||||
onclick: move |evt| {
|
onclick: move |evt| {
|
||||||
let next = theme().toggle();
|
let next = theme().cycle();
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
let coords = evt.client_coordinates();
|
let prev_resolved = resolved();
|
||||||
let x = coords.x;
|
let new_resolved = next.resolve(system_dark());
|
||||||
let y = coords.y;
|
if prev_resolved != new_resolved {
|
||||||
// JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。
|
// 实际明暗翻转 → 触发圆形展开动画。
|
||||||
let _ = js_sys::eval(
|
// JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。
|
||||||
&format!(
|
let coords = evt.client_coordinates();
|
||||||
"if (window.__startThemeTransition) \
|
let x = coords.x;
|
||||||
window.__startThemeTransition({x}, {y});", // theme.set 推迟到动画结束:其触发的 Dioxus 微任务重渲染会打断 VT。
|
let y = coords.y;
|
||||||
x = x,
|
let _ = js_sys::eval(
|
||||||
y = y,
|
&format!(
|
||||||
),
|
"if (window.__startThemeTransition) \
|
||||||
);
|
window.__startThemeTransition({x}, {y});", // theme.set 推迟到动画结束:其触发的 Dioxus 微任务重渲染会打断 VT。
|
||||||
let gen = click_gen() + 1;
|
x = x,
|
||||||
click_gen.set(gen);
|
y = y,
|
||||||
let mut theme_clone = theme;
|
),
|
||||||
let gen_clone = click_gen;
|
);
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
let gen = click_gen() + 1;
|
||||||
crate::utils::time::sleep_ms(450).await;
|
click_gen.set(gen);
|
||||||
if gen_clone() == gen {
|
let mut theme_clone = theme;
|
||||||
theme_clone.set(next);
|
let gen_clone = click_gen;
|
||||||
}
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
});
|
crate::utils::time::sleep_ms(450).await;
|
||||||
|
if gen_clone() == gen {
|
||||||
|
theme_clone.set(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 切换前后明暗不变(如 Light → System 且系统浅色):直接更新,无动画。
|
||||||
|
theme.set(next);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@ -206,48 +354,86 @@ pub fn ThemeToggle() -> Element {
|
|||||||
theme.set(next);
|
theme.set(next);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
if theme() == Theme::Dark {
|
// 图标按当前主题意图(而非 resolved)选择,用户能看出自己在哪种模式。
|
||||||
svg {
|
match theme() {
|
||||||
xmlns: "http://www.w3.org/2000/svg",
|
Theme::Dark => rsx! {
|
||||||
height: "24px",
|
svg {
|
||||||
view_box: "0 -960 960 960",
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
width: "24px",
|
height: "24px",
|
||||||
fill: "currentColor",
|
view_box: "0 -960 960 960",
|
||||||
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" }
|
width: "24px",
|
||||||
}
|
fill: "currentColor",
|
||||||
} else {
|
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" }
|
||||||
svg {
|
}
|
||||||
xmlns: "http://www.w3.org/2000/svg",
|
},
|
||||||
height: "24px",
|
Theme::Light => rsx! {
|
||||||
view_box: "0 -960 960 960",
|
svg {
|
||||||
width: "24px",
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
fill: "currentColor",
|
height: "24px",
|
||||||
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" }
|
view_box: "0 -960 960 960",
|
||||||
}
|
width: "24px",
|
||||||
|
fill: "currentColor",
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Theme::System => rsx! {
|
||||||
|
svg {
|
||||||
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
|
height: "24px",
|
||||||
|
view_box: "0 -960 960 960",
|
||||||
|
width: "24px",
|
||||||
|
fill: "currentColor",
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 主题意图的中文标签,用于 aria-label / title。
|
||||||
|
fn mode_label(theme: Theme) -> &'static str {
|
||||||
|
match theme {
|
||||||
|
Theme::Light => "浅色",
|
||||||
|
Theme::Dark => "深色",
|
||||||
|
Theme::System => "跟随系统",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_switches_light_to_dark() {
|
fn cycle_rotates_three_states() {
|
||||||
assert_eq!(Theme::Light.toggle(), Theme::Dark);
|
// 三态循环:Light → Dark → System → Light。
|
||||||
|
assert_eq!(Theme::Light.cycle(), Theme::Dark);
|
||||||
|
assert_eq!(Theme::Dark.cycle(), Theme::System);
|
||||||
|
assert_eq!(Theme::System.cycle(), Theme::Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_switches_dark_to_light() {
|
fn cycle_returns_to_origin_after_full_loop() {
|
||||||
assert_eq!(Theme::Dark.toggle(), Theme::Light);
|
// 走完一圈(三次)应回到起点。
|
||||||
|
assert_eq!(Theme::Light.cycle().cycle().cycle(), Theme::Light);
|
||||||
|
assert_eq!(Theme::Dark.cycle().cycle().cycle(), Theme::Dark);
|
||||||
|
assert_eq!(Theme::System.cycle().cycle().cycle(), Theme::System);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_is_an_involution() {
|
fn resolve_light_and_dark_ignore_system() {
|
||||||
// 连续切换两次应当回到原始主题。
|
// Light / Dark 的 resolve 与系统偏好无关。
|
||||||
assert_eq!(Theme::Light.toggle().toggle(), Theme::Light);
|
assert_eq!(Theme::Light.resolve(true), ResolvedTheme::Light);
|
||||||
assert_eq!(Theme::Dark.toggle().toggle(), Theme::Dark);
|
assert_eq!(Theme::Light.resolve(false), ResolvedTheme::Light);
|
||||||
|
assert_eq!(Theme::Dark.resolve(true), ResolvedTheme::Dark);
|
||||||
|
assert_eq!(Theme::Dark.resolve(false), ResolvedTheme::Dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_system_follows_system_dark() {
|
||||||
|
// System 的 resolve 由 system_dark 决定。
|
||||||
|
assert_eq!(Theme::System.resolve(true), ResolvedTheme::Dark);
|
||||||
|
assert_eq!(Theme::System.resolve(false), ResolvedTheme::Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -255,7 +441,24 @@ mod tests {
|
|||||||
// Theme 派生了 PartialEq,相同变体必须相等。
|
// Theme 派生了 PartialEq,相同变体必须相等。
|
||||||
assert_eq!(Theme::Light, Theme::Light);
|
assert_eq!(Theme::Light, Theme::Light);
|
||||||
assert_eq!(Theme::Dark, Theme::Dark);
|
assert_eq!(Theme::Dark, Theme::Dark);
|
||||||
|
assert_eq!(Theme::System, Theme::System);
|
||||||
assert_ne!(Theme::Light, Theme::Dark);
|
assert_ne!(Theme::Light, Theme::Dark);
|
||||||
|
assert_ne!(Theme::Dark, Theme::System);
|
||||||
|
assert_ne!(Theme::System, Theme::Light);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_theme_derives_equality() {
|
||||||
|
assert_eq!(ResolvedTheme::Light, ResolvedTheme::Light);
|
||||||
|
assert_eq!(ResolvedTheme::Dark, ResolvedTheme::Dark);
|
||||||
|
assert_ne!(ResolvedTheme::Light, ResolvedTheme::Dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_label_covers_all_variants() {
|
||||||
|
assert_eq!(mode_label(Theme::Light), "浅色");
|
||||||
|
assert_eq!(mode_label(Theme::Dark), "深色");
|
||||||
|
assert_eq!(mode_label(Theme::System), "跟随系统");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -274,6 +477,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn theme_preload_script_falls_back_to_prefers_color_scheme() {
|
fn theme_preload_script_falls_back_to_prefers_color_scheme() {
|
||||||
// 当 localStorage 中无主题时,脚本应回退到系统颜色偏好。
|
// 当 localStorage 中无主题时,脚本应回退到系统颜色偏好。
|
||||||
|
// System 模式移除 localStorage 后首屏即走此分支,保证零闪烁跟随系统。
|
||||||
assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
|
assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user