Skip to main content

frust_shell_common/
theme_default.rs

1//! Design-system-facing default-theme seed seam:
2//! `set_default_theme`/`default_theme`.
3//!
4//! # The gap this closes
5//!
6//! Every shell seeds itself with a built-in fallback theme
7//! (`Theme::neutral()`) that a design-system plugin has no way to
8//! replace — the plugin can only reach for [`crate::theme_override::set_app_theme`],
9//! but that seam **forces** the active theme end-to-end, pinning it against a
10//! live platform appearance change until `clear_app_theme` runs (see
11//! [`crate::theme_override::effective_brightness_for_platform_change`]). A
12//! Glyph-themed app installed that way would silently stop honouring system
13//! dark mode — wrong for a design system that only wants to supply the app's
14//! *starting point*, not commandeer its appearance forever. This module is
15//! the narrower seam that closes that gap: it supplies the **base** theme a
16//! shell seeds itself with in place of its own built-in fallback, while
17//! leaving brightness free to keep following the platform.
18//!
19//! # Precedence
20//!
21//! A shell resolves the active theme in this order, highest wins:
22//!
23//! 1. [`crate::theme_override::set_app_theme`] — an app-forced override, if
24//!    one is active. Brightness is pinned; see that module's
25//!    override-wins-over-appearance rule.
26//! 2. [`set_default_theme`] — the design-system-supplied base, if one was
27//!    seeded. Brightness is **not** pinned: the shell's base theme object
28//!    re-derives light/dark from the platform's own appearance against this
29//!    same base (why [`default_theme`] is a non-destructive read — see
30//!    below).
31//! 3. The shell's own built-in fallback (`Theme::neutral()`), when neither of
32//!    the above was ever set. A design system is installed, never assumed.
33//!
34//! # Layering choice
35//!
36//! This process-global slot lives in `frust-shell-common`, mirroring
37//! [`crate::theme_override`]'s slot shape (see that module's doc comment for
38//! the fuller layering rationale, which applies here unchanged):
39//! `frust-shell-common` already owns the shared, non-FFI "poll a
40//! process-global once per frame" plumbing every shell composes around, and
41//! already depends on `frust-theme` for the [`Theme`] type this slot holds.
42//!
43//! # Thread contract
44//!
45//! Like `theme_override` and `font_registry`, this is a plain `Mutex`-guarded
46//! slot with **no thread restriction** — [`set_default_theme`] may be called
47//! from any thread (documented, not enforced by a panic): a `Mutex` guards
48//! every access, and a shell only *reads* the slot at construction time and
49//! when `clear_app_theme` is called (to revert to the base seeded here), so a
50//! write racing in from a background thread is simply picked up (or not) on
51//! the next such read.
52//!
53//! # Non-destructive read
54//!
55//! Unlike [`crate::font_registry`]'s drain-on-poll shape,
56//! [`default_theme`] does **not** consume the slot: a shell needs the same
57//! seeded base again every time it re-seeds itself (when `clear_app_theme` is
58//! called to revert from an app override), not just once at construction. Two
59//! consecutive calls to [`default_theme`] with no intervening
60//! [`set_default_theme`] call return the same value.
61//!
62//! # Timing
63//!
64//! Intended to be called before a shell's first frame — typically from a
65//! design-system plugin's `install()`, which runs during app construction.
66//! A call *after* the first frame takes effect only on the next
67//! `clear_app_theme`-driven reseed, which may never happen if no app override
68//! is ever set. Late calls are supported but carry this limitation: a plugin
69//! cannot dynamically re-theme a live app by calling this at runtime.
70
71use std::sync::Mutex;
72
73use frust_theme::Theme;
74
75/// The process-wide default-theme slot: the design-system-supplied base
76/// [`Theme`] (`None` when no default has ever been seeded) plus a generation
77/// counter bumped on every [`set_default_theme`] call — mirrors
78/// [`crate::theme_override`]'s `OverrideSlot` shape.
79struct DefaultSlot {
80    theme: Option<Theme>,
81    #[allow(dead_code)]
82    generation: u64,
83}
84
85static DEFAULT: Mutex<DefaultSlot> = Mutex::new(DefaultSlot {
86    theme: None,
87    generation: 0,
88});
89
90/// Supply the base theme a shell seeds itself with, in place of its built-in
91/// fallback. Call before the first frame — typically from a design-system
92/// plugin's `install()`.
93///
94/// Unlike [`crate::theme_override::set_app_theme`], this does NOT pin
95/// brightness: the shell's retained theme object continues to re-derive
96/// light/dark from the platform's appearance against this same base (see the
97/// module docs' Precedence section). A late call (after the first frame) takes
98/// effect only if the app later calls `clear_app_theme`; until then, any
99/// active override dominates.
100///
101/// Callable from any thread (see the module docs' thread contract); the
102/// process-wide slot is a plain `Mutex`, not a UI-thread-only primitive.
103pub fn set_default_theme(theme: Theme) {
104    let mut slot = DEFAULT.lock().unwrap_or_else(|e| e.into_inner());
105    slot.theme = Some(theme);
106    slot.generation += 1;
107}
108
109/// Read the seeded default, if any (`None` when [`set_default_theme`] has
110/// never been called). **Non-destructive** — a shell may need it again when
111/// reverting an app override via `clear_app_theme`, to re-seed the base (see
112/// the module docs' Non-destructive read section); unlike
113/// [`crate::font_registry::FontRegistryWatcher::poll`], repeated calls with
114/// no intervening [`set_default_theme`] all return the same value rather than
115/// draining the slot.
116pub fn default_theme() -> Option<Theme> {
117    DEFAULT
118        .lock()
119        .unwrap_or_else(|e| e.into_inner())
120        .theme
121        .clone()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use frust_theme::Brightness;
128    use std::sync::Mutex as StdMutex;
129
130    // Serializes every test in this module against the shared process-wide
131    // `DEFAULT` static — mirrors `theme_override`'s `TEST_LOCK` pattern for a
132    // global the crate under test owns.
133    static TEST_LOCK: StdMutex<()> = StdMutex::new(());
134
135    /// Reset the process-wide slot to its pristine (never-seeded) state so
136    /// each test starts from a known baseline regardless of execution order.
137    fn reset_slot() {
138        let mut slot = DEFAULT.lock().unwrap_or_else(|e| e.into_inner());
139        slot.theme = None;
140        slot.generation = 0;
141    }
142
143    #[test]
144    fn default_theme_is_none_before_any_set() {
145        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
146        reset_slot();
147
148        assert_eq!(default_theme(), None);
149    }
150
151    #[test]
152    fn default_theme_read_is_non_destructive() {
153        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
154        reset_slot();
155
156        // A design-language-free baseline on purpose: a design system's own
157        // baseline lives in that design system's crate, which THIS crate does
158        // not depend on. Nothing below is design-system-specific — the slot
159        // stores whatever `Theme` it is handed.
160        let theme = Theme::neutral();
161        set_default_theme(theme.clone());
162
163        // Two (in fact three) consecutive reads all return the same value —
164        // no drain-on-read behavior like `font_registry`'s watcher.
165        assert_eq!(default_theme(), Some(theme.clone()));
166        assert_eq!(default_theme(), Some(theme.clone()));
167        assert_eq!(default_theme(), Some(theme));
168    }
169
170    #[test]
171    fn set_default_theme_replaces_a_previous_default() {
172        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
173        reset_slot();
174
175        set_default_theme(Theme::neutral());
176        assert_eq!(default_theme(), Some(Theme::neutral()));
177
178        // The second seed must be *visibly* different from the first, or the
179        // read-back below would pass even if the replace were a no-op. The
180        // cheapest visible edit over the same baseline: flip its brightness.
181        let replacement = Theme::neutral().with_brightness(Brightness::Dark);
182        assert_ne!(
183            replacement,
184            Theme::neutral(),
185            "the replacement must differ from the first seed for this test to be able to fail"
186        );
187        set_default_theme(replacement.clone());
188        assert_eq!(default_theme(), Some(replacement));
189    }
190
191    #[test]
192    fn set_default_theme_from_a_spawned_thread_is_observed() {
193        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
194        reset_slot();
195
196        // Design-system-independent baseline — see the note in
197        // `default_theme_read_is_non_destructive`.
198        let theme = Theme::neutral();
199        let handle = std::thread::spawn({
200            let theme = theme.clone();
201            move || set_default_theme(theme)
202        });
203        handle.join().expect("spawned thread must not panic");
204
205        assert_eq!(default_theme(), Some(theme));
206    }
207}