Show poster thumbnails in the detail panes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:47:50 +02:00
co-authored by Claude Sonnet 5.5
parent ff204655d3
commit a4303ed3ca
15 changed files with 724 additions and 22 deletions
+149
View File
@@ -0,0 +1,149 @@
package ui
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
"time"
"github.com/gdamore/tcell/v2"
)
// stripes returns a w×h image whose top half is red and bottom half blue.
func stripes(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := color.RGBA{255, 0, 0, 255}
if y >= h/2 {
c = color.RGBA{0, 0, 255, 255}
}
img.SetRGBA(x, y, c)
}
}
return img
}
func TestBoxScaleAverages(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 2, 2))
src.SetRGBA(0, 0, color.RGBA{200, 0, 0, 255})
src.SetRGBA(1, 0, color.RGBA{0, 0, 0, 255})
src.SetRGBA(0, 1, color.RGBA{0, 0, 0, 255})
src.SetRGBA(1, 1, color.RGBA{0, 0, 0, 255})
got := boxScale(src, 1, 1).RGBAAt(0, 0)
if got.R < 49 || got.R > 51 || got.G != 0 || got.A != 255 {
t.Errorf("average = %+v, want R≈50", got)
}
// Upscaling must not leave holes.
up := boxScale(stripes(1, 2), 3, 4)
if up.RGBAAt(2, 0).R != 255 || up.RGBAAt(2, 3).B != 255 {
t.Errorf("upscaled corners: %+v %+v", up.RGBAAt(2, 0), up.RGBAAt(2, 3))
}
}
func TestDecodeCover(t *testing.T) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
t.Fatal(err)
}
img, err := decodeCover(buf.Bytes())
if err != nil {
t.Fatal(err)
}
if b := img.Bounds(); b.Dy() != storedCoverHeight || b.Dx() != 106 {
t.Errorf("stored size %v, want 106x%d", b, storedCoverHeight)
}
buf.Reset()
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
t.Fatal(err)
}
if img, err := decodeCover(buf.Bytes()); err != nil || img.Bounds().Dy() != 30 {
t.Errorf("small png kept as is: %v %v", img, err)
}
if _, err := decodeCover([]byte("<html>not an image</html>")); err == nil {
t.Error("garbage must be rejected")
}
// A header that promises a huge image is refused before decoding it.
var huge bytes.Buffer
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
if _, err := decodeCover(huge.Bytes()); err == nil {
t.Error("oversized image must be rejected")
}
}
func TestCoverViewDrawsHalfBlocks(t *testing.T) {
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
screen.SetSize(20, 10)
c := newCoverView()
c.setImage(stripes(40, 60)) // 2:3 poster
c.SetRect(0, 0, 20, 10) // 20 columns × 20 pixel rows: a 13×20 pixel poster
c.Draw(screen)
// Row 0 holds pixel rows 0 (top) and 1: both red. The middle cell row
// (index 4/5) crosses into blue; the last row is blue on both halves.
rgba := func(c tcell.Color) color.RGBA {
r, g, b := c.RGB()
return color.RGBA{uint8(r), uint8(g), uint8(b), 255}
}
check := func(row int, wantFg, wantBg color.RGBA) {
t.Helper()
ch, _, style, _ := screen.GetContent(0, row)
fg, bg, _ := style.Decompose()
if ch != '▀' || rgba(fg) != wantFg || rgba(bg) != wantBg {
t.Errorf("row %d: %q fg=%v bg=%v", row, ch, rgba(fg), rgba(bg))
}
}
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
check(0, red, red)
check(9, blue, blue)
// Beyond the poster's width nothing is drawn.
if ch, _, _, _ := screen.GetContent(19, 0); ch == '▀' {
t.Error("poster stretched beyond its aspect ratio")
}
}
func TestLoadCoverCachesAndDedupes(t *testing.T) {
a := newTestApp(t)
fetches := 0
var buf bytes.Buffer
png.Encode(&buf, stripes(10, 20))
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
fetches++
return buf.Bytes(), nil
}}
// Run posted callbacks inline instead of through the (not running) event loop.
delivered := make(chan error, 2)
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err })
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err }) // joins the request in flight
if len(a.coverCache.waiting["k"]) != 2 {
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
}
if fetches > 1 {
t.Errorf("fetched %d times", fetches)
}
}
func TestLoadCoverRemembersFailures(t *testing.T) {
a := newTestApp(t)
a.coverCache.failed["bad"] = time.Now()
var gotErr error
a.loadCover(coverRef{key: "bad", fetch: func(context.Context) ([]byte, error) {
t.Error("a recent failure must not be retried")
return nil, nil
}}, func(_ *image.RGBA, err error) { gotErr = err })
if gotErr == nil {
t.Error("expected an error for a recently failed cover")
}
}