@@ -59,6 +59,12 @@ pub fn Program(comptime Model: type) type {
5959 /// without gaps on resume.
6060 clock_epoch : std.Io.Clock.Timestamp ,
6161 last_frame_time : u64 ,
62+ /// Anchor for absolute frame pacing. Separate from `clock_epoch` so we can
63+ /// rebase after suspend/resume or a long-overrun frame without disturbing
64+ /// user-visible `context.elapsed` / `context.frame` (which `pending_tick`
65+ /// and `every` depend on).
66+ pacing_epoch : std.Io.Clock.Timestamp ,
67+ pacing_frame_offset : u64 ,
6268 pending_tick : ? u64 ,
6369 every_interval : ? u64 ,
6470 last_every_tick : u64 ,
@@ -104,6 +110,8 @@ pub fn Program(comptime Model: type) type {
104110 .running = false ,
105111 .clock_epoch = clock_epoch ,
106112 .last_frame_time = 0 ,
113+ .pacing_epoch = clock_epoch ,
114+ .pacing_frame_offset = 0 ,
107115 .pending_tick = null ,
108116 .every_interval = null ,
109117 .last_every_tick = 0 ,
@@ -201,7 +209,9 @@ pub fn Program(comptime Model: type) type {
201209 unicode .setWidthStrategy (effective_width_strategy );
202210
203211 self .clock_epoch = std .Io .Clock .Timestamp .now (self .io , .boot );
204- self .last_frame_time = 0 ;
212+ self .last_frame_time = self .elapsedNs ();
213+ self .pacing_epoch = self .clock_epoch ;
214+ self .pacing_frame_offset = 0 ;
205215 self .context .elapsed = 0 ;
206216 self .context .delta = 0 ;
207217 self .context .frame = 0 ;
@@ -222,25 +232,12 @@ pub fn Program(comptime Model: type) type {
222232
223233 /// Execute a single frame: poll input, process events, render.
224234 pub fn tick (self : * Self ) ! void {
225- const now = self .elapsedNs ();
226- const delta = now - self .last_frame_time ;
227-
228- // Enforce framerate limit
229- const min_frame_time_ns : u64 = if (self .options .fps > 0 )
230- @divFloor (std .time .ns_per_s , self .options .fps )
231- else
232- 16_666_666 ; // ~60fps default
233-
234- if (delta < min_frame_time_ns ) {
235- sleepNs (self .io , min_frame_time_ns - delta );
236- }
237-
238- const frame_time = self .elapsedNs ();
239- const actual_delta = frame_time - self .last_frame_time ;
240- self .last_frame_time = frame_time ;
235+ const tick_start = self .elapsedNs ();
236+ const actual_delta : u64 = if (self .context .frame == 0 ) 0 else tick_start - self .last_frame_time ;
237+ self .last_frame_time = tick_start ;
241238
242239 self .context .delta = actual_delta ;
243- self .context .elapsed = frame_time ;
240+ self .context .elapsed = tick_start ;
244241 self .context .frame += 1 ;
245242
246243 self .resetFrameAllocator ();
@@ -261,9 +258,9 @@ pub fn Program(comptime Model: type) type {
261258 }
262259 }
263260
264- // Read input
261+ // Non-blocking drain; input typed during pacing sits in the TTY buffer.
265262 var input_buf : [256 ]u8 = undefined ;
266- const bytes_read = try self .terminal .? .readInput (& input_buf , 16 );
263+ const bytes_read = try self .terminal .? .readInput (& input_buf , 0 );
267264
268265 if (bytes_read > 0 ) {
269266 const events = try keyboard .parseAll (self .context .allocator , input_buf [0.. bytes_read ]);
@@ -286,7 +283,7 @@ pub fn Program(comptime Model: type) type {
286283 // Deliver tick to user's update if Model.Msg has a tick variant
287284 if (@hasField (UserMsg , "tick" )) {
288285 const user_msg = UserMsg { .tick = .{
289- .timestamp = @intCast (frame_time ),
286+ .timestamp = @intCast (tick_start ),
290287 .delta = actual_delta ,
291288 } };
292289 const cmd = self .dispatchToModel (user_msg );
@@ -301,7 +298,7 @@ pub fn Program(comptime Model: type) type {
301298 self .last_every_tick = self .context .elapsed ;
302299 if (@hasField (UserMsg , "tick" )) {
303300 const user_msg = UserMsg { .tick = .{
304- .timestamp = @intCast (frame_time ),
301+ .timestamp = @intCast (tick_start ),
305302 .delta = actual_delta ,
306303 } };
307304 const cmd = self .dispatchToModel (user_msg );
@@ -313,6 +310,31 @@ pub fn Program(comptime Model: type) type {
313310 // Render
314311 try self .render ();
315312 try self .flushPendingImage ();
313+
314+ // Pace at end of tick; first tick skips so initial paint is immediate.
315+ const min_frame_time_ns : u64 = if (self .options .fps > 0 )
316+ @divFloor (std .time .ns_per_s , self .options .fps )
317+ else
318+ 16_666_666 ; // ~60fps default
319+ const frames_since_anchor = self .context .frame - self .pacing_frame_offset ;
320+ if (frames_since_anchor > 1 ) {
321+ const deadline_offset_ns : u64 = frames_since_anchor * min_frame_time_ns ;
322+ // If we've fallen far behind the schedule (long-overrun frame, or
323+ // boot-clock advanced past the anchor while suspended), rebase the
324+ // anchor instead of burst-rendering frames to "catch up."
325+ const elapsed_since_anchor = self .pacingElapsedNs ();
326+ if (elapsed_since_anchor > deadline_offset_ns + 4 * min_frame_time_ns ) {
327+ self .pacing_epoch = std .Io .Clock .Timestamp .now (self .io , .boot );
328+ self .pacing_frame_offset = self .context .frame ;
329+ } else {
330+ // Absolute deadline so sleep overshoot doesn't compound.
331+ const deadline : std.Io.Clock.Timestamp = self .pacing_epoch .addDuration (.{
332+ .raw = .{ .nanoseconds = @intCast (deadline_offset_ns ) },
333+ .clock = .boot ,
334+ });
335+ deadline .wait (self .io ) catch unreachable ;
336+ }
337+ }
316338 }
317339
318340 /// Dispatch a message to the model, applying the filter if set
@@ -415,8 +437,11 @@ pub fn Program(comptime Model: type) type {
415437 term .setup () catch {};
416438 }
417439
418- // Avoid a large post-resume frame delta.
440+ // Avoid a large post-resume frame delta, and rebase the pacing anchor
441+ // so we don't burst-render to "catch up" the suspended interval.
419442 self .last_frame_time = self .elapsedNs ();
443+ self .pacing_epoch = std .Io .Clock .Timestamp .now (self .io , .boot );
444+ self .pacing_frame_offset = self .context .frame ;
420445
421446 // Force re-render
422447 self .last_view_hash = 0 ;
@@ -761,6 +786,14 @@ pub fn Program(comptime Model: type) type {
761786 return @intCast (ns );
762787 }
763788
789+ /// Nanoseconds elapsed on the boot clock since `pacing_epoch`.
790+ fn pacingElapsedNs (self : * const Self ) u64 {
791+ const dur = self .pacing_epoch .untilNow (self .io );
792+ const ns = dur .raw .nanoseconds ;
793+ if (ns <= 0 ) return 0 ;
794+ return @intCast (ns );
795+ }
796+
764797 fn sleepNs (io : std.Io , nanoseconds : u64 ) void {
765798 if (nanoseconds == 0 ) return ;
766799 std .Io .sleep (io , .fromNanoseconds (nanoseconds ), .boot ) catch unreachable ;
0 commit comments