Skip to main content

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}