a73x

51f41728

main: wire blink state machine into the render loop

a73x   2026-04-19 07:10

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>

diff --git a/src/main.zig b/src/main.zig
index f40f522..5c1e818 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -338,13 +338,22 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
    var resize_pending = false;
    var scale_pending = false;
    var consecutive_vk_timeouts: u32 = 0;
    var blink_state: BlinkState = .{};
    var previous_has_focus: bool = keyboard.has_focus;

    while (!window.should_close and p.isChildAlive()) {
        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        // Blink deadline wired up in Task 4; null for now.
        const timeout = computePollTimeoutMs(repeat_timeout_ms, null, render_pending);
        const blink_timeout_ms = remainingRepeatTimeoutMs(blink_state.next_deadline_ns);
        const timeout = computePollTimeoutMs(repeat_timeout_ms, blink_timeout_ms, render_pending);
        try frame_loop.waitForWork(&pollfds_extra, timeout);

        // Phase-flip first so a flipped-this-tick deadline marks render_pending
        // before the `if (!render_pending) continue;` bail below.
        const tick = tickBlinkPhase(blink_state, std.time.nanoTimestamp());
        blink_state = tick.state;
        var cursor_rebuild_from_blink = tick.flipped;
        if (tick.flipped) render_pending = true;

        // PTY output
        if (pollfds_extra[0].revents & std.posix.POLL.IN != 0) {
            while (true) {
@@ -495,6 +504,33 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        try term.snapshot();
        frame_timing.snapshot_us = usFromTimer(&section_timer);

        // Recompute blink arm/disarm from current state. Edge-driven updates
        // are deliberately avoided — `want_blink` is a pure function of the
        // state we see right now, so no transition can be "missed".
        const current_cursor = term.render_state.cursor;
        const viewport_present = current_cursor.viewport != null;
        const want_blink = current_cursor.visible
            and viewport_present
            and current_cursor.blinking
            and keyboard.has_focus;

        const cursor_identity_changed =
            !std.meta.eql(current_cursor.viewport, previous_cursor.viewport)
            or current_cursor.visual_style != previous_cursor.visual_style
            or current_cursor.visible != previous_cursor.visible
            or current_cursor.blinking != previous_cursor.blinking;
        const focus_regained = keyboard.has_focus and !previous_has_focus;

        const reconf = reconfigureBlink(
            blink_state,
            want_blink,
            cursor_identity_changed,
            focus_regained,
            std.time.nanoTimestamp(),
        );
        blink_state = reconf.state;
        if (reconf.cursor_rebuild) cursor_rebuild_from_blink = true;

        section_timer = std.time.Timer.start() catch unreachable;
        const default_bg = term.backgroundColor();
        const bg_uv = atlas.cursorUV();
@@ -517,6 +553,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
                },
            },
        );
        const cursor_rebuild_effective = refresh_plan.cursor_rebuild or cursor_rebuild_from_blink;

        var rows_rebuilt: usize = 0;
        var row_idx: usize = 0;
@@ -551,11 +588,19 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        }

        var cursor_rebuilt = false;
        if (refresh_plan.cursor_rebuild) {
        if (cursor_rebuild_effective) {
            var cursor_instances_buf: [1]renderer.Instance = undefined;
            var cursor_instances: []const renderer.Instance = &.{};
            if (term.render_state.cursor.viewport) |cursor| {
                const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) {
            const draw_cursor = shouldDrawCursor(
                current_cursor.visible,
                current_cursor.viewport != null,
                keyboard.has_focus,
                current_cursor.blinking,
                blink_state.blink_on,
            );
            if (draw_cursor) {
                const cursor = current_cursor.viewport.?; // guaranteed by shouldDrawCursor
                const shape: renderer.CursorShape = switch (current_cursor.visual_style) {
                    .block, .block_hollow => .block,
                    .underline => .underline,
                    .bar => .bar,
@@ -715,6 +760,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        frame_timing.present_us = submit_timing.present_us;

        frame_ring.push(frame_timing);
        previous_has_focus = keyboard.has_focus;

        clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
        if (!bench_unthrottled) frame_loop.commitRender() catch {};