Skip to content

ADR 0047: A real block cursor for Vim Normal mode

Status

Accepted

Context

Vim mode (Phase 1, docs/adr/0046) landed with the editor's existing 2px insertion-bar caret unchanged in every mode, including Normal. In real Vim (and every terminal Vim runs in), Normal mode is unmistakable at a glance because the cursor becomes a solid block covering the character it's "on" — the editor's own bar caret gave no such cue, so Normal mode looked visually identical to plain text editing. User feedback after using Phase 1: give Normal mode a real block cursor, make sure the character under it stays legible, keep the block's blink from ever hitting full opacity, and make sure the existing position-glide/blink-reset animation still drives it through every motion and edit, not just a static highlight.

Decision

Block only in Normal mode

Insert keeps the bar — it still means "an insertion point," same as with Vim mode off. Visual keeps it too: Visual already highlights the selection range itself (translucent overlay, existing highlightRange), and the bar there just marks that selection's moving end without a second, competing fill on top of it. Only vimModeActive() && m_vimMode == VimMode::Normal switches the caret render path.

Glyph width, measured the same run-aware way as everything else

The block's width is the actual rendered width of the character under the cursor — not a fixed m_charWidth guess, which is exactly the "assumed fixed-pitch" bug ADR 0013 already fixed once for the bar caret and mouse hit-testing. vimBlockGlyphAt() (editor_viewport.cpp) reuses vimNextCharBoundary (codepoint-aware, so a multi-byte UTF-8 character is measured whole) to find the glyph's end, then measures it with the same capturesForLine/xForColumn machinery drawLine itself uses — so a bold/italic capture under the cursor gets the correct width and font, not the plain font's.

At end of line or end of buffer there's no real character to cover — this editor's own cursor convention already sits at the line's '\n' byte there (the named fidelity gap from ADR 0046), not "on" a character. vimBlockGlyphAt returns an empty glyph and falls back to m_charWidth for the block's width in that case, so the cursor still renders as a normal-sized block instead of collapsing to zero width.

The character stays legible: knock it out on top, terminal-cursor style

A block filled in the text's own color would simply hide whatever character it lands on. Real Vim's terminal-cell block cursor solves this by inverting the cell — the glyph renders in the background color on top of the solid block. This editor does the same: after filling the block rect, vimBlockGlyphAt's returned glyph (if non-empty, i.e. skipped for the end-of-line/buffer case, where there's nothing to draw) is redrawn on top in m_backgroundColor, using that character's own font (fontForCapture) so italic/bold glyphs still look correct knocked out.

Never fully opaque

kVimBlockCursorMaxAlpha = 200 (of 255, ≈78%) caps the block's fill alpha — the existing breathing-blink formula (caretAlpha, unchanged, still driven by m_idleTicks and reset to solid on every cursor-moving action via resetCaretBlink()) is scaled into [0, 200] instead of [0, 255] before filling the block: blockAlpha = (caretAlpha * kVimBlockCursorMaxAlpha) / 255. A solid, fully-opaque block would flash harder than every other translucent overlay this app already uses (selection highlight, panel backgrounds) and would fight for attention with the knocked-out glyph on top of it. The bar caret in Insert/Visual is untouched — still the original uncapped [0, 255] range.

Animation: inherited, not re-implemented

The block's position comes from the same m_renderedCaretPos[i] eased values the bar caret already used (caretTargetFor + updateAnimation's per-frame glide toward it, docs/adr/0015) — no new animation path. Its blink timing comes from the same caretAlpha computation, itself driven by m_idleTicks, which every existing cursor-moving/editing call site already resets via resetCaretBlink() — including every Vim motion and mutation, since they all funnel through keyPressEvent, which calls it unconditionally near the top before any mode-specific dispatch. Nothing Vim-specific needed adding here: giving Normal mode a block cursor was purely a paintEvent rendering change, not a new animation system.

Consequences

Normal mode is now visually unmistakable at a glance, matching every Vim user's terminal-trained expectations, while Insert and Visual stay exactly as they were. Verified live: the block correctly resizes across different character widths and a styled (italic) capture, knocks out and stays legible over a live character, falls back to a normal-width block at end of line, visibly breathes (blink samples across several frames showed clearly varying — never static — intensity) while never reaching the bar caret's full peak brightness, and glides/updates correctly through motions (l), edits (x + u undo), and mode transitions. ctest --test-dir build still 9/9.