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