1use leptos::prelude::*;
18
19use crate::components::ui::{
20 IconName, SegmentedControl, SegmentedItem, SegmentedKind, SegmentedSize,
21};
22
23#[cfg(any(target_arch = "wasm32", test))]
25const THEME_KEY: &str = "yggdrasil-theme";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ResolvedTheme {
33 Light,
35 Dark,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Theme {
42 Light,
44 Dark,
46 System,
48}
49
50impl Theme {
51 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
70fn 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#[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
106pub fn provide_theme() -> RwSignal<Theme> {
123 let theme = RwSignal::new(Theme::System);
125 let system_dark = RwSignal::new(read_system_dark());
126 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::new(move |_| {
134 theme.set(detect_initial_theme());
135 initialized.set(true);
136 });
137
138 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 #[cfg(target_arch = "wasm32")]
176 {
177 let prev_resolved = StoredValue::new(None::<ResolvedTheme>);
179 Effect::new(move |_| {
180 if theme.get() != Theme::System {
182 return;
183 }
184 let current = resolved.get();
185 if prev_resolved.get_value() == Some(current) {
187 return;
188 }
189 prev_resolved.set_value(Some(current));
190 let is_dark = current == ResolvedTheme::Dark;
192 crate::bridges::globals::apply_resolved_theme(is_dark);
193 });
194 }
195
196 #[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 system_dark.set(media.matches());
212 let handler = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
213 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
231pub fn use_theme() -> RwSignal<Theme> {
235 expect_context::<RwSignal<Theme>>()
236}
237
238pub 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
259const 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#[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
295fn 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#[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 crate::bridges::globals::start_theme_transition(origin.0, origin.1);
329 }
330 }
331 theme.set(mode);
332}
333
334#[component]
343pub fn ThemeToggle(#[prop(optional)] icon_only: bool) -> impl IntoView {
344 let theme = use_theme();
345 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
378fn 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 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 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 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 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 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}