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}