diff --git a/internal/tui/content.go b/internal/tui/content.go index e0d6b150..d1f187f0 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -267,8 +267,11 @@ func (c *contentList) keepPlaceIn(postings []mail.Posting) { cursorID = posting.ID } + // A posting that left takes the cursor's row with it, not its place: the cursor + // stays on the same row, on whatever came up into it, rather than jumping to the top. + row := max(c.cursor, 0) c.postings = postings - c.cursor = 0 + c.cursor = min(row, max(len(c.postings)-1, 0)) for i := range c.postings { if c.postings[i].ID == cursorID { c.cursor = i @@ -344,11 +347,39 @@ func partitionSections(postings []mail.Posting) []mail.Posting { } // markSeen moves a posting into "Previously Seen", clearing the bubbled up -// state the way Postings::SeenController does. +// state the way Postings::SeenController does. A posting under the cursor goes +// down on its own: the cursor stays where it was and lands on the thread that +// followed it, so working down New for You is one key per thread rather than a +// chase after the one just put away. func (c *contentList) markSeen(index int) { + var nextID int64 + scrollOff := c.scrollOff + // Only a posting that leaves its section goes down on its own; e on one already in + // Previously Seen moves nothing, so it moves the cursor nowhere either. + if index == c.cursor && !c.hideSeenState && sectionOf(c.postings[index]) != sectionPreviouslySeen { + if index+1 < len(c.postings) { + nextID = c.postings[index+1].ID + } else if index > 0 { + nextID = c.postings[index-1].ID + } + } c.postings[index].Seen = true c.postings[index].BubbledUp = false c.resort() + if nextID == 0 { + return + } + // resort followed the marked posting down into Previously Seen and scrolled the + // window after it; the window goes back where it was, so the rows the reader was + // looking at stay put and only the marked one leaves them. + c.scrollOff = scrollOff + for i := range c.postings { + if c.postings[i].ID == nextID { + c.cursor = i + break + } + } + c.settleCover() } // markUnseen moves a posting to the front of "New for You", including one the reader is diff --git a/internal/tui/mail_selection_test.go b/internal/tui/mail_selection_test.go index c5f74ccf..56db0107 100644 --- a/internal/tui/mail_selection_test.go +++ b/internal/tui/mail_selection_test.go @@ -566,12 +566,13 @@ func TestMailViewRefusesTheCursorWhenARefreshTookTheSelection(t *testing.T) { }) } + cursorID := v.postingList.selectedPosting().ID v.HandleContentKey(keyPress("e")) done, ok := runCmd(v.HandleContentKey(keyPress("e"))).(postingActionDoneMsg) if !ok || done.err != nil { t.Fatalf("the second e returned %#v, want it to mark the cursor's thread", done) } - if !slices.Equal(recorded.body.PostingIDs, []int64{103}) { + if !slices.Equal(recorded.body.PostingIDs, []int64{cursorID}) { t.Errorf("marked %v seen, want the cursor's thread", recorded.body.PostingIDs) } } @@ -582,11 +583,11 @@ func TestMailViewActsOnTheCursorOnceTheReaderMovesOnFromALostSelection(t *testin selectTwoThreads(v) refreshWithoutTheTestThreads(v) - v.HandleContentKey(keyPress("down")) + v.HandleContentKey(keyPress("up")) if _, ok := runCmd(v.HandleContentKey(keyPress("e"))).(postingActionDoneMsg); !ok { t.Fatal("e after moving the cursor did nothing") } - if !slices.Equal(recorded.body.PostingIDs, []int64{104}) { + if !slices.Equal(recorded.body.PostingIDs, []int64{103}) { t.Errorf("marked %v seen, want the row the cursor moved to", recorded.body.PostingIDs) } } @@ -601,10 +602,11 @@ func TestEscapeLetsGoOfALostSelection(t *testing.T) { if !v.ClearSelection() { t.Fatal("esc did not claim a lost selection") } + cursorID := v.postingList.selectedPosting().ID if _, ok := runCmd(v.HandleContentKey(keyPress("e"))).(postingActionDoneMsg); !ok { t.Fatal("e after esc did nothing") } - if !slices.Equal(recorded.body.PostingIDs, []int64{103}) { + if !slices.Equal(recorded.body.PostingIDs, []int64{cursorID}) { t.Errorf("marked %v seen, want the cursor's thread", recorded.body.PostingIDs) } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ddb60002..2a3fa8b7 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1137,9 +1137,110 @@ func TestContentListMovesSeenBubbledUpPostingToItsSection(t *testing.T) { if cl.postings[1].BubbledUp { t.Error("marking a bubbled up posting seen should clear its bubbled up state") } - if got := cl.selectedPosting(); got == nil || got.ID != 1 { - t.Errorf("cursor should follow the moved posting: %+v", got) + if got := cl.selectedPosting(); got == nil || got.ID != 2 { + t.Errorf("cursor should stay put on the next posting: %+v", got) + } +} + +// Marking the thread under the cursor seen sends it down to Previously Seen on its own: +// the highlight stays where it was, on the thread that came next, and the window does not +// scroll after the one put away. +func TestMarkingSeenLeavesTheCursorOnTheNextThread(t *testing.T) { + cl := &contentList{} + cl.setPostings([]mail.Posting{ + {ID: 1, Name: "Lunch on Thursday?", CreatedAt: time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)}, + {ID: 2, Name: "Weekly release notes", CreatedAt: time.Date(2026, 8, 20, 11, 0, 0, 0, time.UTC)}, + {ID: 3, Name: "Invoice for July hosting", CreatedAt: time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)}, + {ID: 4, Name: "Quarterly planning agenda", CreatedAt: time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC), Seen: true}, + }) + cl.setSize(80, 20) + cl.moveDown() + + cl.markSeen(1) + + if got := cl.selectedPosting(); got == nil || got.ID != 3 { + t.Errorf("cursor should land on the thread that followed: %+v", got) + } + if cl.cursor != 1 { + t.Errorf("cursor moved to row %d, want it to stay on row 1", cl.cursor) + } + if cl.scrollOff != 0 { + t.Errorf("the window scrolled to %d", cl.scrollOff) + } + if cl.postings[2].ID != 2 || !cl.postings[2].Seen { + t.Errorf("the seen thread should head Previously Seen: %+v", cl.postings) + } + + // The last of New for You moves the cursor on to what followed it, not back onto itself. + cl.markSeen(1) + if got := cl.selectedPosting(); got == nil || got.ID != 2 { + t.Errorf("cursor should move past the last new thread: %+v", got) + } + + // e on a thread already in Previously Seen moves nothing, the cursor included — at + // the end of the list it used to step back to the thread above. + cl.cursor = len(cl.postings) - 1 + cl.markSeen(cl.cursor) + if cl.cursor != len(cl.postings)-1 { + t.Errorf("marking a seen thread seen moved the cursor to %d", cl.cursor) + } +} + +// A list long enough to scroll: marking the thread under the cursor seen sends it far +// below, and the window used to follow it there and then snap back with the next thread +// on the top row — every row on screen moved. The window stays where it was. +func TestMarkingSeenKeepsTheWindowWhereItWas(t *testing.T) { + cl := &contentList{} + cl.setPostings(longImbox(20, 10)) + cl.setSize(80, 32) + for range 3 { + cl.moveDown() + } + before := stripANSI(cl.view()) + + cl.markSeen(3) + + if cl.scrollOff != 0 || cl.cursor != 3 { + t.Errorf("cursor=%d scrollOff=%d, want row 3 in an unscrolled window", cl.cursor, cl.scrollOff) + } + after := strings.Split(stripANSI(cl.view()), "\n") + if first := strings.Split(before, "\n")[0]; after[0] != first { + t.Errorf("the top of the list moved: %q, was %q", after[0], first) + } +} + +// A live re-read that takes away the thread under the cursor — read or filed somewhere +// else — leaves the cursor on its row, on the thread that came up into it, rather than +// sending it back to the top of the list. +func TestARefreshThatDropsTheCursorsThreadKeepsItsRow(t *testing.T) { + cl := &contentList{} + postings := longImbox(20, 0) + cl.setPostings(postings) + cl.setSize(80, 32) + for range 3 { + cl.moveDown() + } + + head := append(append([]mail.Posting(nil), postings[:3]...), postings[4:15]...) + cl.refreshHead(head, postingIDs(postings[:15])) + + if got := cl.selectedPosting(); cl.cursor != 3 || got == nil || got.ID != postings[4].ID { + t.Errorf("cursor=%d on %+v, want row 3 on %q", cl.cursor, got, postings[4].Name) + } +} + +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) + for i := range unseen + seen { + postings = append(postings, mail.Posting{ + ID: int64(i + 1), + Name: fmt.Sprintf("%s #%d", subjects[i%len(subjects)], i+1), + CreatedAt: time.Date(2026, 9, 29, 9, 0, 0, 0, time.UTC).Add(-time.Duration(i) * time.Hour), + Seen: i >= unseen, + }) } + return postings } func TestContentListAlignsDateColumn(t *testing.T) {