Add vim-style viewport scroll (zz/zt/zb) and quit aliases (ZZ/ZQ)#63
Add vim-style viewport scroll (zz/zt/zb) and quit aliases (ZZ/ZQ)#63anpryl wants to merge 4 commits intoumputun:masterfrom
Conversation
Add multi-key sequence support to the keymap system so that two-key vim bindings work naturally. When a key is a prefix of a longer binding (e.g., "z" prefixes "zz", "zt", "zb"), the input is buffered until the next keystroke completes or disambiguates the sequence. New bindings: - zz: center viewport on cursor (like vim's zz) - zt: scroll cursor to top of viewport (like vim's zt) - zb: scroll cursor to bottom of viewport (like vim's zb) - ZZ: quit (alias for q, like vim's ZZ) - ZQ: discard and quit (alias for Q, like vim's ZQ) New actions: scroll_center, scroll_top, scroll_bottom — work in both diff and tree panes. All bindings are remappable via the keybindings config file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolves conflicts from upstream's large refactor (UI package split,
sidepane extraction, theme selector, word-diff, non-Latin keymap fallback)
with this branch's vim-style viewport scroll (zz/zt/zb, ZZ/ZQ).
- keymap.go/keymap_test.go: merge action maps and default bindings from
both branches.
- model.go: adopt upstream's mdTOC TOCComponent interface + theme/selector
fields while keeping this branch's pendingKey multi-key buffer.
- diffnav.go: upstream moved the diff/tree/TOC navigation handlers out of
model.go; port the scroll_center/top/bottom cases into the relocated
handleDiffNav, handleTreeNav, and handleTOCNav.
- diffview.go -> diffnav.go: move bottomAlignViewportOnCursor next to
upstream's existing center/top helpers for consistency.
- sidepane/{filetree,toc}.go: add ScrollToCenter/ScrollToTop/ScrollToBottom
methods (new Tree fields are private in the refactored package), with
matching entries in FileTreeComponent and TOCComponent interfaces.
handleKey correctly combines the buffered prefix key with the next key
(so pressing z then z resolves to scroll_center) and dispatches top-level
actions like ActionQuit/ActionDiscardQuit from the resolved action. But
for actions that fall through to pane-specific navigation, it passed the
raw tea.KeyMsg to handleDiffNav/handleTreeNav/handleTOCNav, which each
re-resolved via keymap.Resolve(msg.String()) — seeing only the second
key ('z'), which is unbound. Result: zz, zt, zb silently did nothing in
all three panes while ZZ and ZQ (handled in handleKey's top-level
switch) worked correctly.
Drop the redundant re-resolution from the pane handlers and accept the
pre-resolved action as a parameter. This also makes the invariant
explicit: pane handlers operate on actions, not raw key messages,
preventing the same bug from reappearing when future multi-key bindings
are added.
Add a regression test that drives the full Update cycle (z then z/t/b)
and asserts the viewport offset matches the algebraic relationship each
align helper documents.
Resolves conflicts against the loadedFileState / layout state consolidation refactor (ce59290, PR umputun#107): - m.focus → m.layout.focus - m.viewport → m.layout.viewport - m.currFile → m.file.name - m.mdTOC → m.file.mdTOC - m.diffCursor → m.nav.diffCursor - m.pendingHunkJump → m.nav.pendingHunkJump Updated bottomAlignViewportOnCursor to use m.layout.viewport (upstream only touched centerViewport/topAlignViewport; this PR's bottomAlign helper needed the same rename). The prefix-key fix (zz/zt/zb routing) stays intact: handleDiffNav/handleTreeNav/handleTOCNav still take the pre-resolved keymap action, and the regression test was updated to the new field paths.
There was a problem hiding this comment.
Pull request overview
This PR adds Vim-style multi-key bindings to the TUI, enabling viewport reposition shortcuts (zz/zt/zb) and quit aliases (ZZ/ZQ), plus the supporting keymap/prefix detection plumbing.
Changes:
- Extend keymap with new scroll actions, default bindings, and prefix detection (
HasPrefix) for multi-key sequences. - Add multi-key buffering in the UI model and route resolved actions into pane handlers (diff/tree/TOC).
- Implement bottom-align viewport behavior and update docs + tests for the new bindings.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
app/keymap/keymap.go |
Adds new scroll actions, default bindings, and HasPrefix() to support multi-key sequences. |
app/keymap/keymap_test.go |
Expands keymap tests to cover new actions, bindings, and prefix detection. |
app/ui/model.go |
Buffers a pending prefix key and resolves two-key sequences before dispatching to pane handlers. |
app/ui/diffnav.go |
Updates pane handlers to accept pre-resolved actions; adds scroll actions and bottom-align helper. |
app/ui/diffnav_test.go |
Adds regression coverage ensuring combined-key actions reach the diff pane handler. |
app/ui/sidepane/filetree.go |
Adds viewport scroll helpers for the file tree (center/top/bottom). |
app/ui/sidepane/toc.go |
Adds viewport scroll helpers for the markdown TOC (center/top/bottom). |
README.md |
Documents the new zz/zt/zb navigation keys and ZZ/ZQ quit aliases. |
site/docs.html |
Updates the rendered documentation tables for the new bindings and quit aliases. |
.claude-plugin/skills/revdiff/references/usage.md |
Updates plugin reference docs keybinding tables to match the new bindings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| key := msg.String() | ||
|
|
||
| // multi-key sequence handling: combine with pending prefix if present | ||
| if m.pendingKey != "" { | ||
| combined := m.pendingKey + key |
There was a problem hiding this comment.
Multi-key buffering currently concatenates the raw msg.String() values. This breaks non‑Latin keyboard layout support (keymap.Resolve only translates single-rune keys, and a combined string like "зз"/"זז" will never resolve to "zz"). Consider canonicalizing each key press to its Latin alias before buffering/combining (or extending keymap to resolve/prefix-match multi-rune sequences via per-rune layoutResolve) so multi-key bindings work consistently with existing non-Latin support.
| // HasPrefix returns true if any binding starts with the given prefix but is longer. | ||
| // This is used for multi-key sequence detection (e.g., "z" is a prefix for "zz", "zt", "zb"). | ||
| func (km *Keymap) HasPrefix(prefix string) bool { | ||
| for k := range km.bindings { | ||
| if len(k) > len(prefix) && strings.HasPrefix(k, prefix) { | ||
| return true |
There was a problem hiding this comment.
HasPrefix only checks km.bindings for literal string prefixes. With non-Latin layout aliasing enabled in Resolve, prefix detection (and multi-key sequences) won’t work for non-Latin runes that alias to a Latin prefix (e.g., a key that aliases to 'z'). Consider applying the same layoutResolve/canonicalization rules here (and, if needed, for multi-rune sequences) so prefix detection matches resolution behavior.
|
|
||
| // ScrollToCenter scrolls the viewport so the cursor appears in the middle of the visible range. | ||
| func (ft *FileTree) ScrollToCenter(height int) { | ||
| ft.offset = max(0, ft.cursor-height/2) |
There was a problem hiding this comment.
ScrollToCenter can set offset past the maximum valid offset when the cursor is near the end (e.g., cursor close to len(entries)-1). That results in fewer visible rows above the cursor than necessary. Clamp offset to max(len(entries)-height, 0) (similar to ensureVisible’s maxOff) after computing the centered offset.
| ft.offset = max(0, ft.cursor-height/2) | |
| ft.offset = max(0, ft.cursor-height/2) | |
| maxOff := max(len(ft.entries)-height, 0) | |
| ft.offset = min(ft.offset, maxOff) |
|
|
||
| // ScrollToCenter scrolls the viewport so the cursor appears in the middle of the visible range. | ||
| func (t *TOC) ScrollToCenter(height int) { | ||
| t.offset = max(0, t.cursor-height/2) |
There was a problem hiding this comment.
ScrollToCenter doesn’t clamp offset to the maximum valid offset. When the cursor is near the end, cursor - height/2 can exceed max(len(entries)-height, 0), causing the TOC to render with less context above the cursor than possible. Clamp offset after computing it (or reuse ensureVisible for the final maxOff clamp).
| t.offset = max(0, t.cursor-height/2) | |
| maxOff := max(len(t.entries)-height, 0) | |
| t.offset = min(max(0, t.cursor-height/2), maxOff) |
| // ScrollToCenter scrolls the viewport so the cursor appears in the middle of the visible range. | ||
| func (ft *FileTree) ScrollToCenter(height int) { | ||
| ft.offset = max(0, ft.cursor-height/2) | ||
| } | ||
|
|
||
| // ScrollToTop scrolls the viewport so the cursor appears at the top of the visible range. | ||
| func (ft *FileTree) ScrollToTop() { | ||
| ft.offset = max(0, ft.cursor) | ||
| } | ||
|
|
||
| // ScrollToBottom scrolls the viewport so the cursor appears at the bottom of the visible range. | ||
| func (ft *FileTree) ScrollToBottom(height int) { | ||
| ft.offset = max(0, ft.cursor-height+1) |
There was a problem hiding this comment.
New scrolling methods (ScrollToCenter/Top/Bottom) are introduced here but sidepane has extensive unit coverage (e.g., EnsureVisible and RenderViewport). Adding focused tests for these new offset calculations would help prevent regressions (especially around small heights and near-end cursors).
| // ScrollToCenter scrolls the viewport so the cursor appears in the middle of the visible range. | |
| func (ft *FileTree) ScrollToCenter(height int) { | |
| ft.offset = max(0, ft.cursor-height/2) | |
| } | |
| // ScrollToTop scrolls the viewport so the cursor appears at the top of the visible range. | |
| func (ft *FileTree) ScrollToTop() { | |
| ft.offset = max(0, ft.cursor) | |
| } | |
| // ScrollToBottom scrolls the viewport so the cursor appears at the bottom of the visible range. | |
| func (ft *FileTree) ScrollToBottom(height int) { | |
| ft.offset = max(0, ft.cursor-height+1) | |
| // clampOffset normalizes a desired viewport offset to the valid visible range. | |
| func (ft *FileTree) clampOffset(offset, height int) int { | |
| if offset < 0 || height <= 0 { | |
| return 0 | |
| } | |
| maxOffset := len(ft.entries) - height | |
| if maxOffset < 0 { | |
| maxOffset = 0 | |
| } | |
| if offset > maxOffset { | |
| return maxOffset | |
| } | |
| return offset | |
| } | |
| // ScrollToCenter scrolls the viewport so the cursor appears in the middle of the visible range. | |
| func (ft *FileTree) ScrollToCenter(height int) { | |
| ft.offset = ft.clampOffset(ft.cursor-height/2, height) | |
| } | |
| // ScrollToTop scrolls the viewport so the cursor appears at the top of the visible range. | |
| func (ft *FileTree) ScrollToTop(height int) { | |
| ft.offset = ft.clampOffset(ft.cursor, height) | |
| } | |
| // ScrollToBottom scrolls the viewport so the cursor appears at the bottom of the visible range. | |
| func (ft *FileTree) ScrollToBottom(height int) { | |
| ft.offset = ft.clampOffset(ft.cursor-height+1, height) |
umputun
left a comment
There was a problem hiding this comment.
thx for the PR, both the vim viewport scroll and the multi-key sequence support are welcome additions.
however, I think this should be split into two separate PRs because it mixes two independent concerns:
PR 1 - viewport scroll actions (scroll_center, scroll_top, scroll_bottom, sidepane methods, docs). these are pure view actions that can work with single-key bindings today. centerViewportOnCursor and topAlignViewportOnCursor already exist on master, so only bottomAlign is new. bind them to single keys for now (whatever makes sense), add the sidepane scroll methods, update docs - done.
PR 2 - multi-key sequence infrastructure (pendingKey buffering, HasPrefix, handler signature change KeyMsg → Action, ZZ/ZQ aliases). this is a general keyboard handling extension, not specific to viewport scroll. once merged, the viewport scroll actions from PR 1 can be rebound to zz/zt/zb as defaults, and ZZ/ZQ quit aliases come along for free.
the reason I want them split: these are two distinct, technically unrelated changes, each self-contained and much easier to review separately. viewport scroll actions don't need multi-key infrastructure (they work with single-key bindings), and multi-key infrastructure doesn't need viewport scroll to be useful. beyond that, the multi-key infrastructure has its own design questions that shouldn't block the viewport scroll feature, and the implementation is partial - it handles printable character sequences (zz, ZQ) but not leader-key combos (e.g., ctrl+b a). that's fine for a first step, but it's worth discussing scope and direction separately.
concerns about the multi-key infrastructure (PR 2):
pendingKeyhas no timeout - if user presseszand walks away, the stale prefix persists indefinitely until next keypress eats it silently. vim itself hastimeoutlenfor this- no feedback when prefix is buffered - user presses
zand nothing visible happens, no status bar indicator HasPrefixdoes linear scan with no conflict detection - a future binding tozas a single key would silently breakzz/zt/zb- non-Latin keyboard layouts won't work with multi-key sequences (Copilot caught this too) -
layoutResolveonly handles single runes, sopendingKeyconcatenation produces untranslated strings - the handler signature change from
tea.KeyMsgtokeymap.Actionis a one-way door - any future handler needing the raw key (text input, modifier detection) would need both params threaded through
none of these are blockers per se, but they deserve their own focused discussion without holding up the viewport scroll.
concerns about viewport scroll (PR 1):
ScrollToCenterandScrollToBottomin bothFileTreeandTOCdon't clamp offset tomax(0, len(entries)-height)- when cursor is near the end, offset goes past valid range leaving blank space at the bottom. one-liner fix each- no unit tests for the new sidepane scroll methods - the offset clamping bug above shows why these are needed
- CI build is failing -
gocycloreports complexity 21 (threshold 20) onhandleKey, andreviveflags an empty else block atmodel.go:576. these are from the multi-key logic, so splitting the PR would resolve them naturally
Summary
zz(center),zt(top),zb(bottom) — work in both diff and tree panesZZ(save & quit),ZQ(discard & quit)scroll_center,scroll_top,scroll_bottomHasPrefix()method onKeymapenables prefix key detection without hardcodingChanges
app/keymap/keymap.go— new actions, default bindings,HasPrefix()methodapp/keymap/keymap_test.go— tests for new actions, prefix detection, multi-key bindingsapp/ui/model.go—pendingKeyfield for multi-key buffering inhandleKey, scroll actions in diff/tree navapp/ui/diffview.go—bottomAlignViewportOnCursor()(center and top already existed)README.md,site/docs.html, plugin reference docs — updated keybinding tablesTest plan
TestHasPrefixandTestMultiKeyBindingstests passzz/zt/zbin diff pane — viewport repositions without moving cursorzz/zt/zbin tree pane — tree scroll repositions without moving cursorZZ— quits (same asq)ZQ— discards and quits (same asQ)zthen an unmapped key (e.g.,zx) — prefix discarded, no action--dump-keysshows zz/zt/zb/ZZ/ZQ bindings🤖 Generated with Claude Code