euv_ui/component/theme/hook/impl.rs
1use super::*;
2
3/// Implementation of theme state functionality.
4///
5/// Provides methods for creating theme state, detecting system theme changes,
6/// and managing theme-related reactive state.
7impl ThemeState {
8 /// Creates theme state for managing light/dark mode and root CSS classes.
9 ///
10 /// Detects the system color scheme preference at startup and initializes the
11 /// theme signal accordingly. Uses `watch!` to reactively update the root class
12 /// whenever the theme or mobile signal changes.
13 ///
14 /// # Arguments
15 ///
16 /// - `Signal<bool>` - The reactive signal indicating whether the viewport is mobile-sized.
17 ///
18 /// # Returns
19 ///
20 /// - `ThemeState` - The reactive theme state containing the theme signal and root class signal.
21 pub fn use_theme_state(mobile_signal: Signal<bool>) -> ThemeState {
22 let theme: Signal<String> = App::use_signal(Self::detect_system_theme);
23 Self::use_system_theme_change(theme);
24 let initial_theme: String = theme.get();
25 let initial_mobile: bool = mobile_signal.get();
26 let initial_root: &'static str = if initial_mobile {
27 c_mobile_app_root().get_name()
28 } else {
29 c_app_root().get_name()
30 };
31 let root_class: Signal<String> = App::use_signal(|| {
32 format!(
33 "{initial_root} {scheme_class} {theme_class}",
34 scheme_class = scheme_class_name(),
35 theme_class = Self::theme_class_name(&initial_theme)
36 )
37 });
38 watch!(mobile_signal, theme, |mobile: bool, theme_value: String| {
39 let root: &'static str = if mobile {
40 c_mobile_app_root().get_name()
41 } else {
42 c_app_root().get_name()
43 };
44 root_class.set(format!(
45 "{root} {scheme_class} {theme_class}",
46 scheme_class = scheme_class_name(),
47 theme_class = Self::theme_class_name(&theme_value)
48 ));
49 });
50 ThemeState { theme, root_class }
51 }
52
53 /// Subscribes to system color scheme changes and updates the theme signal.
54 ///
55 /// Creates a `MediaQueryList` for `prefers-color-scheme: dark` and listens
56 /// for `change` events. When the system theme changes, the theme signal is
57 /// updated accordingly. The listener is automatically cleaned up when the
58 /// hook context is cleared.
59 ///
60 /// # Arguments
61 ///
62 /// - `Signal<String>` - The theme signal to update when the system theme changes.
63 pub fn use_system_theme_change(theme_signal: Signal<String>) {
64 let Some(window) = window() else {
65 return;
66 };
67 let media_query: Option<MediaQueryList> = window
68 .match_media(THEME_DARK_SCHEME_MEDIA_QUERY)
69 .ok()
70 .flatten();
71 let Some(mql) = media_query else {
72 return;
73 };
74 let closure: Closure<dyn FnMut(Event)> = Closure::wrap(Box::new(move |_: Event| {
75 let detected: String = Self::detect_system_theme();
76 let current: String = theme_signal.get();
77 if current != detected {
78 theme_signal.set(detected);
79 }
80 }));
81 let _: Result<(), JsValue> = mql.add_event_listener_with_callback(
82 THEME_MEDIA_QUERY_CHANGE_EVENT,
83 closure.as_ref().unchecked_ref(),
84 );
85 closure.forget();
86 }
87
88 /// Detects the current system color scheme preference.
89 ///
90 /// Uses `window.matchMedia("(prefers-color-scheme: dark)")` to check whether
91 /// the operating system is in dark mode. Falls back to light theme if the
92 /// detection fails.
93 ///
94 /// # Returns
95 ///
96 /// - `String` - The detected system theme name ("light" or "dark").
97 pub fn detect_system_theme() -> String {
98 let Some(window) = window() else {
99 return THEME_LIGHT.to_string();
100 };
101 let is_dark: bool = window
102 .match_media(THEME_DARK_SCHEME_MEDIA_QUERY)
103 .ok()
104 .flatten()
105 .map(|mql: MediaQueryList| mql.matches())
106 .unwrap_or_default();
107 if is_dark {
108 THEME_DARK.to_string()
109 } else {
110 THEME_LIGHT.to_string()
111 }
112 }
113
114 /// Returns the CSS class name for the given theme value.
115 ///
116 /// # Arguments
117 ///
118 /// - `&str` - The theme name ("light" or "dark").
119 ///
120 /// # Returns
121 ///
122 /// - `&'static str` - The CSS class name for the theme.
123 pub(crate) fn theme_class_name(theme: &str) -> &'static str {
124 if theme == THEME_DARK {
125 c_theme_dark().get_name()
126 } else {
127 c_theme_light().get_name()
128 }
129 }
130
131 /// Creates a click event handler that toggles the theme between "light" and "dark".
132 ///
133 /// The switch stays smooth even on pages with many elements (e.g. the list
134 /// page) because individual components no longer carry their own colour
135 /// (`background` / `color` / `border-color`) transitions — only the app root
136 /// container animates its colour change, so the page fades as a whole while
137 /// each component snaps to the new theme instantly. This avoids hundreds of
138 /// elements running simultaneous colour transitions, which previously caused
139 /// dropped frames.
140 ///
141 /// # Arguments
142 ///
143 /// - `Signal<String>` - The theme signal to toggle.
144 ///
145 /// # Returns
146 ///
147 /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler that flips the theme value.
148 pub fn toggle(theme_signal: Signal<String>) -> Option<Rc<dyn Fn(Event)>> {
149 Some(Rc::new(move |_: Event| {
150 let current: String = theme_signal.get();
151 if current == THEME_LIGHT {
152 theme_signal.set(THEME_DARK.to_string());
153 } else {
154 theme_signal.set(THEME_LIGHT.to_string());
155 }
156 }))
157 }
158}