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
35 changes: 33 additions & 2 deletions internal/tui/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions internal/tui/mail_selection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}
}
Expand All @@ -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)
}
}
Expand Down
105 changes: 103 additions & 2 deletions internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading