From 786167beea9fbfefd3a68b3141df865bb4d500c9 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 27 Jun 2026 19:02:35 +0200 Subject: [PATCH] fix: deliver timer delta (not frame delta) in Tick.delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Tick.delta` was set to the per-frame render delta (~16ms at 60fps) for both one-shot and repeating timers, so `everyMs(100)` reported ~16ms instead of ~100ms — contradicting its "nanoseconds since last tick" doc. Deliver the actual timer delta instead: - repeating (`every`): time since the previous tick (`elapsed - last_every_tick`) - one-shot (`tick`): time since the tick was scheduled, via a new `pending_tick_scheduled_at` anchor The per-frame render delta remains available via `ctx.delta` / `ctx.deltaSec()` / `ctx.fps()`. On suspend/resume both timer anchors are rebased to the resume instant so the first post-resume delta is a normal interval rather than the whole suspended span. Rewrote the `Tick.delta` doc comment to match the actual behavior. Fixes #116 --- src/core/message.zig | 11 ++++++++++- src/core/program.zig | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/core/message.zig b/src/core/message.zig index e230a42..74937d7 100644 --- a/src/core/message.zig +++ b/src/core/message.zig @@ -21,7 +21,16 @@ pub const WindowSize = struct { pub const Tick = struct { /// Monotonic timestamp in nanoseconds since program start. timestamp: i64, - delta: u64, // nanoseconds since last tick + /// Nanoseconds elapsed since the previous tick event from this timer. + /// + /// For a repeating timer (`everyMs`/`every`) this is the time between + /// consecutive ticks (≈ the requested interval). For a one-shot timer + /// (`tickMs`/`tick`) this is the time since the tick was scheduled. + /// + /// This is the timer delta, not the per-frame render delta. The render + /// delta (time since the previous rendered frame) is available separately + /// as `ctx.delta` / `ctx.deltaSec()` / `ctx.fps()`. + delta: u64, }; /// Focus change message diff --git a/src/core/program.zig b/src/core/program.zig index 81a6922..5f7195c 100644 --- a/src/core/program.zig +++ b/src/core/program.zig @@ -67,6 +67,10 @@ pub fn Program(comptime Model: type) type { pacing_epoch: std.Io.Clock.Timestamp, pacing_frame_offset: u64, pending_tick: ?u64, + /// `context.elapsed` captured when the active one-shot `pending_tick` was + /// scheduled, so the delivered `Tick.delta` reflects the time since the + /// tick was requested rather than the per-frame render delta. + pending_tick_scheduled_at: u64, every_interval: ?u64, last_every_tick: u64, last_view_hash: u64, @@ -112,6 +116,7 @@ pub fn Program(comptime Model: type) type { .pacing_epoch = clock_epoch, .pacing_frame_offset = 0, .pending_tick = null, + .pending_tick_scheduled_at = 0, .every_interval = null, .last_every_tick = 0, .last_view_hash = 0, @@ -285,9 +290,11 @@ pub fn Program(comptime Model: type) type { self.pending_tick = null; // Deliver tick to user's update if Model.Msg has a tick variant if (@hasField(UserMsg, "tick")) { + // Time since the tick was scheduled, not the frame delta. + const tick_delta = self.context.elapsed -| self.pending_tick_scheduled_at; const user_msg = UserMsg{ .tick = .{ .timestamp = @intCast(tick_start), - .delta = actual_delta, + .delta = tick_delta, } }; const cmd = self.dispatchToModel(user_msg); try self.processCommand(cmd); @@ -298,11 +305,13 @@ pub fn Program(comptime Model: type) type { // Handle repeating tick if (self.every_interval) |interval| { if (self.context.elapsed - self.last_every_tick >= interval) { + // Time since the previous repeating tick, not the frame delta. + const tick_delta = self.context.elapsed -| self.last_every_tick; self.last_every_tick = self.context.elapsed; if (@hasField(UserMsg, "tick")) { const user_msg = UserMsg{ .tick = .{ .timestamp = @intCast(tick_start), - .delta = actual_delta, + .delta = tick_delta, } }; const cmd = self.dispatchToModel(user_msg); try self.processCommand(cmd); @@ -432,12 +441,27 @@ pub fn Program(comptime Model: type) type { term.setup() catch {}; } - // Avoid a large post-resume frame delta, and rebase the pacing anchor - // so we don't burst-render to "catch up" the suspended interval. - self.last_frame_time = self.elapsedNs(); + // Avoid a large post-resume delta, and rebase the pacing anchor so we + // don't burst-render to "catch up" the suspended interval. Advance the + // user-visible clock to "now" so the timer checks below run against a + // consistent post-resume `elapsed`. + const resume_elapsed = self.elapsedNs(); + self.last_frame_time = resume_elapsed; + self.context.elapsed = resume_elapsed; self.pacing_epoch = std.Io.Clock.Timestamp.now(self.io, .boot); self.pacing_frame_offset = self.context.frame; + // Re-anchor the timers to "now" so the first post-resume Tick.delta is a + // normal interval rather than the whole suspended span (mirrors the + // last_frame_time and pacing resets above: we resume the cadence from + // here instead of bursting to catch up the suspended time). The + // repeating timer fires one interval after resume; an already-overdue + // one-shot fires next with a ~zero delta. Anchoring to `resume_elapsed` + // (== context.elapsed) also keeps the later `elapsed - last_every_tick` + // subtraction from underflowing. + self.last_every_tick = resume_elapsed; + self.pending_tick_scheduled_at = resume_elapsed; + // Force re-render self.last_view_hash = 0; @@ -456,6 +480,7 @@ pub fn Program(comptime Model: type) type { }, .tick => |ns| { self.pending_tick = self.context.elapsed + ns; + self.pending_tick_scheduled_at = self.context.elapsed; }, .every => |ns| { self.every_interval = ns;