Skip to content

Commit 4ede187

Browse files
author
Pavel Koptilin
authored
fix: make ctrl+d/u scroll half-page instead of full page (#25)
* feat: split ctrl+d/u from PgDn/PgUp for half-page scrolling ctrl+d/u now scroll half a page in diff, tree, and TOC panes, matching vim convention. PgDn/PgUp remain full-page scroll. * docs: verify documentation for half-page scroll feature * fix: address code review findings - Update README.md and usage.md: ctrl+d/u described as "Half-page scroll" instead of "Page scroll" - Add changelog entry for half-page scroll fix - Add missing ctrl+u test case in TestModel_TreeCtrlDUMovesHalfPage * move completed plan: 2026-04-05-half-page-scroll.md * chore: remove completed plan file * revert: remove changelog entry (maintainer responsibility) * fix: scroll viewport explicitly on ctrl+d/u half-page movement Replace syncViewportToCursor() with explicit YOffset adjustment so viewport scrolls by half page simultaneously with cursor, matching vim/less ctrl+d/u behavior. Add viewport.YOffset assertions to half-page tests.
1 parent e317b3c commit 4ede187

5 files changed

Lines changed: 200 additions & 18 deletions

File tree

.claude-plugin/skills/revdiff/references/usage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ When `--only` specifies a file that has no git changes (or when no git repo exis
6666
| left/right | Horizontal scroll in diff pane |
6767
| `Tab` | Switch between file tree and diff pane |
6868
| `PgDown/PgUp` | Page scroll in file tree and diff pane |
69-
| `Ctrl+d/Ctrl+u` | Page scroll in file tree and diff pane |
69+
| `Ctrl+d/Ctrl+u` | Half-page scroll in file tree and diff pane |
7070
| `Home/End` | Jump to first/last item |
7171
| `Enter` | Switch to diff pane (tree) / start annotation (diff pane) |
7272
| `n/p` | Next/previous changed file; next/prev header in markdown TOC mode (n = next match when search active) |

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ This mode activates when all three conditions are met: single file, markdown ext
316316
| left/right | Horizontal scroll in diff pane |
317317
| `Tab` | Switch between file tree and diff pane |
318318
| `PgDown/PgUp` | Page scroll in file tree and diff pane |
319-
| `Ctrl+d/Ctrl+u` | Page scroll in file tree and diff pane |
319+
| `Ctrl+d/Ctrl+u` | Half-page scroll in file tree and diff pane |
320320
| `Home/End` | Jump to first/last item |
321321
| `Enter` | Switch to diff pane (tree) / start annotation (diff pane) |
322322
| `n/p` | Next/previous changed file; next/prev header in markdown TOC mode (n = next match when search active) |

ui/diffview.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,45 @@ func (m *Model) moveDiffCursorPageUp() {
513513
m.viewport.SetContent(m.renderDiff())
514514
}
515515

516+
// moveDiffCursorHalfPageDown moves the diff cursor down by half a visual page.
517+
// scrolls viewport by half page explicitly, matching vim/less ctrl+d behavior.
518+
func (m *Model) moveDiffCursorHalfPageDown() {
519+
halfPage := max(1, m.viewport.Height/2)
520+
startY := m.cursorViewportY()
521+
for {
522+
prev := m.diffCursor
523+
m.moveDiffCursorDown()
524+
if m.diffCursor == prev {
525+
break
526+
}
527+
if m.cursorViewportY()-startY >= halfPage {
528+
break
529+
}
530+
}
531+
maxOffset := max(0, m.viewport.TotalLineCount()-m.viewport.Height)
532+
m.viewport.SetYOffset(min(m.viewport.YOffset+halfPage, maxOffset))
533+
m.viewport.SetContent(m.renderDiff())
534+
}
535+
536+
// moveDiffCursorHalfPageUp moves the diff cursor up by half a visual page.
537+
// scrolls viewport by half page explicitly, matching vim/less ctrl+u behavior.
538+
func (m *Model) moveDiffCursorHalfPageUp() {
539+
halfPage := max(1, m.viewport.Height/2)
540+
startY := m.cursorViewportY()
541+
for {
542+
prev := m.diffCursor
543+
m.moveDiffCursorUp()
544+
if m.diffCursor == prev {
545+
break
546+
}
547+
if startY-m.cursorViewportY() >= halfPage {
548+
break
549+
}
550+
}
551+
m.viewport.SetYOffset(max(0, m.viewport.YOffset-halfPage))
552+
m.viewport.SetContent(m.renderDiff())
553+
}
554+
516555
// moveDiffCursorToStart moves the diff cursor to the first selectable position.
517556
// if a file-level annotation exists, the cursor goes to -1 (file annotation line).
518557
func (m *Model) moveDiffCursorToStart() {

ui/model.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -444,10 +444,14 @@ func (m Model) handleTreeNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
444444
m.tree.moveDown()
445445
case msg.String() == "k" || msg.String() == "up":
446446
m.tree.moveUp()
447-
case msg.Type == tea.KeyPgDown || msg.String() == "ctrl+d":
447+
case msg.Type == tea.KeyPgDown:
448448
m.tree.pageDown(m.treePageSize())
449-
case msg.Type == tea.KeyPgUp || msg.String() == "ctrl+u":
449+
case msg.String() == "ctrl+d":
450+
m.tree.pageDown(max(1, m.treePageSize()/2))
451+
case msg.Type == tea.KeyPgUp:
450452
m.tree.pageUp(m.treePageSize())
453+
case msg.String() == "ctrl+u":
454+
m.tree.pageUp(max(1, m.treePageSize()/2))
451455
case msg.Type == tea.KeyHome:
452456
m.tree.moveToFirst()
453457
case msg.Type == tea.KeyEnd:
@@ -469,14 +473,22 @@ func (m Model) handleTOCNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
469473
m.mdTOC.moveDown()
470474
case msg.String() == "k" || msg.String() == "up":
471475
m.mdTOC.moveUp()
472-
case msg.Type == tea.KeyPgDown || msg.String() == "ctrl+d":
476+
case msg.Type == tea.KeyPgDown:
473477
for range m.treePageSize() {
474478
m.mdTOC.moveDown()
475479
}
476-
case msg.Type == tea.KeyPgUp || msg.String() == "ctrl+u":
480+
case msg.String() == "ctrl+d":
481+
for range max(1, m.treePageSize()/2) {
482+
m.mdTOC.moveDown()
483+
}
484+
case msg.Type == tea.KeyPgUp:
477485
for range m.treePageSize() {
478486
m.mdTOC.moveUp()
479487
}
488+
case msg.String() == "ctrl+u":
489+
for range max(1, m.treePageSize()/2) {
490+
m.mdTOC.moveUp()
491+
}
480492
case msg.Type == tea.KeyHome:
481493
m.mdTOC.cursor = 0
482494
case msg.Type == tea.KeyEnd:
@@ -522,10 +534,14 @@ func (m Model) handleDiffNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
522534
case msg.String() == "k" || msg.String() == "up":
523535
m.moveDiffCursorUp()
524536
m.syncViewportToCursor()
525-
case msg.Type == tea.KeyPgDown || msg.String() == "ctrl+d":
537+
case msg.Type == tea.KeyPgDown:
526538
m.moveDiffCursorPageDown()
527-
case msg.Type == tea.KeyPgUp || msg.String() == "ctrl+u":
539+
case msg.String() == "ctrl+d":
540+
m.moveDiffCursorHalfPageDown()
541+
case msg.Type == tea.KeyPgUp:
528542
m.moveDiffCursorPageUp()
543+
case msg.String() == "ctrl+u":
544+
m.moveDiffCursorHalfPageUp()
529545
case msg.Type == tea.KeyHome:
530546
m.moveDiffCursorToStart()
531547
case msg.Type == tea.KeyEnd:

ui/model_test.go

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,11 +1728,12 @@ func TestModel_PgDownMovesCursorByPageHeight(t *testing.T) {
17281728
model = result.(Model)
17291729
assert.Equal(t, pageHeight, model.diffCursor, "PgDown should move cursor by viewport height")
17301730

1731-
// ctrl+d should also move by page height from current position
1731+
// ctrl+d should move by half page height from current position
17321732
prevCursor := model.diffCursor
17331733
result, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
17341734
model = result.(Model)
1735-
assert.Equal(t, prevCursor+pageHeight, model.diffCursor, "ctrl+d should move cursor by viewport height")
1735+
halfPage := pageHeight / 2
1736+
assert.Equal(t, prevCursor+halfPage, model.diffCursor, "ctrl+d should move cursor by half viewport height")
17361737
}
17371738

17381739
func TestModel_PgUpMovesCursorByPageHeight(t *testing.T) {
@@ -1763,11 +1764,135 @@ func TestModel_PgUpMovesCursorByPageHeight(t *testing.T) {
17631764
model = result.(Model)
17641765
assert.Equal(t, 80-pageHeight, model.diffCursor, "PgUp should move cursor up by viewport height")
17651766

1766-
// ctrl+u should also move up by page height
1767+
// ctrl+u should move up by half page height
17671768
model.diffCursor = 80
17681769
result, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
17691770
model = result.(Model)
1770-
assert.Equal(t, 80-pageHeight, model.diffCursor, "ctrl+u should move cursor up by viewport height")
1771+
halfPage := pageHeight / 2
1772+
assert.Equal(t, 80-halfPage, model.diffCursor, "ctrl+u should move cursor up by half viewport height")
1773+
}
1774+
1775+
func TestModel_CtrlDMovesHalfPageDown(t *testing.T) {
1776+
lines := make([]diff.DiffLine, 100)
1777+
for i := range lines {
1778+
lines[i] = diff.DiffLine{NewNum: i + 1, Content: "line", ChangeType: diff.ChangeContext}
1779+
}
1780+
1781+
m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines})
1782+
1783+
result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
1784+
model := result.(Model)
1785+
1786+
result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines})
1787+
model = result.(Model)
1788+
model.focus = paneDiff
1789+
assert.Equal(t, 0, model.diffCursor)
1790+
1791+
pageHeight := model.viewport.Height
1792+
halfPage := pageHeight / 2
1793+
require.Positive(t, halfPage, "half page must be positive")
1794+
1795+
// ctrl+d moves cursor and viewport by half page
1796+
result, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
1797+
model = result.(Model)
1798+
assert.Equal(t, halfPage, model.diffCursor, "ctrl+d should move cursor by half viewport height")
1799+
assert.Equal(t, halfPage, model.viewport.YOffset, "ctrl+d should scroll viewport by half page")
1800+
1801+
// PgDn moves full page from start for comparison
1802+
model.diffCursor = 0
1803+
model.viewport.SetYOffset(0)
1804+
model.viewport.SetContent(model.renderDiff())
1805+
result, _ = model.Update(tea.KeyMsg{Type: tea.KeyPgDown})
1806+
model = result.(Model)
1807+
assert.Equal(t, pageHeight, model.diffCursor, "PgDown should move cursor by full viewport height")
1808+
}
1809+
1810+
func TestModel_CtrlUMovesHalfPageUp(t *testing.T) {
1811+
lines := make([]diff.DiffLine, 100)
1812+
for i := range lines {
1813+
lines[i] = diff.DiffLine{NewNum: i + 1, Content: "line", ChangeType: diff.ChangeContext}
1814+
}
1815+
1816+
m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines})
1817+
1818+
result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
1819+
model := result.(Model)
1820+
1821+
result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines})
1822+
model = result.(Model)
1823+
model.focus = paneDiff
1824+
1825+
pageHeight := model.viewport.Height
1826+
halfPage := pageHeight / 2
1827+
require.Positive(t, halfPage, "half page must be positive")
1828+
1829+
// start at line 80 with viewport scrolled to match
1830+
model.diffCursor = 80
1831+
model.viewport.SetYOffset(80)
1832+
model.viewport.SetContent(model.renderDiff())
1833+
prevOffset := model.viewport.YOffset
1834+
1835+
// ctrl+u moves cursor and viewport by half page up
1836+
result, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
1837+
model = result.(Model)
1838+
assert.Equal(t, 80-halfPage, model.diffCursor, "ctrl+u should move cursor up by half viewport height")
1839+
assert.Equal(t, prevOffset-halfPage, model.viewport.YOffset, "ctrl+u should scroll viewport up by half page")
1840+
1841+
// PgUp moves full page up from 80 for comparison
1842+
model.diffCursor = 80
1843+
model.viewport.SetYOffset(80)
1844+
model.viewport.SetContent(model.renderDiff())
1845+
result, _ = model.Update(tea.KeyMsg{Type: tea.KeyPgUp})
1846+
model = result.(Model)
1847+
assert.Equal(t, 80-pageHeight, model.diffCursor, "PgUp should move cursor up by full viewport height")
1848+
}
1849+
1850+
func TestModel_TreeCtrlDUMovesHalfPage(t *testing.T) {
1851+
files := make([]string, 50)
1852+
for i := range files {
1853+
files[i] = fmt.Sprintf("pkg/file%02d.go", i)
1854+
}
1855+
m := testModel(files, nil)
1856+
m.tree = newFileTree(files)
1857+
m.focus = paneTree
1858+
m.height = 20
1859+
1860+
pageSize := m.treePageSize()
1861+
halfPage := max(1, pageSize/2)
1862+
1863+
// ctrl+d from start
1864+
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
1865+
model := result.(Model)
1866+
assert.Equal(t, fmt.Sprintf("pkg/file%02d.go", halfPage), model.tree.selectedFile(),
1867+
"ctrl+d should move by half page")
1868+
1869+
// ctrl+u from end area
1870+
m3 := testModel(files, nil)
1871+
m3.tree = newFileTree(files)
1872+
m3.focus = paneTree
1873+
m3.height = 20
1874+
// move to file 39
1875+
m3.tree.moveToLast()
1876+
for range 10 {
1877+
m3.tree.moveUp()
1878+
}
1879+
assert.Equal(t, "pkg/file39.go", m3.tree.selectedFile())
1880+
1881+
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
1882+
model3 := result.(Model)
1883+
assert.Equal(t, fmt.Sprintf("pkg/file%02d.go", 39-halfPage), model3.tree.selectedFile(),
1884+
"ctrl+u should move by half page")
1885+
1886+
// PgDn from start should move full page
1887+
m2 := testModel(files, nil)
1888+
m2.tree = newFileTree(files)
1889+
m2.focus = paneTree
1890+
m2.height = 20
1891+
1892+
result, _ = m2.Update(tea.KeyMsg{Type: tea.KeyPgDown})
1893+
model2 := result.(Model)
1894+
assert.Equal(t, fmt.Sprintf("pkg/file%02d.go", pageSize), model2.tree.selectedFile(),
1895+
"PgDn should move by full page")
17711896
}
17721897

17731898
func TestModel_HomeEndMoveCursorToBoundaries(t *testing.T) {
@@ -2058,7 +2183,7 @@ func TestModel_TreePgUpMovesCursorByPage(t *testing.T) {
20582183
assert.Equal(t, expected, model.tree.selectedFile(), "PgUp in tree should move cursor by page size")
20592184
}
20602185

2061-
func TestModel_TreeCtrlDMovesCursorByPage(t *testing.T) {
2186+
func TestModel_TreeCtrlDMovesCursorByHalfPage(t *testing.T) {
20622187
files := make([]string, 50)
20632188
for i := range files {
20642189
files[i] = fmt.Sprintf("pkg/file%02d.go", i)
@@ -2072,14 +2197,15 @@ func TestModel_TreeCtrlDMovesCursorByPage(t *testing.T) {
20722197

20732198
pageSize := m.treePageSize()
20742199
require.Positive(t, pageSize)
2200+
halfPage := max(1, pageSize/2)
20752201

20762202
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
20772203
model := result.(Model)
2078-
assert.Equal(t, fmt.Sprintf("pkg/file%02d.go", pageSize), model.tree.selectedFile(),
2079-
"ctrl+d in tree should move cursor by page size")
2204+
assert.Equal(t, fmt.Sprintf("pkg/file%02d.go", halfPage), model.tree.selectedFile(),
2205+
"ctrl+d in tree should move cursor by half page size")
20802206
}
20812207

2082-
func TestModel_TreeCtrlUMovesCursorByPage(t *testing.T) {
2208+
func TestModel_TreeCtrlUMovesCursorByHalfPage(t *testing.T) {
20832209
files := make([]string, 50)
20842210
for i := range files {
20852211
files[i] = fmt.Sprintf("pkg/file%02d.go", i)
@@ -2091,6 +2217,7 @@ func TestModel_TreeCtrlUMovesCursorByPage(t *testing.T) {
20912217

20922218
pageSize := m.treePageSize()
20932219
require.Positive(t, pageSize)
2220+
halfPage := max(1, pageSize/2)
20942221

20952222
m.tree.moveToLast()
20962223
for range 10 {
@@ -2100,8 +2227,8 @@ func TestModel_TreeCtrlUMovesCursorByPage(t *testing.T) {
21002227

21012228
result, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
21022229
model := result.(Model)
2103-
expected := fmt.Sprintf("pkg/file%02d.go", 39-pageSize)
2104-
assert.Equal(t, expected, model.tree.selectedFile(), "ctrl+u in tree should move cursor by page size")
2230+
expected := fmt.Sprintf("pkg/file%02d.go", 39-halfPage)
2231+
assert.Equal(t, expected, model.tree.selectedFile(), "ctrl+u in tree should move cursor by half page size")
21052232
}
21062233

21072234
func TestModel_TreeHomeEndMoveToBoundaries(t *testing.T) {

0 commit comments

Comments
 (0)