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}