rosace_theme/provider.rs
1//! Global theme provider: `use_theme()` and `set_theme()`.
2
3use std::sync::OnceLock;
4
5use rosace_state::GlobalAtom;
6use rosace_trace::event::AtomId;
7
8use crate::built_in;
9use crate::theme::ThemeData;
10
11/// App-registered light/dark pair, set once at startup via
12/// [`register_theme_pair`]. [`sync_system_theme`]/[`set_theme_mode`] resolve
13/// through this (falling back to the generic `built_in` themes if the app
14/// never registered one) so a customized `theme.rs` (edited colors, a
15/// platform-specific `Themes` bundle, etc.) is honored by system-brightness
16/// switching too, instead of being silently discarded in favor of the
17/// generic built-ins.
18static THEME_PAIR: OnceLock<(ThemeData, ThemeData)> = OnceLock::new();
19
20/// Registers the app's own light/dark `ThemeData` — call once at startup
21/// (e.g. `rosace_theme::register_theme_pair(crate::theme::light(), crate::theme::dark())`)
22/// so [`ThemeMode::System`]/[`set_theme_mode`] apply YOUR themes rather than
23/// the generic `built_in::light_theme()`/`dark_theme()`. A second call is a
24/// no-op (first registration wins, matching `OnceLock` semantics) — call it
25/// exactly once, before the first frame.
26pub fn register_theme_pair(light: ThemeData, dark: ThemeData) {
27 let _ = THEME_PAIR.set((light, dark));
28}
29
30fn theme_pair() -> (ThemeData, ThemeData) {
31 THEME_PAIR.get().cloned().unwrap_or_else(|| (built_in::light_theme(), built_in::dark_theme()))
32}
33
34/// Stable atom ID reserved for the current-theme atom.
35///
36/// Must not collide with any dynamically generated atom IDs used elsewhere.
37/// Using a high fixed value (0xFFFF) leaves the low range free for runtime atoms.
38const THEME_ATOM_ID: AtomId = AtomId(0xFFFF);
39
40/// Reserved atom ID for the theme-mode atom (must not collide with other
41/// reserved IDs — see `THEME_ATOM_ID` above at 0xFFFF, `safe_area`'s
42/// `SAFE_AREA_ATOM_ID` at 0xFFFE, `platform`'s `PLATFORM_ATOM_ID` at 0xFFFD,
43/// `media_query`'s `MEDIA_QUERY_ATOM_ID` at 0xFFF4).
44const THEME_MODE_ATOM_ID: AtomId = AtomId(0xFFFC);
45
46/// App-wide theme atom. Defaults to the built-in light theme.
47///
48/// Changing this atom triggers a full re-render of all subscribed components.
49static CURRENT_THEME: GlobalAtom<ThemeData> =
50 GlobalAtom::new(THEME_ATOM_ID, built_in::light_theme);
51
52/// Whether the active theme should follow the OS light/dark setting, or is
53/// pinned by the app/user. Mirrors `rosace_core::platform`'s enum+atom shape.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ThemeMode {
56 /// Follow `rosace_core::media_query().is_dark` — the default. Every
57 /// native env-push call site calls [`sync_system_theme`] after updating
58 /// the media query, which swaps in `dark_theme()`/`light_theme()`.
59 System,
60 /// Locked to light regardless of the OS setting.
61 Light,
62 /// Locked to dark regardless of the OS setting.
63 Dark,
64}
65
66static CURRENT_THEME_MODE: GlobalAtom<ThemeMode> = GlobalAtom::new(THEME_MODE_ATOM_ID, || ThemeMode::System);
67
68/// Returns the active theme mode (`System` by default).
69pub fn use_theme_mode() -> ThemeMode {
70 CURRENT_THEME_MODE.get()
71}
72
73/// Pins the theme to `mode`. Passing `ThemeMode::System` re-enables
74/// following the OS setting on the next [`sync_system_theme`] call (e.g. the
75/// next native env-push, or call it directly to apply immediately).
76pub fn set_theme_mode(mode: ThemeMode) {
77 CURRENT_THEME_MODE.set(mode);
78 if mode != ThemeMode::System {
79 let (light, dark) = theme_pair();
80 set_theme(if mode == ThemeMode::Dark { dark } else { light });
81 // See the matching comment in `sync_system_theme` — forces the
82 // already-imminent next frame to actually repaint instead of
83 // waiting on an unrelated dirty trigger.
84 rosace_state::reset_to_global_dirty();
85 }
86}
87
88/// Applies the OS brightness (`rosace_core::media_query().is_dark`) to the
89/// active theme, but only while `use_theme_mode() == ThemeMode::System` — an
90/// app/user that pinned `Light`/`Dark` via [`set_theme_mode`] is left alone.
91/// Native platform code calls this right after every
92/// `rosace_core::set_media_query(..)` push.
93pub fn sync_system_theme() {
94 if use_theme_mode() != ThemeMode::System {
95 return;
96 }
97 let is_dark = rosace_core::media_query::use_media_query().is_dark;
98 let (light, dark) = theme_pair();
99 set_theme(if is_dark { dark } else { light });
100 // `set_theme` writes a `GlobalAtom`, which has no per-component
101 // subscribers in the dirty-tracking graph (unlike a `ctx.state()` atom
102 // read inside `build()`) — so `mark_dirty` is a silent no-op here and
103 // the actual repaint would otherwise wait for some UNRELATED event
104 // (mouse move, hover, an animation tick) to incidentally trigger the
105 // next real frame. A push from OUTSIDE the input-dispatch path (native
106 // OS env-change callback, not a widget click) has no such event to
107 // ride along on, so the theme change appeared to "eventually" apply
108 // after a random multi-second delay — reproduced and root-caused live.
109 // Force the next already-imminent frame (`set_theme`'s own
110 // `request_frame()` wakes the loop promptly) to actually repaint.
111 rosace_state::reset_to_global_dirty();
112}
113
114/// Returns a clone of the currently active `ThemeData`.
115///
116/// Components should call this during their `build()` method to access design
117/// tokens without any manual subscription setup.
118pub fn use_theme() -> ThemeData {
119 CURRENT_THEME.get()
120}
121
122/// Replaces the active theme with `theme` and notifies all subscribers.
123///
124/// Typically called at app startup or in response to a user preference change.
125/// Globally enable/disable animation on the LIVE theme — one call makes
126/// every Switch/Checkbox/Radio (and future animated widget) ease or snap.
127pub fn set_animations(enabled: bool) {
128 let mut t = use_theme();
129 t.animation.enabled = enabled;
130 set_theme(t);
131}
132
133pub fn set_theme(theme: ThemeData) {
134 CURRENT_THEME.set_always(theme); // ThemeData carries a non-PartialEq ext map
135}
136
137// ---------------------------------------------------------------------------
138// Tests
139// ---------------------------------------------------------------------------
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn use_theme_returns_valid_theme() {
147 let theme = use_theme();
148 // The default theme is the light theme, so is_dark should be false.
149 // However, if another test already called set_theme(), we just check
150 // that the returned value is structurally valid (spacing > 0).
151 assert!(theme.spacing.md > 0.0, "spacing.md should be positive");
152 assert!(theme.radius.md >= 0.0, "radius.md should be non-negative");
153 }
154
155 #[test]
156 fn set_theme_updates_the_global() {
157 use crate::built_in::dark_theme;
158
159 let dark = dark_theme();
160 set_theme(dark);
161
162 let current = use_theme();
163 assert!(current.is_dark, "theme should now be dark after set_theme");
164
165 // Restore light theme so other tests are not affected.
166 set_theme(crate::built_in::light_theme());
167 }
168
169 #[test]
170 fn use_theme_typography_is_consistent() {
171 let theme = use_theme();
172 assert!(
173 theme.typography.display_large.size > theme.typography.body_large.size,
174 "display_large should be larger than body_large in the active theme"
175 );
176 }
177
178 #[test]
179 fn sync_system_theme_follows_os_brightness_in_system_mode() {
180 set_theme_mode(ThemeMode::System);
181
182 let mut mq = rosace_core::media_query::use_media_query();
183 mq.is_dark = true;
184 rosace_core::set_media_query(mq);
185 sync_system_theme();
186 assert!(use_theme().is_dark, "System mode should follow OS dark push");
187
188 mq.is_dark = false;
189 rosace_core::set_media_query(mq);
190 sync_system_theme();
191 assert!(!use_theme().is_dark, "System mode should follow OS light push");
192 }
193
194 #[test]
195 fn sync_system_theme_leaves_a_pinned_mode_alone() {
196 set_theme_mode(ThemeMode::Dark);
197
198 let mut mq = rosace_core::media_query::use_media_query();
199 mq.is_dark = false; // OS says light...
200 rosace_core::set_media_query(mq);
201 sync_system_theme(); // ...but the app pinned Dark, so this must be a no-op.
202 assert!(use_theme().is_dark, "pinned ThemeMode::Dark must ignore OS brightness pushes");
203
204 set_theme_mode(ThemeMode::System); // restore for other tests
205 set_theme(crate::built_in::light_theme());
206 }
207}