Skip to main content

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}