Skip to main content

lumen_primitives/
wake.rs

1//! Reactive wake: schedule a follow-up tick whenever the current tick
2//! wrote typed properties that downstream consumers haven't observed
3//! yet.
4//!
5//! ## Why this exists - "the dialog is slow"
6//!
7//! The app only ticks on wakes (input events, or the window backend's
8//! `work_pending` re-arm). Several state chains need **one more tick**
9//! after the tick that writes a [`PropertyStore`] cell before their
10//! effect is visible:
11//!
12//! * a click handler writes `dialog_open` -> `reconcile_if_blocks`
13//!   mounts / despawns the `<dialog>` body on the *next* tick,
14//! * `push_slider_to_signal` mirrors a drag into its signal -> bound
15//!   labels pull it on the next tick,
16//! * a derivation writes a derived cell that another consumer reads.
17//!
18//! Without this system, that "next tick" never gets scheduled: the
19//! window backend re-arms only when [`AnimationsActive`] or
20//! `FrameDirty` says so, and a bare property write raises neither. The
21//! transition then sat parked until an unrelated wake - measured live
22//! on widget-garden: **~550 ms to open the dialog, ~4 s to close it**
23//! (whenever the next incidental event happened to arrive).
24//!
25//! The fix: at the end of every tick (`A11ySync`, *before*
26//! `clear_property_store_dirty` wipes the per-tick queue), raise
27//! [`AnimationsActive`] when the dirty queue is non-empty. The window
28//! backend's `work_pending` check then re-arms a redraw, the follow-up
29//! tick runs within a frame, and the consumers converge. Quiescence is
30//! preserved: semantically-equal writes never enter the dirty queue,
31//! so a settled UI stops requesting ticks the moment no cell actually
32//! changes.
33
34use bevy_ecs::prelude::*;
35use lumen_core::property_store::PropertyStore;
36use lumen_core::render_world::AnimationsActive;
37
38/// Raise [`AnimationsActive`] when this tick ends with undrained
39/// property writes, so the write's downstream consumers (if/for
40/// reconcilers, binding pulls, derivations-of-derivations) get their
41/// follow-up tick within one frame instead of waiting for the next
42/// unrelated input event.
43///
44/// Registered by [`crate::ControlsPlugin`] in `TickStage::A11ySync`,
45/// ordered before `lumen_core::property_store::clear_property_store_dirty`
46/// (which empties the queue at the very end of the tick).
47pub fn request_tick_on_property_writes(
48    store: Option<Res<PropertyStore>>,
49    anim: Option<Res<AnimationsActive>>,
50) {
51    let (Some(store), Some(anim)) = (store, anim) else {
52        return;
53    };
54    if !store.dirty_peek().is_empty() {
55        anim.request();
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use bevy_ecs::system::RunSystemOnce;
63
64    fn world_with(store_dirty: bool) -> World {
65        let mut world = World::new();
66        let mut store = PropertyStore::default();
67        if store_dirty {
68            store.set_global_str("dialog_open", "1");
69        }
70        world.insert_resource(store);
71        world.insert_resource(AnimationsActive::default());
72        world
73    }
74
75    #[test]
76    fn dirty_store_requests_a_follow_up_tick() {
77        let mut world = world_with(true);
78        world
79            .run_system_once(request_tick_on_property_writes)
80            .unwrap();
81        assert!(
82            world.resource::<AnimationsActive>().get(),
83            "an undrained property write must schedule another tick"
84        );
85    }
86
87    #[test]
88    fn clean_store_stays_quiescent() {
89        let mut world = world_with(false);
90        world
91            .run_system_once(request_tick_on_property_writes)
92            .unwrap();
93        assert!(
94            !world.resource::<AnimationsActive>().get(),
95            "no writes -> no wake -> the scheduler may park"
96        );
97    }
98
99    #[test]
100    fn equal_value_rewrite_does_not_spin() {
101        // Semantically-equal writes skip the dirty push entirely, so a
102        // steady-state system re-writing the same value every tick
103        // cannot keep the app awake.
104        let mut world = world_with(true);
105        world.resource_mut::<PropertyStore>().clear_dirty();
106        world
107            .resource_mut::<PropertyStore>()
108            .set_global_str("dialog_open", "1"); // same value as before
109        world
110            .run_system_once(request_tick_on_property_writes)
111            .unwrap();
112        assert!(
113            !world.resource::<AnimationsActive>().get(),
114            "equal-value rewrite must not request a tick"
115        );
116    }
117}