3394c210
Cursor blink implementation plan
a73x 2026-04-19 06:12
Commit message
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 | | ||