From e17630001735e7db02e8ce0de5f5518ec5460642 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:29:44 +0200 Subject: [PATCH] Page through the TUI's lists with PgUp and PgDn Co-Authored-By: Claude Opus 5.5 --- docs/tui.md | 2 + internal/tui/contacts.go | 5 ++ internal/tui/contacts_list.go | 8 +++ internal/tui/content.go | 73 +++++++++++++++++++++ internal/tui/mail.go | 21 ++++++ internal/tui/screener.go | 14 ++++ internal/tui/tui_test.go | 117 ++++++++++++++++++++++++++++++++++ 7 files changed, 240 insertions(+) diff --git a/docs/tui.md b/docs/tui.md index 85c50790..a333d475 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -32,6 +32,8 @@ the boxes on `9`, the web app's shortcut, showing the Imbox's already-read threa newest-seen first with the usual thread actions available; Escape returns to the box you were in. Every list keeps going: scroll towards the bottom of a box, label, collection or search and the next threads are read in behind you, so there are no pages to step through. +PgUp and PgDn move a screen at a time through any list — a box, a search, a bundle, The +Screener or your contacts — keeping the cursor on the same row of the screen. The thread actions use HEY's web shortcuts, in either letter case except `l`, whose uppercase belongs to Labels: diff --git a/internal/tui/contacts.go b/internal/tui/contacts.go index d056dac5..590cbc5b 100644 --- a/internal/tui/contacts.go +++ b/internal/tui/contacts.go @@ -374,6 +374,11 @@ func (v *contactsView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case tea.KeyDown: v.list.moveDown() return v.loadMoreContacts() + case tea.KeyPgUp: + v.list.pageUp() + case tea.KeyPgDown: + v.list.pageDown() + return v.loadMoreContacts() case tea.KeyEnter: if contact := v.list.selected(); contact != nil { return v.requestContactDetail(contact.ID) diff --git a/internal/tui/contacts_list.go b/internal/tui/contacts_list.go index 7ed649a0..fdf56b15 100644 --- a/internal/tui/contacts_list.go +++ b/internal/tui/contacts_list.go @@ -48,6 +48,14 @@ func (l *contactList) moveDown() { } } +func (l *contactList) pageDown() { + l.cursor, l.scrollOff = pageBy(l.cursor, l.scrollOff, l.visibleCount(), len(l.contacts), 1) +} + +func (l *contactList) pageUp() { + l.cursor, l.scrollOff = pageBy(l.cursor, l.scrollOff, l.visibleCount(), len(l.contacts), -1) +} + // visibleCount is how many contacts the window holds. Each one takes one line. func (l *contactList) visibleCount() int { return max(l.height, 1) diff --git a/internal/tui/content.go b/internal/tui/content.go index d1f187f0..0b7b8fea 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -455,6 +455,79 @@ func (c *contentList) moveDown() { } } +// pageDown scrolls the list on by the window it shows, the way a pager does: what was +// just past the bottom comes up to the top, and the cursor keeps its row on screen. On +// the last window already there is nowhere to scroll, so the cursor goes to the end. +func (c *contentList) pageDown() { + c.selectionLost = false + count := c.itemCount() + if count == 0 { + return + } + row := c.cursor - c.scrollOff + scrollOff := min(c.scrollOff+c.visibleItemsFrom(c.scrollOff), c.lastWindowStart()) + if scrollOff <= c.scrollOff { + c.cursor = count - 1 + } else { + c.scrollOff = scrollOff + c.cursor = min(scrollOff+row, count-1) + } + c.ensureVisible() +} + +// pageUp scrolls the list back by a window, keeping the cursor's row on screen. At the +// top already, the cursor goes to the first thread. +func (c *contentList) pageUp() { + c.selectionLost = false + if c.itemCount() == 0 { + return + } + if c.scrollOff == 0 { + c.cursor = 0 + return + } + row := c.cursor - c.scrollOff + // The window above is the one that ends where this one starts. + start := c.scrollOff + for start > 0 && start-1+c.visibleItemsFrom(start-1) >= c.scrollOff { + start-- + } + c.scrollOff = start + c.cursor = min(start+row, c.itemCount()-1) + c.ensureVisible() +} + +// lastWindowStart is the first posting of the window that ends the list, so a page +// down never scrolls the last thread up past the bottom of the screen. +func (c *contentList) lastWindowStart() int { + count := c.itemCount() + start := max(count-1, 0) + for start > 0 && start-1+c.visibleItemsFrom(start-1) >= count { + start-- + } + return start +} + +// pageBy moves a cursor and its window a page at a time through a list whose rows are +// all one height, the way contentList.pageDown and pageUp do through postings: the +// window moves by what it shows and the cursor keeps its row on screen, or goes to the +// end when the window cannot move any further. +func pageBy(cursor, scroll, visible, count, direction int) (int, int) { + if count == 0 { + return cursor, scroll + } + visible = max(visible, 1) + row := cursor - scroll + moved := min(max(scroll+direction*visible, 0), max(count-visible, 0)) + if moved == scroll { + if direction > 0 { + return count - 1, scroll + } + return 0, scroll + } + return min(max(moved+row, 0), count-1), moved +} + // listHeight is the rows the postings get. A cover holds back its divider and // the art's floor at the bottom of the list, so the cover is always on screen // rather than something you could scroll past. diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 8e7d9a23..8be945f4 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1402,6 +1402,11 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case tea.KeyDown: v.searchList.moveDown() return v.loadMoreSearchResults() + case tea.KeyPgUp: + v.searchList.pageUp() + case tea.KeyPgDown: + v.searchList.pageDown() + return v.loadMoreSearchResults() case tea.KeyEnter: return v.openSelected() default: @@ -1425,6 +1430,11 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case tea.KeyDown: v.bundleList.moveDown() return v.loadMoreBundlePostings() + case tea.KeyPgUp: + v.bundleList.pageUp() + case tea.KeyPgDown: + v.bundleList.pageDown() + return v.loadMoreBundlePostings() case tea.KeyEnter: return v.openSelected() default: @@ -1446,6 +1456,11 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case tea.KeyDown: v.seenList.moveDown() return v.loadMoreSeenPostings() + case tea.KeyPgUp: + v.seenList.pageUp() + case tea.KeyPgDown: + v.seenList.pageDown() + return v.loadMoreSeenPostings() case tea.KeyEnter: return v.openSelected() default: @@ -1490,6 +1505,12 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case tea.KeyDown: v.postingList.moveDown() return v.loadMorePostings() + case tea.KeyPgUp: + v.postingList.pageUp() + return nil + case tea.KeyPgDown: + v.postingList.pageDown() + return v.loadMorePostings() case tea.KeyEnter: return v.openSelected() default: diff --git a/internal/tui/screener.go b/internal/tui/screener.go index 02a7eef3..4b29b8d0 100644 --- a/internal/tui/screener.go +++ b/internal/tui/screener.go @@ -206,6 +206,14 @@ func (p *screenerPane) moveDown(visible int) { } } +func (p *screenerPane) pageDown(visible int) { + p.cursor, p.scroll = pageBy(p.cursor, p.scroll, visible, len(p.rows), 1) +} + +func (p *screenerPane) pageUp(visible int) { + p.cursor, p.scroll = pageBy(p.cursor, p.scroll, visible, len(p.rows), -1) +} + func (p *screenerPane) ensureVisible(visible int) { if p.cursor < p.scroll { p.scroll = p.cursor @@ -434,6 +442,12 @@ func (v *screenerView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case tea.KeyDown: v.pane().moveDown(v.visibleRows()) return v.loadMoreRows() + case tea.KeyPgUp: + v.pane().pageUp(v.visibleRows()) + return nil + case tea.KeyPgDown: + v.pane().pageDown(v.visibleRows()) + return v.loadMoreRows() } switch key { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 2a3fa8b7..bf0ab5c2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -103,6 +103,10 @@ func keyPress(key string) tea.KeyPressMsg { k = tea.Key{Code: tea.KeyUp} case "down": k = tea.Key{Code: tea.KeyDown} + case "pgup": + k = tea.Key{Code: tea.KeyPgUp} + case "pgdown": + k = tea.Key{Code: tea.KeyPgDown} case " ", "space": k = tea.Key{Code: tea.KeySpace, Text: " "} } @@ -1229,6 +1233,119 @@ func TestARefreshThatDropsTheCursorsThreadKeepsItsRow(t *testing.T) { } } +// PgDn scrolls the list on by the window it shows, so what was just below the bottom +// comes up to the top, and the cursor keeps its row on screen; PgUp takes it back. +func TestContentListPagesByTheWindow(t *testing.T) { + cl := &contentList{} + cl.setPostings(longImbox(40, 0)) + cl.setSize(80, 20) + cl.moveDown() + window := cl.visibleItemsFrom(0) + bottom := stripANSI(cl.view()) + lastShown := cl.postings[window-1].Name + if !strings.Contains(bottom, lastShown) { + t.Fatalf("the window should end on %q", lastShown) + } + + cl.pageDown() + + if cl.scrollOff != window { + t.Errorf("scrollOff = %d, want the next window at %d", cl.scrollOff, window) + } + if cl.cursor != window+1 { + t.Errorf("cursor = %d, want it on the same row of the next window (%d)", cl.cursor, window+1) + } + if strings.Contains(stripANSI(cl.view()), lastShown+"\n") { + t.Errorf("the old window is still on screen") + } + + cl.pageUp() + + if cl.scrollOff != 0 || cl.cursor != 1 { + t.Errorf("PgUp landed on cursor=%d scrollOff=%d, want back at 1 and 0", cl.cursor, cl.scrollOff) + } +} + +// At either end there is no window to scroll to, so the keys take the cursor to the +// first or last thread, and a page down never scrolls the last thread off the bottom. +func TestContentListPagingStopsAtTheEnds(t *testing.T) { + cl := &contentList{} + cl.setPostings(longImbox(40, 0)) + cl.setSize(80, 20) + + for range 10 { + cl.pageDown() + } + if cl.cursor != 39 { + t.Errorf("cursor = %d, want the last thread", cl.cursor) + } + if cl.scrollOff != cl.lastWindowStart() { + t.Errorf("scrollOff = %d, want the last full window at %d", cl.scrollOff, cl.lastWindowStart()) + } + if !strings.Contains(stripANSI(cl.view()), cl.postings[39].Name) { + t.Error("the last thread is not on screen") + } + + for range 10 { + cl.pageUp() + } + if cl.cursor != 0 || cl.scrollOff != 0 { + t.Errorf("cursor=%d scrollOff=%d, want the top of the list", cl.cursor, cl.scrollOff) + } + + short := &contentList{} + short.setPostings(longImbox(3, 0)) + short.setSize(80, 20) + short.pageDown() + if short.cursor != 2 || short.scrollOff != 0 { + t.Errorf("a list that fits: cursor=%d scrollOff=%d, want the last thread without scrolling", short.cursor, short.scrollOff) + } +} + +func TestPageByMovesFixedHeightLists(t *testing.T) { + for _, testCase := range []struct { + name string + cursor, scroll, direction int + wantCursor, wantScroll int + }{ + {name: "down a page", cursor: 2, scroll: 0, direction: 1, wantCursor: 12, wantScroll: 10}, + {name: "down into the last window", cursor: 32, scroll: 30, direction: 1, wantCursor: 37, wantScroll: 35}, + {name: "down on the last window", cursor: 37, scroll: 35, direction: 1, wantCursor: 44, wantScroll: 35}, + {name: "up a page", cursor: 22, scroll: 20, direction: -1, wantCursor: 12, wantScroll: 10}, + {name: "up into the first window", cursor: 7, scroll: 5, direction: -1, wantCursor: 2, wantScroll: 0}, + {name: "up on the first window", cursor: 4, scroll: 0, direction: -1, wantCursor: 0, wantScroll: 0}, + } { + t.Run(testCase.name, func(t *testing.T) { + cursor, scroll := pageBy(testCase.cursor, testCase.scroll, 10, 45, testCase.direction) + if cursor != testCase.wantCursor || scroll != testCase.wantScroll { + t.Errorf("pageBy = cursor %d scroll %d, want %d and %d", cursor, scroll, testCase.wantCursor, testCase.wantScroll) + } + }) + } + if cursor, scroll := pageBy(0, 0, 10, 0, 1); cursor != 0 || scroll != 0 { + t.Errorf("an empty list moved to %d/%d", cursor, scroll) + } +} + +// The keys reach every list through the mail view, not just the list type. +func TestMailListTakesPageKeys(t *testing.T) { + v := newMailView(testVC()) + v.vc.width = 80 + v.vc.height = 24 + v.boxes = testBoxes() + v.postingList.setPostings(longImbox(40, 0)) + v.postingList.setSize(80, 20) + + v.HandleContentKey(keyPress("pgdown")) + if v.postingList.scrollOff == 0 || v.postingList.cursor == 0 { + t.Errorf("PgDn left the list at cursor=%d scrollOff=%d", v.postingList.cursor, v.postingList.scrollOff) + } + v.HandleContentKey(keyPress("pgup")) + if v.postingList.scrollOff != 0 || v.postingList.cursor != 0 { + t.Errorf("PgUp left the list at cursor=%d scrollOff=%d", v.postingList.cursor, v.postingList.scrollOff) + } +} + func longImbox(unseen, seen int) []mail.Posting { subjects := []string{"Quarterly planning agenda", "Lunch on Friday?", "Invoice for September hosting", "Weekly release notes", "Offsite travel details"} postings := make([]mail.Posting, 0, unseen+seen)