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