ADR 0049: A typing pop-in animation¶
Status¶
Accepted
Context¶
The last item from the same round of feedback that produced ADR 0048 ("add an animation while typing to give a good effect") was deliberately left undesigned there — unlike the other three requests in that batch, it had no single obvious shape. Two concrete directions were put to the user: animate each newly typed character (fade + scale from ~85% to 100%), or give the caret itself a pulse on each keystroke. The user picked the per-character pop-in.
Decision¶
Where it hooks in: insertText/insertTextAt, not a new call site¶
insertText() is the one funnel every "typing-shaped" insertion already
goes through — plain character entry, Tab, Enter, paste, find/replace's
replacement text, and completion-accept all call it. Rather than adding
a separate "this was really typed" signal, the animation is gated by
the inserted bytes themselves: bytes.size() <= kTypingAnimationMaxBytes
(8) && !bytes.contains('\n'). In practice this means plain characters
(including a multi-byte UTF-8 one) and Tab's four spaces animate; a
paste, a multi-line completion insert, or anything containing a newline
does not — a real paste popping in as one giant scaled block would look
wrong, not good, and Enter's own '\n' has no visible glyph to animate
in the first place. Also gated on m_animationsEnabled, same opt-in
pattern as every other animation in this file — with animations off,
insertTextAt's animate parameter is always false and
m_typingAnimations never grows.
insertTextAt(int i, const QByteArray &bytes, bool animate) gained the
one new parameter; on a successful ase_buffer_insert, it pushes
{cursor, bytes.size(), 0} (the pre-increment cursor offset is exactly
the insertion's start) into m_typingAnimations before advancing
cursor past the inserted bytes. Multi-cursor typing is already safe
by construction: insertText()'s existing loop processes cursors
highest-offset-first specifically so an earlier iteration can never
write before a later one's position — the same invariant the file's
delete primitives already rely on (docs/adr/0012) — so each pushed
start offset stays valid for the rest of that call without needing
any adjustment for the other cursors' insertions.
Rendering: repaint over the glyph, don't touch drawLine's segmentation¶
drawLine already painted these bytes at full size and opacity by the
time the animation overlay runs (same frame). Rather than teaching
drawLine's per-capture run segmentation about a third, transient
"still popping in" state, the overlay pass (right after the main text
loop, same translate/clip context) re-paints just that byte range's
rect: fills it with m_backgroundColor first (erasing what drawLine
already drew there), then draws the glyph again through a
painter.translate/scale/translate-back transform centered on its
own rect, at colorForCapture's color scaled by the same progress
value. This keeps every other rendering path (selection highlight,
diagnostics, the caret) completely unaware the effect exists.
Progress is eased (t = 1 - (1-progress)², the same ease-out shape as
kEaseFactor-driven motion elsewhere) over kTypingAnimationTicks = 4
ticks of the existing 30ms blink-timer cadence (~120ms total) — chosen
to read as a snappy "just landed" pop rather than a sluggish delay
before newly typed text looks finished. Scale interpolates
kTypingAnimationStartScale (0.85) → 1.0; alpha interpolates 0 → the
capture's own resting alpha (so a typed character inside a dimmed
comment or number run still fades to that dimmed level, not to full
opacity).
Aging and cleanup: updateAnimation() advances, snapAnimationToTarget() clears¶
updateAnimation() (already the one place per-frame animation state
advances) increments every entry's elapsedTicks and drops it once
either the window elapses or its byte range no longer fits inside
m_cache (a defensive bounds check — the common real hazard, an
immediate Backspace after typing, is handled directly:
deleteBackward/deleteForward aren't touched by this ADR, but the
bounds check means a shrunk buffer just silently stops rendering the
stale entry rather than reading out of range). snapAnimationToTarget()
— already called after undo/redo, Vim mutations, and paste to make the
caret jump instantly instead of gliding — now also clears
m_typingAnimations outright: everything else snaps to "already
settled" there, so a character still mid-pop when one of those fires
should too, rather than finishing its animation over text that's no
longer the reason it started.
Consequences¶
Verified live: a single typed character visibly starts smaller/dimmer
than surrounding text and reaches full size/opacity within the
animation window; rapid sequential typing (bcdef) and an immediate
Backspace right after produced no crash or visual corruption; with
animations = false, a typed character appears instantly at full
size/opacity, confirming the opt-in gate actually gates it.
ctest --test-dir build still 9/9 (unaffected — no core-layer change).
Not covered by this ADR: Vim mode's own mutation primitives
(vimPasteAfter, vimOpenLineAbove, ...) intentionally bypass
insertText() entirely (ADR 0046, undo-group nesting) and so never
trigger this animation — Vim's p/o/etc. insert instantly, same as
before. Extending the pop-in there, if wanted, is separate follow-up
work, not a gap in this change.