diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fdcb2f..bcdcd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **Moving through lists is quick again with real-image posters:** scaling and encoding a poster + for the terminal (about 200 ms since posters are sent at the size of their box) now happens in + the background; the poster appears when it is ready, and the selection never waits for it. - **Trakt lists show faster after a restart:** the Radarr/Sonarr lookups of their titles are kept on disk too (3 days, `lookups.json`), so a list takes one request to Trakt and a quick library check per title instead of a lookup (about a second each) per title. Whether a title is in the diff --git a/internal/ui/app.go b/internal/ui/app.go index e4c9d3e..768b8e7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -245,6 +245,12 @@ func New(cfg *config.Config) *App { func (a *App) newGraphics() *kittyGraphics { k := newKittyGraphics(a.writeTerminal) k.cellPixels = func() (int, int) { return a.cellW, a.cellH } + k.background = func(prepare func() prepared, ready func(prepared)) { + go func() { + p := prepare() + a.post(func() { ready(p) }) + }() + } switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); { case v == "": case strings.Contains(v, "/"): diff --git a/internal/ui/cover.go b/internal/ui/cover.go index 97f5d7c..1eabbeb 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -447,6 +447,9 @@ func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool if err != nil { return false } + if id == 0 { + return true // being prepared: the place stays empty until it is ready + } if c.gfx.debug != nil { if drawn := [5]int{int(id), x, y, cols, rows}; drawn != c.lastDrawn { c.lastDrawn = drawn diff --git a/internal/ui/kitty.go b/internal/ui/kitty.go index b653d40..6c4f3e4 100644 --- a/internal/ui/kitty.go +++ b/internal/ui/kitty.go @@ -49,8 +49,27 @@ type kittyGraphics struct { // an image that is smaller than its placement (Ghostty shows it at its own // size, so a small poster came out smaller than the others). cellPixels func() (w, h int) + + // background, if set, runs prepare off the UI goroutine and then ready on + // it. Scaling and encoding a poster takes about 200 ms; done in Draw, every + // step to a title with a poster not uploaded yet waited that long. Without + // it (tests) posters are prepared at once. + background func(prepare func() prepared, ready func(prepared)) + preparing map[footprint]bool + done map[footprint]prepared // prepared, not uploaded yet } +// prepared is a poster encoded for upload. +type prepared struct { + encoded string // base64 PNG + w, h int // its size in pixels + err error +} + +// maxPrepared bounds the prepared posters that wait for a draw (the +// selection moved on before they were ready). +const maxPrepared = 8 + // settleDelay is how long the window size must stay the same before real // images are shown again. While a window is dragged to another size, tens of // sizes go by; uploading and placing images for each of them races with the @@ -73,7 +92,8 @@ type footprint struct { } func newKittyGraphics(write func([]byte) error) *kittyGraphics { - return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}} + return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}, + preparing: map[footprint]bool{}, done: map[footprint]prepared{}} } // kittyAPC builds one graphics command: ESC _ G ; ESC \. @@ -90,26 +110,65 @@ func kittyAPC(keys string, payload []byte) []byte { } // show makes sure the part rect of the image is uploaded and placed over -// cols×rows cells, and returns the id the placeholder cells must carry. The same -// footprint is reused; a different one for the same poster replaces the old -// upload (which is removed from the terminal). +// cols×rows cells, and returns the id the placeholder cells must carry, or 0 +// while the poster is still being prepared in the background (show is called +// again on the redraw when it is ready). The same footprint is reused; a +// different one for the same poster replaces the old upload (which is removed +// from the terminal). func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows int) (uint32, error) { key := footprint{img, rect, cols, rows} if id, ok := k.ids[key]; ok { return id, nil } - var src image.Image = img.SubImage(rect) + cw, ch := 0, 0 if k.cellPixels != nil { - if cw, ch := k.cellPixels(); cw > 0 && ch > 0 { - src = scaleImage(src, cols*cw, rows*ch) + cw, ch = k.cellPixels() + } + prepare := func() prepared { return encodePoster(img, rect, cols*cw, rows*ch) } + p, ok := k.done[key] + switch { + case ok: + delete(k.done, key) + case k.background == nil: + p = prepare() + default: + if !k.preparing[key] { + k.preparing[key] = true + k.background(prepare, func(p prepared) { + delete(k.preparing, key) + if len(k.done) >= maxPrepared { + clear(k.done) + } + k.done[key] = p + }) } + return 0, nil + } + if p.err != nil { + return 0, p.err + } + return k.upload(key, p) +} + +// encodePoster scales the part rect of img to w×h pixels (if both are known) +// and encodes it. Safe for use from any goroutine. +func encodePoster(img *image.RGBA, rect image.Rectangle, w, h int) prepared { + var src image.Image = img.SubImage(rect) + if w > 0 && h > 0 { + src = scaleImage(src, w, h) } var data bytes.Buffer enc := png.Encoder{CompressionLevel: png.BestSpeed} if err := enc.Encode(&data, src); err != nil { - return 0, err + return prepared{err: err} } - encoded := base64.StdEncoding.EncodeToString(data.Bytes()) + return prepared{encoded: base64.StdEncoding.EncodeToString(data.Bytes()), w: src.Bounds().Dx(), h: src.Bounds().Dy()} +} + +// upload sends a prepared poster and places it. +func (k *kittyGraphics) upload(key footprint, p prepared) (uint32, error) { + img, cols, rows := key.img, key.cols, key.rows + encoded := p.encoded id := k.nextID for first, rest := true, encoded; len(rest) > 0; first = false { n := min(len(rest), kittyChunk) @@ -143,7 +202,7 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i k.latest[img] = key if k.debug != nil { k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, sent as %dx%d px, placed over %dx%d cells", - id, img.Bounds().Dx(), img.Bounds().Dy(), rect, src.Bounds().Dx(), src.Bounds().Dy(), cols, rows)) + id, img.Bounds().Dx(), img.Bounds().Dy(), key.rect, p.w, p.h, cols, rows)) } return id, nil } @@ -156,6 +215,7 @@ func (k *kittyGraphics) deleteAll() { } k.ids = map[footprint]uint32{} k.latest = map[*image.RGBA]footprint{} + clear(k.done) // posters in preparation still arrive, but are not used without a draw asking for them } // placeholderCell returns what to draw in the cell at (row, col) of the image diff --git a/internal/ui/kitty_test.go b/internal/ui/kitty_test.go index 6f01c93..4593304 100644 --- a/internal/ui/kitty_test.go +++ b/internal/ui/kitty_test.go @@ -417,3 +417,29 @@ func TestPostersAreSentAtTheBoxSize(t *testing.T) { t.Errorf("upload without cell size: %q", msgs) } } + +func TestPostersArePreparedInTheBackground(t *testing.T) { + var rec recorder + k := newKittyGraphics(rec.write) + var queued []func() + k.background = func(prepare func() prepared, ready func(prepared)) { + p := prepare() + queued = append(queued, func() { ready(p) }) + } + img := image.NewRGBA(image.Rect(0, 0, 40, 60)) + if id, err := k.show(img, img.Bounds(), 4, 3); id != 0 || err != nil || len(rec.cmds) != 0 { + t.Fatalf("first draw: id %d, err %v, %d commands", id, err, len(rec.cmds)) + } + k.show(img, img.Bounds(), 4, 3) // a redraw meanwhile does not prepare it twice + if len(queued) != 1 { + t.Fatalf("%d preparations", len(queued)) + } + queued[0]() + id, err := k.show(img, img.Bounds(), 4, 3) + if id == 0 || err != nil || len(rec.cmds) == 0 { + t.Fatalf("once ready: id %d, err %v, %d commands", id, err, len(rec.cmds)) + } + if again, _ := k.show(img, img.Bounds(), 4, 3); again != id || len(queued) != 1 { + t.Errorf("uploaded posters are reused: id %d, %d preparations", again, len(queued)) + } +}