a73x

51f41728

main: wire blink state machine into the render loop

a73x   2026-04-19 07:10

Commit message
main: wire blink state machine into the render loop

Deadline folded into computePollTimeoutMs alongside key-repeat.
tickBlinkPhase runs right after poll() so a just-flipped tick
sets render_pending before the idle-bail. reconfigureBlink runs
post-snapshot and detects cursor/focus identity changes against
previous_cursor + previous_has_focus.

Draw rule now gated on shouldDrawCursor; DECSCUSR shapes dispatch
into renderer.cursorInstance.

As a side effect the 500ms tick gives the main loop a heartbeat
whenever blink is enabled, which is enough to recover from a
suspend wedge in the common interactive case (bare shell). See
docs/superpowers/specs/2026-04-18-cursor-blink-design.md for the
limit on that benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

src/main.zig
Old New
@@ -338,13 +338,22 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
338 var resize_pending = false; 338 var resize_pending = false;
339 var scale_pending = false; 339 var scale_pending = false;
340 var consecutive_vk_timeouts: u32 = 0; 340 var consecutive_vk_timeouts: u32 = 0;
341 var blink_state: BlinkState = .{};
342 var previous_has_focus: bool = keyboard.has_focus;
341 343
342 while (!window.should_close and p.isChildAlive()) { 344 while (!window.should_close and p.isChildAlive()) {
343 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs()); 345 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
344 // Blink deadline wired up in Task 4; null for now. 346 const blink_timeout_ms = remainingRepeatTimeoutMs(blink_state.next_deadline_ns);
345 const timeout = computePollTimeoutMs(repeat_timeout_ms, null, render_pending); 347 const timeout = computePollTimeoutMs(repeat_timeout_ms, blink_timeout_ms, render_pending);
346 try frame_loop.waitForWork(&pollfds_extra, timeout); 348 try frame_loop.waitForWork(&pollfds_extra, timeout);
347 349
350 // Phase-flip first so a flipped-this-tick deadline marks render_pending
351 // before the `if (!render_pending) continue;` bail below.
352 const tick = tickBlinkPhase(blink_state, std.time.nanoTimestamp());
353 blink_state = tick.state;
354 var cursor_rebuild_from_blink = tick.flipped;
355 if (tick.flipped) render_pending = true;
356
348 // PTY output 357 // PTY output
349 if (pollfds_extra[0].revents & std.posix.POLL.IN != 0) { 358 if (pollfds_extra[0].revents & std.posix.POLL.IN != 0) {
350 while (true) { 359 while (true) {
@@ -495,6 +504,33 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
495 try term.snapshot(); 504 try term.snapshot();
496 frame_timing.snapshot_us = usFromTimer(&section_timer); 505 frame_timing.snapshot_us = usFromTimer(&section_timer);
497 506
507 // Recompute blink arm/disarm from current state. Edge-driven updates
508 // are deliberately avoided — `want_blink` is a pure function of the
509 // state we see right now, so no transition can be "missed".
510 const current_cursor = term.render_state.cursor;
511 const viewport_present = current_cursor.viewport != null;
512 const want_blink = current_cursor.visible
513 and viewport_present
514 and current_cursor.blinking
515 and keyboard.has_focus;
516
517 const cursor_identity_changed =
518 !std.meta.eql(current_cursor.viewport, previous_cursor.viewport)
519 or current_cursor.visual_style != previous_cursor.visual_style
520 or current_cursor.visible != previous_cursor.visible
521 or current_cursor.blinking != previous_cursor.blinking;
522 const focus_regained = keyboard.has_focus and !previous_has_focus;
523
524 const reconf = reconfigureBlink(
525 blink_state,
526 want_blink,
527 cursor_identity_changed,
528 focus_regained,
529 std.time.nanoTimestamp(),
530 );
531 blink_state = reconf.state;
532 if (reconf.cursor_rebuild) cursor_rebuild_from_blink = true;
533
498 section_timer = std.time.Timer.start() catch unreachable; 534 section_timer = std.time.Timer.start() catch unreachable;
499 const default_bg = term.backgroundColor(); 535 const default_bg = term.backgroundColor();
500 const bg_uv = atlas.cursorUV(); 536 const bg_uv = atlas.cursorUV();
@@ -517,6 +553,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
517 }, 553 },
518 }, 554 },
519 ); 555 );
556 const cursor_rebuild_effective = refresh_plan.cursor_rebuild or cursor_rebuild_from_blink;
520 557
521 var rows_rebuilt: usize = 0; 558 var rows_rebuilt: usize = 0;
522 var row_idx: usize = 0; 559 var row_idx: usize = 0;
@@ -551,11 +588,19 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
551 } 588 }
552 589
553 var cursor_rebuilt = false; 590 var cursor_rebuilt = false;
554 if (refresh_plan.cursor_rebuild) { 591 if (cursor_rebuild_effective) {
555 var cursor_instances_buf: [1]renderer.Instance = undefined; 592 var cursor_instances_buf: [1]renderer.Instance = undefined;
556 var cursor_instances: []const renderer.Instance = &.{}; 593 var cursor_instances: []const renderer.Instance = &.{};
557 if (term.render_state.cursor.viewport) |cursor| { 594 const draw_cursor = shouldDrawCursor(
558 const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) { 595 current_cursor.visible,
596 current_cursor.viewport != null,
597 keyboard.has_focus,
598 current_cursor.blinking,
599 blink_state.blink_on,
600 );
601 if (draw_cursor) {
602 const cursor = current_cursor.viewport.?; // guaranteed by shouldDrawCursor
603 const shape: renderer.CursorShape = switch (current_cursor.visual_style) {
559 .block, .block_hollow => .block, 604 .block, .block_hollow => .block,
560 .underline => .underline, 605 .underline => .underline,
561 .bar => .bar, 606 .bar => .bar,
@@ -715,6 +760,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
715 frame_timing.present_us = submit_timing.present_us; 760 frame_timing.present_us = submit_timing.present_us;
716 761
717 frame_ring.push(frame_timing); 762 frame_ring.push(frame_timing);
763 previous_has_focus = keyboard.has_focus;
718 764
719 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); 765 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
720 if (!bench_unthrottled) frame_loop.commitRender() catch {}; 766 if (!bench_unthrottled) frame_loop.commitRender() catch {};