lumen_core/tick.rs
1//! Main-world tick stages and the [`Tick`] resource.
2//!
3//! - Each stage is a `bevy_ecs` [`SystemSet`].
4//! - Ordering is enforced by `.chain()` in [`crate::app::App::new`].
5//! - The render schedule runs after the main schedule and the extract step; see [`crate::render_world`].
6
7use crate::plugin_events::plugin_events_pending;
8use crate::property_store::external_properties_pending;
9use crate::render_world::{AnimationsActive, FrameDirty};
10use crate::time::{Duration, Instant};
11use bevy_ecs::prelude::*;
12
13/// The five ordered main-world stages of a Lumen tick.
14#[derive(SystemSet, Clone, Copy, Debug, Hash, PartialEq, Eq)]
15pub enum TickStage {
16 /// Ingests OS events (keyboard, mouse, IME, window). Window backend writes here.
17 Input,
18 /// Drains the bounded [`crate::command::CommandQueue`] and applies deferred mutations.
19 CommandDrain,
20 /// Runs application systems: state mutation, animations, scripts.
21 Systems,
22 /// Runs the layout engine: dirty flush, taffy round-trip, absolute-coord write-back.
23 LayoutSync,
24 /// Computes the accessibility-tree diff and pushes it to the OS.
25 A11ySync,
26}
27
28/// Per-tick frame clock resource.
29///
30/// - [`Self::now`] is captured at the start of each [`crate::app::App::tick`] before the [`TickStage::Input`] systems run.
31/// - [`Self::dt`] is `now - previous_now` (zero on the first tick).
32/// - [`Self::frame`] is a monotonic counter incremented once per tick (starts at 0; reaches 1 on the first tick).
33///
34/// Wave 1 migrates the animation primitives off [`Instant::now`] to read this resource so headless tests can
35/// drive deterministic frame clocks; foundation only installs and updates the resource.
36#[derive(Resource, Clone, Copy, Debug)]
37pub struct Tick {
38 /// Wall-clock instant captured at the start of the current tick.
39 pub now: Instant,
40 /// Elapsed time since the previous tick's [`Self::now`]. Zero on the first tick.
41 pub dt: Duration,
42 /// Monotonic tick counter; 0 before the first tick, 1 after, and so on.
43 pub frame: u64,
44}
45
46impl Default for Tick {
47 fn default() -> Self {
48 Self {
49 now: Instant::now(),
50 dt: Duration::ZERO,
51 frame: 0,
52 }
53 }
54}
55
56impl Tick {
57 /// Advances the clock by capturing a fresh `Instant::now()` and bumping [`Self::frame`].
58 /// Called by [`crate::app::App::tick`] at the top of each tick, before the main schedule runs.
59 pub fn advance(&mut self) {
60 let now = Instant::now();
61 self.dt = now.saturating_duration_since(self.now);
62 self.now = now;
63 self.frame = self.frame.wrapping_add(1);
64 }
65}
66
67/// Whether the tick that just ran left work behind, so a driver that only
68/// wakes on events has to schedule another frame.
69///
70/// Five sources, each of which reaches `false` on its own once the system
71/// settles, so a caller that loops on this can never spin forever:
72///
73/// 1. The external typed-property bus still holds undrained writes, from a
74/// cross-thread producer or a main-thread script write that landed after
75/// this tick's drain. It empties once drained.
76/// 2. The plugin-event bus still holds events a portable plugin pushed (see
77/// [`crate::plugin_events`]). It likewise empties once drained.
78/// 3. A driver reported unfinished work this tick through
79/// [`AnimationsActive`]: a hover or press tween, an opacity transition,
80/// scroll inertia, an element still waiting on content that has not
81/// arrived. The flag is cleared at the top of every tick and re-raised
82/// only while the thing it is keyed on is still outstanding, and every
83/// driver keys its claim on something that settles or leaves the tree.
84/// 4. [`FrameDirty`] is still set, which a system dirtying state after the
85/// encode leaves behind. The next present clears it.
86/// 5. A [`WakeDeadline`] requested this tick has already passed. A driver
87/// that reaches a deadline hands its work out on the tick that follows,
88/// so the claim settles with it.
89///
90/// This is a frame predicate, not a state predicate: an app with a permanent
91/// animation raises the third source forever. A caller that needs to know
92/// when an app's *state* stopped moving compares the state itself, as the
93/// prerenderer does.
94pub fn work_pending(world: &World) -> bool {
95 external_properties_pending()
96 || plugin_events_pending()
97 || world
98 .get_resource::<AnimationsActive>()
99 .is_some_and(|a| a.get())
100 || world.get_resource::<FrameDirty>().is_some_and(|f| f.dirty)
101 || wake_deadline(world).is_some_and(|at| at <= Instant::now())
102}
103
104/// The earliest instant a driver asked for another tick at, if any.
105///
106/// The companion of [`work_pending`] for work that is due later rather than
107/// now: a driver that parks on events alone sleeps until this instant (winit's
108/// `ControlFlow::WaitUntil`, a timed park) instead of polling, and ticks when
109/// it passes. `None` means nothing is scheduled and the loop may wait for an
110/// event indefinitely.
111pub fn wake_deadline(world: &World) -> Option<Instant> {
112 world
113 .get_resource::<WakeDeadline>()
114 .and_then(WakeDeadline::get)
115}
116
117/// Per-tick "tick again no later than this" request, the timed counterpart of
118/// [`AnimationsActive`].
119///
120/// A system whose work comes due at a known instant with nothing else to wake
121/// the loop (a script timer, a delayed transition) calls [`Self::request`]
122/// with that instant. Several requests keep the earliest. Like
123/// [`AnimationsActive`], [`reset_wake_deadline`] clears it at the top of every
124/// tick and each driver re-requests only while its deadline is still
125/// outstanding, so a loop that settles has nothing left to wake for.
126///
127/// Interior-mutable so drivers can request through a shared `Res`.
128#[derive(Resource, Debug, Default)]
129pub struct WakeDeadline(std::sync::Mutex<Option<Instant>>);
130
131impl WakeDeadline {
132 /// Ask for a tick no later than `at`. Keeps the earliest of all requests
133 /// made since the last [`Self::clear`].
134 pub fn request(&self, at: Instant) {
135 let mut slot = self.0.lock().unwrap_or_else(|p| p.into_inner());
136 *slot = Some(slot.map_or(at, |cur| cur.min(at)));
137 }
138
139 /// The earliest instant requested since the last [`Self::clear`].
140 pub fn get(&self) -> Option<Instant> {
141 *self.0.lock().unwrap_or_else(|p| p.into_inner())
142 }
143
144 /// Drop every request. Called by [`reset_wake_deadline`] at tick start.
145 pub fn clear(&self) {
146 *self.0.lock().unwrap_or_else(|p| p.into_inner()) = None;
147 }
148}
149
150/// Clears [`WakeDeadline`] at the top of every tick (registered in
151/// [`TickStage::Input`], chained before the `Systems` stage where drivers
152/// request), so a deadline nobody renews stops waking the loop.
153pub fn reset_wake_deadline(deadline: Res<WakeDeadline>) {
154 deadline.clear();
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn the_earliest_request_wins_until_cleared() {
163 let deadline = WakeDeadline::default();
164 assert_eq!(deadline.get(), None);
165 let now = Instant::now();
166 deadline.request(now + Duration::from_secs(5));
167 deadline.request(now + Duration::from_secs(2));
168 deadline.request(now + Duration::from_secs(9));
169 assert_eq!(deadline.get(), Some(now + Duration::from_secs(2)));
170 deadline.clear();
171 assert_eq!(deadline.get(), None);
172 }
173
174 #[test]
175 fn only_a_passed_deadline_is_pending_work() {
176 let mut world = World::new();
177 world.init_resource::<WakeDeadline>();
178 assert!(!work_pending(&world));
179 let later = Instant::now() + Duration::from_secs(60);
180 world.resource::<WakeDeadline>().request(later);
181 assert!(!work_pending(&world), "a future deadline is not due yet");
182 assert_eq!(wake_deadline(&world), Some(later));
183 world.resource::<WakeDeadline>().request(Instant::now());
184 assert!(work_pending(&world), "a passed deadline is due");
185 }
186}