Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +35 to +36

The thread actions use HEY's web shortcuts, in either letter case except `l`, whose
uppercase belongs to Labels:
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions internal/tui/contacts_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions internal/tui/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +467 to +473
}
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.
Expand Down
21 changes: 21 additions & 0 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions internal/tui/screener.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
117 changes: 117 additions & 0 deletions internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: " "}
}
Expand Down Expand Up @@ -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)
Expand Down
Loading