From 314e54a358867d4665d0d131adcccc9f7f6eaba3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:18:51 +0200 Subject: [PATCH 1/4] Keep the cursor in place when marking a thread seen Co-Authored-By: Claude Opus 5.5 --- internal/tui/content.go | 23 ++++++++++++++++++++++- internal/tui/tui_test.go | 40 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/internal/tui/content.go b/internal/tui/content.go index e0d6b150..f8a23bb9 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -344,11 +344,32 @@ 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 + if index == c.cursor && !c.hideSeenState { + 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 + } + 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/tui_test.go b/internal/tui/tui_test.go index ddb60002..f988311e 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1137,8 +1137,44 @@ 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) } } From ab9edda6f68786536a54126963e9ea218e26707c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:22:25 +0200 Subject: [PATCH 2/4] Keep the list's window still when a thread leaves the cursor Marking the cursor's thread seen re-sorted with the cursor still on it, so the window scrolled down after it and then snapped back with the next thread on the top row. A live re-read that dropped the cursor's thread sent the cursor to the top of the list. Both now leave the window and the cursor's row where they were. Co-Authored-By: Claude Opus 5.5 --- internal/tui/content.go | 10 ++++- internal/tui/mail_selection_test.go | 10 +++-- internal/tui/tui_test.go | 57 +++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/internal/tui/content.go b/internal/tui/content.go index f8a23bb9..ca190e43 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 @@ -350,6 +353,7 @@ func partitionSections(postings []mail.Posting) []mail.Posting { // chase after the one just put away. func (c *contentList) markSeen(index int) { var nextID int64 + scrollOff := c.scrollOff if index == c.cursor && !c.hideSeenState { if index+1 < len(c.postings) { nextID = c.postings[index+1].ID @@ -363,6 +367,10 @@ func (c *contentList) markSeen(index int) { 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 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 f988311e..11f0b5a5 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1178,6 +1178,63 @@ func TestMarkingSeenLeavesTheCursorOnTheNextThread(t *testing.T) { } } +// 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"} + var postings []mail.Posting + 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) { long := mail.Posting{ ID: 300, From 5a0d84106bd29f301cf64ea00e1808f80bbbf1d5 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:46:32 +0200 Subject: [PATCH 3/4] Preallocate the long Imbox test helper Co-Authored-By: Claude Opus 5.5 --- internal/tui/tui_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 11f0b5a5..4c8068d3 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1223,7 +1223,7 @@ func TestARefreshThatDropsTheCursorsThreadKeepsItsRow(t *testing.T) { 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"} - var postings []mail.Posting + postings := make([]mail.Posting, 0, unseen+seen) for i := range unseen + seen { postings = append(postings, mail.Posting{ ID: int64(i + 1), From 0ceb56c8830a5126afdcf85419933e2eb90b91e3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:49:10 +0200 Subject: [PATCH 4/4] Leave the cursor alone when e marks a thread that is already seen Co-Authored-By: Claude Opus 5.5 --- internal/tui/content.go | 4 +++- internal/tui/tui_test.go | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/tui/content.go b/internal/tui/content.go index ca190e43..d1f187f0 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -354,7 +354,9 @@ func partitionSections(postings []mail.Posting) []mail.Posting { func (c *contentList) markSeen(index int) { var nextID int64 scrollOff := c.scrollOff - if index == c.cursor && !c.hideSeenState { + // 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 { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 4c8068d3..2a3fa8b7 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1176,6 +1176,14 @@ func TestMarkingSeenLeavesTheCursorOnTheNextThread(t *testing.T) { 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