fix(vim): keep the j/k cursor clear of the HUD bands when scrolling - #1154
fix(vim): keep the j/k cursor clear of the HUD bands when scrolling#1154rNoz wants to merge 1 commit into
Conversation
|
@backnotprop you are gonna like this one :) Proofs both at the issue and PR, just cooked during the last 9h of work. |
6c5e1aa to
ac4f133
Compare
Review (at
|
ac4f133 to
de51575
Compare
Document Vim navigation moved the cursor with
`Element.scrollIntoView({ block: 'nearest' })`, which parks the target
flush against the nearest viewport edge — exactly where the sticky action
bar (top) and the key HUD / status pill (bottom) float. Motion to the top
or bottom of a document then hid the caret behind an overlay, while a
mouse wheel (which the browser lets overshoot) kept the same line nearer
centre.
Add vimScroll.ts: a pure computeVimScrollDelta returning the signed
scrollTop delta needed to clear a HUD band at each edge (0 when the
target is already inside the safe band; a target taller than the band
aligns to its top edge so reading order wins), resolveVimScrollMargin
sizing the fallback band as clamp(20% of viewport height, 24px..160px),
and a scrollVimTargetIntoView wrapper.
Both bands are measured from live geometry instead of guessed constants:
the top band widens past the ratio margin to clear the sticky action
bar, and the bottom band derives from the portaled key HUD / mode pill
rects ([data-vim-key-hud] / [data-vim-mode-badge]). The opt-in key HUD
(fixed bottom: 150, height: 88 — a ~238px band, past the 160px clamp) is
actually cleared, while the default pill reserves ~49px instead of
over-reserving 160px. An expanded key HUD is a deliberate modal state
that can outgrow the viewport, so it keeps the ratio margin.
The scrolling element is the native-scroll host fed through
ScrollViewportContext, so the wrapper takes that element from the caller
(useScrollViewport() in Viewer, the same node the reticle measures
against) and falls back to the historical scrollIntoView when it is
absent, so behaviour never regresses.
Route every cursor/target move in useVimSelection through it, and add
vimScroll.test.ts to the DOM allowlist in test.yml so its integration
tests run in CI.
de51575 to
cd2408f
Compare
|
Saw the rebase onto current main, thanks, that resolves the staleness cleanly. The one blocking item from the review (deriving the bottom band from the live HUD rect instead of the fixed constant, plus the test.yml DOM registration) is still open whenever you get to it, no rush. |
|
@backnotprop review addressed at cd2408f, CI green. Bottom band is now geometry-derived, mirroring the top band.
Nits folded in: the sticky test uses the realistic 44px cluster on a short viewport (where the widening genuinely engages) and starts scrolled down so the asserted Verified locally: 19/19 with |
Summary
Fixes #1153.
Document Vim navigation now reveals the cursor target with a margin instead of pinning it flush against the viewport edge, so
j/kmotion no longer parks the caret behind the sticky action bar (top) or the key HUD / status pill (bottom). A target already inside the safe band does not scroll, and a target with no scroll viewport falls back to the previous behavior unchanged.The scroll math lives in a small pure helper (
computeVimScrollDelta); the piece that makes it actually engage is where the viewport comes from. The document scroll host is a native-scroll<div>with no OverlayScrollbars attribute to rediscover, so the helper takes the scrolling element fromScrollViewportContext— the same node the reticle already measures against — rather than by selector. Resolving it by the (absent)[data-overlayscrollbars-viewport]attribute would silently match nothing and fall back to the old edge-pinning behavior, which is exactly the trap this avoids.Proofs using Plannotator in my local integration branch, including this feature:
Screen.Recording.2026-07-30.at.08.12.03.mov
As you see, working flawlessly and w/o requiring the mouse to scroll and view items under the HUD overlays.
Why
Every cursor/target move used
Element.scrollIntoView({ block: 'nearest' }), which pins the target flush against the nearest viewport edge — exactly where the fixed HUD overlays float. Keyboard motion to the top or bottom of a document then hid the caret behind an overlay, while a mouse wheel (which the browser lets overshoot) kept the same line nearer the centre. This reproduces the mouse feel for keyboard motion.Changes
packages/ui/utils/vimScroll.ts:computeVimScrollDelta(viewport, target, band)— a pure function returning the signedscrollTopdelta needed to clear the HUD bands, or0when the target already sits inside the safe band. A target taller than the band is aligned to its top edge (reading order wins — the start of the block is never pushed above the top margin to chase its bottom).resolveVimScrollMargin(viewportHeight)— the band size,clamp(20% of height, 24px..160px), so short viewports keep a usable margin and tall ones do not reserve most of the screen.scrollVimTargetIntoView(element)— resolves the owning scroll viewport the same way the reticle geometry does ([data-overlayscrollbars-viewport]), widens the top band to also clear a sticky action bar when present, and applies the delta. Falls back toscrollIntoView({ block: 'nearest' })when there is no such viewport, and ignores zero-size (unrendered) targets.ScrollViewportContext(useScrollViewport()inViewer, the same node the reticle measures against) and pass it intoscrollVimTargetIntoView. The document scroll host is a native-scroll<div>(OverlayScrollArea) that carries no[data-overlayscrollbars-viewport]attribute, so resolving the viewport by that selector silently matched nothing and fell back to the old edge-pinningscrollIntoView; taking the element from context is what makes the band actually engage. The selector is kept as a secondary fallback for hosts that mount the real OverlayScrollbars library.packages/ui/hooks/useVimSelection.tsthroughscrollVimTargetIntoView(threading the context viewport), instead ofscrollIntoView({ block: 'nearest' }).Semantics and limits
viewport.scrollTop += delta; the browser clamps to[0, scrollHeight - clientHeight], so a delta larger than the available room simply reveals as far as the document allows.[24px, 160px]; on ordinary viewports this reads as a Vimscrolloff-style margin rather than a constant recentre.j/ktracks the caret without animation lag; the reticle's existing rAF-coalesced scroll listener repaints without a feedback loop.--render-htmlannotate surface has its own injected-script navigation with the same edge-parking behavior; it is not addressed here.Validation
packages/ui/utils/vimScroll.test.ts: 16 passed (7 pure-math cases covering the safe band, the j-to-bottom and k-to-top bugs, tall-target top-alignment, a too-tall target straddling both edges, and a viewport offset from the page top; 3 margin clamp cases; 6 DOM cases underDOM_TESTS=1covering the explicit-viewport scroll, the attribute-fallback path, an already-safe no-op, sticky-bar widening, the no-viewport fallback, and a zero-size target).npx tsc --noEmit -p packages/ui/tsconfig.json: clean.npx tsc --noEmit -p packages/ui/tsconfig.strict-consumer.json: clean (@plannotator/core/uistays browser-safe and zero-dep — nonode:imports).Compatibility
The helper is additive. Any target without a resolvable OverlayScrollbars viewport takes the identical
scrollIntoView({ block: 'nearest' })path as before, so non-Plannotator hosts and any surface without the reticle are unchanged. No public API, prop, or payload changes.Related work
None. This is a self-contained ergonomics fix to Vim-mode document navigation.