GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(backend): the Shell trait, and a Pacer that turns the loop over any shell
The last Wayland-only piece of the runner that a second shell would
have to copy was the loop's turn itself, written between a calloop
dispatch and a protocol-error check: the tick's dt (clamped to 100 ms,
and to one frame after an idle sleep), the app's desired_size, key
repeat, title tracking, the present-or-warm-down decision, and the
ACTIVE / idle cadence (the 2026-09-11 rule that a quiet client sleeps
instead of waking 60 times a second).
backend/shell.rs now holds it. `Shell` is a window system's side of
the loop: exit requested, a configure that wins the turn's size,
request_size, a per-turn sync, set_title, frame_pending (where a shell
paces presents, and may give up on a release that never comes),
configured, and present (fresh, or a warm-down re-render). `Pacer`
owns the pacing state (last tick, slept idle, the warm window, the
last title) and `turn(&mut shell)` runs one turn and returns a Step:
Exit, or Sleep(the cadence until the next turn). A shell with a loop
of its own (the browser's animation frames, an AppKit run loop) calls
turn from it and sleeps or schedules for what it says.
EngineState implements Shell with the Wayland side of each hook moved
as it was: the overflow-margin resize and drift, the popover region and
menu popup sync, the frame-callback gate with its starvation fallback,
the menu popup's lead on a fresh frame. run_session's loop is now
dispatch, the connection's health checks, pacer.turn, and the close
fade. warm_until moves off EngineState into the Pacer; IDLE_DISPATCH
moves to shell.rs and is re-exported at its old path.
Eight new tests turn the Pacer by hand against a mock shell: a quiet
window sleeps idle or at the app's poll interval; a redraw presents
and then warms down at frame rate; a pending frame withholds the
present and keeps the redraw; before the first configure a redraw is
spent without a present (as the loop always did); the app's size and
title reach the shell, and a configure wins its turn; a held key keeps
the frame cadence; an exit ends the turn before anything ticks; the
first tick after an idle sleep is one frame long. Removing the idle
clamp or the warm window fails them.
Verified against the pre-change binary: test results unchanged (523
pass, the same 2 pre-existing failures), the plate golden
byte-identical, the demo identical to the pixel over the 24 scripted
steps under headless sway, and the loop's cadence counted the same
both ways with CCE_PRESENT_DEBUG: 5 wake-ups in 5 s idle, 125 in 2 s
of a held key, and back to 5 in 5 s after it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 16 +-
src/backend/mod.rs | 1 +
src/backend/shell.rs | 425 +++++++++++++++++++++++++++++++++++++++++++
src/backend/window_runner.rs | 373 +++++++++++++++----------------------
4 files changed, 589 insertions(+), 226 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 933b177..07e0c1b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1095,13 +1095,21 @@ cce-system-interface) to confirm behavior, not just the test suite.
the pinch fallback — fed in cce-ui's own terms and unit-tested with no compositor),
`frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
widget shaping, text and the popover-occlusion rects, tessellated into a `BuiltFrame` the
- renderer draws — no window system in it, tested with no GPU), `tessellate.rs`, `text.rs`,
- and `window_runner.rs`, the Wayland shell: it maps evdev
+ renderer draws — no window system in it, tested with no GPU), `shell.rs` (the `Shell`
+ trait — a window system's side of the run loop: exit, size requests, per-turn sync,
+ title, the frame gate, configured, present — and `Pacer`, one turn of the loop over any
+ shell: the tick's `dt` and its idle clamp, `desired_size`, key repeat, the title, the
+ present-or-warm-down decision, and the ACTIVE / idle cadence; tested against a mock
+ shell), `tessellate.rs`, `text.rs`, and `window_runner.rs`, the Wayland shell
+ (`EngineState` implements `Shell`; its loop is dispatch, the connection's health checks,
+ `pacer.turn`, and the close fade): it maps evdev
buttons, xkb keysyms and `wl_pointer` axis frames into driver calls and carries out the
grabs and cursors the driver asks for, and presents what `build_frame` built (grid patch,
input region, glyph upload, the extent gate and buffer scale, the frame callback,
- `stage_renderer`, the draw). A routing change belongs in `driver.rs` and a change to
- what a frame contains in `frame.rs`, never in the Wayland code. `menu_popup.rs` and `dnd.rs` are Wayland-only.
+ `stage_renderer`, the draw). A routing change belongs in `driver.rs`, a change to
+ what a frame contains in `frame.rs` and a pacing change in `shell.rs`, never in the
+ Wayland code. A second shell implements `Shell` and calls `Pacer::turn` from its own
+ loop (an animation frame, a run-loop observer), sleeping or scheduling for the `Step`. `menu_popup.rs` and `dnd.rs` are Wayland-only.
- `protocol.rs` — inline-generated Wayland protocol bindings.
- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 263e358..71359c7 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,6 +1,7 @@
pub mod app;
pub mod driver;
pub mod frame;
+pub mod shell;
pub mod dnd;
pub mod menu_popup;
pub mod tessellate;
diff --git a/src/backend/shell.rs b/src/backend/shell.rs
new file mode 100644
index 0000000..be2c40f
--- /dev/null
+++ b/src/backend/shell.rs
@@ -0,0 +1,425 @@
+//! The run loop's shared half: what one turn of the loop does once a shell
+//! has dispatched whatever its window system delivered. A [`Shell`] is a
+//! window system's side of the contract — the Wayland runner's `EngineState`
+//! is one — and a [`Pacer`] drives it one [`turn`](Pacer::turn) at a time:
+//! the app's tick at a sane `dt`, its requested size and title, key repeat,
+//! the decision to present (fresh, or a warm-down re-render) and the cadence
+//! the loop should sleep at until the next turn.
+//!
+//! Before this the turn was written into the Wayland runner's loop, between
+//! a calloop dispatch and a protocol-error check. A shell with a different
+//! loop — the browser's animation frames, an AppKit run loop — calls `turn`
+//! from wherever its loop turns and sleeps (or schedules) for what it says.
+
+use std::time::{Duration, Instant};
+
+use super::app::Application;
+use super::driver::{Driver, Turn};
+
+/// Loop cadence while something is in motion: one turn per frame.
+pub const ACTIVE_DISPATCH: Duration = Duration::from_millis(16);
+
+/// Default cap on the runner's idle sleep — see `Application::idle_poll_interval`.
+pub const IDLE_DISPATCH: Duration = Duration::from_millis(1000);
+
+/// How long the cadence stays at frame rate after the last genuine redraw.
+///
+/// Sparse, isolated commits get their frame callbacks serviced multiple
+/// compositor frames late (measured 22-128ms on cce-fx, growing per sparse
+/// commit), while a continuously committing surface is serviced in one frame
+/// (~16ms). A short warm-down keeps interactive sequences (hover, typing,
+/// scrolling) in the healthy continuous regime; idle still idles.
+pub const WARM_DOWN: Duration = Duration::from_millis(200);
+
+/// Upper bound on an idle sleep. The loop is woken early by any event the
+/// shell's window system delivers and by messages on the app's sender, so
+/// this only caps how long an app-side poll that bypasses both (see
+/// `Application::idle_poll_interval`) can wait. `CCE_UI_IDLE_MS` overrides
+/// it — `16` restores the old always-ticking loop for a bisect.
+pub fn idle_dispatch() -> Duration {
+ static IDLE: std::sync::OnceLock<Duration> = std::sync::OnceLock::new();
+ *IDLE.get_or_init(|| {
+ std::env::var("CCE_UI_IDLE_MS")
+ .ok()
+ .and_then(|v| v.parse::<u64>().ok())
+ .map(Duration::from_millis)
+ .unwrap_or(IDLE_DISPATCH)
+ })
+}
+
+/// A window system's side of the run loop.
+pub trait Shell {
+ type App: Application;
+
+ /// The input driver and the app's turn, borrowed apart.
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, Self::App>);
+
+ fn app(&self) -> &Self::App;
+
+ /// The runner's dirty flag: a frame is wanted.
+ fn redraw(&mut self) -> &mut bool;
+
+ /// The app asked to exit (its `update` set the flag).
+ fn exit_requested(&self) -> bool;
+
+ /// The window system sized the window since the last turn: this turn the
+ /// app's own `desired_size` is not asked (the configure wins). Clears it.
+ fn take_just_configured(&mut self) -> bool;
+
+ /// The app wants its window `w` x `h` (frame px). The shell resizes — or
+ /// does not, if it already is — and raises `redraw` when it did.
+ fn request_size(&mut self, w: u32, h: u32);
+
+ /// Once a turn, after the app's tick and size: whatever the window system
+ /// keeps in step with the app (the Wayland shell's overflow rim, popover
+ /// region and menu popup).
+ fn sync(&mut self) {}
+
+ /// The app's title changed.
+ fn set_title(&mut self, title: &str);
+
+ /// A presented frame has not yet been released by the window system's
+ /// pacing (Wayland: the frame callback is outstanding), so presenting now
+ /// must wait. Asked once a turn, with `redraw` already known, so a shell
+ /// can give up on a release that is never coming.
+ fn frame_pending(&mut self) -> bool;
+
+ /// The window has been configured: there is a surface to present on.
+ fn configured(&self) -> bool;
+
+ /// Build and present a frame. `fresh` is a frame something asked for;
+ /// otherwise it is a warm-down re-render (see [`WARM_DOWN`]).
+ fn present(&mut self, fresh: bool);
+}
+
+/// What the loop should do after a turn.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Step {
+ /// The app asked to exit; the session ends (after whatever leave-taking
+ /// the shell does — the Wayland shell waits out the compositor's fade).
+ Exit,
+ /// Turn again after at most this long, or sooner if an event arrives.
+ Sleep(Duration),
+}
+
+/// The loop's pacing state, one per session.
+pub struct Pacer {
+ last_tick: Instant,
+ /// The loop slept idle before this turn: its interval is not animation time.
+ slept_idle: bool,
+ /// Turns stay at frame rate until this instant (see [`WARM_DOWN`]).
+ warm_until: Option<Instant>,
+ last_title: String,
+}
+
+impl Pacer {
+ /// Before the first turn the loop runs at [`ACTIVE_DISPATCH`].
+ pub fn new(title: String) -> Self {
+ Self { last_tick: Instant::now(), slept_idle: false, warm_until: None, last_title: title }
+ }
+
+ /// One turn of the loop. The cadence is ACTIVE while anything is in
+ /// motion (a redraw pending or just done, an animation, a held key, the
+ /// post-activity warm-down); otherwise the app's own poll interval or
+ /// [`idle_dispatch`]. Before 2026-09-11 it was a flat 16 ms whatever the
+ /// state: every cce-ui client woke 60 times a second forever — ~1200
+ /// wakeups/s across a session's twenty clients — and each wake ran tick,
+ /// desired_size, title and margin checks for nothing.
+ pub fn turn<S: Shell>(&mut self, shell: &mut S) -> Step {
+ if shell.exit_requested() {
+ return Step::Exit;
+ }
+
+ let now = Instant::now();
+ let mut dt = now.duration_since(self.last_tick).as_secs_f32();
+ self.last_tick = now;
+ if dt > 0.1 {
+ dt = 0.1;
+ }
+ // Waking from an idle sleep: the interval is not animation time. An
+ // animation an event just started must take its first step at frame
+ // size, not leap 100 ms in one tick.
+ if self.slept_idle {
+ dt = dt.min(1.0 / 60.0);
+ }
+
+ {
+ let (driver, t) = shell.turn();
+ driver.tick(t, dt);
+ }
+
+ if !shell.take_just_configured() {
+ if let Some((w, h)) = shell.app().desired_size() {
+ shell.request_size(w, h);
+ }
+ }
+
+ shell.sync();
+
+ {
+ let (driver, t) = shell.turn();
+ driver.repeat_keys(t);
+ }
+ let title = shell.app().settings().title;
+ if title != self.last_title {
+ shell.set_title(&title);
+ self.last_title = title;
+ }
+
+ let pending = shell.frame_pending();
+
+ if *shell.redraw() {
+ // Genuine dirt (input, app state, animation) extends the warm window;
+ // warm-down renders below do NOT, so idle decays in one window.
+ self.warm_until = Some(Instant::now() + WARM_DOWN);
+ }
+ let mut rendered = false;
+ if *shell.redraw() && !pending {
+ *shell.redraw() = false;
+ if shell.configured() {
+ shell.present(true);
+ rendered = true;
+ }
+ } else if !*shell.redraw() && !pending && self.warm_until.is_some_and(|t| Instant::now() < t) {
+ // Warm-down re-render, paced by the shell's frame release.
+ if shell.configured() {
+ shell.present(false);
+ rendered = true;
+ }
+ }
+
+ // Anything still moving keeps the frame cadence; a pending frame on
+ // its own does not (its release arrives as an event) unless a redraw
+ // is queued behind it, which is what the shell's `frame_pending`
+ // times. `redraw` still set here means the frame was withheld (a
+ // frame pending, or no configure yet) and must be retried soon.
+ let warm = self.warm_until.is_some_and(|t| Instant::now() < t);
+ let key_held = shell.turn().0.pressed_key.is_some();
+ let busy = *shell.redraw() || rendered || warm || key_held;
+ self.slept_idle = !busy;
+ Step::Sleep(if busy {
+ ACTIVE_DISPATCH
+ } else {
+ let app_poll = shell.app().idle_poll_interval();
+ app_poll.map_or(idle_dispatch(), |d| d.min(idle_dispatch()))
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ //! The loop's policy, turned by hand against a shell that records what
+ //! it was asked to do.
+ use super::*;
+ use crate::backend::app::{AppSender, LogicalPosition, WindowSettings};
+ use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta};
+
+ #[derive(Default)]
+ struct App {
+ title: String,
+ desired: Option<(u32, u32)>,
+ poll: Option<Duration>,
+ /// Each tick's dt.
+ ticks: Vec<f32>,
+ /// Ask for a frame from the next tick.
+ animate: bool,
+ }
+
+ impl Application for App {
+ type Message = ();
+ fn create(_: AppSender<()>) -> Self {
+ unreachable!("built directly")
+ }
+ fn settings(&self) -> WindowSettings {
+ WindowSettings { title: self.title.clone(), app_id: "mock".into(), width: 10, height: 10, fullscreen: false, min_size: None }
+ }
+ fn update(&mut self, _: (), _: &mut bool, _: &mut bool) {}
+ fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+ self.ticks.push(dt);
+ *needs_rebuild |= self.animate;
+ }
+ fn handle_pointer_move(&mut self, _: LogicalPosition, _: &mut bool) {}
+ fn handle_mouse_input(&mut self, _: MouseButton, _: ElementState, _: LogicalPosition, _: &mut bool) -> Option<()> {
+ None
+ }
+ fn handle_mouse_wheel(&mut self, _: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {}
+ fn handle_key_input(&mut self, _: &KeyEvent, _: &mut bool) -> Option<()> {
+ None
+ }
+ fn desired_size(&self) -> Option<(u32, u32)> {
+ self.desired
+ }
+ fn idle_poll_interval(&self) -> Option<Duration> {
+ self.poll
+ }
+ }
+
+ #[derive(Debug, Clone, PartialEq)]
+ enum Did {
+ Size(u32, u32),
+ Title(String),
+ Present(bool),
+ }
+
+ struct Mock {
+ app: App,
+ driver: Driver,
+ redraw: bool,
+ exit: bool,
+ just_configured: bool,
+ pending: bool,
+ configured: bool,
+ did: Vec<Did>,
+ }
+
+ impl Mock {
+ fn new() -> Self {
+ Self {
+ app: App::default(),
+ driver: Driver::new(),
+ redraw: false,
+ exit: false,
+ just_configured: false,
+ pending: false,
+ configured: true,
+ did: Vec::new(),
+ }
+ }
+ }
+
+ impl Shell for Mock {
+ type App = App;
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, App>) {
+ (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
+ }
+ fn app(&self) -> &App {
+ &self.app
+ }
+ fn redraw(&mut self) -> &mut bool {
+ &mut self.redraw
+ }
+ fn exit_requested(&self) -> bool {
+ self.exit
+ }
+ fn take_just_configured(&mut self) -> bool {
+ std::mem::replace(&mut self.just_configured, false)
+ }
+ fn request_size(&mut self, w: u32, h: u32) {
+ self.did.push(Did::Size(w, h));
+ }
+ fn set_title(&mut self, title: &str) {
+ self.did.push(Did::Title(title.into()));
+ }
+ fn frame_pending(&mut self) -> bool {
+ self.pending
+ }
+ fn configured(&self) -> bool {
+ self.configured
+ }
+ fn present(&mut self, fresh: bool) {
+ self.did.push(Did::Present(fresh));
+ }
+ }
+
+ fn idle() -> Step {
+ Step::Sleep(idle_dispatch())
+ }
+
+ #[test]
+ fn a_quiet_window_sleeps_idle_or_at_the_apps_poll() {
+ let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
+ assert_eq!(p.turn(&mut s), idle());
+ assert!(s.did.is_empty(), "nothing to do: {:?}", s.did);
+
+ s.app.poll = Some(Duration::from_millis(250));
+ assert_eq!(p.turn(&mut s), Step::Sleep(Duration::from_millis(250).min(idle_dispatch())));
+ }
+
+ #[test]
+ fn a_redraw_presents_then_warms_down_at_frame_rate() {
+ let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
+ s.redraw = true;
+ assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
+ assert_eq!(s.did, vec![Did::Present(true)]);
+ assert!(!s.redraw);
+
+ // Inside the warm window: re-render, and keep the frame cadence.
+ assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
+ assert_eq!(s.did.last(), Some(&Did::Present(false)));
+ }
+
+ #[test]
+ fn a_pending_frame_withholds_the_present_and_keeps_the_redraw() {
+ let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
+ s.redraw = true;
+ s.pending = true;
+ assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
+ assert!(s.did.is_empty());
+ assert!(s.redraw, "the frame is withheld, not dropped");
+
+ s.pending = false;
+ p.turn(&mut s);
+ assert_eq!(s.did, vec![Did::Present(true)]);
+ }
+
+ #[test]
+ fn before_the_first_configure_a_redraw_is_spent_without_a_present() {
+ // What the loop always did: the configure itself raises a redraw.
+ let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
+ s.configured = false;
+ s.redraw = true;
+ p.turn(&mut s);
+ assert!(s.did.is_empty());
+ assert!(!s.redraw);
+ }
+
+ #[test]
+ fn the_apps_size_and_title_reach_the_shell_and_a_configure_wins_its_turn() {
+ let (mut p, mut s) = (Pacer::new("a".into()), Mock::new());
+ s.app.desired = Some((300, 200));
+ s.app.title = "b".into();
+ s.just_configured = true;
+ p.turn(&mut s);
+ assert_eq!(s.did, vec![Did::Title("b".into())], "no size on a configure's turn, the title once");
+
+ s.did.clear();
+ p.turn(&mut s);
+ assert_eq!(s.did, vec![Did::Size(300, 200)], "the size on the next; the title is unchanged");
+ }
+
+ #[test]
+ fn a_held_key_keeps_the_frame_cadence() {
+ let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
+ let (driver, t) = s.turn();
+ driver.key(t, Key::Character("a".into()), Some("a".into()), ElementState::Pressed);
+ // The key's own dispatch asked for nothing; the hold alone keeps it busy.
+ s.redraw = false;
+ assert_eq!(p.turn(&mut s), Step::Sleep(ACTIVE_DISPATCH));
+ }
+
+ #[test]
+ fn an_exit_ends_the_turn_before_anything_ticks() {
+ let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
+ s.exit = true;
+ assert_eq!(p.turn(&mut s), Step::Exit);
+ assert!(s.app.ticks.is_empty());
+ }
+
+ #[test]
+ fn the_first_tick_after_an_idle_sleep_is_one_frame_long() {
+ let (mut p, mut s) = (Pacer::new(String::new()), Mock::new());
+ assert_eq!(p.turn(&mut s), idle());
+ std::thread::sleep(Duration::from_millis(40));
+ // Woken from idle by something that animates: its first step is a frame.
+ s.app.animate = true;
+ p.turn(&mut s);
+ let woke = *s.app.ticks.last().unwrap();
+ assert!(woke <= 1.0 / 60.0 + 1e-6, "dt after an idle sleep: {woke}");
+
+ // Busy now, so the next interval IS animation time, up to 100 ms.
+ std::thread::sleep(Duration::from_millis(40));
+ p.turn(&mut s);
+ let busy = *s.app.ticks.last().unwrap();
+ assert!((0.035..=0.1).contains(&busy), "dt while busy: {busy}");
+ }
+}
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index be3e656..ac51e57 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -43,12 +43,12 @@ use crate::vk::VkRenderer;
pub use super::app::*;
pub use super::driver::PressedKey;
use super::frame::build_frame;
+use super::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
use super::driver::{Driver, Modifiers, Press, PressSite, ResizeEdge, ScrollFrame, ScrollSource, Turn};
pub use super::tessellate::*;
pub use super::text::*;
-/// Default cap on the runner's idle sleep — see `Application::idle_poll_interval`.
-pub const IDLE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(1000);
+pub use super::shell::IDLE_DISPATCH;
pub struct EngineState<A: Application> {
pub registry_state: RegistryState,
@@ -123,13 +123,6 @@ pub struct EngineState<A: Application> {
/// When the pending frame callback was armed — the starvation fallback's
/// clock (see the render gate in `run`).
pub frame_callback_armed_at: Option<std::time::Instant>,
- /// Keep rendering (vsync-paced) briefly after the last genuine dirty frame.
- /// Sparse, isolated commits get their frame callbacks serviced multiple
- /// compositor frames late (measured 22-128ms on cce-fx, growing per sparse
- /// commit), while a continuously committing surface is serviced in one
- /// frame (~16ms). A short warm-down keeps interactive sequences (hover,
- /// typing, scrolling) in the healthy continuous regime; idle still idles.
- pub warm_until: Option<std::time::Instant>,
/// Consecutive renders skipped by the extent gate (pending swapchain size
/// != the size the current logical size and scale call for). Normally 0 or
/// 1; a persistent count means no frame is presenting and deserves a warn.
@@ -364,15 +357,6 @@ impl<A: Application> EngineState<A> {
self.sent_popover_region = next;
}
- /// The driver and the app's turn, borrowed apart: every input dispatch
- /// is `let (driver, t) = self.turn(); driver.<event>(t, ..)`.
- fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
- (
- &mut self.driver,
- Turn { app: self.inner.as_mut().unwrap(), redraw: &mut self.redraw, exit: &mut self.exit },
- )
- }
-
fn logical_size(&self) -> LogicalSize {
LogicalSize::new(self.logical_width, self.logical_height)
}
@@ -510,6 +494,128 @@ impl<A: Application> EngineState<A> {
}
}
+impl<A: Application> Shell for EngineState<A> {
+ type App = A;
+
+ /// The driver and the app's turn, borrowed apart: every input dispatch
+ /// is `let (driver, t) = self.turn(); driver.<event>(t, ..)`.
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
+ (
+ &mut self.driver,
+ Turn { app: self.inner.as_mut().unwrap(), redraw: &mut self.redraw, exit: &mut self.exit },
+ )
+ }
+
+ fn app(&self) -> &A {
+ self.inner.as_ref().unwrap()
+ }
+
+ fn redraw(&mut self) -> &mut bool {
+ &mut self.redraw
+ }
+
+ fn exit_requested(&self) -> bool {
+ self.exit
+ }
+
+ fn take_just_configured(&mut self) -> bool {
+ std::mem::replace(&mut self.just_configured, false)
+ }
+
+ fn request_size(&mut self, w: u32, h: u32) {
+ // desired_size is a window-frame size; the surface adds the
+ // right/bottom overflow rim (0 for margin-less apps).
+ let m = self.inner.as_ref().unwrap().overflow_margin() as f32;
+ let (sw, sh) = (w as f32 + m, h as f32 + m);
+ if (self.logical_width - sw).abs() > 0.001 || (self.logical_height - sh).abs() > 0.001 {
+ self.frame_logical = (w as f32, h as f32);
+ self.applied_margin = m;
+ self.resize(sw, sh);
+ self.redraw = true;
+ }
+ }
+
+ fn sync(&mut self) {
+ // Overflow-margin drift (configure-sized apps): the rim can change at
+ // runtime — a popover overhanging the window frame — so re-derive the
+ // surface from the stored frame whenever the app's answer moves. While
+ // the rim is live, re-publish geometry every loop: the input region
+ // tracks the animating popover rects.
+ let m_now = self.inner.as_ref().unwrap().overflow_margin() as f32;
+ if (m_now - self.applied_margin).abs() > 0.001 && self.frame_logical.0 > 0.0 {
+ self.applied_margin = m_now;
+ let (fw, fh) = self.frame_logical;
+ self.resize(fw + m_now, fh + m_now);
+ self.redraw = true;
+ }
+ if self.applied_margin > 0.0 || self.overflow_was_active {
+ self.publish_window_geometry();
+ self.overflow_was_active = self.applied_margin > 0.0;
+ }
+ self.send_popover_region();
+ self.sync_menu_popup();
+ }
+
+ fn set_title(&mut self, title: &str) {
+ if let Some(ref window) = self.window {
+ window.set_title(title);
+ window.commit();
+ }
+ }
+
+ fn frame_pending(&mut self) -> bool {
+ // Frame-callback starvation fallback: the compositor only sends
+ // frame-done for surfaces it actually renders, so a callback armed
+ // while the window sat off-viewport (or the scene went static) may
+ // never fire — and the vsync gate then freezes the app forever
+ // with a perfectly live event loop (input processes, state changes,
+ // nothing repaints). If a redraw has been waiting on a callback well
+ // past any real vsync interval, stop waiting and draw.
+ //
+ // Gated on the renderer's present mode: forcing a present past a
+ // dead callback is only safe under MAILBOX (the present replaces the
+ // queued buffer). Under FIFO the driver's throttle waits on the
+ // previous present's frame event, so the forced present itself
+ // blocks forever inside the driver — the exact freeze this fallback
+ // exists to prevent. There the gate stays closed: pixels may stale
+ // until the next frame-done/configure, but the loop stays alive.
+ if self.redraw
+ && self.frame_callback_pending
+ && self.renderer.as_ref().is_some_and(|r| r.forced_present_safe())
+ && self.frame_callback_armed_at.is_none_or(|t| t.elapsed().as_millis() > 250)
+ {
+ self.frame_callback_pending = false;
+ if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
+ let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
+ eprintln!("[vk] t={} starvation fallback fired (callback never came)", t);
+ }
+ }
+ self.frame_callback_pending
+ }
+
+ fn configured(&self) -> bool {
+ self.first_configure_received
+ }
+
+ fn present(&mut self, fresh: bool) {
+ if !fresh {
+ // A warm-down re-render: the window alone.
+ self.render();
+ return;
+ }
+ // A menu handed over from the window commits first, so
+ // there is no moment with neither (`take_menu_popup_lead`).
+ let lead = self.take_menu_popup_lead();
+ if lead {
+ self.render_menu_popup();
+ }
+ self.render();
+ if !lead {
+ self.render_menu_popup();
+ }
+ }
+}
+
impl<A: Application> Drop for EngineState<A> {
fn drop(&mut self) {
// The popup's renderer lets go of its surface before the popup (and
@@ -1638,7 +1744,6 @@ fn run_session<'l, A: Application>(
damage_owed: true,
frame_callback_pending: false,
frame_callback_armed_at: None,
- warm_until: None,
extent_gate_skips: 0,
first_configure_received: false,
driver: Driver::new(),
@@ -1810,25 +1915,6 @@ fn run_session<'l, A: Application>(
*FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
}
- /// Loop cadence while something is in motion: one tick per frame.
- const ACTIVE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(16);
-
- /// Upper bound on an idle sleep. The loop is woken early by any Wayland
- /// event or calloop-channel message, so this only caps how long an
- /// app-side poll that bypasses both (see `Application::idle_poll_interval`)
- /// can wait. `CCE_UI_IDLE_MS` overrides it — `16` restores the old
- /// always-ticking loop for a bisect.
- fn idle_dispatch() -> std::time::Duration {
- static IDLE: std::sync::OnceLock<std::time::Duration> = std::sync::OnceLock::new();
- *IDLE.get_or_init(|| {
- std::env::var("CCE_UI_IDLE_MS")
- .ok()
- .and_then(|v| v.parse::<u64>().ok())
- .map(std::time::Duration::from_millis)
- .unwrap_or(IDLE_DISPATCH)
- })
- }
-
/// Seconds after session start at which to inject a simulated connection
/// loss, from `CCE_UI_FAULT_RECONNECT`. Resolved once: this is read from
/// the per-iteration path.
@@ -1843,19 +1929,13 @@ fn run_session<'l, A: Application>(
})
}
- let mut last_title = settings.title.clone();
- let mut last_tick = std::time::Instant::now();
let mut end = SessionEnd::AppExit;
let session_start = std::time::Instant::now();
- // The loop's cadence. ACTIVE while anything is in motion (a redraw
- // pending or just done, an animation, a held key, the post-activity
- // warm-down); otherwise the app's own poll interval or IDLE_DISPATCH.
- // Before 2026-09-11 this was a flat 16 ms whatever the state: every
- // cce-ui client woke 60 times a second forever — ~1200 wakeups/s across
- // a session's twenty clients — and each wake ran tick, desired_size,
- // title and margin checks for nothing.
+ // The loop's pacing — what a turn does and how long to sleep after it —
+ // is the shared `Pacer`'s (backend::shell); this loop is the Wayland
+ // side: dispatch, the connection's health, the close fade.
+ let mut pacer = Pacer::new(settings.title.clone());
let mut next_timeout = ACTIVE_DISPATCH;
- let mut slept_idle = false;
loop {
// Frame callbacks arrive with a p50 of 0ms but a ~0.5s tail, while the
// compositor's own trace shows it firing them within one or two vsyncs
@@ -1909,184 +1989,33 @@ fn run_session<'l, A: Application>(
break;
}
}
- if engine_state.exit {
- // The close dissolve. It is the COMPOSITOR that fades us — it
- // ramps our scene subtree's opacity, which takes the backdrop
- // blur, drop shadow and bevel down with the window; all this side
- // has to do is not vanish before it finishes. So keep the surface
- // mapped and the loop turning for exactly as long as the
- // compositor asked for, then leave. Dispatching (rather than
- // sleeping) keeps the connection pumped and lets any last
- // animation finish on screen while the window dissolves.
- let fade = crate::ipc::request_close_fade();
- if !fade.is_zero() {
- let until = std::time::Instant::now() + fade;
- loop {
- let left = until.saturating_duration_since(std::time::Instant::now());
- if left.is_zero() {
- break;
- }
- if event_loop.dispatch(left.min(ACTIVE_DISPATCH), &mut engine_state).is_err() {
- break;
+ match pacer.turn(&mut engine_state) {
+ Step::Sleep(timeout) => next_timeout = timeout,
+ Step::Exit => {
+ // The close dissolve. It is the COMPOSITOR that fades us — it
+ // ramps our scene subtree's opacity, which takes the backdrop
+ // blur, drop shadow and bevel down with the window; all this side
+ // has to do is not vanish before it finishes. So keep the surface
+ // mapped and the loop turning for exactly as long as the
+ // compositor asked for, then leave. Dispatching (rather than
+ // sleeping) keeps the connection pumped and lets any last
+ // animation finish on screen while the window dissolves.
+ let fade = crate::ipc::request_close_fade();
+ if !fade.is_zero() {
+ let until = std::time::Instant::now() + fade;
+ loop {
+ let left = until.saturating_duration_since(std::time::Instant::now());
+ if left.is_zero() {
+ break;
+ }
+ if event_loop.dispatch(left.min(ACTIVE_DISPATCH), &mut engine_state).is_err() {
+ break;
+ }
}
}
- }
- break;
- }
-
- let now = std::time::Instant::now();
- let mut dt = now.duration_since(last_tick).as_secs_f32();
- last_tick = now;
- if dt > 0.1 {
- dt = 0.1;
- }
- // Waking from an idle sleep: the interval is not animation time. An
- // animation an event just started must take its first step at frame
- // size, not leap 100 ms in one tick.
- if slept_idle {
- dt = dt.min(1.0 / 60.0);
- }
-
- {
- let (driver, t) = engine_state.turn();
- driver.tick(t, dt);
- }
-
- let just_configured = engine_state.just_configured;
- engine_state.just_configured = false;
-
- if !just_configured {
- if let Some((w, h)) = engine_state.inner.as_ref().unwrap().desired_size() {
- // desired_size is a window-frame size; the surface adds the
- // right/bottom overflow rim (0 for margin-less apps).
- let m = engine_state.inner.as_ref().unwrap().overflow_margin() as f32;
- let (sw, sh) = (w as f32 + m, h as f32 + m);
- if (engine_state.logical_width - sw).abs() > 0.001 || (engine_state.logical_height - sh).abs() > 0.001 {
- engine_state.frame_logical = (w as f32, h as f32);
- engine_state.applied_margin = m;
- engine_state.resize(sw, sh);
- engine_state.redraw = true;
- }
- }
- }
-
- // Overflow-margin drift (configure-sized apps): the rim can change at
- // runtime — a popover overhanging the window frame — so re-derive the
- // surface from the stored frame whenever the app's answer moves. While
- // the rim is live, re-publish geometry every loop: the input region
- // tracks the animating popover rects.
- {
- let m_now = engine_state.inner.as_ref().unwrap().overflow_margin() as f32;
- if (m_now - engine_state.applied_margin).abs() > 0.001 && engine_state.frame_logical.0 > 0.0 {
- engine_state.applied_margin = m_now;
- let (fw, fh) = engine_state.frame_logical;
- engine_state.resize(fw + m_now, fh + m_now);
- engine_state.redraw = true;
- }
- if engine_state.applied_margin > 0.0 || engine_state.overflow_was_active {
- engine_state.publish_window_geometry();
- engine_state.overflow_was_active = engine_state.applied_margin > 0.0;
- }
- engine_state.send_popover_region();
- }
- engine_state.sync_menu_popup();
-
- {
- let (driver, t) = engine_state.turn();
- driver.repeat_keys(t);
- }
- let current_title = engine_state.inner.as_ref().unwrap().settings().title;
- if current_title != last_title {
- if let Some(ref window) = engine_state.window {
- window.set_title(¤t_title);
- window.commit();
- }
- last_title = current_title;
- }
-
- // Frame-callback starvation fallback: the compositor only sends
- // frame-done for surfaces it actually renders, so a callback armed
- // while the window sat off-viewport (or the scene went static) may
- // never fire — and the vsync gate below then freezes the app forever
- // with a perfectly live event loop (input processes, state changes,
- // nothing repaints). If a redraw has been waiting on a callback well
- // past any real vsync interval, stop waiting and draw.
- //
- // Gated on the renderer's present mode: forcing a present past a
- // dead callback is only safe under MAILBOX (the present replaces the
- // queued buffer). Under FIFO the driver's throttle waits on the
- // previous present's frame event, so the forced present itself
- // blocks forever inside the driver — the exact freeze this fallback
- // exists to prevent. There the gate stays closed: pixels may stale
- // until the next frame-done/configure, but the loop stays alive.
- if engine_state.redraw
- && engine_state.frame_callback_pending
- && engine_state
- .renderer
- .as_ref()
- .is_some_and(|r| r.forced_present_safe())
- && engine_state
- .frame_callback_armed_at
- .is_none_or(|t| t.elapsed().as_millis() > 250)
- {
- engine_state.frame_callback_pending = false;
- if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
- let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
- eprintln!("[vk] t={} starvation fallback fired (callback never came)", t);
- }
- }
-
- if engine_state.redraw {
- // Genuine dirt (input, app state, animation) extends the warm window;
- // warm-down renders below do NOT, so idle decays in one window.
- engine_state.warm_until =
- Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
- }
- let mut rendered = false;
- if engine_state.redraw && !engine_state.frame_callback_pending {
- engine_state.redraw = false;
- if engine_state.first_configure_received {
- // A menu handed over from the window commits first, so
- // there is no moment with neither (`take_menu_popup_lead`).
- let lead = engine_state.take_menu_popup_lead();
- if lead {
- engine_state.render_menu_popup();
- }
- engine_state.render();
- if !lead {
- engine_state.render_menu_popup();
- }
- rendered = true;
- }
- } else if !engine_state.redraw
- && !engine_state.frame_callback_pending
- && engine_state
- .warm_until
- .is_some_and(|t| std::time::Instant::now() < t)
- {
- // Warm-down re-render of the cached frame, paced by frame callbacks.
- if engine_state.first_configure_received {
- engine_state.render();
- rendered = true;
+ break;
}
}
-
- // Anything still moving keeps the frame cadence; a frame callback
- // outstanding on its own does not (it arrives as an event) unless a
- // redraw is queued behind it, which is what the starvation fallback
- // above times. `redraw` still set here means the frame was withheld
- // (callback pending, or no configure yet) and must be retried soon.
- let warm = engine_state
- .warm_until
- .is_some_and(|t| std::time::Instant::now() < t);
- let busy = engine_state.redraw || rendered || warm || engine_state.driver.pressed_key.is_some();
- next_timeout = if busy {
- ACTIVE_DISPATCH
- } else {
- let app_poll = engine_state.inner.as_ref().unwrap().idle_poll_interval();
- app_poll.map_or(idle_dispatch(), |d| d.min(idle_dispatch()))
- };
- slept_idle = !busy;
}
// Tear the session down: drop its Wayland source from the persistent loop