a73x

3394c210

Cursor blink implementation plan

a73x   2026-04-19 06:12

Commit message
Cursor blink implementation plan

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>

docs/superpowers/plans/2026-04-19-cursor-blink-implementation.md
Old New
@@ -0,0 +1,930 @@
1 # Cursor Blink Implementation Plan
2
3 > **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.
4
5 **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`.
6
7 **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.
8
9 **Tech Stack:** Zig 0.15, vulkan-zig, zig-wayland, vendored `ghostty_vt` (as `ghostty`) for VT parsing.
10
11 ---
12
13 ## Reference facts (grep once, reuse)
14
15 - Cursor instance built inline at `src/main.zig:553-577` — this is what Task 4 replaces.
16 - Key-repeat deadline helpers at `src/main.zig:786-797` — shape to copy.
17 - Existing `computePollTimeoutMs` test at `src/main.zig:1633-1637` — extend.
18 - `planRowRefresh` + `CursorRefreshContext` at `src/main.zig:1527-1561` — touched only at the call site (L505-518), not modified internally.
19 - `renderer.Instance` struct at `src/renderer.zig:241-248` — cursorInstance returns this.
20 - `font.Atlas.cursorUV()` at `src/font.zig:229-240` returns `GlyphUV { u0, v0, u1, v1, width, height, bearing_x, bearing_y, advance_x }`.
21 - 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`.
22 - Ghostty render cursor fields confirmed: `visible: bool`, `blinking: bool`, `viewport: ?Viewport`, `visual_style: cursor.Style`.
23 - `keyboard.has_focus: bool` at `src/wayland.zig:219`.
24 - The test runner is `zig build test`. Unit tests live inline alongside the code they test.
25
26 ---
27
28 ## Task 1: Extract `cursorInstance` pure helper in `src/renderer.zig`
29
30 **Files:**
31 - Modify: `src/renderer.zig:241-248` (context) — add `cursorInstance` free function and its tests nearby
32 - Modify: `src/main.zig:553-577` — call the new helper (minimal churn; full replace happens in Task 4)
33
34 **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.
35
36 - [ ] **Step 1: Add the `CursorShape` alias and the failing test at the bottom of `src/renderer.zig`.**
37
38 ```zig
39 // --- cursor instance (pure, unit-tested) ---
40
41 pub const CursorShape = enum { block, underline, bar };
42
43 pub fn cursorInstance(
44 shape: CursorShape,
45 cell_w: u32,
46 cell_h: u32,
47 buffer_scale: u32,
48 uv: GlyphUV,
49 ) Instance {
50 @compileError("cursorInstance: not yet implemented");
51 }
52
53 test "cursorInstance: block fills the whole cell at scale 1" {
54 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 };
55 const inst = cursorInstance(.block, 10, 20, 1, uv);
56 try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 20.0 }, &inst.glyph_size);
57 try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
58 }
59
60 test "cursorInstance: underline is a 2px bar at the cell bottom (scale 1)" {
61 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 };
62 const inst = cursorInstance(.underline, 10, 20, 1, uv);
63 try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 2.0 }, &inst.glyph_size);
64 try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 18.0 }, &inst.glyph_bearing);
65 }
66
67 test "cursorInstance: bar is a 2px column at cell left (scale 1)" {
68 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 };
69 const inst = cursorInstance(.bar, 10, 20, 1, uv);
70 try std.testing.expectEqualSlices(f32, &[_]f32{ 2.0, 20.0 }, &inst.glyph_size);
71 try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
72 }
73
74 test "cursorInstance: underline line width scales with buffer_scale" {
75 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 };
76 const inst = cursorInstance(.underline, 20, 40, 2, uv);
77 try std.testing.expectEqualSlices(f32, &[_]f32{ 20.0, 4.0 }, &inst.glyph_size);
78 try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 36.0 }, &inst.glyph_bearing);
79 }
80
81 test "cursorInstance: bar line width scales with buffer_scale" {
82 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 };
83 const inst = cursorInstance(.bar, 20, 40, 2, uv);
84 try std.testing.expectEqualSlices(f32, &[_]f32{ 4.0, 40.0 }, &inst.glyph_size);
85 try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
86 }
87
88 test "cursorInstance: uv_rect comes from the provided UV" {
89 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 };
90 const inst = cursorInstance(.block, 10, 20, 1, uv);
91 try std.testing.expectEqualSlices(f32, &[_]f32{ 0.1, 0.2, 0.3, 0.4 }, &inst.uv_rect);
92 }
93 ```
94
95 At the top of `src/renderer.zig` near other imports, confirm `GlyphUV` is importable. If not, add:
96 ```zig
97 const GlyphUV = @import("font.zig").Atlas.GlyphUV;
98 ```
99 (Check the existing import list first — `font.zig` may already be imported under a different name.)
100
101 - [ ] **Step 2: Run the tests to verify they fail.**
102
103 Run: `zig build test 2>&1 | grep -E 'cursorInstance|FAIL|error'`
104 Expected: compile error on `@compileError("cursorInstance: not yet implemented")` — that's the intentional failure that proves the test file compiles against the new symbol.
105
106 - [ ] **Step 3: Implement `cursorInstance`.**
107
108 Replace the function body (keep the signature above; replace the `@compileError`):
109
110 ```zig
111 pub fn cursorInstance(
112 shape: CursorShape,
113 cell_w: u32,
114 cell_h: u32,
115 buffer_scale: u32,
116 uv: GlyphUV,
117 ) Instance {
118 const cell_w_f: f32 = @floatFromInt(cell_w);
119 const cell_h_f: f32 = @floatFromInt(cell_h);
120 const line_w_f: f32 = @floatFromInt(2 * buffer_scale);
121
122 const size_xy: [2]f32 = switch (shape) {
123 .block => .{ cell_w_f, cell_h_f },
124 .underline => .{ cell_w_f, line_w_f },
125 .bar => .{ line_w_f, cell_h_f },
126 };
127 const bearing_xy: [2]f32 = switch (shape) {
128 .block, .bar => .{ 0, 0 },
129 .underline => .{ 0, cell_h_f - line_w_f },
130 };
131
132 return .{
133 .cell_pos = .{ 0, 0 }, // caller overwrites with actual grid position
134 .glyph_size = size_xy,
135 .glyph_bearing = bearing_xy,
136 .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
137 .fg = .{ 1.0, 1.0, 1.0, 0.5 },
138 .bg = .{ 0, 0, 0, 0 },
139 };
140 }
141 ```
142
143 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.
144
145 - [ ] **Step 4: Run tests to verify they pass.**
146
147 Run: `zig build test`
148 Expected: all tests pass, no warnings.
149
150 - [ ] **Step 5: Map ghostty_vt `visual_style` to `renderer.CursorShape` at the call site.**
151
152 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).
153
154 Replace:
155 ```zig
156 var cursor_rebuilt = false;
157 if (refresh_plan.cursor_rebuild) {
158 var cursor_instances_buf: [1]renderer.Instance = undefined;
159 var cursor_instances: []const renderer.Instance = &.{};
160 if (term.render_state.cursor.viewport) |cursor| {
161 const cursor_uv = atlas.cursorUV();
162 cursor_instances_buf[0] = .{
163 .cell_pos = .{
164 @floatFromInt(cursor.x),
165 @floatFromInt(cursor.y),
166 },
167 .glyph_size = .{
168 @floatFromInt(cell_w),
169 @floatFromInt(cell_h),
170 },
171 .glyph_bearing = .{ 0, 0 },
172 .uv_rect = .{
173 cursor_uv.u0,
174 cursor_uv.v0,
175 cursor_uv.u1,
176 cursor_uv.v1,
177 },
178 .fg = .{ 1.0, 1.0, 1.0, 0.5 },
179 .bg = .{ 0, 0, 0, 0 },
180 };
181 cursor_instances = cursor_instances_buf[0..1];
182 }
183 ```
184
185 with:
186 ```zig
187 var cursor_rebuilt = false;
188 if (refresh_plan.cursor_rebuild) {
189 var cursor_instances_buf: [1]renderer.Instance = undefined;
190 var cursor_instances: []const renderer.Instance = &.{};
191 if (term.render_state.cursor.viewport) |cursor| {
192 const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) {
193 .block, .block_hollow => .block,
194 .underline => .underline,
195 .bar => .bar,
196 };
197 var inst = renderer.cursorInstance(
198 shape,
199 cell_w,
200 cell_h,
201 @as(u32, @intCast(geom.buffer_scale)),
202 atlas.cursorUV(),
203 );
204 inst.cell_pos = .{
205 @floatFromInt(cursor.x),
206 @floatFromInt(cursor.y),
207 };
208 cursor_instances_buf[0] = inst;
209 cursor_instances = cursor_instances_buf[0..1];
210 }
211 ```
212
213 - [ ] **Step 6: Run full tests + manual smoke to verify no regression.**
214
215 Run: `zig build test`
216 Expected: all tests pass.
217
218 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:
219 - `printf '\e[4 q'` → expect underline cursor (thin bar at cell bottom).
220 - `printf '\e[6 q'` → expect bar cursor (thin column at cell left).
221 - `printf '\e[2 q'` → back to block.
222
223 (Blink mode ignored until Task 4; shape changes should be instant since any pty byte already sets `render_pending`.)
224
225 - [ ] **Step 7: Commit.**
226
227 ```bash
228 git add src/renderer.zig src/main.zig
229 git commit -m "$(cat <<'EOF'
230 cursor: extract cursorInstance helper with DECSCUSR shape dispatch
231
232 Pure helper returning a renderer.Instance for block/underline/bar.
233 Replaces the inline instance build in main.zig so shape geometry
234 is unit-testable without a Vulkan device.
235
236 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
237 EOF
238 )"
239 ```
240
241 ---
242
243 ## Task 2: Extend `computePollTimeoutMs` to take a second deadline
244
245 **Files:**
246 - Modify: `src/main.zig:794-797` — signature + body
247 - Modify: `src/main.zig:343-345` — call site
248 - Modify: `src/main.zig:1633-1637` — existing test; add new cases
249
250 **What:** Grow `computePollTimeoutMs` from one nullable deadline-ms to two (repeat + blink). Minimum wins; `render_pending=true` still short-circuits to `0`.
251
252 - [ ] **Step 1: Replace the existing test with an expanded set that also covers the new cases.**
253
254 At `src/main.zig:1633-1637`, replace:
255 ```zig
256 test "event loop waits indefinitely when idle and wakes for imminent repeat" {
257 try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false));
258 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true));
259 try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false));
260 }
261 ```
262
263 with:
264 ```zig
265 test "computePollTimeoutMs: idle with no deadlines returns -1" {
266 try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, null, false));
267 }
268
269 test "computePollTimeoutMs: render_pending forces zero regardless of deadlines" {
270 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, null, true));
271 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, 5, true));
272 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(3, 7, true));
273 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, null, true));
274 }
275
276 test "computePollTimeoutMs: single deadline passes through" {
277 try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, null, false));
278 try std.testing.expectEqual(@as(i32, 500), computePollTimeoutMs(null, 500, false));
279 }
280
281 test "computePollTimeoutMs: returns min of two deadlines" {
282 try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, 500, false));
283 try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(500, 17, false));
284 try std.testing.expectEqual(@as(i32, 7), computePollTimeoutMs(7, 7, false));
285 }
286 ```
287
288 - [ ] **Step 2: Run tests to verify they fail.**
289
290 Run: `zig build test 2>&1 | grep -E 'computePollTimeoutMs|FAIL|error'`
291 Expected: compile failure — `computePollTimeoutMs` takes 2 args, tests now pass 3.
292
293 - [ ] **Step 3: Widen the signature and body at `src/main.zig:794-797`.**
294
295 Replace:
296 ```zig
297 fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 {
298 if (render_pending) return 0;
299 return next_repeat_in_ms orelse -1;
300 }
301 ```
302
303 with:
304 ```zig
305 fn computePollTimeoutMs(
306 next_repeat_in_ms: ?i32,
307 next_blink_in_ms: ?i32,
308 render_pending: bool,
309 ) i32 {
310 if (render_pending) return 0;
311 if (next_repeat_in_ms) |r| {
312 if (next_blink_in_ms) |b| return if (r < b) r else b;
313 return r;
314 }
315 return next_blink_in_ms orelse -1;
316 }
317 ```
318
319 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.
320
321 - [ ] **Step 4: Update the single call site at `src/main.zig:343-345` with a placeholder `null` for the blink deadline.**
322
323 Replace:
324 ```zig
325 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
326 const timeout = computePollTimeoutMs(repeat_timeout_ms, render_pending);
327 ```
328
329 with:
330 ```zig
331 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
332 // Blink deadline wired up in Task 4; null for now.
333 const timeout = computePollTimeoutMs(repeat_timeout_ms, null, render_pending);
334 ```
335
336 This keeps Task 2 green-on-commit. Task 4 flips the `null` to `remainingRepeatTimeoutMs(blink_state.next_deadline_ns)` once `blink_state` exists.
337
338 - [ ] **Step 5: Build and run tests.**
339
340 Run: `zig build test`
341 Expected: full binary builds, all tests pass (the four new `computePollTimeoutMs` cases plus everything else).
342
343 - [ ] **Step 6: Commit.**
344
345 ```bash
346 git add src/main.zig
347 git commit -m "$(cat <<'EOF'
348 main: widen computePollTimeoutMs to accept a second deadline
349
350 Prep for cursor blink deadline. Call site still passes null for
351 the blink slot until the blink state machine lands.
352
353 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
354 EOF
355 )"
356 ```
357
358 ---
359
360 ## Task 3: Add blink pure helpers + draw-rule helper in `src/main.zig`
361
362 **Files:**
363 - 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).
364
365 - [ ] **Step 1: Add the types, constants, and helper stubs + failing tests.**
366
367 Directly after the existing `computePollTimeoutMs` at `src/main.zig:797`, insert:
368
369 ```zig
370 const blink_period_ns: i128 = 500 * std.time.ns_per_ms;
371
372 const BlinkState = struct {
373 blink_on: bool = true,
374 next_deadline_ns: ?i128 = null,
375 };
376
377 const BlinkPhaseTick = struct {
378 state: BlinkState,
379 flipped: bool,
380 };
381
382 fn tickBlinkPhase(state: BlinkState, now_ns: i128) BlinkPhaseTick {
383 @compileError("tickBlinkPhase: not yet implemented");
384 }
385
386 const BlinkReconfigure = struct {
387 state: BlinkState,
388 cursor_rebuild: bool,
389 };
390
391 fn reconfigureBlink(
392 state: BlinkState,
393 want_blink: bool,
394 cursor_identity_changed: bool,
395 focus_regained: bool,
396 now_ns: i128,
397 ) BlinkReconfigure {
398 @compileError("reconfigureBlink: not yet implemented");
399 }
400
401 fn shouldDrawCursor(
402 visible: bool,
403 viewport_present: bool,
404 has_focus: bool,
405 blinking: bool,
406 blink_on: bool,
407 ) bool {
408 @compileError("shouldDrawCursor: not yet implemented");
409 }
410 ```
411
412 Then, alongside the other tests near `src/main.zig:1633`, add:
413
414 ```zig
415 // --- blink state machine ---
416
417 test "tickBlinkPhase: no deadline → no flip" {
418 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
419 const r = tickBlinkPhase(s, 1_000_000_000);
420 try std.testing.expect(!r.flipped);
421 try std.testing.expectEqual(s.blink_on, r.state.blink_on);
422 try std.testing.expect(r.state.next_deadline_ns == null);
423 }
424
425 test "tickBlinkPhase: deadline in future → no flip" {
426 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
427 const r = tickBlinkPhase(s, 1_500_000_000);
428 try std.testing.expect(!r.flipped);
429 try std.testing.expectEqual(s, r.state);
430 }
431
432 test "tickBlinkPhase: deadline reached → flip, new deadline set at now + 500ms" {
433 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
434 const now: i128 = 2_000_000_000;
435 const r = tickBlinkPhase(s, now);
436 try std.testing.expect(r.flipped);
437 try std.testing.expect(!r.state.blink_on);
438 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
439 }
440
441 test "tickBlinkPhase: deadline overshot → flip (reschedules from now, not deadline)" {
442 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 1_000_000_000 };
443 const now: i128 = 5_000_000_000;
444 const r = tickBlinkPhase(s, now);
445 try std.testing.expect(r.flipped);
446 try std.testing.expect(r.state.blink_on); // flipped from off → on
447 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
448 }
449
450 test "reconfigureBlink: inactive → active arms timer and forces on-phase" {
451 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
452 const r = reconfigureBlink(s, true, false, false, 10_000_000_000);
453 try std.testing.expect(r.cursor_rebuild);
454 try std.testing.expect(r.state.blink_on);
455 try std.testing.expectEqual(@as(?i128, 10_000_000_000 + blink_period_ns), r.state.next_deadline_ns);
456 }
457
458 test "reconfigureBlink: active, no transition → no-op" {
459 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
460 const r = reconfigureBlink(s, true, false, false, 10_100_000_000);
461 try std.testing.expect(!r.cursor_rebuild);
462 try std.testing.expectEqual(s, r.state);
463 }
464
465 test "reconfigureBlink: active → inactive mid off-phase resets to solid" {
466 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
467 const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
468 try std.testing.expect(r.cursor_rebuild);
469 try std.testing.expect(r.state.blink_on);
470 try std.testing.expect(r.state.next_deadline_ns == null);
471 }
472
473 test "reconfigureBlink: active → inactive on-phase still disarms (for future eligibility)" {
474 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
475 const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
476 try std.testing.expect(r.cursor_rebuild);
477 try std.testing.expect(r.state.blink_on);
478 try std.testing.expect(r.state.next_deadline_ns == null);
479 }
480
481 test "reconfigureBlink: inactive → inactive is a no-op" {
482 const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
483 const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
484 try std.testing.expect(!r.cursor_rebuild);
485 try std.testing.expectEqual(s, r.state);
486 }
487
488 test "reconfigureBlink: cursor identity change while active resets phase + extends deadline" {
489 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
490 const now: i128 = 10_200_000_000;
491 const r = reconfigureBlink(s, true, true, false, now);
492 try std.testing.expect(r.cursor_rebuild);
493 try std.testing.expect(r.state.blink_on);
494 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
495 }
496
497 test "reconfigureBlink: focus regained while active resets phase" {
498 const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
499 const now: i128 = 10_200_000_000;
500 const r = reconfigureBlink(s, true, false, true, now);
501 try std.testing.expect(r.cursor_rebuild);
502 try std.testing.expect(r.state.blink_on);
503 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
504 }
505
506 // --- draw rule ---
507
508 test "shouldDrawCursor: invisible never drawn" {
509 try std.testing.expect(!shouldDrawCursor(false, true, true, true, true));
510 try std.testing.expect(!shouldDrawCursor(false, false, false, false, false));
511 }
512
513 test "shouldDrawCursor: no viewport never drawn" {
514 try std.testing.expect(!shouldDrawCursor(true, false, true, true, true));
515 }
516
517 test "shouldDrawCursor: unfocused → always drawn (solid)" {
518 try std.testing.expect(shouldDrawCursor(true, true, false, true, false));
519 try std.testing.expect(shouldDrawCursor(true, true, false, true, true));
520 try std.testing.expect(shouldDrawCursor(true, true, false, false, false));
521 }
522
523 test "shouldDrawCursor: blink mode off → always drawn (solid)" {
524 try std.testing.expect(shouldDrawCursor(true, true, true, false, false));
525 try std.testing.expect(shouldDrawCursor(true, true, true, false, true));
526 }
527
528 test "shouldDrawCursor: focused + blinking → follows phase" {
529 try std.testing.expect(shouldDrawCursor(true, true, true, true, true));
530 try std.testing.expect(!shouldDrawCursor(true, true, true, true, false));
531 }
532
533 // --- integration: poll-timeout + tickBlinkPhase walk ---
534
535 test "blink: poll timeout + tickBlinkPhase walk through two full phases" {
536 var state: BlinkState = .{ .blink_on = true, .next_deadline_ns = 0 + blink_period_ns };
537 var now: i128 = 0;
538
539 // First wake: poll reports "500ms" remaining. Advance clock to deadline.
540 const timeout_0 = remainingRepeatTimeoutMs(state.next_deadline_ns);
541 try std.testing.expectEqual(@as(?i32, 500), timeout_0);
542 now = state.next_deadline_ns.?;
543 const tick_0 = tickBlinkPhase(state, now);
544 try std.testing.expect(tick_0.flipped);
545 try std.testing.expect(!tick_0.state.blink_on);
546 state = tick_0.state;
547
548 // Second wake: deadline bumped by 500ms from now.
549 try std.testing.expectEqual(@as(?i128, now + blink_period_ns), state.next_deadline_ns);
550 now = state.next_deadline_ns.?;
551 const tick_1 = tickBlinkPhase(state, now);
552 try std.testing.expect(tick_1.flipped);
553 try std.testing.expect(tick_1.state.blink_on);
554 }
555 ```
556
557 - [ ] **Step 2: Run tests to verify they fail on the stubs.**
558
559 Run: `zig build test 2>&1 | grep -E 'tickBlinkPhase|reconfigureBlink|shouldDrawCursor|FAIL|error' | head -30`
560 Expected: compile errors from the three `@compileError` stubs.
561
562 - [ ] **Step 3: Implement `tickBlinkPhase`.**
563
564 Replace the stub body with:
565
566 ```zig
567 fn tickBlinkPhase(state: BlinkState, now_ns: i128) BlinkPhaseTick {
568 const deadline = state.next_deadline_ns orelse return .{ .state = state, .flipped = false };
569 if (now_ns < deadline) return .{ .state = state, .flipped = false };
570 return .{
571 .state = .{
572 .blink_on = !state.blink_on,
573 .next_deadline_ns = now_ns + blink_period_ns,
574 },
575 .flipped = true,
576 };
577 }
578 ```
579
580 - [ ] **Step 4: Implement `reconfigureBlink`.**
581
582 Replace the stub body with:
583
584 ```zig
585 fn reconfigureBlink(
586 state: BlinkState,
587 want_blink: bool,
588 cursor_identity_changed: bool,
589 focus_regained: bool,
590 now_ns: i128,
591 ) BlinkReconfigure {
592 if (want_blink) {
593 if (state.next_deadline_ns == null) {
594 return .{
595 .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
596 .cursor_rebuild = true,
597 };
598 }
599 if (cursor_identity_changed or focus_regained) {
600 return .{
601 .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
602 .cursor_rebuild = true,
603 };
604 }
605 return .{ .state = state, .cursor_rebuild = false };
606 }
607 // want_blink == false
608 if (state.next_deadline_ns != null or !state.blink_on) {
609 return .{
610 .state = .{ .blink_on = true, .next_deadline_ns = null },
611 .cursor_rebuild = true,
612 };
613 }
614 return .{ .state = state, .cursor_rebuild = false };
615 }
616 ```
617
618 - [ ] **Step 5: Implement `shouldDrawCursor`.**
619
620 Replace the stub body with:
621
622 ```zig
623 fn shouldDrawCursor(
624 visible: bool,
625 viewport_present: bool,
626 has_focus: bool,
627 blinking: bool,
628 blink_on: bool,
629 ) bool {
630 if (!visible) return false;
631 if (!viewport_present) return false;
632 if (!has_focus) return true;
633 if (!blinking) return true;
634 return blink_on;
635 }
636 ```
637
638 - [ ] **Step 6: Run all tests.**
639
640 Run: `zig build test`
641 Expected: all blink + draw-rule + integration tests pass. Existing tests unaffected.
642
643 - [ ] **Step 7: Commit.**
644
645 ```bash
646 git add src/main.zig
647 git commit -m "$(cat <<'EOF'
648 main: add blink state machine + draw-rule pure helpers
649
650 tickBlinkPhase, reconfigureBlink, shouldDrawCursor as pure
651 functions with unit tests. Next commit wires them into the
652 main loop.
653
654 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
655 EOF
656 )"
657 ```
658
659 ---
660
661 ## Task 4: Wire blink into the main loop
662
663 **Files:**
664 - 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).
665
666 This is the integration task. No new pure functions — just plumbing. The helpers from Tasks 1 and 3 do the thinking.
667
668 - [ ] **Step 1: Add new locals in the `runTerminal` locals block.**
669
670 Just after `var consecutive_vk_timeouts: u32 = 0;` at `src/main.zig:340`, add:
671
672 ```zig
673 var blink_state: BlinkState = .{};
674 var previous_has_focus: bool = keyboard.has_focus;
675 ```
676
677 - [ ] **Step 1b: Flip the Task-2 placeholder `null` to the real blink deadline.**
678
679 At `src/main.zig:343-345` (edited in Task 2 Step 4), change:
680 ```zig
681 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
682 // Blink deadline wired up in Task 4; null for now.
683 const timeout = computePollTimeoutMs(repeat_timeout_ms, null, render_pending);
684 ```
685 to:
686 ```zig
687 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
688 const blink_timeout_ms = remainingRepeatTimeoutMs(blink_state.next_deadline_ns);
689 const timeout = computePollTimeoutMs(repeat_timeout_ms, blink_timeout_ms, render_pending);
690 ```
691
692 - [ ] **Step 2: Flip phase right after `waitForWork` returns.**
693
694 Immediately after the `try frame_loop.waitForWork(&pollfds_extra, timeout);` call at `src/main.zig:345`, insert:
695
696 ```zig
697 // Phase-flip first so a flipped-this-tick deadline marks render_pending
698 // before the `if (!render_pending) continue;` bail below.
699 const tick = tickBlinkPhase(blink_state, std.time.nanoTimestamp());
700 blink_state = tick.state;
701 var cursor_rebuild_from_blink = tick.flipped;
702 if (tick.flipped) render_pending = true;
703 ```
704
705 `cursor_rebuild_from_blink` is function-scope mutable because Step 4 below OR's more work into it after snapshot.
706
707 - [ ] **Step 3: Bail-and-continue path unchanged.**
708
709 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).
710
711 - [ ] **Step 4: After `term.snapshot()`, reconfigure the timer and detect identity change.**
712
713 Find the block at `src/main.zig:492-494`:
714 ```zig
715 const previous_cursor = term.render_state.cursor;
716 var section_timer = std.time.Timer.start() catch unreachable;
717 try term.snapshot();
718 ```
719
720 Add, directly after the `try term.snapshot();` line:
721
722 ```zig
723 // Recompute blink arm/disarm from current state. Edge-driven updates
724 // are deliberately avoided — `want_blink` is a pure function of the
725 // state we see right now, so no transition can be "missed".
726 const current_cursor = term.render_state.cursor;
727 const viewport_present = current_cursor.viewport != null;
728 const want_blink = current_cursor.visible
729 and viewport_present
730 and current_cursor.blinking
731 and keyboard.has_focus;
732
733 const cursor_identity_changed =
734 !std.meta.eql(current_cursor.viewport, previous_cursor.viewport)
735 or current_cursor.visual_style != previous_cursor.visual_style
736 or current_cursor.visible != previous_cursor.visible
737 or current_cursor.blinking != previous_cursor.blinking;
738 const focus_regained = keyboard.has_focus and !previous_has_focus;
739
740 const reconf = reconfigureBlink(
741 blink_state,
742 want_blink,
743 cursor_identity_changed,
744 focus_regained,
745 std.time.nanoTimestamp(),
746 );
747 blink_state = reconf.state;
748 if (reconf.cursor_rebuild) cursor_rebuild_from_blink = true;
749 ```
750
751 Note: `std.meta.eql` on `?Viewport` works because `Viewport` is a plain struct of integers/bool.
752
753 - [ ] **Step 5: OR the blink rebuild into the cursor-rebuild decision at the plan call site.**
754
755 After `planRowRefresh(...)` returns into `refresh_plan` (currently `src/main.zig:505-518`), add one line right after the `refresh_plan` assignment:
756
757 ```zig
758 const cursor_rebuild_effective = refresh_plan.cursor_rebuild or cursor_rebuild_from_blink;
759 ```
760
761 Then change the cursor-rebuild branch gate at `src/main.zig:553`:
762 ```zig
763 if (refresh_plan.cursor_rebuild) {
764 ```
765 to:
766 ```zig
767 if (cursor_rebuild_effective) {
768 ```
769
770 Rationale: we don't extend `RowRefreshPlan` or `CursorRefreshContext` — the blink input is an orthogonal trigger and lives outside the planner.
771
772 - [ ] **Step 6: Apply `shouldDrawCursor` inside the cursor-rebuild branch.**
773
774 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."
775
776 Replace the current:
777 ```zig
778 if (term.render_state.cursor.viewport) |cursor| {
779 const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) {
780 .block, .block_hollow => .block,
781 .underline => .underline,
782 .bar => .bar,
783 };
784 var inst = renderer.cursorInstance(
785 shape,
786 cell_w,
787 cell_h,
788 @as(u32, @intCast(geom.buffer_scale)),
789 atlas.cursorUV(),
790 );
791 inst.cell_pos = .{
792 @floatFromInt(cursor.x),
793 @floatFromInt(cursor.y),
794 };
795 cursor_instances_buf[0] = inst;
796 cursor_instances = cursor_instances_buf[0..1];
797 }
798 ```
799
800 with:
801 ```zig
802 const draw_cursor = shouldDrawCursor(
803 current_cursor.visible,
804 current_cursor.viewport != null,
805 keyboard.has_focus,
806 current_cursor.blinking,
807 blink_state.blink_on,
808 );
809 if (draw_cursor) {
810 const cursor = current_cursor.viewport.?; // guaranteed by shouldDrawCursor
811 const shape: renderer.CursorShape = switch (current_cursor.visual_style) {
812 .block, .block_hollow => .block,
813 .underline => .underline,
814 .bar => .bar,
815 };
816 var inst = renderer.cursorInstance(
817 shape,
818 cell_w,
819 cell_h,
820 @as(u32, @intCast(geom.buffer_scale)),
821 atlas.cursorUV(),
822 );
823 inst.cell_pos = .{
824 @floatFromInt(cursor.x),
825 @floatFromInt(cursor.y),
826 };
827 cursor_instances_buf[0] = inst;
828 cursor_instances = cursor_instances_buf[0..1];
829 }
830 ```
831
832 - [ ] **Step 7: Update `previous_has_focus` at end of iteration.**
833
834 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:
835
836 ```zig
837 previous_has_focus = keyboard.has_focus;
838 ```
839
840 - [ ] **Step 8: Build and run all tests.**
841
842 Run: `zig build test`
843 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:
844 ```zig
845 const cursor_identity_changed =
846 (current_cursor.viewport == null) != (previous_cursor.viewport == null)
847 or (current_cursor.viewport != null and previous_cursor.viewport != null
848 and (current_cursor.viewport.?.x != previous_cursor.viewport.?.x
849 or current_cursor.viewport.?.y != previous_cursor.viewport.?.y
850 or current_cursor.viewport.?.wide_tail != previous_cursor.viewport.?.wide_tail))
851 or current_cursor.visual_style != previous_cursor.visual_style
852 or current_cursor.visible != previous_cursor.visible
853 or current_cursor.blinking != previous_cursor.blinking;
854 ```
855
856 - [ ] **Step 9: Manual smoke.**
857
858 Build and run waystty. From inside, test each branch of the spec:
859
860 1. **Baseline block blink, focused:** open waystty. Cursor blinks at 500 ms.
861 2. **Blink off via mode:** `printf '\e[?12l'`. Cursor steady.
862 3. **Blink on via mode:** `printf '\e[?12h'`. Blink resumes.
863 4. **Shape via DECSCUSR:**
864 - `printf '\e[1 q'` → blinking block.
865 - `printf '\e[2 q'` → steady block.
866 - `printf '\e[3 q'` → blinking underline.
867 - `printf '\e[4 q'` → steady underline.
868 - `printf '\e[5 q'` → blinking bar.
869 - `printf '\e[6 q'` → steady bar.
870 5. **Hide / show:** `printf '\e[?25l'` → cursor vanishes. `\e[?25h` → cursor returns on-phase.
871 6. **Focus:** click away from waystty. Cursor steady. Click back. Cursor reappears in on-phase immediately.
872 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.
873 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.
874
875 - [ ] **Step 10: Commit.**
876
877 ```bash
878 git add src/main.zig
879 git commit -m "$(cat <<'EOF'
880 main: wire blink state machine into the render loop
881
882 Deadline folded into computePollTimeoutMs alongside key-repeat.
883 tickBlinkPhase runs right after poll() so a just-flipped tick
884 sets render_pending before the idle-bail. reconfigureBlink runs
885 post-snapshot and detects cursor/focus identity changes against
886 previous_cursor + previous_has_focus.
887
888 Draw rule now gated on shouldDrawCursor; DECSCUSR shapes dispatch
889 into renderer.cursorInstance.
890
891 As a side effect the 500ms tick gives the main loop a heartbeat
892 whenever blink is enabled, which is enough to recover from a
893 suspend wedge in the common interactive case (bare shell). See
894 docs/superpowers/specs/2026-04-18-cursor-blink-design.md for the
895 limit on that benefit.
896
897 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
898 EOF
899 )"
900 ```
901
902 ---
903
904 ## Post-implementation: run the grep gate
905
906 The existing `tests/check_unbounded_vk.sh` gate is unaffected because no Vulkan sites change. Confirm:
907
908 - [ ] `bash tests/check_unbounded_vk.sh` — expected: pass.
909
910 ---
911
912 ## Spec coverage check (done while writing this plan)
913
914 | Spec section | Task |
915 |---|---|
916 | Goal: blink + DECSCUSR + 500ms | Tasks 1, 3, 4 |
917 | Non-goals acknowledged in commit message | Task 4 Step 10 |
918 | Architecture: state machine as main-loop locals | Task 4 Step 1 |
919 | Architecture: poll timeout extended | Task 2 |
920 | Architecture: cursorInstance in renderer | Task 1 |
921 | Draw rule | Task 3 (tests) + Task 4 Step 6 (applied) |
922 | Timer state machine: arm/disarm | Task 3 (reconfigureBlink) + Task 4 Step 4 |
923 | Phase flip | Task 3 (tickBlinkPhase) + Task 4 Step 2 |
924 | Cursor identity / focus-regain reset | Task 4 Step 4 |
925 | Rebuild plumbing: OR at call site, not in planRowRefresh | Task 4 Step 5 |
926 | Shape dispatch table | Task 1 Step 3 |
927 | Unit tests per helper | Tasks 1, 3 |
928 | Integration-ish test: tickBlinkPhase + computePollTimeoutMs | Task 3 Step 1 (last block) |
929 | Manual smoke list | Task 4 Step 9 |
930 | Forward note on collapsing to varargs | Task 2 Step 3 comment |