Skip to main content

ashlar_ui/
wake.rs

1//! The frame scheduler: ask for frames with a named [`Reason`], so an app can say why each frame
2//! ran.
3//!
4//! An ashlar app paints on demand: a frame runs only for input or because something asked for
5//! one. Every such request goes through [`wake`] / [`wake_after`] (or a [`Waker`] on another
6//! thread) instead of `Context::request_repaint`. The context's [`Scheduler`] counts requests per
7//! reason and credits each frame to every reason whose wake was due when it started; a frame
8//! nobody asked for (pointer and keyboard input, a resize, egui's own animations) counts as
9//! [`Reason::INPUT`]. Requests made during a frame are for the frames after it. A timed wake that
10//! is not due yet when another frame runs stays pending: the scheduler asks egui for it again
11//! ([`Scheduler::rearm`]), so code asks once per deadline, not once per frame.
12//!
13//! The host calls [`Scheduler::begin_frame`] once per frame, before the frame's logic runs (in
14//! eframe: `App::raw_input_hook`), and [`Scheduler::rearm`] during the frame. `ashlar-app`'s
15//! runtime does both; a host of its own does them too.
16//!
17//! ```
18//! use std::time::Duration;
19//! use ashlar_ui::wake::{self, Reason};
20//!
21//! const THUMBNAILS: Reason = Reason("thumbnails"); // an app's own reason
22//! let ctx = egui::Context::default();
23//! wake::wake(&ctx, Reason::JOB);
24//! wake::wake_after(&ctx, THUMBNAILS, Duration::from_millis(100));
25//! let worker = wake::waker(&ctx, Reason::AUTOSAVE); // Send + Sync, for a worker thread
26//! std::thread::spawn(move || worker.wake()).join().ok();
27//! let stats = wake::Scheduler::of(&ctx).stats();
28//! assert_eq!(stats.requests_for(Reason::JOB), 1);
29//! assert_eq!(stats.requests(), 3);
30//! ```
31
32use std::collections::BTreeMap;
33use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
34use std::time::Duration;
35
36use serde::{Deserialize, Serialize};
37
38/// Why a frame was asked for: a short camelCase name, used as the key in [`WakeStats`]. Apps
39/// define their own next to the standard ones (`const THUMBNAILS: Reason = Reason("thumbnails")`).
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
41pub struct Reason(pub &'static str);
42
43impl Reason {
44    /// Nobody asked: input, a resize, egui's own animations.
45    pub const INPUT: Reason = Reason("input");
46    /// Fonts, theme and app setup over the first frames.
47    pub const STARTUP: Reason = Reason("startup");
48    /// A control-port request or injected agent input waiting to be processed.
49    pub const CONTROL: Reason = Reason("control");
50    /// An OS menu item was chosen.
51    pub const MENU: Reason = Reason("menu");
52    /// The document changed during a frame: one more so everything shows the new state.
53    pub const DOCUMENT: Reason = Reason("document");
54    /// A screenshot waiting for a presented frame.
55    pub const SCREENSHOT: Reason = Reason("screenshot");
56    /// Background job progress or results.
57    pub const JOB: Reason = Reason("job");
58    /// An autosave is due, or the autosave writer finished.
59    pub const AUTOSAVE: Reason = Reason("autosave");
60    /// Changed settings are due to be written.
61    pub const SETTINGS: Reason = Reason("settings");
62    /// A short UI timer (a hold hint, a click-to-rename delay).
63    pub const TIMER: Reason = Reason("timer");
64    /// An animation or fade the app runs itself.
65    pub const ANIMATION: Reason = Reason("animation");
66
67    /// The standard reasons.
68    pub const STANDARD: [Reason; 11] = [
69        Reason::INPUT,
70        Reason::STARTUP,
71        Reason::CONTROL,
72        Reason::MENU,
73        Reason::DOCUMENT,
74        Reason::SCREENSHOT,
75        Reason::JOB,
76        Reason::AUTOSAVE,
77        Reason::SETTINGS,
78        Reason::TIMER,
79        Reason::ANIMATION,
80    ];
81
82    pub fn name(self) -> &'static str {
83        self.0
84    }
85}
86
87impl std::fmt::Display for Reason {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.0)
90    }
91}
92
93/// Pending wake times kept per reason (one code path asks again for the same deadline; distinct
94/// deadlines are few).
95const MAX_DUE: usize = 8;
96
97/// Due times are egui input times (seconds); a frame starting this much early still counts.
98const SLACK_S: f64 = 1e-3;
99
100#[derive(Debug, Default)]
101struct State {
102    /// The egui time each reason asked to run at (`-inf` = now).
103    /// The egui times each reason asked to run at, earliest first (`-inf` = now); at most
104    /// [`MAX_DUE`] per reason.
105    due: BTreeMap<&'static str, Vec<f64>>,
106    requests: BTreeMap<&'static str, u64>,
107    frames: BTreeMap<&'static str, u64>,
108    total: u64,
109}
110
111fn bump(m: &mut BTreeMap<&'static str, u64>, k: &'static str) {
112    let n = m.entry(k).or_insert(0);
113    *n = n.saturating_add(1);
114}
115
116/// Counters since start (cumulative: diff two readings to measure an interval). Serialized as
117/// `{"frames": n, "frames_by": {reason: n}, "requests_by": {reason: n}}` with the reasons that
118/// have counts.
119#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
120pub struct WakeStats {
121    /// Frames run, in total.
122    pub frames: u64,
123    /// Frames credited to each reason (a frame can have several).
124    pub frames_by: BTreeMap<String, u64>,
125    /// Wake requests per reason.
126    pub requests_by: BTreeMap<String, u64>,
127}
128
129impl WakeStats {
130    pub fn frames_for(&self, r: Reason) -> u64 {
131        self.frames_by.get(r.0).copied().unwrap_or(0)
132    }
133
134    pub fn requests_for(&self, r: Reason) -> u64 {
135        self.requests_by.get(r.0).copied().unwrap_or(0)
136    }
137
138    /// All wake requests.
139    pub fn requests(&self) -> u64 {
140        self.requests_by.values().fold(0, |a, b| a.saturating_add(*b))
141    }
142}
143
144/// One context's wake requests and frame counters, shared through the egui context
145/// ([`Scheduler::of`]). Cheap to clone (a handle).
146#[derive(Clone, Debug, Default)]
147pub struct Scheduler {
148    state: Arc<Mutex<State>>,
149}
150
151fn id() -> egui::Id {
152    egui::Id::new("ashlar.wake.scheduler")
153}
154
155impl Scheduler {
156    /// The scheduler of this egui context (created on first use).
157    pub fn of(ctx: &egui::Context) -> Scheduler {
158        ctx.data_mut(|d| d.get_temp_mut_or_default::<Scheduler>(id()).clone())
159    }
160
161    /// Make this the scheduler of `ctx` (again, after something reset the context's memory).
162    pub fn install(&self, ctx: &egui::Context) {
163        ctx.data_mut(|d| d.insert_temp(id(), self.clone()));
164    }
165
166    fn lock(&self) -> MutexGuard<'_, State> {
167        self.state.lock().unwrap_or_else(PoisonError::into_inner)
168    }
169
170    /// Record a request for `reason` at egui time `due` (`-inf` = the next frame).
171    pub fn request(&self, reason: Reason, due: f64) {
172        let mut s = self.lock();
173        bump(&mut s.requests, reason.0);
174        let list = s.due.entry(reason.0).or_default();
175        // the same deadline asked again (within the slack) is one wake
176        if list.iter().any(|d| (d - due).abs() <= SLACK_S || (d.is_infinite() && due.is_infinite())) {
177            return;
178        }
179        let at = list.partition_point(|d| *d < due);
180        list.insert(at, due);
181        if list.len() > MAX_DUE {
182            // keep the earliest ones and the latest: every pending deadline still gets a frame
183            // by then
184            list.remove(MAX_DUE - 1);
185        }
186    }
187
188    /// Start of a frame at egui time `now`: credit it to the reasons that were due and forget
189    /// them; wakes not due yet stay pending (see [`Scheduler::rearm`]).
190    pub fn begin_frame(&self, now: f64) {
191        let mut s = self.lock();
192        let due = std::mem::take(&mut s.due);
193        let mut any = false;
194        for (r, mut list) in due {
195            let n = list.partition_point(|d| *d <= now + SLACK_S);
196            if n > 0 {
197                bump(&mut s.frames, r);
198                any = true;
199                list.drain(..n);
200            }
201            if !list.is_empty() {
202                s.due.insert(r, list);
203            }
204        }
205        if !any {
206            bump(&mut s.frames, Reason::INPUT.0);
207        }
208        s.total = s.total.saturating_add(1);
209    }
210
211    /// Forget a pending wake for `reason` (the work it was for finished early).
212    pub fn cancel(&self, reason: Reason) {
213        self.lock().due.remove(reason.0);
214    }
215
216    /// Ask egui again for the timed wakes still pending (egui forgets a timed repaint when another
217    /// frame runs first). Call it during each frame; it does not count as a request.
218    pub fn rearm(&self, ctx: &egui::Context) {
219        let next = self.lock().due.values().filter_map(|l| l.first().copied()).fold(f64::INFINITY, f64::min);
220        if !next.is_finite() {
221            return;
222        }
223        let (now, predicted_dt) = ctx.input(|i| (i.time, i.predicted_dt));
224        let delay = Duration::try_from_secs_f64((next - now).max(0.0)).unwrap_or_default();
225        if delay.is_zero() {
226            ctx.request_repaint();
227        } else {
228            let early = Duration::try_from_secs_f32(predicted_dt).unwrap_or_default();
229            ctx.request_repaint_after(delay.saturating_add(early).saturating_add(Duration::from_millis(1)));
230        }
231    }
232
233    pub fn stats(&self) -> WakeStats {
234        let s = self.lock();
235        let owned = |m: &BTreeMap<&'static str, u64>| m.iter().map(|(k, v)| ((*k).to_string(), *v)).collect();
236        WakeStats { frames: s.total, frames_by: owned(&s.frames), requests_by: owned(&s.requests) }
237    }
238}
239
240/// Ask for a frame now. Callable from any thread (not from inside a `ctx.input`/`ctx.data`
241/// closure, which holds the context's lock).
242pub fn wake(ctx: &egui::Context, reason: Reason) {
243    Scheduler::of(ctx).request(reason, f64::NEG_INFINITY);
244    ctx.request_repaint();
245}
246
247/// Ask for a frame after `delay` (measured from the current frame's time). The frame starts no
248/// earlier than that (egui's `request_repaint_after` starts one predicted frame early).
249pub fn wake_after(ctx: &egui::Context, reason: Reason, delay: Duration) {
250    request_after(ctx, &Scheduler::of(ctx), reason, delay, false);
251}
252
253/// Like [`wake_after`], but this becomes the reason's only pending wake: for code that owns one
254/// deadline and moves it (a debounced save restarting its quiet period), so the deadlines it
255/// gave up on cost no frames.
256pub fn reschedule(ctx: &egui::Context, reason: Reason, delay: Duration) {
257    request_after(ctx, &Scheduler::of(ctx), reason, delay, true);
258}
259
260fn request_after(ctx: &egui::Context, s: &Scheduler, reason: Reason, delay: Duration, replace: bool) {
261    if replace {
262        s.cancel(reason);
263    }
264    if delay.is_zero() {
265        s.request(reason, f64::NEG_INFINITY);
266        ctx.request_repaint();
267        return;
268    }
269    let (now, predicted_dt) = ctx.input(|i| (i.time, i.predicted_dt));
270    s.request(reason, now + delay.as_secs_f64());
271    // egui starts a timed frame one predicted frame early (so it is presented on time); a timer
272    // wants its frame to start no earlier than `delay`, or the code that asked finds itself not
273    // yet due and asks again. The extra millisecond absorbs clock rounding (f32/f64 seconds).
274    let early = Duration::try_from_secs_f32(predicted_dt).unwrap_or_default();
275    ctx.request_repaint_after(delay.saturating_add(early).saturating_add(Duration::from_millis(1)));
276}
277
278/// A handle that wakes `ctx` for `reason`, for worker threads and hooks
279/// (`jobs.set_wake(move || w.wake())`).
280pub fn waker(ctx: &egui::Context, reason: Reason) -> Waker {
281    Waker { ctx: ctx.clone(), scheduler: Scheduler::of(ctx), reason }
282}
283
284/// Wakes one context for one reason. `Send + Sync + Clone`.
285#[derive(Clone, Debug)]
286pub struct Waker {
287    ctx: egui::Context,
288    scheduler: Scheduler,
289    reason: Reason,
290}
291
292impl Waker {
293    /// Ask for a frame now.
294    pub fn wake(&self) {
295        self.scheduler.request(self.reason, f64::NEG_INFINITY);
296        self.ctx.request_repaint();
297    }
298
299    /// Make a frame after `delay` this reason's only pending wake (see [`reschedule`]).
300    pub fn reschedule(&self, delay: Duration) {
301        request_after(&self.ctx, &self.scheduler, self.reason, delay, true);
302    }
303
304    /// Ask for a frame after `delay`.
305    pub fn wake_after(&self, delay: Duration) {
306        request_after(&self.ctx, &self.scheduler, self.reason, delay, false);
307    }
308
309    pub fn reason(&self) -> Reason {
310        self.reason
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    const STATUS: Reason = Reason("status");
319    const PLAYBACK: Reason = Reason("playback");
320
321    #[test]
322    fn frames_are_credited_to_the_reasons_that_were_due() {
323        let s = Scheduler::default();
324        s.request(PLAYBACK, f64::NEG_INFINITY);
325        s.request(STATUS, 1.0);
326        s.begin_frame(0.5);
327        // the status wake was not due: it stays pending and is credited when it is
328        s.begin_frame(1.0);
329        s.request(STATUS, 2.0);
330        s.request(Reason::JOB, 1.15);
331        s.begin_frame(1.15);
332        let st = s.stats();
333        assert_eq!(st.frames, 3);
334        assert_eq!(st.frames_for(PLAYBACK), 1);
335        assert_eq!(st.frames_for(STATUS), 1);
336        assert_eq!(st.frames_for(Reason::JOB), 1);
337        assert_eq!(st.frames_for(Reason::INPUT), 0);
338        assert_eq!(st.requests_for(STATUS), 2);
339        assert_eq!(st.requests(), 4);
340        let v = serde_json::to_value(&st).unwrap_or_default();
341        assert_eq!(v["frames_by"], serde_json::json!({"status": 1, "playback": 1, "job": 1}));
342        assert_eq!(v["requests_by"], serde_json::json!({"playback": 1, "status": 2, "job": 1}));
343        assert_eq!(v["frames"], serde_json::json!(3));
344        let back: WakeStats = serde_json::from_value(v).unwrap_or_default();
345        assert_eq!(back, st);
346    }
347
348    #[test]
349    fn a_frame_counts_once_per_reason_and_the_earliest_request_wins() {
350        let s = Scheduler::default();
351        s.request(Reason::TIMER, 5.0);
352        s.request(Reason::TIMER, 1.0);
353        s.request(Reason::TIMER, 3.0);
354        s.begin_frame(1.0);
355        let st = s.stats();
356        assert_eq!((st.frames_for(Reason::TIMER), st.requests_for(Reason::TIMER), st.frames), (1, 3, 1));
357    }
358
359    #[test]
360    fn deadlines_not_yet_due_stay_pending_each_one_gets_its_frame() {
361        let s = Scheduler::default();
362        s.request(Reason::SETTINGS, 1.0);
363        s.request(Reason::SETTINGS, 1.0); // the same deadline asked again
364        s.request(Reason::SETTINGS, 1.4);
365        s.begin_frame(0.2); // input came first: both stay pending
366        s.begin_frame(1.0);
367        s.begin_frame(1.2);
368        s.begin_frame(1.4);
369        let st = s.stats();
370        assert_eq!((st.frames_for(Reason::SETTINGS), st.frames_for(Reason::INPUT), st.requests_for(Reason::SETTINGS)), (2, 2, 3));
371        s.request(Reason::TIMER, 9.0);
372        s.cancel(Reason::TIMER);
373        s.begin_frame(9.0);
374        assert_eq!(s.stats().frames_for(Reason::TIMER), 0);
375        for i in 0..100 {
376            s.request(Reason::JOB, 20.0 + f64::from(i));
377        }
378        s.begin_frame(200.0);
379        assert_eq!(s.stats().frames_for(Reason::JOB), 1, "a capped list still ends with the latest deadline");
380    }
381
382    #[test]
383    fn standard_names_are_unique() {
384        let mut names: Vec<_> = Reason::STANDARD.iter().map(|r| r.name()).collect();
385        names.sort_unstable();
386        names.dedup();
387        assert_eq!(names.len(), Reason::STANDARD.len());
388    }
389
390    #[test]
391    fn wakes_reach_the_context_and_its_scheduler() {
392        let ctx = egui::Context::default();
393        let w = waker(&ctx, Reason::JOB);
394        let t = std::thread::spawn(move || w.wake());
395        assert!(t.join().is_ok());
396        wake_after(&ctx, Reason::TIMER, Duration::from_millis(10));
397        let st = Scheduler::of(&ctx).stats();
398        assert_eq!((st.requests_for(Reason::JOB), st.requests_for(Reason::TIMER)), (1, 1));
399        // a reset of egui's memory loses the scheduler unless it is installed again
400        let s = Scheduler::of(&ctx);
401        ctx.memory_mut(|m| *m = egui::Memory::default());
402        s.install(&ctx);
403        assert_eq!(Scheduler::of(&ctx).stats().requests(), 2);
404    }
405}