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}