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

commitd3bf9054b3bef875a2940dc471e2f96b5589a8af
parent0f848439b1
authorLucas Galante <[email protected]>
date2026-09-04 10:01
fix(runner): tick the widget roster once per frame, not twice

The frame loop ticked UiContext's tick_receivers itself after the app's
tick, on the assumption that receivers were wall-clock based and a second
tick only re-reported 'changed'. ScrollMotion (and the slider/ramp glides
before it) integrate dt, so every app that ticks its context in its own
Application::tick — files, fonts, graph, list, mail, data-editor,
system-interface, the demo — ran glides at twice the configured rate. The
runner now reads UiContext::tick_count around the app's tick and only
ticks the roster when the app did not.

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

 src/backend/window_runner.rs | 16 +++++++++++-----
 src/context.rs               | 12 ++++++++++++
 2 files changed, 23 insertions(+), 5 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 217b9fd..08691c5 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -5673,17 +5673,23 @@ fn run_session<'l, A: Application>(
         }
 
         let mut rebuild = false;
+        let roster_ticks_before =
+            engine_state.inner.as_mut().unwrap().ui_context_mut().map(|ctx| ctx.tick_count());
         engine_state.inner.as_mut().unwrap().tick(dt, &mut rebuild);
         if rebuild {
             engine_state.redraw = true;
         }
         // Tick the app's retained UiContext (widget tick_receivers — e.g. an
-        // animating Dropdown popover) for apps that expose it. Apps that also
-        // tick it themselves are safe to double-tick: receivers' animations are
-        // wall-clock-based, so an extra tick only re-reports "changed".
+        // animating Dropdown popover) for apps that expose it — but only when
+        // the app's own tick did not already do so this frame. Receivers
+        // integrate `dt` (scroll glides, slider inertia), so the old
+        // "double-ticking is harmless" assumption ran every glide at twice
+        // its configured rate in apps that tick the context themselves.
         if let Some(ctx) = engine_state.inner.as_mut().unwrap().ui_context_mut() {
-            if ctx.tick(dt) {
-                engine_state.redraw = true;
+            if Some(ctx.tick_count()) == roster_ticks_before {
+                if ctx.tick(dt) {
+                    engine_state.redraw = true;
+                }
             }
         }
 
diff --git a/src/context.rs b/src/context.rs
index 5731499..0d45670 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -80,6 +80,11 @@ pub struct UiContext {
     pub is_dragging: bool,
     pub any_dirty: bool,
     pub tick_receivers: Vec<WidgetId>,
+    /// How many times `tick` has run. The runner reads it around the app's
+    /// own `Application::tick` to see whether the app already advanced the
+    /// roster this frame — receivers integrate `dt` (scroll glides, slider
+    /// inertia), so a second tick per frame would run them at double speed.
+    tick_count: u64,
     pub spatial_grid: SpatialGrid,
     pub last_scroll_time: Option<std::time::Instant>,
     pub scroll_initiate_widget_id: Option<WidgetId>,
@@ -106,6 +111,7 @@ impl UiContext {
             is_dragging: false,
             any_dirty: false,
             tick_receivers: Vec::new(),
+            tick_count: 0,
             spatial_grid: SpatialGrid::new(100.0),
             last_scroll_time: None,
             scroll_initiate_widget_id: None,
@@ -441,7 +447,13 @@ impl UiContext {
         true
     }
 
+    /// Number of `tick` calls so far (see the field doc).
+    pub fn tick_count(&self) -> u64 {
+        self.tick_count
+    }
+
     pub fn tick(&mut self, dt: f32) -> bool {
+        self.tick_count = self.tick_count.wrapping_add(1);
         let mut changed = false;
         let ids = self.tick_receivers.clone();
         for id in ids {