frust_shell_common/system_ui.rs
1//! App-facing system-UI (system-bar) override slot: `frust::set_system_ui_mode`
2//! (Flutter `SystemChrome.setEnabledSystemUIMode` parity).
3//!
4//! # The gap this closes
5//!
6//! Before this module every app that wanted to hide the status/navigation
7//! bars hardcoded the platform call directly in generated project glue (e.g.
8//! `examples/shadertoy/android/.../MainActivity.kt`'s
9//! `WindowCompat.getInsetsController(...).hide(systemBars())`, or
10//! `FrustViewController.swift`'s `prefersStatusBarHidden`) — there was no
11//! app-facing Rust API and no cross-platform channel to drive one from. This
12//! module is the Rust-side half; each mobile shell's own FFI layer decodes
13//! and applies it, threaded into that shell's per-frame wiring.
14//!
15//! # Layering choice
16//!
17//! Same rationale as [`crate::theme_override`]: `frust-shell-common` already
18//! owns the "process-global `Mutex` slot + generation counter, polled once
19//! per frame by a per-shell watcher" pattern
20//! ([`crate::theme_override::ThemeOverrideWatcher`],
21//! [`crate::font_registry::FontRegistryWatcher`]) — this module mirrors that
22//! shape exactly rather than introducing a new one. It adds no new
23//! dependency: `SystemUiMode`/`SystemUiOverlay` are plain enums, not
24//! `frust-theme` types.
25//!
26//! # Thread contract
27//!
28//! Like [`crate::theme_override::set_app_theme`], [`set_system_ui_mode`] is
29//! callable from any thread — a plain `Mutex` guards the slot, and each
30//! shell only *observes* it once per frame on its own UI thread via
31//! [`SystemUiWatcher::poll`] (or the FFI-side [`encoded_state`] peek —
32//! see below).
33//!
34//! # FFI encoding
35//!
36//! [`encoded_state`] is the single source of the wire format both mobile
37//! shells' FFI getters export verbatim; each shell's own Kotlin/Swift
38//! decoder is built against this doc. The packed `u64` is
39//! `(generation << 8) | mode_bits`:
40//!
41//! - low byte, mode discriminant: `0` = [`SystemUiMode::EdgeToEdge`], `1` =
42//! [`SystemUiMode::Immersive`], `2` = [`SystemUiMode::ImmersiveSticky`],
43//! `3` = [`SystemUiMode::LeanBack`], `4` = [`SystemUiMode::Manual`].
44//! - for `Manual`, two additional flag bits on top of the `4` discriminant:
45//! bit 4 (`0x10`) = `top`, bit 5 (`0x20`) = `bottom`.
46//! - generation occupies every bit above the low byte, so a platform side
47//! can tell "changed since I last looked" apart from "still the same
48//! value" the same way [`SystemUiWatcher::poll`] does, without needing a
49//! second FFI call.
50//!
51//! Generation `0` (the initial, never-called state) means nothing has been
52//! requested yet — a platform shell should leave its own default system-bar
53//! behavior untouched until it observes a generation advance.
54//!
55//! # Platform behavior differences
56//!
57//! This module models the full Flutter-parity vocabulary, but neither
58//! platform can express all five modes faithfully:
59//!
60//! - **Android 16 (API 36+) forces edge-to-edge** and silently ignores every
61//! other mode (a Flutter breaking change carried over here, not a Frust
62//! choice) — an app targeting API 36+ that requests
63//! [`SystemUiMode::Immersive`] (or any non-`EdgeToEdge` mode) sees no
64//! effect on those OS versions.
65//! - **iOS has no sticky/non-sticky or lean-back distinction.** Every
66//! hiding mode (`Immersive`/`ImmersiveSticky`/`LeanBack`) maps to the same
67//! iOS behavior: status bar hidden + home-indicator *auto*-hide (never a
68//! force-hide) — the system, not the app, decides when a swipe re-reveals
69//! it, and always swallows the edge-swipe gesture rather than delivering
70//! it to the app (unlike Android's `Immersive`, which lets the gesture
71//! through). `Manual { top, bottom }` on iOS folds to hiding the status
72//! bar when `!top` and has no separate control for `bottom` (there is no
73//! iOS home-indicator equivalent of a bottom system bar to show/hide
74//! independently).
75
76use std::sync::Mutex;
77
78/// One of the two system bars a [`SystemUiMode::Manual`] mode can name —
79/// Flutter's `SystemUiOverlay` kept here for doc/mapping parity even though
80/// [`SystemUiMode::Manual`] itself uses named bools (`top`/`bottom`) rather
81/// than a `Vec<SystemUiOverlay>`, the more Rust-idiomatic shape for a
82/// fixed two-element set.
83#[derive(Clone, Copy, PartialEq, Eq, Debug)]
84pub enum SystemUiOverlay {
85 /// The top system bar (Android status bar; iOS status bar).
86 Top,
87 /// The bottom system bar (Android navigation bar; iOS home indicator).
88 Bottom,
89}
90
91/// The requested system-bar visibility mode (Flutter `SystemUiMode` parity —
92/// see the module docs' platform-behavior-differences section for where
93/// Android/iOS diverge from this vocabulary).
94#[derive(Clone, Copy, PartialEq, Eq, Debug)]
95pub enum SystemUiMode {
96 /// Default: bars visible, app draws edge-to-edge behind them.
97 EdgeToEdge,
98 /// Hide all bars; any edge swipe re-shows them (system keeps the gesture).
99 Immersive,
100 /// Hide all bars; transient overlay on swipe, auto-hides again.
101 ImmersiveSticky,
102 /// Hide all bars; re-shown by system interactions (tap on Android leanback).
103 LeanBack,
104 /// Show exactly the listed overlays.
105 Manual {
106 /// Whether the top bar (status bar) is shown.
107 top: bool,
108 /// Whether the bottom bar (nav bar / home indicator) is shown.
109 bottom: bool,
110 },
111}
112
113/// The process-wide slot: the last requested [`SystemUiMode`] plus a
114/// generation counter bumped on every [`set_system_ui_mode`] call, so a
115/// [`SystemUiWatcher`] (or the FFI-side [`encoded_state`] peek) can tell
116/// "changed since I last looked" apart from "still the same value".
117struct SystemUiSlot {
118 mode: SystemUiMode,
119 generation: u64,
120}
121
122/// Initial state: [`SystemUiMode::EdgeToEdge`] at generation `0` — "nothing
123/// to apply", per the module docs' FFI-encoding section. Platform defaults
124/// stand until an app actually calls [`set_system_ui_mode`].
125static SYSTEM_UI: Mutex<SystemUiSlot> = Mutex::new(SystemUiSlot {
126 mode: SystemUiMode::EdgeToEdge,
127 generation: 0,
128});
129
130/// Request a system-bar visibility mode, reaching whichever shell is running
131/// the next time it polls (once per frame — see the module docs' thread
132/// contract). Callable from any thread; the process-wide slot is a plain
133/// `Mutex`, not a UI-thread-only primitive.
134pub fn set_system_ui_mode(mode: SystemUiMode) {
135 let mut slot = SYSTEM_UI.lock().unwrap_or_else(|e| e.into_inner());
136 slot.mode = mode;
137 slot.generation += 1;
138}
139
140/// A cheap peek at the slot's current `(generation, mode)` pair, for a
141/// caller that wants the raw state without consuming/tracking a
142/// [`SystemUiWatcher`]'s "last seen" cursor — e.g. [`encoded_state`], or an
143/// FFI glue module polling from the platform side.
144pub fn current_system_ui_mode() -> (u64, SystemUiMode) {
145 let slot = SYSTEM_UI.lock().unwrap_or_else(|e| e.into_inner());
146 (slot.generation, slot.mode)
147}
148
149/// Pack the slot's current `(generation, mode)` into a single `u64` for an
150/// FFI getter to return verbatim — see the module docs' FFI-encoding
151/// section for the exact bit layout. Each mobile shell exports this
152/// unchanged; its own Kotlin/Swift decoder is built against it.
153pub fn encoded_state() -> u64 {
154 let (generation, mode) = current_system_ui_mode();
155 let low: u64 = match mode {
156 SystemUiMode::EdgeToEdge => 0,
157 SystemUiMode::Immersive => 1,
158 SystemUiMode::ImmersiveSticky => 2,
159 SystemUiMode::LeanBack => 3,
160 SystemUiMode::Manual { top, bottom } => 4 | ((top as u64) << 4) | ((bottom as u64) << 5),
161 };
162 (generation << 8) | low
163}
164
165/// Per-shell-instance watcher over the process-wide system-UI slot: each
166/// shell owns one, polling it once per frame (mirroring
167/// [`crate::theme_override::ThemeOverrideWatcher`]) to detect a
168/// [`set_system_ui_mode`] call since the last poll.
169#[derive(Debug, Default)]
170pub struct SystemUiWatcher {
171 /// The slot generation as of the last [`poll`](Self::poll) call. Starts
172 /// at `0`, matching the slot's initial generation, so a shell that never
173 /// observes a `set_system_ui_mode` call never sees a change.
174 last_generation: u64,
175}
176
177impl SystemUiWatcher {
178 /// A fresh watcher, matching the slot's initial (never-requested) state.
179 pub fn new() -> Self {
180 Self { last_generation: 0 }
181 }
182
183 /// Poll the slot once. Returns:
184 /// - `None` — no [`set_system_ui_mode`] call since the last poll (or
185 /// since construction); the shell does nothing.
186 /// - `Some(mode)` — a new mode to apply.
187 pub fn poll(&mut self) -> Option<SystemUiMode> {
188 let slot = SYSTEM_UI.lock().unwrap_or_else(|e| e.into_inner());
189 if slot.generation == self.last_generation {
190 return None;
191 }
192 self.last_generation = slot.generation;
193 Some(slot.mode)
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::sync::Mutex as StdMutex;
201 use std::thread;
202
203 // Serializes every test in this module against the shared process-wide
204 // `SYSTEM_UI` static — mirrors `theme_override`'s `TEST_LOCK` pattern for
205 // a global the crate under test owns.
206 static TEST_LOCK: StdMutex<()> = StdMutex::new(());
207
208 /// Reset the process-wide slot to its pristine (never-requested) state so
209 /// each test starts from a known baseline regardless of execution order.
210 fn reset_slot() {
211 let mut slot = SYSTEM_UI.lock().unwrap_or_else(|e| e.into_inner());
212 slot.mode = SystemUiMode::EdgeToEdge;
213 slot.generation = 0;
214 }
215
216 #[test]
217 fn set_system_ui_mode_bumps_generation_and_watcher_observes_it_once() {
218 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
219 reset_slot();
220
221 let mut watcher = SystemUiWatcher::new();
222 // No call yet: a fresh watcher sees no pending change.
223 assert_eq!(watcher.poll(), None);
224
225 set_system_ui_mode(SystemUiMode::Immersive);
226
227 let observed = watcher.poll();
228 assert_eq!(observed, Some(SystemUiMode::Immersive));
229 // The same generation is not re-delivered on a second poll.
230 assert_eq!(watcher.poll(), None);
231 }
232
233 #[test]
234 fn independent_watchers_each_see_the_change_once() {
235 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
236 reset_slot();
237
238 let mut a = SystemUiWatcher::new();
239 let mut b = SystemUiWatcher::new();
240 set_system_ui_mode(SystemUiMode::LeanBack);
241
242 assert_eq!(a.poll(), Some(SystemUiMode::LeanBack));
243 assert_eq!(b.poll(), Some(SystemUiMode::LeanBack));
244 assert_eq!(a.poll(), None);
245 assert_eq!(b.poll(), None);
246 }
247
248 #[test]
249 fn never_calling_the_api_leaves_a_fresh_watcher_silent() {
250 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
251 reset_slot();
252
253 let mut watcher = SystemUiWatcher::new();
254 assert_eq!(watcher.poll(), None);
255 assert_eq!(watcher.poll(), None);
256 }
257
258 #[test]
259 fn set_from_a_spawned_thread_is_observed_on_the_polling_thread() {
260 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
261 reset_slot();
262
263 let mut watcher = SystemUiWatcher::new();
264 assert_eq!(watcher.poll(), None);
265
266 thread::spawn(|| {
267 set_system_ui_mode(SystemUiMode::ImmersiveSticky);
268 })
269 .join()
270 .unwrap();
271
272 assert_eq!(watcher.poll(), Some(SystemUiMode::ImmersiveSticky));
273 }
274
275 #[test]
276 fn encoded_state_packs_generation_and_each_mode() {
277 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
278 reset_slot();
279
280 // Initial state: generation 0, EdgeToEdge (mode bits 0).
281 assert_eq!(encoded_state(), 0);
282
283 set_system_ui_mode(SystemUiMode::EdgeToEdge);
284 assert_eq!(encoded_state(), 1u64 << 8);
285
286 set_system_ui_mode(SystemUiMode::Immersive);
287 assert_eq!(encoded_state(), (2u64 << 8) | 1);
288
289 set_system_ui_mode(SystemUiMode::ImmersiveSticky);
290 assert_eq!(encoded_state(), (3u64 << 8) | 2);
291
292 set_system_ui_mode(SystemUiMode::LeanBack);
293 assert_eq!(encoded_state(), (4u64 << 8) | 3);
294 }
295
296 #[test]
297 fn encoded_state_packs_manual_flag_combinations() {
298 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
299 reset_slot();
300
301 set_system_ui_mode(SystemUiMode::Manual {
302 top: false,
303 bottom: false,
304 });
305 assert_eq!(encoded_state(), (1u64 << 8) | 4);
306
307 set_system_ui_mode(SystemUiMode::Manual {
308 top: true,
309 bottom: false,
310 });
311 assert_eq!(encoded_state(), (2u64 << 8) | 4 | (1 << 4));
312
313 set_system_ui_mode(SystemUiMode::Manual {
314 top: false,
315 bottom: true,
316 });
317 assert_eq!(encoded_state(), (3u64 << 8) | 4 | (1 << 5));
318
319 set_system_ui_mode(SystemUiMode::Manual {
320 top: true,
321 bottom: true,
322 });
323 assert_eq!(encoded_state(), (4u64 << 8) | 4 | (1 << 4) | (1 << 5));
324 }
325
326 #[test]
327 fn current_system_ui_mode_peeks_without_consuming_a_watcher_cursor() {
328 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
329 reset_slot();
330
331 assert_eq!(current_system_ui_mode(), (0, SystemUiMode::EdgeToEdge));
332 set_system_ui_mode(SystemUiMode::Immersive);
333 assert_eq!(current_system_ui_mode(), (1, SystemUiMode::Immersive));
334 // A peek doesn't consume anything — repeated calls see the same value.
335 assert_eq!(current_system_ui_mode(), (1, SystemUiMode::Immersive));
336 }
337}