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 }