3394c210
Cursor blink implementation plan
a73x 2026-04-19 06:12
Four TDD tasks: 1. cursorInstance helper + DECSCUSR shape dispatch 2. computePollTimeoutMs widens to two deadlines 3. Blink state machine + draw-rule pure helpers 4. Wire everything into the main loop Each task is independently commitable (Task 2 lands a null placeholder; Task 4 Step 1b flips it to the real deadline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/docs/superpowers/plans/2026-04-19-cursor-blink-implementation.md b/docs/superpowers/plans/2026-04-19-cursor-blink-implementation.md new file mode 100644 index 0000000..03e0d34 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-cursor-blink-implementation.md @@ -0,0 +1,930 @@ # Cursor Blink Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a blinking cursor to waystty with full `DECSCUSR` shape support (block / underline / bar), `CSI ? 12 h/l` blink-mode, focused-only blink, and 500 ms phase. See spec `docs/superpowers/specs/2026-04-18-cursor-blink-design.md`. **Architecture:** Four pure helpers — `cursorInstance` in `src/renderer.zig`, and `tickBlinkPhase` / `reconfigureBlink` / `shouldDrawCursor` in `src/main.zig` — wired into the existing deadline-driven main loop alongside the key-repeat deadline. No new module, no new thread, no new protocol. Ghostty's `render_state.cursor.visual_style` (shape), `.visible`, `.blinking`, `.viewport` plus `keyboard.has_focus` drive everything. **Tech Stack:** Zig 0.15, vulkan-zig, zig-wayland, vendored `ghostty_vt` (as `ghostty`) for VT parsing. --- ## Reference facts (grep once, reuse) - Cursor instance built inline at `src/main.zig:553-577` — this is what Task 4 replaces. - Key-repeat deadline helpers at `src/main.zig:786-797` — shape to copy. - Existing `computePollTimeoutMs` test at `src/main.zig:1633-1637` — extend. - `planRowRefresh` + `CursorRefreshContext` at `src/main.zig:1527-1561` — touched only at the call site (L505-518), not modified internally. - `renderer.Instance` struct at `src/renderer.zig:241-248` — cursorInstance returns this. - `font.Atlas.cursorUV()` at `src/font.zig:229-240` returns `GlyphUV { u0, v0, u1, v1, width, height, bearing_x, bearing_y, advance_x }`. - Ghostty cursor shape enum: `visual_style: cursor.Style` where `Style = enum { bar, block, underline, block_hollow }`. Confirmed at `~/.cache/zig/p/ghostty-1.3.2-dev-*/src/terminal/cursor.zig`. - Ghostty render cursor fields confirmed: `visible: bool`, `blinking: bool`, `viewport: ?Viewport`, `visual_style: cursor.Style`. - `keyboard.has_focus: bool` at `src/wayland.zig:219`. - The test runner is `zig build test`. Unit tests live inline alongside the code they test. --- ## Task 1: Extract `cursorInstance` pure helper in `src/renderer.zig` **Files:** - Modify: `src/renderer.zig:241-248` (context) — add `cursorInstance` free function and its tests nearby - Modify: `src/main.zig:553-577` — call the new helper (minimal churn; full replace happens in Task 4) **What:** A pure function that returns a `renderer.Instance` for a given cursor shape. Block stays the current behavior; underline/bar add the two new shapes. Fallback-to-block handles `.block_hollow` or any future variant we don't explicitly support. - [ ] **Step 1: Add the `CursorShape` alias and the failing test at the bottom of `src/renderer.zig`.** ```zig // --- cursor instance (pure, unit-tested) --- pub const CursorShape = enum { block, underline, bar }; pub fn cursorInstance( shape: CursorShape, cell_w: u32, cell_h: u32, buffer_scale: u32, uv: GlyphUV, ) Instance { @compileError("cursorInstance: not yet implemented"); } test "cursorInstance: block fills the whole cell at scale 1" { const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 }; const inst = cursorInstance(.block, 10, 20, 1, uv); try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 20.0 }, &inst.glyph_size); try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing); } test "cursorInstance: underline is a 2px bar at the cell bottom (scale 1)" { const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 }; const inst = cursorInstance(.underline, 10, 20, 1, uv); try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 2.0 }, &inst.glyph_size); try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 18.0 }, &inst.glyph_bearing); } test "cursorInstance: bar is a 2px column at cell left (scale 1)" { const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 }; const inst = cursorInstance(.bar, 10, 20, 1, uv); try std.testing.expectEqualSlices(f32, &[_]f32{ 2.0, 20.0 }, &inst.glyph_size); try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing); } test "cursorInstance: underline line width scales with buffer_scale" { const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 }; const inst = cursorInstance(.underline, 20, 40, 2, uv); try std.testing.expectEqualSlices(f32, &[_]f32{ 20.0, 4.0 }, &inst.glyph_size); try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 36.0 }, &inst.glyph_bearing); } test "cursorInstance: bar line width scales with buffer_scale" { const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 }; const inst = cursorInstance(.bar, 20, 40, 2, uv); try std.testing.expectEqualSlices(f32, &[_]f32{ 4.0, 40.0 }, &inst.glyph_size); try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing); } test "cursorInstance: uv_rect comes from the provided UV" { const uv: GlyphUV = .{ .u0 = 0.1, .v0 = 0.2, .u1 = 0.3, .v1 = 0.4, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 }; const inst = cursorInstance(.block, 10, 20, 1, uv); try std.testing.expectEqualSlices(f32, &[_]f32{ 0.1, 0.2, 0.3, 0.4 }, &inst.uv_rect); } ``` At the top of `src/renderer.zig` near other imports, confirm `GlyphUV` is importable. If not, add: ```zig const GlyphUV = @import("font.zig").Atlas.GlyphUV; ``` (Check the existing import list first — `font.zig` may already be imported under a different name.) - [ ] **Step 2: Run the tests to verify they fail.** Run: `zig build test 2>&1 | grep -E 'cursorInstance|FAIL|error'` Expected: compile error on `@compileError("cursorInstance: not yet implemented")` — that's the intentional failure that proves the test file compiles against the new symbol. - [ ] **Step 3: Implement `cursorInstance`.** Replace the function body (keep the signature above; replace the `@compileError`): ```zig pub fn cursorInstance( shape: CursorShape, cell_w: u32, cell_h: u32, buffer_scale: u32, uv: GlyphUV, ) Instance { const cell_w_f: f32 = @floatFromInt(cell_w); const cell_h_f: f32 = @floatFromInt(cell_h); const line_w_f: f32 = @floatFromInt(2 * buffer_scale); const size_xy: [2]f32 = switch (shape) { .block => .{ cell_w_f, cell_h_f }, .underline => .{ cell_w_f, line_w_f }, .bar => .{ line_w_f, cell_h_f }, }; const bearing_xy: [2]f32 = switch (shape) { .block, .bar => .{ 0, 0 }, .underline => .{ 0, cell_h_f - line_w_f }, }; return .{ .cell_pos = .{ 0, 0 }, // caller overwrites with actual grid position .glyph_size = size_xy, .glyph_bearing = bearing_xy, .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 }, .fg = .{ 1.0, 1.0, 1.0, 0.5 }, .bg = .{ 0, 0, 0, 0 }, }; } ``` Note: `cell_pos` defaults to `(0, 0)`. The caller overwrites it with the current cursor's viewport position before submitting. This keeps the helper pure (no viewport dependency) and matches how the current inline build works. - [ ] **Step 4: Run tests to verify they pass.** Run: `zig build test` Expected: all tests pass, no warnings. - [ ] **Step 5: Map ghostty_vt `visual_style` to `renderer.CursorShape` at the call site.** The current cursor build block at `src/main.zig:552-577` currently hardcodes block. Update it to call the helper, but keep the shape coming from ghostty — `block_hollow` maps to `.block` (fallback, consistent with the spec). Replace: ```zig var cursor_rebuilt = false; if (refresh_plan.cursor_rebuild) { var cursor_instances_buf: [1]renderer.Instance = undefined; var cursor_instances: []const renderer.Instance = &.{}; if (term.render_state.cursor.viewport) |cursor| { const cursor_uv = atlas.cursorUV(); cursor_instances_buf[0] = .{ .cell_pos = .{ @floatFromInt(cursor.x), @floatFromInt(cursor.y), }, .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h), }, .glyph_bearing = .{ 0, 0 }, .uv_rect = .{ cursor_uv.u0, cursor_uv.v0, cursor_uv.u1, cursor_uv.v1, }, .fg = .{ 1.0, 1.0, 1.0, 0.5 }, .bg = .{ 0, 0, 0, 0 }, }; cursor_instances = cursor_instances_buf[0..1]; } ``` with: ```zig var cursor_rebuilt = false; if (refresh_plan.cursor_rebuild) { 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) { .block, .block_hollow => .block, .underline => .underline, .bar => .bar, }; var inst = renderer.cursorInstance( shape, cell_w, cell_h, @as(u32, @intCast(geom.buffer_scale)), atlas.cursorUV(), ); inst.cell_pos = .{ @floatFromInt(cursor.x), @floatFromInt(cursor.y), }; cursor_instances_buf[0] = inst; cursor_instances = cursor_instances_buf[0..1]; } ``` - [ ] **Step 6: Run full tests + manual smoke to verify no regression.** Run: `zig build test` Expected: all tests pass. Manual: `zig build run` (or whatever the repo's invocation is; `make run` per CLAUDE.md preference — check the Makefile). Type at the prompt. Cursor should still render as a block, same appearance as before. Then: - `printf '\e[4 q'` → expect underline cursor (thin bar at cell bottom). - `printf '\e[6 q'` → expect bar cursor (thin column at cell left). - `printf '\e[2 q'` → back to block. (Blink mode ignored until Task 4; shape changes should be instant since any pty byte already sets `render_pending`.) - [ ] **Step 7: Commit.** ```bash git add src/renderer.zig src/main.zig git commit -m "$(cat <<'EOF' cursor: extract cursorInstance helper with DECSCUSR shape dispatch Pure helper returning a renderer.Instance for block/underline/bar. Replaces the inline instance build in main.zig so shape geometry is unit-testable without a Vulkan device. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF )" ``` --- ## Task 2: Extend `computePollTimeoutMs` to take a second deadline **Files:** - Modify: `src/main.zig:794-797` — signature + body - Modify: `src/main.zig:343-345` — call site - Modify: `src/main.zig:1633-1637` — existing test; add new cases **What:** Grow `computePollTimeoutMs` from one nullable deadline-ms to two (repeat + blink). Minimum wins; `render_pending=true` still short-circuits to `0`. - [ ] **Step 1: Replace the existing test with an expanded set that also covers the new cases.** At `src/main.zig:1633-1637`, replace: ```zig test "event loop waits indefinitely when idle and wakes for imminent repeat" { try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false)); try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true)); try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false)); } ``` with: ```zig test "computePollTimeoutMs: idle with no deadlines returns -1" { try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, null, false)); } test "computePollTimeoutMs: render_pending forces zero regardless of deadlines" { try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, null, true)); try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, 5, true)); try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(3, 7, true)); try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, null, true)); } test "computePollTimeoutMs: single deadline passes through" { try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, null, false)); try std.testing.expectEqual(@as(i32, 500), computePollTimeoutMs(null, 500, false)); } test "computePollTimeoutMs: returns min of two deadlines" { try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, 500, false)); try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(500, 17, false)); try std.testing.expectEqual(@as(i32, 7), computePollTimeoutMs(7, 7, false)); } ``` - [ ] **Step 2: Run tests to verify they fail.** Run: `zig build test 2>&1 | grep -E 'computePollTimeoutMs|FAIL|error'` Expected: compile failure — `computePollTimeoutMs` takes 2 args, tests now pass 3. - [ ] **Step 3: Widen the signature and body at `src/main.zig:794-797`.** Replace: ```zig fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 { if (render_pending) return 0; return next_repeat_in_ms orelse -1; } ``` with: ```zig fn computePollTimeoutMs( next_repeat_in_ms: ?i32, next_blink_in_ms: ?i32, render_pending: bool, ) i32 { if (render_pending) return 0; if (next_repeat_in_ms) |r| { if (next_blink_in_ms) |b| return if (r < b) r else b; return r; } return next_blink_in_ms orelse -1; } ``` Forward-looking note (do not implement): if a third deadline source appears (suspend heartbeat, bell timeout, auto-hide mouse cursor), collapse to `computePollTimeoutMs(render_pending: bool, deadlines: []const ?i32)` and take the min. Filed in the spec's forward-looking section. - [ ] **Step 4: Update the single call site at `src/main.zig:343-345` with a placeholder `null` for the blink deadline.** Replace: ```zig const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs()); const timeout = computePollTimeoutMs(repeat_timeout_ms, render_pending); ``` with: ```zig 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); ``` This keeps Task 2 green-on-commit. Task 4 flips the `null` to `remainingRepeatTimeoutMs(blink_state.next_deadline_ns)` once `blink_state` exists. - [ ] **Step 5: Build and run tests.** Run: `zig build test` Expected: full binary builds, all tests pass (the four new `computePollTimeoutMs` cases plus everything else). - [ ] **Step 6: Commit.** ```bash git add src/main.zig git commit -m "$(cat <<'EOF' main: widen computePollTimeoutMs to accept a second deadline Prep for cursor blink deadline. Call site still passes null for the blink slot until the blink state machine lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF )" ``` --- ## Task 3: Add blink pure helpers + draw-rule helper in `src/main.zig` **Files:** - Modify: `src/main.zig` — add `BlinkState`, `blink_period_ns`, `tickBlinkPhase`, `reconfigureBlink`, `shouldDrawCursor`, and their unit tests near the other loop helpers (`remainingRepeatTimeoutMs` / `computePollTimeoutMs` at L786-797 are a natural neighborhood). - [ ] **Step 1: Add the types, constants, and helper stubs + failing tests.** Directly after the existing `computePollTimeoutMs` at `src/main.zig:797`, insert: ```zig 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 { @compileError("tickBlinkPhase: not yet implemented"); } 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 { @compileError("reconfigureBlink: not yet implemented"); } fn shouldDrawCursor( visible: bool, viewport_present: bool, has_focus: bool, blinking: bool, blink_on: bool, ) bool { @compileError("shouldDrawCursor: not yet implemented"); } ``` Then, alongside the other tests near `src/main.zig:1633`, add: ```zig // --- 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" { var state: BlinkState = .{ .blink_on = true, .next_deadline_ns = 0 + blink_period_ns }; var now: i128 = 0; // 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); } ``` - [ ] **Step 2: Run tests to verify they fail on the stubs.** Run: `zig build test 2>&1 | grep -E 'tickBlinkPhase|reconfigureBlink|shouldDrawCursor|FAIL|error' | head -30` Expected: compile errors from the three `@compileError` stubs. - [ ] **Step 3: Implement `tickBlinkPhase`.** Replace the stub body with: ```zig 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, }; } ``` - [ ] **Step 4: Implement `reconfigureBlink`.** Replace the stub body with: ```zig 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 }; } ``` - [ ] **Step 5: Implement `shouldDrawCursor`.** Replace the stub body with: ```zig 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; } ``` - [ ] **Step 6: Run all tests.** Run: `zig build test` Expected: all blink + draw-rule + integration tests pass. Existing tests unaffected. - [ ] **Step 7: Commit.** ```bash git add src/main.zig git commit -m "$(cat <<'EOF' main: add blink state machine + draw-rule pure helpers 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> EOF )" ``` --- ## Task 4: Wire blink into the main loop **Files:** - Modify: `src/main.zig` — main loop at the `runTerminal` function. Changes at: locals block (~L336-340), poll-timeout call (~L343-345), after-poll flip (~new insertion after L345), after-snapshot reconfigure + identity detection (~inserted around L492-518), cursor instance branch at L553-577 (tighten to shouldDrawCursor), end-of-iter bookkeeping (new). This is the integration task. No new pure functions — just plumbing. The helpers from Tasks 1 and 3 do the thinking. - [ ] **Step 1: Add new locals in the `runTerminal` locals block.** Just after `var consecutive_vk_timeouts: u32 = 0;` at `src/main.zig:340`, add: ```zig var blink_state: BlinkState = .{}; var previous_has_focus: bool = keyboard.has_focus; ``` - [ ] **Step 1b: Flip the Task-2 placeholder `null` to the real blink deadline.** At `src/main.zig:343-345` (edited in Task 2 Step 4), change: ```zig 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); ``` to: ```zig const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs()); const blink_timeout_ms = remainingRepeatTimeoutMs(blink_state.next_deadline_ns); const timeout = computePollTimeoutMs(repeat_timeout_ms, blink_timeout_ms, render_pending); ``` - [ ] **Step 2: Flip phase right after `waitForWork` returns.** Immediately after the `try frame_loop.waitForWork(&pollfds_extra, timeout);` call at `src/main.zig:345`, insert: ```zig // 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; ``` `cursor_rebuild_from_blink` is function-scope mutable because Step 4 below OR's more work into it after snapshot. - [ ] **Step 3: Bail-and-continue path unchanged.** The `if (!render_pending) continue;` at `src/main.zig:423` stays as-is. If blink didn't flip and nothing else set `render_pending`, we continue and the next poll will sleep until the blink deadline (thanks to Task 2 folding `blink_state.next_deadline_ns` into the timeout). - [ ] **Step 4: After `term.snapshot()`, reconfigure the timer and detect identity change.** Find the block at `src/main.zig:492-494`: ```zig const previous_cursor = term.render_state.cursor; var section_timer = std.time.Timer.start() catch unreachable; try term.snapshot(); ``` Add, directly after the `try term.snapshot();` line: ```zig // 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; ``` Note: `std.meta.eql` on `?Viewport` works because `Viewport` is a plain struct of integers/bool. - [ ] **Step 5: OR the blink rebuild into the cursor-rebuild decision at the plan call site.** After `planRowRefresh(...)` returns into `refresh_plan` (currently `src/main.zig:505-518`), add one line right after the `refresh_plan` assignment: ```zig const cursor_rebuild_effective = refresh_plan.cursor_rebuild or cursor_rebuild_from_blink; ``` Then change the cursor-rebuild branch gate at `src/main.zig:553`: ```zig if (refresh_plan.cursor_rebuild) { ``` to: ```zig if (cursor_rebuild_effective) { ``` Rationale: we don't extend `RowRefreshPlan` or `CursorRefreshContext` — the blink input is an orthogonal trigger and lives outside the planner. - [ ] **Step 6: Apply `shouldDrawCursor` inside the cursor-rebuild branch.** Inside the branch at `src/main.zig:553-577` (already minimally rewritten in Task 1 Step 5), tighten the `if (term.render_state.cursor.viewport) |cursor|` check to a `shouldDrawCursor` check so blink-off and unfocused-when-hidden all converge on "no instance." Replace the current: ```zig if (term.render_state.cursor.viewport) |cursor| { const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) { .block, .block_hollow => .block, .underline => .underline, .bar => .bar, }; var inst = renderer.cursorInstance( shape, cell_w, cell_h, @as(u32, @intCast(geom.buffer_scale)), atlas.cursorUV(), ); inst.cell_pos = .{ @floatFromInt(cursor.x), @floatFromInt(cursor.y), }; cursor_instances_buf[0] = inst; cursor_instances = cursor_instances_buf[0..1]; } ``` with: ```zig 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, }; var inst = renderer.cursorInstance( shape, cell_w, cell_h, @as(u32, @intCast(geom.buffer_scale)), atlas.cursorUV(), ); inst.cell_pos = .{ @floatFromInt(cursor.x), @floatFromInt(cursor.y), }; cursor_instances_buf[0] = inst; cursor_instances = cursor_instances_buf[0..1]; } ``` - [ ] **Step 7: Update `previous_has_focus` at end of iteration.** Find the tail of the main `while` loop body (just before the closing `}` of the `while (!window.should_close and p.isChildAlive())` block — after `frame_ring.push(frame_timing);` at `src/main.zig:719`). Add: ```zig previous_has_focus = keyboard.has_focus; ``` - [ ] **Step 8: Build and run all tests.** Run: `zig build test` Expected: everything passes. If the `cursor_identity_changed` `std.meta.eql` call complains about the `?Viewport` type not being comparable, replace with the manual form: ```zig const cursor_identity_changed = (current_cursor.viewport == null) != (previous_cursor.viewport == null) or (current_cursor.viewport != null and previous_cursor.viewport != null and (current_cursor.viewport.?.x != previous_cursor.viewport.?.x or current_cursor.viewport.?.y != previous_cursor.viewport.?.y or current_cursor.viewport.?.wide_tail != previous_cursor.viewport.?.wide_tail)) or current_cursor.visual_style != previous_cursor.visual_style or current_cursor.visible != previous_cursor.visible or current_cursor.blinking != previous_cursor.blinking; ``` - [ ] **Step 9: Manual smoke.** Build and run waystty. From inside, test each branch of the spec: 1. **Baseline block blink, focused:** open waystty. Cursor blinks at 500 ms. 2. **Blink off via mode:** `printf '\e[?12l'`. Cursor steady. 3. **Blink on via mode:** `printf '\e[?12h'`. Blink resumes. 4. **Shape via DECSCUSR:** - `printf '\e[1 q'` → blinking block. - `printf '\e[2 q'` → steady block. - `printf '\e[3 q'` → blinking underline. - `printf '\e[4 q'` → steady underline. - `printf '\e[5 q'` → blinking bar. - `printf '\e[6 q'` → steady bar. 5. **Hide / show:** `printf '\e[?25l'` → cursor vanishes. `\e[?25h` → cursor returns on-phase. 6. **Focus:** click away from waystty. Cursor steady. Click back. Cursor reappears in on-phase immediately. 7. **Cursor move resets phase:** while blinking, press `left`/`right` — observe each press leaves the cursor visible for a fresh 500 ms before the next flip. 8. **Suspend/resume (best effort — only works when blink mode is on at sleep time):** leave waystty at a shell prompt (blink enabled). Sleep the machine (e.g. `systemctl suspend`). Wake. Cursor should still be blinking; if nothing on screen, move the mouse or press a key — recovery via `OutOfDateKHR` + swapchain recreate should land within 500 ms of the first subsequent render attempt. Known caveat (in spec's non-goals): if blink was disabled at sleep time, the loop still wedges. - [ ] **Step 10: Commit.** ```bash git add src/main.zig git commit -m "$(cat <<'EOF' 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> EOF )" ``` --- ## Post-implementation: run the grep gate The existing `tests/check_unbounded_vk.sh` gate is unaffected because no Vulkan sites change. Confirm: - [ ] `bash tests/check_unbounded_vk.sh` — expected: pass. --- ## Spec coverage check (done while writing this plan) | Spec section | Task | |---|---| | Goal: blink + DECSCUSR + 500ms | Tasks 1, 3, 4 | | Non-goals acknowledged in commit message | Task 4 Step 10 | | Architecture: state machine as main-loop locals | Task 4 Step 1 | | Architecture: poll timeout extended | Task 2 | | Architecture: cursorInstance in renderer | Task 1 | | Draw rule | Task 3 (tests) + Task 4 Step 6 (applied) | | Timer state machine: arm/disarm | Task 3 (reconfigureBlink) + Task 4 Step 4 | | Phase flip | Task 3 (tickBlinkPhase) + Task 4 Step 2 | | Cursor identity / focus-regain reset | Task 4 Step 4 | | Rebuild plumbing: OR at call site, not in planRowRefresh | Task 4 Step 5 | | Shape dispatch table | Task 1 Step 3 | | Unit tests per helper | Tasks 1, 3 | | Integration-ish test: tickBlinkPhase + computePollTimeoutMs | Task 3 Step 1 (last block) | | Manual smoke list | Task 4 Step 9 | | Forward note on collapsing to varargs | Task 2 Step 3 comment |