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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
2026-10-21 11:31:51 +02:00
co-authored by Claude Opus 5.5
parent 080ef9ec8e
commit a71ca6e3fb
3 changed files with 72 additions and 3 deletions
+3
View File
@@ -2,6 +2,9 @@
## Unreleased ## 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 - **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. 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, `auto` now draws them there as plain colored cells (new `ui.cover_style` value `cells`): coarser,
+39 -3
View File
@@ -3,6 +3,7 @@ package ui
import ( import (
"image" "image"
"strings" "strings"
"time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -14,6 +15,12 @@ const (
minCoverRows = 6 // a smaller poster is not worth showing minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8 minCoverCols = 8
minTextRows = 6 // the poster never takes these rows from the text 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 // detailPane shows the details of one title as one flowing text with the
@@ -27,9 +34,11 @@ type detailPane struct {
cover *coverView cover *coverView
detail detailText detail detailText
lastRef coverRef // the poster of the current detail, to fetch it again after a style change 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 want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content 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. // What the text was last wrapped for.
laidOut struct { laidOut struct {
@@ -61,7 +70,34 @@ func (d *detailPane) Show(text detailText, ref coverRef) {
d.cover.setImage(nil) d.cover.setImage(nil)
return 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 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…") d.cover.setNote("loading cover…")
want := ref.key want := ref.key
d.app.loadCover(ref, func(img *image.RGBA, err error) { d.app.loadCover(ref, func(img *image.RGBA, err error) {
+30
View File
@@ -1,8 +1,11 @@
package ui package ui
import ( import (
"context"
"image"
"strings" "strings"
"testing" "testing"
"time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -107,3 +110,30 @@ func TestDetailPaneFlowsAroundPoster(t *testing.T) {
t.Error("a short pane must drop the poster") 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")
}
}