git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/backend/shell.rs (18.4K)

  1 //! The run loop's shared half: what one turn of the loop does once a shell
  2 //! has dispatched whatever its window system delivered. A [`Shell`] is a
  3 //! window system's side of the contract — the Wayland runner's `EngineState`
  4 //! is one — and a [`Pacer`] drives it one [`turn`](Pacer::turn) at a time:
  5 //! the app's tick at a sane `dt`, its requested size and title, key repeat,
  6 //! the decision to present (fresh, or a warm-down re-render) and the cadence
  7 //! the loop should sleep at until the next turn.
  8 //!
  9 //! Before this the turn was written into the Wayland runner's loop, between
 10 //! a calloop dispatch and a protocol-error check. A shell with a different
 11 //! loop — the browser's animation frames, an AppKit run loop — calls `turn`
 12 //! from wherever its loop turns and sleeps (or schedules) for what it says.
 13 
 14 use std::time::Duration;
 15 use web_time::Instant;
 16 
 17 use super::app::Application;
 18 use super::driver::{Driver, Turn};
 19 
 20 /// Loop cadence while something is in motion: one turn per frame.
 21 pub const ACTIVE_DISPATCH: Duration = Duration::from_millis(16);
 22 
 23 /// Default cap on the runner's idle sleep — see `Application::idle_poll_interval`.
 24 pub const IDLE_DISPATCH: Duration = Duration::from_millis(1000);
 25 
 26 /// How long the cadence stays at frame rate after the last genuine redraw.
 27 ///
 28 /// Sparse, isolated commits get their frame callbacks serviced multiple
 29 /// compositor frames late (measured 22-128ms on cce-fx, growing per sparse
 30 /// commit), while a continuously committing surface is serviced in one frame
 31 /// (~16ms). A short warm-down keeps interactive sequences (hover, typing,
 32 /// scrolling) in the healthy continuous regime; idle still idles.
 33 ///
 34 /// A warm-down step need not draw: the Wayland shell commits a frame callback
 35 /// with no buffer (`EngineState::keepalive_commit`). Until 2026-10-05 it
 36 /// re-rendered the whole frame and presented it with full damage — about 12
 37 /// identical frames after every hover or keystroke, each re-blurred by the
 38 /// compositor.
 39 pub const WARM_DOWN: Duration = Duration::from_millis(200);
 40 
 41 /// A redraw this soon after the previous one, or after input, belongs to a
 42 /// sequence and gets the [`WARM_DOWN`]; one further from both is an
 43 /// isolated update and does not.
 44 ///
 45 /// The warm-down is for interaction and for streams (a terminal printing, a
 46 /// sync narrating progress): a run of commits close together, where a late
 47 /// frame callback would delay the next one. An app's own isolated update —
 48 /// the status bar's stats once a second, a clock once a minute — has no next
 49 /// frame to delay, and each one used to buy twelve more wakes at frame rate:
 50 /// the stats module woke ~13 times a second for one redraw (2026-10-06).
 51 pub const SEQUENCE_GAP: Duration = Duration::from_millis(500);
 52 
 53 /// Upper bound on an idle sleep. The loop is woken early by any event the
 54 /// shell's window system delivers and by messages on the app's sender, so
 55 /// this only caps how long an app-side poll that bypasses both (see
 56 /// `Application::idle_poll_interval`) can wait. `CCE_UI_IDLE_MS` overrides
 57 /// it — `16` restores the old always-ticking loop for a bisect.
 58 pub fn idle_dispatch() -> Duration {
 59     static IDLE: std::sync::OnceLock<Duration> = std::sync::OnceLock::new();
 60     *IDLE.get_or_init(|| {
 61         std::env::var("CCE_UI_IDLE_MS")
 62             .ok()
 63             .and_then(|v| v.parse::<u64>().ok())
 64             .map(Duration::from_millis)
 65             .unwrap_or(IDLE_DISPATCH)
 66     })
 67 }
 68 
 69 /// A window system's side of the run loop.
 70 pub trait Shell {
 71     type App: Application;
 72 
 73     /// The input driver and the app's turn, borrowed apart.
 74     fn turn(&mut self) -> (&mut Driver, Turn<'_, Self::App>);
 75 
 76     fn app(&self) -> &Self::App;
 77 
 78     /// The runner's dirty flag: a frame is wanted.
 79     fn redraw(&mut self) -> &mut bool;
 80 
 81     /// The app asked to exit (its `update` set the flag).
 82     fn exit_requested(&self) -> bool;
 83 
 84     /// The window system sized the window since the last turn: this turn the
 85     /// app's own `desired_size` is not asked (the configure wins). Clears it.
 86     fn take_just_configured(&mut self) -> bool;
 87 
 88     /// The app wants its window `w` x `h` (frame px). The shell resizes — or
 89     /// does not, if it already is — and raises `redraw` when it did.
 90     fn request_size(&mut self, w: u32, h: u32);
 91 
 92     /// Once a turn, after the app's tick and size: whatever the window system
 93     /// keeps in step with the app (the Wayland shell's overflow rim, popover
 94     /// region and menu popup).
 95     fn sync(&mut self) {}
 96 
 97     /// The app's title changed.
 98     fn set_title(&mut self, title: &str);
 99 
100     /// A presented frame has not yet been released by the window system's
101     /// pacing (Wayland: the frame callback is outstanding), so presenting now
102     /// must wait. Asked once a turn, with `redraw` already known, so a shell
103     /// can give up on a release that is never coming.
104     fn frame_pending(&mut self) -> bool;
105 
106     /// The window has been configured: there is a surface to present on.
107     fn configured(&self) -> bool;
108 
109     /// Build and present a frame. `fresh` is a frame something asked for;
110     /// otherwise it is a warm-down step (see [`WARM_DOWN`]): nothing changed,
111     /// so a shell that can keep its pacing without drawing should.
112     fn present(&mut self, fresh: bool);
113 }
114 
115 /// What the loop should do after a turn.
116 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
117 pub enum Step {
118     /// The app asked to exit; the session ends (after whatever leave-taking
119     /// the shell does — the Wayland shell waits out the compositor's fade).
120     Exit,
121     /// Turn again after at most this long, or sooner if an event arrives.
122     Sleep(Duration),
123 }
124 
125 /// The loop's pacing state, one per session.
126 pub struct Pacer {
127     last_tick: Instant,
128     /// The loop slept idle before this turn: its interval is not animation time.
129     slept_idle: bool,
130     /// Turns stay at frame rate until this instant (see [`WARM_DOWN`]).
131     warm_until: Option<Instant>,
132     /// When the last genuine redraw was presented (see [`SEQUENCE_GAP`]).
133     last_redraw: Option<Instant>,
134     last_title: String,
135 }
136 
137 impl Pacer {
138     /// Before the first turn the loop runs at [`ACTIVE_DISPATCH`].
139     pub fn new(title: String) -> Self {
140         Self { last_tick: Instant::now(), slept_idle: false, warm_until: None, last_redraw: None, last_title: title }
141     }
142 
143     /// One turn of the loop. The cadence is ACTIVE while anything is in
144     /// motion (a redraw pending or just done, an animation, a held key, the
145     /// post-activity warm-down); otherwise the app's own poll interval or
146     /// [`idle_dispatch`]. Before 2026-09-11 it was a flat 16 ms whatever the
147     /// state: every cce-ui client woke 60 times a second forever — ~1200
148     /// wakeups/s across a session's twenty clients — and each wake ran tick,
149     /// desired_size, title and margin checks for nothing.
150     pub fn turn<S: Shell>(&mut self, shell: &mut S) -> Step {
151         if shell.exit_requested() {
152             return Step::Exit;
153         }
154 
155         let now = Instant::now();
156         let mut dt = now.duration_since(self.last_tick).as_secs_f32();
157         self.last_tick = now;
158         if dt > 0.1 {
159             dt = 0.1;
160         }
161         // Waking from an idle sleep: the interval is not animation time. An
162         // animation an event just started must take its first step at frame
163         // size, not leap 100 ms in one tick.
164         if self.slept_idle {
165             dt = dt.min(1.0 / 60.0);
166         }
167 
168         {
169             let (driver, t) = shell.turn();
170             driver.tick(t, dt);
171         }
172 
173         if !shell.take_just_configured() {
174             if let Some((w, h)) = shell.app().desired_size() {
175                 shell.request_size(w, h);
176             }
177         }
178 
179         shell.sync();
180 
181         {
182             let (driver, t) = shell.turn();
183             driver.repeat_keys(t);
184         }
185         let title = shell.app().settings().title;
186         if title != self.last_title {
187             shell.set_title(&title);
188             self.last_title = title;
189         }
190 
191         let pending = shell.frame_pending();
192 
193         if *shell.redraw() {
194             // Genuine dirt that is part of a sequence — input just arrived, or
195             // the last redraw was moments ago — extends the warm window;
196             // warm-down renders below do NOT, so idle decays in one window.
197             // An isolated update gets none (see `SEQUENCE_GAP`).
198             let now = Instant::now();
199             let recent = |t: Option<Instant>| t.is_some_and(|t| now.duration_since(t) < SEQUENCE_GAP);
200             let input = shell.turn().0.last_input;
201             if recent(input) || recent(self.last_redraw) {
202                 self.warm_until = Some(now + WARM_DOWN);
203             }
204             self.last_redraw = Some(now);
205         }
206         let mut rendered = false;
207         if *shell.redraw() && !pending {
208             *shell.redraw() = false;
209             if shell.configured() {
210                 shell.present(true);
211                 rendered = true;
212             }
213         } else if !*shell.redraw() && !pending && self.warm_until.is_some_and(|t| Instant::now() < t) {
214             // Warm-down re-render, paced by the shell's frame release.
215             if shell.configured() {
216                 shell.present(false);
217                 rendered = true;
218             }
219         }
220 
221         // Anything still moving keeps the frame cadence; a pending frame on
222         // its own does not (its release arrives as an event) unless a redraw
223         // is queued behind it, which is what the shell's `frame_pending`
224         // times. `redraw` still set here means the frame was withheld (a
225         // frame pending, or no configure yet) and must be retried soon.
226         let warm = self.warm_until.is_some_and(|t| Instant::now() < t);
227         let key_held = shell.turn().0.pressed_key.is_some();
228         let busy = *shell.redraw() || rendered || warm || key_held;
229         self.slept_idle = !busy;
230         Step::Sleep(if busy {
231             ACTIVE_DISPATCH
232         } else {
233             let app_poll = shell.app().idle_poll_interval();
234             app_poll.map_or(idle_dispatch(), |d| d.min(idle_dispatch()))
235         })
236     }
237 }
238 
239 #[cfg(test)]
240 mod tests {
241     //! The loop's policy, turned by hand against a shell that records what
242     //! it was asked to do.
243     use super::*;
244     use crate::backend::app::{AppSender, LogicalPosition, WindowSettings};
245     use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta};
246 
247     #[derive(Default)]
248     struct App {
249         title: String,
250         desired: Option<(u32, u32)>,
251         poll: Option<Duration>,
252         /// Each tick's dt.
253         ticks: Vec<f32>,
254         /// Ask for a frame from the next tick.
255         animate: bool,
256     }
257 
258     impl Application for App {
259         type Message = ();
260         fn create(_: AppSender<()>) -> Self {
261             unreachable!("built directly")
262         }
263         fn settings(&self) -> WindowSettings {
264             WindowSettings { title: self.title.clone(), app_id: "mock".into(), width: 10, height: 10, fullscreen: false, min_size: None }
265         }
266         fn update(&mut self, _: (), _: &mut bool, _: &mut bool) {}
267         fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
268             self.ticks.push(dt);
269             *needs_rebuild |= self.animate;
270         }
271         fn handle_pointer_move(&mut self, _: LogicalPosition, _: &mut bool) {}
272         fn handle_mouse_input(&mut self, _: MouseButton, _: ElementState, _: LogicalPosition, _: &mut bool) -> Option<()> {
273             None
274         }
275         fn handle_mouse_wheel(&mut self, _: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {}
276         fn handle_key_input(&mut self, _: &KeyEvent, _: &mut bool) -> Option<()> {
277             None
278         }
279         fn desired_size(&self) -> Option<(u32, u32)> {
280             self.desired
281         }
282         fn idle_poll_interval(&self) -> Option<Duration> {
283             self.poll
284         }
285     }
286 
287     #[derive(Debug, Clone, PartialEq)]
288     enum Did {
289         Size(u32, u32),
290         Title(String),
291         Present(bool),
292     }
293 
294     struct Mock {
295         app: App,
296         driver: Driver,
297         redraw: bool,
298         exit: bool,
299         just_configured: bool,
300         pending: bool,
301         configured: bool,
302         did: Vec<Did>,
303     }
304 
305     impl Mock {
306         fn new() -> Self {
307             Self {
308                 app: App::default(),
309                 driver: Driver::new(),
310                 redraw: false,
311                 exit: false,
312                 just_configured: false,
313                 pending: false,
314                 configured: true,
315                 did: Vec::new(),
316             }
317         }
318     }
319 
320     impl Shell for Mock {
321         type App = App;
322         fn turn(&mut self) -> (&mut Driver, Turn<'_, App>) {
323             (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
324         }
325         fn app(&self) -> &App {
326             &self.app
327         }
328         fn redraw(&mut self) -> &mut bool {
329             &mut self.redraw
330         }
331         fn exit_requested(&self) -> bool {
332             self.exit
333         }
334         fn take_just_configured(&mut self) -> bool {
335             std::mem::replace(&mut self.just_configured, false)
336         }
337         fn request_size(&mut self, w: u32, h: u32) {
338             self.did.push(Did::Size(w, h));
339         }
340         fn set_title(&mut self, title: &str) {
341             self.did.push(Did::Title(title.into()));
342         }
343         fn frame_pending(&mut self) -> bool {
344             self.pending
345         }
346         fn configured(&self) -> bool {
347             self.configured
348         }
349         fn present(&mut self, fresh: bool) {
350             self.did.push(Did::Present(fresh));
351         }
352     }
353 
354     fn idle() -> Step {
355         Step::Sleep(idle_dispatch())
356     }
357 
358     #[test]
359     fn a_quiet_window_sleeps_idle_or_at_the_apps_poll() {
360         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
361         assert_eq!(p.turn(&mut s), idle());
362         assert!(s.did.is_empty(), "nothing to do: {:?}", s.did);
363 
364         s.app.poll = Some(Duration::from_millis(250));
365         assert_eq!(p.turn(&mut s), Step::Sleep(Duration::from_millis(250).min(idle_dispatch())));
366     }
367 
368     #[test]
369     fn a_redraw_presents_then_warms_down_at_frame_rate() {
370         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
371         // Interactive: an input event just arrived.
372         s.driver.last_input = Some(Instant::now());
373         s.redraw = true;
374         assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
375         assert_eq!(s.did, vec![Did::Present(true)]);
376         assert!(!s.redraw);
377 
378         // Inside the warm window: re-render, and keep the frame cadence.
379         assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
380         assert_eq!(s.did.last(), Some(&Did::Present(false)));
381     }
382 
383     #[test]
384     fn an_isolated_update_presents_and_goes_idle_without_a_warm_down() {
385         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
386         // No input, no recent redraw: the app's own once-a-second update.
387         s.redraw = true;
388         assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
389         assert_eq!(s.did, vec![Did::Present(true)]);
390         // No warm-down re-render: straight to the idle sleep.
391         assert_eq!(p.turn(&mut s), idle());
392         assert_eq!(s.did, vec![Did::Present(true)], "an isolated update warmed down");
393     }
394 
395     #[test]
396     fn a_redraw_soon_after_another_warms_down_without_input() {
397         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
398         s.redraw = true;
399         p.turn(&mut s);
400         p.turn(&mut s);
401         // A stream: the next update lands well inside SEQUENCE_GAP.
402         s.redraw = true;
403         assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
404         assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
405         assert_eq!(s.did.last(), Some(&Did::Present(false)), "the stream lost its warm-down");
406     }
407 
408     #[test]
409     fn a_pending_frame_withholds_the_present_and_keeps_the_redraw() {
410         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
411         s.redraw = true;
412         s.pending = true;
413         assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
414         assert!(s.did.is_empty());
415         assert!(s.redraw, "the frame is withheld, not dropped");
416 
417         s.pending = false;
418         p.turn(&mut s);
419         assert_eq!(s.did, vec![Did::Present(true)]);
420     }
421 
422     #[test]
423     fn before_the_first_configure_a_redraw_is_spent_without_a_present() {
424         // What the loop always did: the configure itself raises a redraw.
425         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
426         s.configured = false;
427         s.redraw = true;
428         p.turn(&mut s);
429         assert!(s.did.is_empty());
430         assert!(!s.redraw);
431     }
432 
433     #[test]
434     fn the_apps_size_and_title_reach_the_shell_and_a_configure_wins_its_turn() {
435         let (mut p, mut s) = (Pacer::new("a".into()), Mock::new());
436         s.app.desired = Some((300, 200));
437         s.app.title = "b".into();
438         s.just_configured = true;
439         p.turn(&mut s);
440         assert_eq!(s.did, vec![Did::Title("b".into())], "no size on a configure's turn, the title once");
441 
442         s.did.clear();
443         p.turn(&mut s);
444         assert_eq!(s.did, vec![Did::Size(300, 200)], "the size on the next; the title is unchanged");
445     }
446 
447     #[test]
448     fn a_held_key_keeps_the_frame_cadence() {
449         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
450         let (driver, t) = s.turn();
451         driver.key(t, Key::Character("a".into()), Some("a".into()), ElementState::Pressed);
452         // The key's own dispatch asked for nothing; the hold alone keeps it busy.
453         s.redraw = false;
454         assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
455     }
456 
457     #[test]
458     fn an_exit_ends_the_turn_before_anything_ticks() {
459         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
460         s.exit = true;
461         assert_eq!(p.turn(&mut s), Step::Exit);
462         assert!(s.app.ticks.is_empty());
463     }
464 
465     #[test]
466     fn the_first_tick_after_an_idle_sleep_is_one_frame_long() {
467         let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
468         assert_eq!(p.turn(&mut s), idle());
469         std::thread::sleep(Duration::from_millis(40));
470         // Woken from idle by something that animates: its first step is a frame.
471         s.app.animate = true;
472         p.turn(&mut s);
473         let woke = *s.app.ticks.last().unwrap();
474         assert!(woke <= 1.0 / 60.0 + 1e-6, "dt after an idle sleep: {woke}");
475 
476         // Busy now, so the next interval IS animation time, up to 100 ms.
477         std::thread::sleep(Duration::from_millis(40));
478         p.turn(&mut s);
479         let busy = *s.app.ticks.last().unwrap();
480         assert!((0.035..=0.1).contains(&busy), "dt while busy: {busy}");
481     }
482 }