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

commitd5b22a5aeec416567324da7062c16c9d29231e80
parent98008b0d8d
authorLucas Galante <[email protected]>
date2026-09-11 10:16
perf(runner): sleep between ticks while idle instead of turning every 16 ms

The main loop dispatched with a flat 16 ms timeout whatever the state, so
every cce-ui client woke 60 times a second forever — a session's twenty
clients were ~1200 wakeups/s — each wake running tick, desired_size, the
title and margin checks, for nothing. Measured on a status-bar clock: 70
wakeups/s idle, now 2.

The cadence is now adaptive. While anything moves — a redraw pending or
just done, an animation reporting a change, a held key (repeat timing), the
200 ms post-activity warm-down — the loop keeps its 16 ms tick. Otherwise it
sleeps up to IDLE_DISPATCH (1 s; CCE_UI_IDLE_MS overrides it, 16 restores
the old loop for a bisect), woken early by any Wayland event or a message on
the app's calloop Sender, which is how tokio tasks and reader threads already
deliver. dt is clamped to one frame after an idle sleep so an animation an
event just started does not leap 100 ms in its first step.

For the few places that poll something the loop cannot see:
`Application::idle_poll_interval` (default None) bounds the sleep for an app
that drains a std channel in tick, and ColorSelector reports a change every
tick while its live picker is open, since those lines come from a reader
thread. The three apps that poll get overrides in their own repos.

Verified in a scale-2 shadow: the clock segment updates each second on 2
wakeups/s, key repeat still produces a run of characters for a held key,
the compositor sees only the real commits.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                          | 11 ++++++
 src/backend/window_runner.rs       | 71 +++++++++++++++++++++++++++++++++++++-
 src/widget/input/color_selector.rs |  5 +++
 3 files changed, 86 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b4937de..d355287 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,6 +64,17 @@ Every client implements `Application` (`src/backend/window_runner.rs`, re-export
 Key methods (see the trait def around `window_runner.rs:1450`):
 - `new`, `settings()` (→ `WindowSettings`), `layer()` (→ optional `LayerSettings` for
   layer-shell surfaces like the status bar), `update(msg, needs_rebuild, exit)`, `tick(dt, …)`.
+  **`tick` is not a clock.** Since 2026-09-11 the runner sleeps between ticks while the
+  window is idle (no redraw pending, no animation, no key held, no warm-down) — up to
+  `IDLE_DISPATCH` (1 s, `CCE_UI_IDLE_MS` overrides) — and is woken by Wayland events and
+  by messages on the calloop `Sender` handed to `new`. It used to tick a flat 16 ms
+  forever: every client awake 60×/s doing nothing. So: deliver background results
+  through that `Sender`, never by draining a `std::sync::mpsc` in `tick`; if a widget
+  or app must poll something the loop cannot see, say so — a widget returns `true`
+  from `tick` while the session is live (ColorSelector's picker), an app overrides
+  `Application::idle_poll_interval` (cce-authenticator, cce-system-interface,
+  cce-designer while a pane is detached). Any animation keeps the frame cadence by
+  itself because it reports a change.
 - **Draw**: `view` / `view_rounded_quads` / `view_vectors` / `overlay_quads` push legacy
   primitive tuples; `text_items()` returns text; `custom_vertices()` appends raw vertices (e.g.
   graph geometry). `display_list()` is the new opt-in path (see below).
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 3f5a242..7797c3a 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3173,6 +3173,18 @@ pub trait Application: Sized + 'static {
     fn grid_patch(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _scale: f64) {}
     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
+    /// How long the runner may sleep between `tick`s while the window is
+    /// idle — nothing to draw, no animation, no key held, no frame callback
+    /// outstanding. `None` (the default) lets it sleep until a Wayland
+    /// event or a message on the app's calloop `Sender` arrives, bounded by
+    /// [`IDLE_DISPATCH`]. Override with `Some` ONLY if your `tick` polls
+    /// something the loop cannot see — a `std::sync::mpsc` receiver drained
+    /// in `tick`, say — because with the default that poll waits for the
+    /// next unrelated event. The better fix is to send through the calloop
+    /// `Sender` handed to `new`, which wakes the loop by itself.
+    fn idle_poll_interval(&self) -> Option<std::time::Duration> {
+        None
+    }
     /// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
     /// tray-hover highlights). Deliberately separate from the single paint path.
     fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
@@ -3450,6 +3462,9 @@ fn is_repeatable_key(key: &Key) -> bool {
     }
 }
 
+/// 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 struct EngineState<A: Application> {
     pub registry_state: RegistryState,
     pub compositor_state: CompositorState,
@@ -5735,6 +5750,25 @@ 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.
@@ -5753,6 +5787,15 @@ fn run_session<'l, A: Application>(
     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.
+    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
@@ -5764,7 +5807,7 @@ fn run_session<'l, A: Application>(
         } else {
             None
         };
-        if let Err(e) = event_loop.dispatch(std::time::Duration::from_millis(16), &mut engine_state) {
+        if let Err(e) = event_loop.dispatch(next_timeout, &mut engine_state) {
             log::error!("[window_runner] event loop error, ending session: {e:?}");
             end = SessionEnd::ConnectionLost;
             break;
@@ -5816,6 +5859,12 @@ fn run_session<'l, A: Application>(
         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 mut rebuild = false;
         let roster_ticks_before =
@@ -5963,10 +6012,12 @@ fn run_session<'l, A: Application>(
             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 {
                 engine_state.render();
+                rendered = true;
             }
         } else if !engine_state.redraw
             && !engine_state.frame_callback_pending
@@ -5977,8 +6028,26 @@ fn run_session<'l, A: Application>(
             // Warm-down re-render of the cached frame, paced by frame callbacks.
             if engine_state.first_configure_received {
                 engine_state.render();
+                rendered = true;
             }
         }
+
+        // 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.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
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 8fc9bcb..9d78ed4 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -533,6 +533,11 @@ impl Input for ColorSelector {
         // the picker launched with.
         let mut redraw = false;
         if let Some(rx) = &self.live_rx {
+            // A live picker session is activity: the runner sleeps between
+            // ticks when nothing is animating, and these lines come from a
+            // reader thread it cannot see, so keep the frame cadence for as
+            // long as the picker is open.
+            redraw = true;
             let mut lines = Vec::new();
             let mut disconnected = false;
             loop {