From a71ca6e3fb427e46b59569c00b5baa4b65c220de Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 21 Oct 2026 11:31:51 +0200 Subject: [PATCH] Posters wait while the selection moves quickly On a terminal zoomed far out a poster is tens of thousands of cells, and redrawing it on every step of a held arrow key was more output than the terminal could draw (about 100 KB per step at 600x180), so the list lagged. A selection that changes again within 120 ms keeps the poster's place empty and shows the poster once it rests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW --- CHANGELOG.md | 3 +++ internal/ui/detail.go | 42 +++++++++++++++++++++++++++++++++++--- internal/ui/detail_test.go | 30 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d757d..9f239f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **Smooth scrolling on huge terminals:** while an arrow key is held, the poster waits until the + selection rests (120 ms). Every poster rewrites all of its cells, which on a terminal zoomed far + out was more output per step than the terminal could draw, so moving through a list lagged. - **Posters in macOS Terminal:** Terminal.app draws the half and quarter blocks from the font, and their halves do not meet at the middle of the cell, so posters looked like rows of shifted dots. `auto` now draws them there as plain colored cells (new `ui.cover_style` value `cells`): coarser, diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 9612b13..6fabd6f 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -3,6 +3,7 @@ package ui import ( "image" "strings" + "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -14,6 +15,12 @@ const ( minCoverRows = 6 // a smaller poster is not worth showing minCoverCols = 8 minTextRows = 6 // the poster never takes these rows from the text + + // posterSettle: when the selection moves again within this time (a held + // arrow key), the poster waits until the selection rests that long. Every + // poster rewrites all of its cells, which on a huge terminal (zoomed far + // out) is more output than the terminal can draw at key-repeat speed. + posterSettle = 120 * time.Millisecond ) // detailPane shows the details of one title as one flowing text with the @@ -27,9 +34,11 @@ type detailPane struct { cover *coverView detail detailText - lastRef coverRef // the poster of the current detail, to fetch it again after a style change - want string // key of the poster that should be shown - hasCover bool // a poster (or its loading note) is part of the content + lastRef coverRef // the poster of the current detail, to fetch it again after a style change + want string // key of the poster that should be shown + hasCover bool // a poster (or its loading note) is part of the content + lastShow time.Time // when the shown title last changed + settle *time.Timer // shows the poster once the selection rests // What the text was last wrapped for. laidOut struct { @@ -61,7 +70,34 @@ func (d *detailPane) Show(text detailText, ref coverRef) { d.cover.setImage(nil) return } + now := time.Now() + moving := ref.key != d.want && now.Sub(d.lastShow) < posterSettle + if ref.key != d.want { + d.lastShow = now + } d.want, d.hasCover = ref.key, true + if d.settle != nil { + d.settle.Stop() + } + if moving { + // Keep the poster's place (so the text does not reflow) but leave it + // empty until the selection stops. + d.cover.setNote("") + want := ref.key + d.settle = time.AfterFunc(posterSettle, func() { + d.app.post(func() { + if d.want == want { + d.loadPoster(ref) + } + }) + }) + return + } + d.loadPoster(ref) +} + +// loadPoster shows the poster of ref, fetching it first if needed. +func (d *detailPane) loadPoster(ref coverRef) { d.cover.setNote("loading cover…") want := ref.key d.app.loadCover(ref, func(img *image.RGBA, err error) { diff --git a/internal/ui/detail_test.go b/internal/ui/detail_test.go index 12da11f..a819d19 100644 --- a/internal/ui/detail_test.go +++ b/internal/ui/detail_test.go @@ -1,8 +1,11 @@ package ui import ( + "context" + "image" "strings" "testing" + "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -107,3 +110,30 @@ func TestDetailPaneFlowsAroundPoster(t *testing.T) { t.Error("a short pane must drop the poster") } } + +func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) { + a := newTestApp(t) + img := image.NewRGBA(image.Rect(0, 0, 20, 30)) + a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img + ref := func(key string) coverRef { + return coverRef{key: key, fetch: func(context.Context) ([]byte, error) { return nil, nil }} + } + d := newDetailPane(a) + + d.Show(detailText{head: "One"}, ref("one")) + if d.cover.img != img { + t.Fatal("the first selection should show its poster at once") + } + d.Show(detailText{head: "Two"}, ref("two")) + if d.cover.img != nil || !d.hasCover { + t.Errorf("a quick move should keep the poster's place empty: img %v, hasCover %v", d.cover.img != nil, d.hasCover) + } + d.settle.Stop() // the test has no event loop to deliver it + + // The same title again (a refresh) is not a move. + d.lastShow = time.Now().Add(-time.Second) + d.Show(detailText{head: "Two"}, ref("two")) + if d.cover.img != img { + t.Error("a selection at rest should show its poster") + } +}