ADR 0015: Smooth motion — caret glide and smooth scroll¶
Status¶
Accepted
Context¶
Direct feedback: the caret blink and cursor/scroll movement felt
abrupt, not "alive." Phase 7 (ADR 0012) already added an opt-in caret
alpha-fade but explicitly deferred smooth scrolling as too complex for
that phase's budget — it needed exact pixel positions to animate
against, which didn't exist until Phase 8's caret-drift-safe
xForColumn and pixel-space m_scrollX/m_scrollLine (ADR 0013,
ADR 0014). This phase spends that plumbing.
Decisions¶
1. One easing mechanism drives both scroll and caret position¶
m_scrollLine/m_scrollX stay the logical target — nothing that
sets them (wheelEvent, ensureCursorVisible) changed at all. New
m_renderedScrollLine/m_renderedScrollX (doubles) hold what's
actually drawn, eased toward the logical target by a fixed fraction
(kEaseFactor = 0.35) per paint, snapping once within a small
threshold. Each cursor gets the same treatment via
m_renderedCaretPos (one QPointF per cursor) eased toward
caretTargetFor(cursor) — which itself is computed against the
current rendered scroll position, not the logical target, so a caret
that isn't otherwise moving stays visually anchored to its character
while the view glides underneath it during a scroll animation, instead
of being dragged along with the logical (instant) target.
2. Advance the animation from paintEvent, not the timer¶
The existing 30ms m_blinkTimer (unchanged interval, unchanged role —
still just "call update() periodically so something keeps
happening") triggers repaints; updateAnimation() runs at the top of
paintEvent itself, immediately before its results are read. This
sidesteps any staleness question entirely — there's no call site
(keyPressEvent, mousePressEvent, the config-reload path) that needs
to remember to advance the animation before triggering a repaint;
whichever code path calls update(), the next paintEvent always
advances state right before drawing it.
3. Cursor-count changes resync instead of gliding from mismatch¶
m_renderedCaretPos has to stay parallel to m_cursors, which can
change size at several call sites (mouse click, Alt+click, Ctrl+D,
Escape — all from ADR 0012). Rather than updating the array at each
of those sites, updateAnimation just checks size equality each frame
and, on mismatch, rebuilds it at exact target positions (no glide for
that one frame) before continuing. Simpler than threading a resync
call through every multi-cursor mutation point, and the cost — a newly
added cursor appears rather than gliding in from nowhere — is the
right behavior anyway.
4. The one real complexity this phase actually needed: fractional¶
vertical rendering
A fractional m_renderedScrollLine means the topmost visible line is
only partially scrolled into view. paintEvent renders from
floor(m_renderedScrollLine), shifts the whole per-line loop up by
frac * m_lineHeight via the same painter.translate that already
carries the horizontal scroll/gutter offset (ADR 0014), and requests
one extra line at the bottom (height()/lineHeight + 2, not + 1) so
nothing blank slides into view during the shift. The gutter's line
numbers get the identical vertical shift (a second, vertical-only
translate) so they stay aligned with their text lines throughout.
Horizontal easing needed none of this — translate's x argument is
just a double, fractional pixels for free.
5. Carets render in absolute widget space, decoupled from the¶
translated text block
Because a caret's rendered position can lag the logical scroll target
(mid-glide), it can no longer be drawn as "a local-space x/y inside the
same translate as the text" the way Phase 8 had it — that would force
it to jump instantly with any scroll change. Carets are now drawn
after painter.restore(), directly at their m_renderedCaretPos
(already computed as absolute widget coordinates in caretTargetFor),
clipped to the text area so a caret scrolled behind the gutter doesn't
paint over it.
6. Click mapping (offsetForPoint) uses rendered, not logical, scroll¶
A click during an active scroll animation has to map against what's
actually on screen at that instant. offsetForPoint now reads
m_renderedScrollLine/X instead of the logical values — vertically
only to floor() precision, consistent with the pre-existing
deliberate click-approximation philosophy (ADR 0013).
Consequences¶
animations = false (the shipped default) makes updateAnimation
snap every rendered value to its target every frame — verified to
produce pixel-identical consecutive frames after a jump, i.e. true
instant behavior, not just a fast ease. Verified with animations =
true that a large scroll jump visibly interpolates over several
frames (sampled actual line-number-gutter content across rapid
screenshots showing 232 → 237 → 238 converging toward the true target)
rather than jumping.