6dfa62bf
Cursor blink design: DECSCUSR + 500ms phase
a73x 2026-04-19 06:06
Adds design for a blinking cursor with full DECSCUSR shape dispatch (block/underline/bar), CSI ? 12 blink-mode, and focused-only blink. Hardcoded 500ms phase. Non-goals explicit: this is not a general suspend-recovery mechanism; the timer only arms when the terminal mode has blink enabled, so apps that disable blink (tmux, vim) leave the process with no heartbeat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/docs/superpowers/specs/2026-04-18-cursor-blink-design.md b/docs/superpowers/specs/2026-04-18-cursor-blink-design.md new file mode 100644 index 0000000..b296198 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-cursor-blink-design.md @@ -0,0 +1,197 @@ # Cursor Blink Design ## Goal Add a real blinking cursor to waystty with full `DECSCUSR` support (block / underline / bar, steady or blinking), and `CSI ? 12 h/l` for the blink-enable mode. When the terminal mode has blink enabled and the window is focused, the cursor alternates at 500 ms. Unfocused or blink-disabled: the cursor is drawn solid. Hidden (`CSI ? 25 l` or scrolled offscreen): the cursor is not drawn at all. As a side effect, the 500 ms tick gives the main loop a periodic wakeup while blink is active, which lets it recover from a suspend/resume wedge — see **Non-goals** below for the limit on that benefit. ## Background Today waystty renders the cursor as a single solid-white 50 %-alpha instance covering the full cell (`src/main.zig:553-577`). Shape is fixed, blink is absent, and `render_state.cursor.style` / `.blinking` are ignored. The main loop blocks in `poll(2)` with no timeout when nothing is pending (see `computePollTimeoutMs` at `src/main.zig:794` returning `-1`) — that's the structural reason waystty wedges after the machine sleeps: no client-side tick, and on resume the compositor is in a state where it doesn't proactively wake the client either. The VT core (`ghostty_vt`) already tracks all the state we need: - `render_state.cursor.visible: bool` — DECTCEM (`CSI ? 25`). - `render_state.cursor.blinking: bool` — blink mode (`CSI ? 12`, DECSCUSR's blink bit). - `render_state.cursor.style: CursorStyle` — `.block` / `.underline` / `.bar` / `.block_hollow` / … (exact enum frozen by the plan). - `render_state.cursor.viewport: ?Position` — null when the cursor scrolled off the visible region. Focus is already tracked by the wayland keyboard: `keyboard.has_focus` at `src/wayland.zig:219`. The existing deadline-driven key-repeat (`keyboard.nextRepeatDeadlineNs()` + `remainingRepeatTimeoutMs` + `computePollTimeoutMs`) is the template for this feature. ## Non-goals - **Suspend-hang is NOT fixed in the general case.** The blink timer arms *only* when the terminal mode says blink. Apps that disable blink before sleep (tmux, vim, many TUIs) will leave waystty with no heartbeat and, if the compositor is also silent after resume, the process will still wedge. Users running bare shells typically keep blink on, so in the common interactive case this does recover — but this spec is a cursor feature, not a suspend fix. A separate design (logind `PrepareForSleep` subscription, or an unconditional heartbeat) is required to close that gap fully. - **Hollow / outline unfocused variant.** Unfocused cursor is drawn with the same shape and alpha as focused-steady. No new shader path, no 4-edge rectangle trick. Defer until someone asks. - **Cursor color from palette.** Stays solid white at 0.5 alpha. Defer. - **Fading animation between phases.** Hard snap on/off. Defer. - **Configurable blink rate.** 500 ms hardcoded in a named constant. Defer until a second knob justifies adding config plumbing. ## Architecture One file changes for state + logic (`src/main.zig`). One helper lands in the renderer module (`src/renderer.zig`) so cursor geometry is testable without a device. No new module, no new protocol, no new thread. Three concerns separated: 1. **Blink state machine.** Two main-loop locals: `blink_on: bool` (current phase) and `next_blink_deadline_ns: ?i128` (absolute deadline, null when timer inactive). Arm/disarm is *recomputed every iteration* from current state — not driven by edge detection — to eliminate any "did I catch every transition?" class of bug. 2. **Poll integration.** `computePollTimeoutMs` grows a second `?i32` parameter for the blink deadline. Both deadlines fold together via `min`. `remainingRepeatTimeoutMs` (now misnamed, left as-is for this spec; plan may rename) is reused verbatim for deadline → ms conversion. 3. **Shape rendering.** A pure `cursorInstance(style, cell_w, cell_h, buffer_scale, uv) -> Instance` helper in `src/renderer.zig` builds the instance for a given shape. Main loop calls it when the draw rule says the cursor should be drawn. ### Draw rule Per frame, the cursor instance list is populated by this decision: ``` if !cursor.visible → no cursor instance else if cursor.viewport == null → no cursor instance (scrolled offscreen) else if !has_focus → draw solid (ignore blink_on) else if !cursor.blinking → draw solid (ignore blink_on) else if blink_on → draw else → no cursor instance ``` "Draw solid" and "draw" emit identical instances — the only observable difference between the two paths is *when* they're emitted, which is the point of blink. ### Timer state machine Per loop iteration, after `term.snapshot()` has refreshed `render_state.cursor` but before the render path runs: ``` want_blink = cursor.visible && cursor.viewport != null && cursor.blinking && has_focus if want_blink: if next_blink_deadline_ns == null: # transitioning from inactive → active blink_on = true next_blink_deadline_ns = now + 500ms cursor_rebuild_needed = true else: if next_blink_deadline_ns != null or !blink_on: # transitioning from active → inactive, or was mid off-phase blink_on = true next_blink_deadline_ns = null cursor_rebuild_needed = true ``` Because `want_blink == false` forces `blink_on = true`, the reveal after `CSI ? 25 h` or DECSCUSR-turned-blink-off can never leave the cursor in its "off" phase, and the blink_on reset on blink-mode enable happens naturally in the `want_blink` → `true` branch above. Phase flip, after `frame_loop.waitForWork` returns: ``` if next_blink_deadline_ns != null and now >= next_blink_deadline_ns: blink_on = !blink_on next_blink_deadline_ns = now + 500ms render_pending = true cursor_rebuild_needed = true ``` "User-visible promise: cursor shown on significant events." When the cursor moves, the style changes, or focus is regained while `want_blink` stays true, phase is reset to on and the deadline is pushed a full 500 ms out so the user sees the cursor for a full phase before the next flip. Detection uses the existing `previous_cursor` snapshot (already saved at `src/main.zig:492` for `planRowRefresh`): ``` cursor_identity_changed = (cursor.viewport != previous_cursor.viewport) || (cursor.style != previous_cursor.style) || (cursor.visible != previous_cursor.visible) || (cursor.blinking != previous_cursor.blinking) focus_regained = has_focus && !previous_has_focus if want_blink and (cursor_identity_changed or focus_regained): blink_on = true next_blink_deadline_ns = now + 500ms cursor_rebuild_needed = true ``` ### Rebuild plumbing `cursor_rebuild_needed` is OR'd into the existing `cursor_rebuild` input of `planRowRefresh` at the call site (`src/main.zig:505`). No new field in the refresh plan, no new concept. ### Shape dispatch `cursorInstance` in `src/renderer.zig`: | Style | `glyph_size` | `glyph_bearing` | |---|---|---| | `.block` (default + fallback) | `(cell_w, cell_h)` | `(0, 0)` | | `.underline` | `(cell_w, 2 * buffer_scale)` | `(0, cell_h - 2 * buffer_scale)` | | `.bar` | `(2 * buffer_scale, cell_h)` | `(0, 0)` | Any other variant (e.g. `.block_hollow`, `.underline_hollow`) is treated as `.block` for v1. The plan freezes the exact ghostty_vt enum variant names when wiring the dispatch. All variants use `atlas.cursorUV()` (solid white texel), white foreground, `alpha = 0.5`. Line width of `2 * buffer_scale` buffer-pixels makes underline/bar scale correctly on HiDPI. ### Data flow ``` PTY output bytes → ghostty_vt stream parses DECSCUSR / CSI?12 → term.modes + Screen.cursor updated term.snapshot() → render_state.cursor reflects current style/visible/blinking main loop → recompute want_blink; advance deadline; flip phase if due → build cursor instance via renderer.cursorInstance(style, ...) iff draw rule says yes → planRowRefresh(..., cursor_rebuild = moved || cursor_rebuild_needed) → existing render path submits the frame ``` ## Error handling No new failure modes. Deadline arithmetic is pure `i128` and `i32`, bounded by a 500 ms constant. No syscalls, no allocations. The flip does not check or care about the Vulkan path — render errors on the blink-triggered frame are handled by the existing timeout/recovery branches in `drawCells` (`src/main.zig:691-710`) exactly as today. ## Testing ### Unit tests (extend / add inline in `src/main.zig` or `src/renderer.zig`) 1. **`computePollTimeoutMs` extended signature.** Existing tests at `src/main.zig:1634-1636` cover the single-deadline + render_pending matrix. Add: - null repeat, some blink → blink ms. - repeat < blink → repeat ms. - blink < repeat → blink ms. - render_pending = true with any deadlines → 0. 2. **Pure blink state function.** Factor the arm/disarm/flip block into a pure helper: ```zig fn advanceBlink( state: BlinkState, inputs: BlinkInputs, now_ns: i128, ) BlinkOutcome ``` where `BlinkInputs` bundles `{ want_blink, cursor_identity_changed, focus_regained }` and `BlinkOutcome` returns `{ new_state, cursor_rebuild_needed, render_pending_from_flip }`. Tests drive a fake clock through: - Inactive → inactive: no side effects. - Inactive → active: deadline armed, phase = on, rebuild flagged. - Active → inactive mid off-phase: phase reset to on, rebuild flagged. - Flip at deadline: phase toggles, deadline extended, render_pending set. - Cursor identity change while active: phase reset to on, deadline extended 500 ms. 3. **`cursorInstance` shape dispatch.** Free function, no device. Assert `glyph_size` and `glyph_bearing` match the table above for each shape at `buffer_scale ∈ {1, 2}`. 4. **Integration-ish** — combine `advanceBlink` + `computePollTimeoutMs` with a fake clock in one test that walks: active at t=0 → tick at t=500 (flip, deadline next=1000) → tick at t=1000 (flip back). Asserts both timing and phase. No Vulkan, no Wayland. ### Manual smoke (listed in spec, run once) - `printf '\e[5 q'` → blinking bar. - `printf '\e[4 q'` → steady underline. - `printf '\e[3 q'` → blinking underline. - `printf '\e[?12l'` → blink disabled globally, cursor steady whatever DECSCUSR says. - `printf '\e[?25l'` / `\e[?25h` → cursor hide/show. - Click away → cursor steady (no phase progression). Click back → cursor reset to on-phase. - Sleep laptop, wake → while blink is active, cursor resumes blinking (loop alive, recovery worked). If blink was off when the machine slept, test is inconclusive — see non-goals. ### What is deliberately not tested - Render-golden diffs per shape. The existing golden PNG harness is expensive per case; unit tests on `cursorInstance` fields catch geometry regressions and the manual smoke catches any visual glitch. - End-to-end blink-toggle-via-pty driving `zsh` / `bash` prompts — too slow and flaky for the signal-to-noise. ## Forward-looking note If a third deadline source appears (suspend heartbeat, bell timeout, auto-hide mouse cursor), collapse `computePollTimeoutMs` to `(render_pending: bool, deadlines: []const ?i32)` taking the min. Do not add a fourth positional arg. Filed here so the next contributor doesn't. ## Files touched (rough) - `src/main.zig` — new locals, extended `computePollTimeoutMs`, new `advanceBlink` helper, call-site changes for `planRowRefresh` input, draw rule branch in the cursor-instance build block at ~L553-577. - `src/renderer.zig` — new `cursorInstance(style, cell_w, cell_h, buffer_scale, uv) -> Instance` pure helper. - No changes to `src/vk_sync.zig`, `src/wayland.zig`, `src/frame_loop.zig`, or shaders.