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 = .{