Skip to main content

rosace_core/
app_lifecycle.rs

1//! App lifecycle state (D042, built by D110/Phase 29): a
2//! `GlobalAtom<LifecycleState>` + `use_app_lifecycle()` hook.
3//!
4//! This lives in `rosace-core` (not `rosace-platform` or `rosace-ffi`)
5//! because it's a bridge between layers that don't depend on each other —
6//! the same reasoning as `ime_hint.rs`: the SETTERS are platform hosts
7//! (`rosace-ffi`'s mobile event dispatch today; desktop winit or web
8//! page-visibility could set it later), while the READERS are app
9//! components, and `rosace-core` is the lowest common layer both sides
10//! already depend on. D042 originally said "Affects: rosace-platform",
11//! but `rosace-platform` is unreachable from component code — D110
12//! explicitly re-opened the home question, resolved here.
13//!
14//! Distinct from `lifecycle.rs` (`on_mount`/`on_unmount`), which is
15//! per-COMPONENT lifecycle — this is the whole APP's foreground/background
16//! state as reported by the OS.
17
18use rosace_state::GlobalAtom;
19use rosace_trace::event::AtomId;
20
21use crate::context::Context;
22
23/// The app's OS-level lifecycle state (D042's four states).
24///
25/// Mobile semantics (the reason this exists — see `.steering/PHASE_29.md`):
26/// iOS maps `applicationDidBecomeActive`/`WillResignActive`/
27/// `DidEnterBackground`/`WillTerminate`, Android maps
28/// `onResume`/`onPause`/`onStop` (Android has no pre-kill callback, so
29/// `Suspended` is iOS-only in practice). Desktop apps simply stay `Active`
30/// — the default — since no host reports otherwise (desktop lifecycle is
31/// explicitly out of Phase 29's scope).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LifecycleState {
34    /// Foreground, receiving events. The startup default: by the time any
35    /// component can observe this atom, the app is running in front.
36    #[default]
37    Active,
38    /// Foreground but not receiving events (iOS: transitional — system
39    /// dialogs, app switcher, incoming call; Android: `onPause`).
40    Inactive,
41    /// Not visible; still in memory (iOS: `didEnterBackground`, Android:
42    /// `onStop`). Pause expensive work (animation, polling) here.
43    Background,
44    /// About to be terminated by the OS (iOS: `applicationWillTerminate`).
45    /// Last chance to persist state — there may be no further frames.
46    Suspended,
47}
48
49/// Reserved atom ID — next free slot below `KEYBOARD_TYPE_ATOM_ID`
50/// (`0xFFFA`); see `ime_hint.rs` for the full reserved-high-id list.
51const APP_LIFECYCLE_ATOM_ID: AtomId = AtomId(0xFFF9);
52
53static APP_LIFECYCLE: GlobalAtom<LifecycleState> =
54    GlobalAtom::new(APP_LIFECYCLE_ATOM_ID, || LifecycleState::Active);
55
56/// Read the app's lifecycle state from a component's `build()`, subscribing
57/// the component so it re-renders when the state changes (the explicit
58/// `subscribe` is required — `GlobalAtom`s aren't auto-subscribed by
59/// `ctx.state`'s hook machinery; same convention as `FormField::for_ctx`).
60pub fn use_app_lifecycle(ctx: &Context) -> LifecycleState {
61    APP_LIFECYCLE.get_or_init().subscribe(ctx.component_id());
62    APP_LIFECYCLE.get()
63}
64
65/// Read the current lifecycle state without subscribing — for engine/host
66/// code outside the component tree (a component should prefer
67/// [`use_app_lifecycle`] or it won't re-render on changes).
68pub fn app_lifecycle() -> LifecycleState {
69    APP_LIFECYCLE.get()
70}
71
72/// Report a lifecycle transition — called by the platform host (the FFI
73/// event dispatch on mobile). Notifies subscribers, so any component that
74/// read the state via [`use_app_lifecycle`] re-renders.
75pub fn set_app_lifecycle(state: LifecycleState) {
76    APP_LIFECYCLE.set(state);
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::sync::Mutex;
83
84    // `APP_LIFECYCLE` is a process-global static — tests touching it must
85    // be serialized against each other (same reasoning as `rosace-ffi`'s
86    // `KEYBOARD_TYPE_TEST_LOCK` and `capability.rs`'s `TEST_LOCK`), and
87    // each must restore `Active` before releasing the lock.
88    static TEST_LOCK: Mutex<()> = Mutex::new(());
89
90    #[test]
91    fn defaults_to_active() {
92        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
93        set_app_lifecycle(LifecycleState::Active); // in case a prior holder leaked
94        assert_eq!(app_lifecycle(), LifecycleState::Active);
95    }
96
97    #[test]
98    fn set_then_read_round_trips_every_state() {
99        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
100        for state in [
101            LifecycleState::Inactive,
102            LifecycleState::Background,
103            LifecycleState::Suspended,
104            LifecycleState::Active,
105        ] {
106            set_app_lifecycle(state);
107            assert_eq!(app_lifecycle(), state);
108        }
109        // Loop ends on Active — the reset other tests rely on.
110    }
111
112    #[test]
113    fn use_app_lifecycle_subscribes_the_calling_component_for_re_render() {
114        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
115        set_app_lifecycle(LifecycleState::Active);
116
117        let component = rosace_trace::event::ComponentId(4242);
118        let ctx = Context::new(component);
119        assert_eq!(use_app_lifecycle(&ctx), LifecycleState::Active);
120
121        // Drain anything already dirty, then transition: the subscribed
122        // component must land in the dirty set — that IS the re-render
123        // trigger the exit bar is about.
124        let _ = rosace_state::dirty_set::take_dirty_components();
125        set_app_lifecycle(LifecycleState::Background);
126        assert!(
127            rosace_state::dirty_set::take_dirty_components().contains(&component),
128            "a lifecycle transition must mark the subscribed component dirty"
129        );
130
131        APP_LIFECYCLE.get_or_init().unsubscribe(component);
132        set_app_lifecycle(LifecycleState::Active); // reset for other tests
133    }
134}