a73x

2c4e9a52

main: add blink state machine + draw-rule pure helpers

a73x   2026-04-19 07:04

tickBlinkPhase, reconfigureBlink, shouldDrawCursor as pure
functions with unit tests. Next commit wires them into the
main loop.

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

diff --git a/src/main.zig b/src/main.zig
index 384cee0..0c89e84 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -802,6 +802,81 @@ fn computePollTimeoutMs(
    return next_blink_in_ms orelse -1;
}

const blink_period_ns: i128 = 500 * std.time.ns_per_ms;

const BlinkState = struct {
    blink_on: bool = true,
    next_deadline_ns: ?i128 = null,
};

const BlinkPhaseTick = struct {
    state: BlinkState,
    flipped: bool,
};

fn tickBlinkPhase(state: BlinkState, now_ns: i128) BlinkPhaseTick {
    const deadline = state.next_deadline_ns orelse return .{ .state = state, .flipped = false };
    if (now_ns < deadline) return .{ .state = state, .flipped = false };
    return .{
        .state = .{
            .blink_on = !state.blink_on,
            .next_deadline_ns = now_ns + blink_period_ns,
        },
        .flipped = true,
    };
}

const BlinkReconfigure = struct {
    state: BlinkState,
    cursor_rebuild: bool,
};

fn reconfigureBlink(
    state: BlinkState,
    want_blink: bool,
    cursor_identity_changed: bool,
    focus_regained: bool,
    now_ns: i128,
) BlinkReconfigure {
    if (want_blink) {
        if (state.next_deadline_ns == null) {
            return .{
                .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
                .cursor_rebuild = true,
            };
        }
        if (cursor_identity_changed or focus_regained) {
            return .{
                .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
                .cursor_rebuild = true,
            };
        }
        return .{ .state = state, .cursor_rebuild = false };
    }
    // want_blink == false
    if (state.next_deadline_ns != null or !state.blink_on) {
        return .{
            .state = .{ .blink_on = true, .next_deadline_ns = null },
            .cursor_rebuild = true,
        };
    }
    return .{ .state = state, .cursor_rebuild = false };
}

fn shouldDrawCursor(
    visible: bool,
    viewport_present: bool,
    has_focus: bool,
    blinking: bool,
    blink_on: bool,
) bool {
    if (!visible) return false;
    if (!viewport_present) return false;
    if (!has_focus) return true;
    if (!blinking) return true;
    return blink_on;
}

fn extractSelectedText(
    alloc: std.mem.Allocator,
    row_data: anytype,
@@ -1658,6 +1733,148 @@ test "computePollTimeoutMs: returns min of two deadlines" {
    try std.testing.expectEqual(@as(i32, 7), computePollTimeoutMs(7, 7, false));
}

// --- blink state machine ---

test "tickBlinkPhase: no deadline → no flip" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
    const r = tickBlinkPhase(s, 1_000_000_000);
    try std.testing.expect(!r.flipped);
    try std.testing.expectEqual(s.blink_on, r.state.blink_on);
    try std.testing.expect(r.state.next_deadline_ns == null);
}

test "tickBlinkPhase: deadline in future → no flip" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
    const r = tickBlinkPhase(s, 1_500_000_000);
    try std.testing.expect(!r.flipped);
    try std.testing.expectEqual(s, r.state);
}

test "tickBlinkPhase: deadline reached → flip, new deadline set at now + 500ms" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
    const now: i128 = 2_000_000_000;
    const r = tickBlinkPhase(s, now);
    try std.testing.expect(r.flipped);
    try std.testing.expect(!r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

test "tickBlinkPhase: deadline overshot → flip (reschedules from now, not deadline)" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 1_000_000_000 };
    const now: i128 = 5_000_000_000;
    const r = tickBlinkPhase(s, now);
    try std.testing.expect(r.flipped);
    try std.testing.expect(r.state.blink_on); // flipped from off → on
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

test "reconfigureBlink: inactive → active arms timer and forces on-phase" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
    const r = reconfigureBlink(s, true, false, false, 10_000_000_000);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, 10_000_000_000 + blink_period_ns), r.state.next_deadline_ns);
}

test "reconfigureBlink: active, no transition → no-op" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
    const r = reconfigureBlink(s, true, false, false, 10_100_000_000);
    try std.testing.expect(!r.cursor_rebuild);
    try std.testing.expectEqual(s, r.state);
}

test "reconfigureBlink: active → inactive mid off-phase resets to solid" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
    const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expect(r.state.next_deadline_ns == null);
}

test "reconfigureBlink: active → inactive on-phase still disarms (for future eligibility)" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
    const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expect(r.state.next_deadline_ns == null);
}

test "reconfigureBlink: inactive → inactive is a no-op" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
    const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
    try std.testing.expect(!r.cursor_rebuild);
    try std.testing.expectEqual(s, r.state);
}

test "reconfigureBlink: cursor identity change while active resets phase + extends deadline" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
    const now: i128 = 10_200_000_000;
    const r = reconfigureBlink(s, true, true, false, now);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

test "reconfigureBlink: focus regained while active resets phase" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
    const now: i128 = 10_200_000_000;
    const r = reconfigureBlink(s, true, false, true, now);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

// --- draw rule ---

test "shouldDrawCursor: invisible never drawn" {
    try std.testing.expect(!shouldDrawCursor(false, true, true, true, true));
    try std.testing.expect(!shouldDrawCursor(false, false, false, false, false));
}

test "shouldDrawCursor: no viewport never drawn" {
    try std.testing.expect(!shouldDrawCursor(true, false, true, true, true));
}

test "shouldDrawCursor: unfocused → always drawn (solid)" {
    try std.testing.expect(shouldDrawCursor(true, true, false, true, false));
    try std.testing.expect(shouldDrawCursor(true, true, false, true, true));
    try std.testing.expect(shouldDrawCursor(true, true, false, false, false));
}

test "shouldDrawCursor: blink mode off → always drawn (solid)" {
    try std.testing.expect(shouldDrawCursor(true, true, true, false, false));
    try std.testing.expect(shouldDrawCursor(true, true, true, false, true));
}

test "shouldDrawCursor: focused + blinking → follows phase" {
    try std.testing.expect(shouldDrawCursor(true, true, true, true, true));
    try std.testing.expect(!shouldDrawCursor(true, true, true, true, false));
}

// --- integration: poll-timeout + tickBlinkPhase walk ---

test "blink: poll timeout + tickBlinkPhase walk through two full phases" {
    const real_now = std.time.nanoTimestamp();
    var state: BlinkState = .{ .blink_on = true, .next_deadline_ns = real_now + blink_period_ns };
    var now: i128 = real_now;

    // First wake: poll reports "500ms" remaining. Advance clock to deadline.
    const timeout_0 = remainingRepeatTimeoutMs(state.next_deadline_ns);
    try std.testing.expectEqual(@as(?i32, 500), timeout_0);
    now = state.next_deadline_ns.?;
    const tick_0 = tickBlinkPhase(state, now);
    try std.testing.expect(tick_0.flipped);
    try std.testing.expect(!tick_0.state.blink_on);
    state = tick_0.state;

    // Second wake: deadline bumped by 500ms from now.
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), state.next_deadline_ns);
    now = state.next_deadline_ns.?;
    const tick_1 = tickBlinkPhase(state, now);
    try std.testing.expect(tick_1.flipped);
    try std.testing.expect(tick_1.state.blink_on);
}

test "planRowRefresh requests full rebuild for full dirty state" {
    const plan = planRowRefresh(.full, &.{ false, true, false }, .{
        .cursor = .{