Skip to main content

frust_shell_common/
theme_override.rs

1//! App-facing theme override seam: `frust::set_app_theme`/
2//! `clear_app_theme`.
3//!
4//! # The gap this closes
5//!
6//! Before this module, only a *shell* could set the active [`Theme`] —
7//! `RenderRoot::set_theme` (the widget path) and `provide_context` (the
8//! `use_context::<Theme>()` app-code path) were both shell-owned exclusively,
9//! seeded from the platform's light/dark preference (`WindowEvent::ThemeChanged`,
10//! `nativeSetAppearance`, `frust_set_appearance`). An app that wanted to force
11//! a specific `Theme` (e.g. the widget catalog's Material/Cupertino toggle)
12//! had nowhere to hook in — the gallery example's brightness toggle
13//! only touched the `provide_context` copy, which no widget reads (a
14//! documented gap this module closes).
15//!
16//! # Layering choice
17//!
18//! This process-global slot lives in `frust-shell-common`, not
19//! `frust-reactive` or `frust-theme`:
20//!
21//! - **Not `frust-theme`**: that crate is pure data + constructors
22//!   with no process-global/shell-polling concept of its own — adding one
23//!   would give the design-token crate a state-management responsibility it
24//!   has never had, and every consumer (including non-shell contexts, if any
25//!   ever exist) would inherit it.
26//! - **Not `frust-reactive`**: the deep-link slot there
27//!   (`frust_reactive::deep_link`) is a genuinely *reactive* source — app
28//!   code subscribes to `DeepLinks::latest` via `Get`/`Track` and a write wakes
29//!   the shell through the tracked-signal/`FrameWaker` machinery. A theme
30//!   override is not: no widget or `Component::build` tracks it reactively:
31//!   every shell already polls its *own* theme state once per frame (mirroring
32//!   the existing `set_appearance` path) and pushes it through the same two
33//!   non-reactive delivery calls (`RenderRoot::set_theme` + a `provide_context`
34//!   re-provide) the platform-appearance path already uses. Routing it through
35//!   `frust-reactive` would mean a plain `Mutex`-guarded value masquerading
36//!   as a signal for no benefit, and it would hand `frust-reactive` a
37//!   `frust-theme` dependency it has never needed.
38//! - **`frust-shell-common`** already owns the shared, non-FFI plumbing all
39//!   three shells compose (`AppTree`, `guard`, `sanitize_scale`) and is the one
40//!   crate every shell already imports for exactly this kind of "poll a
41//!   process-global once per frame, then push to both theme-delivery paths"
42//!   logic — the [`ThemeOverrideWatcher`] here is the per-shell-instance half
43//!   of that contract, [`set_app_theme`]/[`clear_app_theme`] the process-global
44//!   half. This does add a `frust-theme` dependency to `frust-shell-common`
45//!   (previously core+scene+text only) — a deliberate, narrow addition (see
46//!   `docs/ARCHITECTURE.md`'s Layer Dependencies), not a general theme-crate
47//!   dependency creeping into every layer: `frust-core`/`frust-scene` stay
48//!   theme-free.
49//!
50//! # Thread contract
51//!
52//! Unlike `push_deep_link`'s UI-thread-only panic contract, this slot is a
53//! plain `Mutex<OverrideSlot>` with **no thread restriction** — `set_app_theme`/
54//! `clear_app_theme` may be called from any thread (documented, not enforced by
55//! a panic): a `Mutex` guards every access, and each shell only *observes* the
56//! slot once per frame on its own UI thread via [`ThemeOverrideWatcher::poll`],
57//! so a write racing in from a background thread is simply picked up (or not)
58//! on the next frame — there is no tracked-signal wake to get racy about, so
59//! the stricter `push_deep_link`-style panic-off-thread contract buys nothing
60//! here. This is the simpler of the two available contracts.
61//!
62//! # Override-wins-over-appearance rule
63//!
64//! Once an app calls [`set_app_theme`], a live platform appearance change
65//! (`WindowEvent::ThemeChanged`/`nativeSetAppearance`/`frust_set_appearance`)
66//! must NOT flip the active theme's brightness back — the app-forced theme wins
67//! entirely until [`clear_app_theme`] runs. [`effective_brightness_for_platform_change`]
68//! is the pure, shared decision function every shell's appearance handler calls
69//! to implement this rule identically (see its own doc for the two cases).
70
71use std::sync::Mutex;
72
73use frust_theme::{Brightness, Theme};
74
75/// The process-wide override slot: the app's forced [`Theme`] (`None` when no
76/// override is active) plus a generation counter bumped on every
77/// [`set_app_theme`]/[`clear_app_theme`] call, so a [`ThemeOverrideWatcher`]
78/// can tell "changed since I last looked" apart from "still the same value".
79struct OverrideSlot {
80    theme: Option<Theme>,
81    generation: u64,
82}
83
84static OVERRIDE: Mutex<OverrideSlot> = Mutex::new(OverrideSlot {
85    theme: None,
86    generation: 0,
87});
88
89/// Force the app's active [`Theme`], overriding whatever the platform's own
90/// light/dark preference would otherwise select — reaching BOTH delivery paths
91/// (widget paint/layout via `RenderRoot::set_theme`, and `use_context::<Theme>()`
92/// via `provide_context`) the next time the running shell polls
93/// [`ThemeOverrideWatcher::poll`] (once per frame — see the module docs).
94///
95/// Callable from any thread (see the module docs' thread contract); the
96/// process-wide slot is a plain `Mutex`, not a UI-thread-only primitive.
97pub fn set_app_theme(theme: Theme) {
98    let mut slot = OVERRIDE.lock().unwrap_or_else(|e| e.into_inner());
99    slot.theme = Some(theme);
100    slot.generation += 1;
101}
102
103/// Clear a previously-set override, returning to the platform's own
104/// light/dark-derived default theme on the next poll (see [`set_app_theme`]).
105///
106/// A no-op call (no override was ever set) still bumps the generation, so a
107/// watcher that polled before any [`set_app_theme`]/[`clear_app_theme`] call
108/// and one that polls after a redundant `clear_app_theme` both observe the
109/// same "no override" state deterministically rather than depending on
110/// whether the slot happened to already be `None`.
111pub fn clear_app_theme() {
112    let mut slot = OVERRIDE.lock().unwrap_or_else(|e| e.into_inner());
113    slot.theme = None;
114    slot.generation += 1;
115}
116
117/// Whether an app-forced override is active right now, for a shell's
118/// appearance-change handler to consult before applying a platform brightness
119/// flip (see [`effective_brightness_for_platform_change`]). Reads the slot
120/// directly — unlike [`ThemeOverrideWatcher::poll`], this does not consume or
121/// depend on any per-caller "last seen" state.
122pub fn theme_override_active() -> bool {
123    OVERRIDE
124        .lock()
125        .unwrap_or_else(|e| e.into_inner())
126        .theme
127        .is_some()
128}
129
130/// Per-shell-instance watcher over the process-wide override slot: each of the
131/// three shells owns one, polling it once per frame (desktop: before rebuild in
132/// `RedrawRequested`; mobile: at the top of the frame callback) to detect a
133/// [`set_app_theme`]/[`clear_app_theme`] call since the last poll.
134#[derive(Debug, Default)]
135pub struct ThemeOverrideWatcher {
136    /// The slot generation as of the last [`poll`](Self::poll) call. Starts at
137    /// `0`, matching the slot's initial generation, so a shell that never
138    /// observes a `set_app_theme`/`clear_app_theme` call never sees a change
139    /// (no behavior change when the API is never called).
140    last_generation: u64,
141}
142
143impl ThemeOverrideWatcher {
144    /// A fresh watcher, matching the slot's initial (never-overridden) state.
145    pub fn new() -> Self {
146        Self { last_generation: 0 }
147    }
148
149    /// Poll the slot once. Returns:
150    /// - `None` — no [`set_app_theme`]/[`clear_app_theme`] call since the last
151    ///   poll (or since construction); the shell does nothing.
152    /// - `Some(Some(theme))` — a new forced `theme` to push to both delivery
153    ///   paths.
154    /// - `Some(None)` — the override was cleared; the shell should revert to
155    ///   its platform-derived default theme.
156    pub fn poll(&mut self) -> Option<Option<Theme>> {
157        let slot = OVERRIDE.lock().unwrap_or_else(|e| e.into_inner());
158        if slot.generation == self.last_generation {
159            return None;
160        }
161        self.last_generation = slot.generation;
162        Some(slot.theme.clone())
163    }
164}
165
166/// The override-wins-over-appearance rule (see the module docs), as a pure,
167/// shared decision every shell's platform-appearance handler (`WindowEvent::
168/// ThemeChanged`/`nativeSetAppearance`/`frust_set_appearance`) calls before
169/// mutating its stored theme's brightness:
170///
171/// - `override_active` (an app called [`set_app_theme`] and has not since
172///   called [`clear_app_theme`]): the platform change is ignored entirely —
173///   `current` (the override theme's own brightness) passes through unchanged.
174/// - Otherwise: `platform` (the newly reported platform preference) wins, the
175///   existing pre-override behavior.
176pub fn effective_brightness_for_platform_change(
177    override_active: bool,
178    current: Brightness,
179    platform: Brightness,
180) -> Brightness {
181    if override_active { current } else { platform }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::sync::Mutex as StdMutex;
188
189    // Serializes every test in this module against the shared process-wide
190    // `OVERRIDE` static — mirrors `frust_reactive`'s `WAKER_TEST_LOCK`
191    // pattern for a global the crate under test owns.
192    static TEST_LOCK: StdMutex<()> = StdMutex::new(());
193
194    /// Reset the process-wide slot to its pristine (never-overridden) state so
195    /// each test starts from a known baseline regardless of execution order.
196    fn reset_slot() {
197        let mut slot = OVERRIDE.lock().unwrap_or_else(|e| e.into_inner());
198        slot.theme = None;
199        slot.generation = 0;
200    }
201
202    #[test]
203    fn set_app_theme_bumps_generation_and_watcher_observes_it_once() {
204        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
205        reset_slot();
206
207        let mut watcher = ThemeOverrideWatcher::new();
208        // No call yet: a fresh watcher sees no pending change.
209        assert_eq!(watcher.poll(), None);
210
211        let theme = Theme::neutral();
212        set_app_theme(theme.clone());
213        assert!(theme_override_active());
214
215        let observed = watcher.poll();
216        assert_eq!(observed, Some(Some(theme)));
217        // The same generation is not re-delivered on a second poll.
218        assert_eq!(watcher.poll(), None);
219    }
220
221    #[test]
222    fn clear_app_theme_delivers_none_and_deactivates() {
223        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
224        reset_slot();
225
226        let mut watcher = ThemeOverrideWatcher::new();
227        set_app_theme(Theme::neutral());
228        watcher.poll(); // consume the set
229
230        clear_app_theme();
231        assert!(!theme_override_active());
232        assert_eq!(watcher.poll(), Some(None));
233        assert_eq!(watcher.poll(), None);
234    }
235
236    #[test]
237    fn independent_watchers_each_see_the_change_once() {
238        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
239        reset_slot();
240
241        let mut a = ThemeOverrideWatcher::new();
242        let mut b = ThemeOverrideWatcher::new();
243        let theme = Theme::neutral();
244        set_app_theme(theme.clone());
245
246        assert_eq!(a.poll(), Some(Some(theme.clone())));
247        assert_eq!(b.poll(), Some(Some(theme)));
248        assert_eq!(a.poll(), None);
249        assert_eq!(b.poll(), None);
250    }
251
252    #[test]
253    fn never_calling_the_api_leaves_a_fresh_watcher_silent() {
254        // Acceptance criterion 2: no behavior change when the API is never
255        // called. A watcher that never sees a set/clear call must never report
256        // a pending change, regardless of what earlier tests left in the slot
257        // (reset to the pristine state here, then only ever polled).
258        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
259        reset_slot();
260
261        let mut watcher = ThemeOverrideWatcher::new();
262        assert_eq!(watcher.poll(), None);
263        assert_eq!(watcher.poll(), None);
264        assert!(!theme_override_active());
265    }
266
267    #[test]
268    fn effective_brightness_respects_override_wins_rule() {
269        // No override: the platform's newly reported preference wins (the
270        // pre-override behavior).
271        assert_eq!(
272            effective_brightness_for_platform_change(false, Brightness::Light, Brightness::Dark),
273            Brightness::Dark
274        );
275        // Override active: the platform change is ignored; the override
276        // theme's own current brightness passes through unchanged.
277        assert_eq!(
278            effective_brightness_for_platform_change(true, Brightness::Light, Brightness::Dark),
279            Brightness::Light
280        );
281    }
282}