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:
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user