Skip to content

Add vim-style viewport scroll (zz/zt/zb) and quit aliases (ZZ/ZQ)#63

Closed
anpryl wants to merge 4 commits intoumputun:masterfrom
anpryl:vim-viewport-scroll
Closed

Add vim-style viewport scroll (zz/zt/zb) and quit aliases (ZZ/ZQ)#63
anpryl wants to merge 4 commits intoumputun:masterfrom
anpryl:vim-viewport-scroll

Conversation

@anpryl
Copy link
Copy Markdown

@anpryl anpryl commented Apr 8, 2026

Summary

  • Add multi-key sequence support to the keymap system for two-key vim bindings
  • New viewport scroll bindings: zz (center), zt (top), zb (bottom) — work in both diff and tree panes
  • New quit aliases: ZZ (save & quit), ZQ (discard & quit)
  • 3 new remappable actions: scroll_center, scroll_top, scroll_bottom
  • HasPrefix() method on Keymap enables prefix key detection without hardcoding

Changes

  • app/keymap/keymap.go — new actions, default bindings, HasPrefix() method
  • app/keymap/keymap_test.go — tests for new actions, prefix detection, multi-key bindings
  • app/ui/model.gopendingKey field for multi-key buffering in handleKey, scroll actions in diff/tree nav
  • app/ui/diffview.gobottomAlignViewportOnCursor() (center and top already existed)
  • README.md, site/docs.html, plugin reference docs — updated keybinding tables

Test plan

  • All existing keymap tests pass
  • All existing UI tests pass
  • New TestHasPrefix and TestMultiKeyBindings tests pass
  • Manual: press zz/zt/zb in diff pane — viewport repositions without moving cursor
  • Manual: press zz/zt/zb in tree pane — tree scroll repositions without moving cursor
  • Manual: press ZZ — quits (same as q)
  • Manual: press ZQ — discards and quits (same as Q)
  • Manual: press z then an unmapped key (e.g., zx) — prefix discarded, no action
  • Manual: --dump-keys shows zz/zt/zb/ZZ/ZQ bindings

🤖 Generated with Claude Code

anpryl and others added 4 commits April 8, 2026 08:59
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.
@anpryl anpryl marked this pull request as ready for review April 15, 2026 13:36
@anpryl anpryl requested a review from umputun as a code owner April 15, 2026 13:36
Copilot AI review requested due to automatic review settings April 15, 2026 13:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/ui/model.go
Comment on lines +568 to +572
key := msg.String()

// multi-key sequence handling: combine with pending prefix if present
if m.pendingKey != "" {
combined := m.pendingKey + key
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread app/keymap/keymap.go
Comment on lines +265 to +270
// 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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

// 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)
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment thread app/ui/sidepane/toc.go

// 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)
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +183
// 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)
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

@umputun umputun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 KeyMsgAction, 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):

  1. pendingKey has no timeout - if user presses z and walks away, the stale prefix persists indefinitely until next keypress eats it silently. vim itself has timeoutlen for this
  2. no feedback when prefix is buffered - user presses z and nothing visible happens, no status bar indicator
  3. HasPrefix does linear scan with no conflict detection - a future binding to z as a single key would silently break zz/zt/zb
  4. non-Latin keyboard layouts won't work with multi-key sequences (Copilot caught this too) - layoutResolve only handles single runes, so pendingKey concatenation produces untranslated strings
  5. the handler signature change from tea.KeyMsg to keymap.Action is 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):

  1. ScrollToCenter and ScrollToBottom in both FileTree and TOC don't clamp offset to max(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
  2. no unit tests for the new sidepane scroll methods - the offset clamping bug above shows why these are needed
  3. CI build is failing - gocyclo reports complexity 21 (threshold 20) on handleKey, and revive flags an empty else block at model.go:576. these are from the multi-key logic, so splitting the PR would resolve them naturally

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants