Files
arr-tui/internal/ui/kitty_test.go
T
nilsandClaude Opus 5.5 45e8daaa71 Real-image posters: scale and encode in the background
Done in Draw it took about 200 ms per new poster (BiLinear scale to the
box plus PNG encoding), so each step to another title stalled the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:22:41 +02:00

446 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"bytes"
"encoding/base64"
"image"
"image/png"
"math"
"math/rand"
"regexp"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// recorder collects what is written to the terminal.
type recorder struct{ cmds []string }
func (r *recorder) write(b []byte) error { r.cmds = append(r.cmds, string(b)); return nil }
func noisy(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
rng := rand.New(rand.NewSource(1))
for i := range img.Pix {
img.Pix[i] = uint8(rng.Intn(256))
}
for i := 3; i < len(img.Pix); i += 4 {
img.Pix[i] = 255
}
return img
}
func TestKittyDiacriticsTable(t *testing.T) {
if len(kittyDiacritics) != 297 {
t.Fatalf("%d diacritics, the protocol defines 297", len(kittyDiacritics))
}
if kittyDiacritics[0] != 0x0305 || kittyDiacritics[296] != 0x1D244 {
t.Errorf("table ends are %U and %U", kittyDiacritics[0], kittyDiacritics[296])
}
for i := 1; i < len(kittyDiacritics); i++ {
if kittyDiacritics[i] <= kittyDiacritics[i-1] {
t.Fatalf("table not strictly ascending at %d", i)
}
}
}
func TestKittyUploadChunksAndEncodes(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
img := noisy(200, 300) // incompressible: far more than one 4096-byte chunk of base64
id, err := k.show(img, img.Bounds(), 20, 15)
if err != nil || id != 1 {
t.Fatalf("id=%d err=%v", id, err)
}
if len(rec.cmds) < 4 {
t.Fatalf("only %d commands for a large image", len(rec.cmds))
}
// The upload is followed by the placement, once.
if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=p,U=1,i=1,c=20,r=15,q=2\x1b\\" {
t.Errorf("last command = %q", last)
}
uploads := rec.cmds[:len(rec.cmds)-1]
re := regexp.MustCompile(`^\x1b_G([^;]*);(.*)\x1b\\$`)
var payload strings.Builder
for i, c := range uploads {
m := re.FindStringSubmatch(c)
if m == nil {
t.Fatalf("command %d is not a graphics command: %q", i, c[:min(40, len(c))])
}
keys, data := m[1], m[2]
if len(data) > kittyChunk {
t.Errorf("chunk %d has %d bytes", i, len(data))
}
last := i == len(uploads)-1
switch {
case i == 0 && !strings.HasPrefix(keys, "a=t,f=100,t=d,i=1,q=2,m=1"):
t.Errorf("first command keys = %q", keys)
case i > 0 && !last && keys != "m=1":
t.Errorf("middle command keys = %q", keys)
case last && !strings.HasSuffix(keys, "m=0"):
t.Errorf("last command keys = %q", keys)
}
payload.WriteString(data)
}
raw, err := base64.StdEncoding.DecodeString(payload.String())
if err != nil {
t.Fatal(err)
}
decoded, err := png.Decode(bytes.NewReader(raw))
if err != nil || decoded.Bounds() != img.Bounds() {
t.Fatalf("payload is not the image as PNG: %v %v", decoded, err)
}
r, g, b, _ := decoded.At(17, 23).RGBA()
want := img.RGBAAt(17, 23)
if uint8(r>>8) != want.R || uint8(g>>8) != want.G || uint8(b>>8) != want.B {
t.Error("pixel data changed")
}
// The same footprint is not uploaded twice; another poster gets the next id.
n := len(rec.cmds)
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id || len(rec.cmds) != n {
t.Error("an uploaded footprint must be reused")
}
small := noisy(4, 4)
if other, _ := k.show(small, small.Bounds(), 3, 2); other != 2 {
t.Errorf("second image id = %d", other)
}
}
func TestKittyNewSizeIsANewImage(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
img := noisy(10, 15)
id1, _ := k.show(img, img.Bounds(), 20, 15)
rec.cmds = nil
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id1 || len(rec.cmds) != 0 {
t.Fatalf("same footprint: id %d, %d commands", again, len(rec.cmds))
}
// A different size is never done by changing the placement (a terminal can keep the
// old one): the poster is uploaded under a new id, placed, and the old id removed.
id2, _ := k.show(img, img.Bounds(), 10, 8)
if id2 == id1 {
t.Fatal("a new footprint must get a new id")
}
all := strings.Join(rec.cmds, "")
if !strings.Contains(all, "a=t,f=100,t=d,i=2") || !strings.Contains(all, "a=p,U=1,i=2,c=10,r=8") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("resize sent %q", rec.cmds)
}
if strings.Contains(all, "a=p,U=1,i=1") {
t.Error("the old placement must not be touched")
}
// After deleteAll (what a window resize triggers) everything is uploaded afresh.
rec.cmds = nil
k.deleteAll()
id3, _ := k.show(img, img.Bounds(), 10, 8)
if id3 == id2 || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=I,i=2") || !strings.Contains(strings.Join(rec.cmds, ""), "a=t,f=100,t=d,i=3") {
t.Errorf("after deleteAll: id %d, %q", id3, rec.cmds)
}
if len(k.ids) != 1 || len(k.latest) != 1 {
t.Errorf("bookkeeping: %d ids, %d latest", len(k.ids), len(k.latest))
}
}
func TestPlaceholderCell(t *testing.T) {
ch, comb, style := placeholderCell(0x010203, 2, 5)
if ch != '\U0010EEEE' || len(comb) != 2 || comb[0] != kittyDiacritics[2] || comb[1] != kittyDiacritics[5] {
t.Errorf("cell = %U %U", ch, comb)
}
fg, bg, _ := style.Decompose()
if r, g, b := fg.RGB(); r != 1 || g != 2 || b != 3 {
t.Errorf("the id must be in the foreground color, got %d,%d,%d", r, g, b)
}
if bg != tview.Styles.PrimitiveBackgroundColor {
t.Errorf("background %v must match the panes (%v), or uncovered slivers show", bg, tview.Styles.PrimitiveBackgroundColor)
}
}
func TestKittyTerminalDetection(t *testing.T) {
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
sextantFontAvailable = func() bool { return false } // not what these cases are about
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
kittyGraphicsSupported = func() bool { return true } // the terminal confirms
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
for _, tc := range []struct {
name string
env map[string]string
want bool
}{
{"ghostty term", map[string]string{"TERM": "xterm-ghostty"}, true},
{"ghostty program", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "ghostty"}, true},
{"ghostty resources", map[string]string{"GHOSTTY_RESOURCES_DIR": "/x"}, true},
{"kitty", map[string]string{"TERM": "xterm-kitty"}, true},
{"kitty window", map[string]string{"KITTY_WINDOW_ID": "1"}, true},
{"plain xterm", map[string]string{"TERM": "xterm-256color"}, false},
{"wezterm has no placeholders", map[string]string{"TERM_PROGRAM": "WezTerm"}, false},
{"tmux swallows graphics", map[string]string{"TERM": "xterm-ghostty", "TMUX": "/tmp/x"}, false},
{"screen swallows graphics", map[string]string{"TERM": "screen-256color", "TERM_PROGRAM": "ghostty"}, false},
{"nothing", nil, false},
} {
if got := kittyTerminal(env(tc.env)); got != tc.want {
t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
}
}
for _, tc := range []struct {
setting string
env map[string]string
wantGfx bool
wantName string
}{
{"auto", map[string]string{"TERM": "xterm-ghostty"}, true, "sextant"},
{"", map[string]string{"TERM": "xterm-256color"}, false, "quadrant"},
{"kitty", map[string]string{"TERM": "xterm-256color"}, true, "quadrant"},
{"sextant", map[string]string{"TERM": "xterm-ghostty"}, false, "sextant"},
{"quadrant", map[string]string{"TERM": "xterm-ghostty"}, false, "quadrant"},
} {
blocks, gfx := resolveCovers(tc.setting, env(tc.env))
if gfx != tc.wantGfx || blocks.name != tc.wantName {
t.Errorf("%q %v = %s/%v, want %s/%v", tc.setting, tc.env, blocks.name, gfx, tc.wantName, tc.wantGfx)
}
}
}
// trueColor makes a simulation screen report 24-bit color.
type trueColor struct{ tcell.SimulationScreen }
func (trueColor) Colors() int { return 1 << 24 }
func TestCoverViewGraphicsDrawsPlaceholders(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 20)
rec := &recorder{}
c := newCoverView(quadrantStyle, newKittyGraphics(rec.write), nil)
c.setImage(stripes(40, 60)) // 2:3, cells twice as tall as wide: 13 columns × 10 rows in a 30×10 box
c.SetRect(0, 0, 30, 10)
// Without true color the protocol is not used and blocks are drawn.
c.Draw(sim)
if len(rec.cmds) != 0 {
t.Fatalf("graphics commands sent without true color: %q", rec.cmds)
}
if ch, _, _, _ := sim.GetContent(29, 0); ch == placeholderRune {
t.Error("placeholders drawn without true color")
}
screen := trueColor{sim}
c.Draw(screen)
if len(rec.cmds) != 2 { // upload (one chunk) + placement
t.Fatalf("%d commands: %q", len(rec.cmds), rec.cmds)
}
if !strings.Contains(rec.cmds[1], "c=13,r=10") {
t.Errorf("placement = %q", rec.cmds[1])
}
// Right-aligned: columns 17..29, rows 0..9.
ch, comb, _, _ := sim.GetContent(17, 0)
if ch != placeholderRune || len(comb) < 2 || comb[0] != kittyDiacritics[0] || comb[1] != kittyDiacritics[0] {
t.Errorf("top left cell = %U %U", ch, comb)
}
ch, comb, _, _ = sim.GetContent(29, 9)
if ch != placeholderRune || comb[0] != kittyDiacritics[9] || comb[1] != kittyDiacritics[12] {
t.Errorf("bottom right cell = %U %U", ch, comb)
}
c.Draw(screen) // a second frame uploads nothing again
if len(rec.cmds) != 2 {
t.Errorf("redraw sent %d more commands", len(rec.cmds)-2)
}
// A cell aspect of 2.5 (taller cells) changes the footprint of the same box.
c.cellAspect = func() float64 { return 2.5 }
c.Draw(screen)
// The box is now 17×10 cells, a different shape: a new image id is uploaded and
// placed, the old upload is removed.
all := strings.Join(rec.cmds[2:], "")
if !strings.Contains(all, "a=p,U=1,i=2,c=17,r=10") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("after the cell aspect changed: %q", rec.cmds[2:])
}
}
func TestCropToBox(t *testing.T) {
b := image.Rect(0, 0, 167, 250) // a 2:3 poster
// A box slightly narrower than the poster: the sides are trimmed.
got := cropToBox(b, 17, 13, 2.1) // box shape 17/(13*2.1) = 0.6227 < 0.668
if got.Dy() != 250 || got.Dx() >= 167 || got.Min.X != (167-got.Dx())/2 {
t.Errorf("narrow box: %v", got)
}
if shape := float64(got.Dx()) / float64(got.Dy()); math.Abs(shape-17.0/(13*2.1)) > 0.005 {
t.Errorf("cropped shape %.4f, want %.4f", shape, 17.0/(13*2.1))
}
// A box slightly wider: top and bottom are trimmed.
got = cropToBox(b, 18, 12, 2.1) // 18/(12*2.1) = 0.714 > 0.668
if got.Dx() != 167 || got.Dy() >= 250 || got.Min.Y != (250-got.Dy())/2 {
t.Errorf("wide box: %v", got)
}
// A box of exactly the poster's shape changes nothing.
if got := cropToBox(image.Rect(0, 0, 40, 60), 20, 15, 2); got != image.Rect(0, 0, 40, 60) {
t.Errorf("exact box: %v", got)
}
// Offsets of sub-images are respected.
if got := cropToBox(image.Rect(10, 20, 50, 80), 18, 10, 2); got.Min.X != 10 || got.Max.X != 50 || got.Min.Y < 20 || got.Max.Y > 80 || got.Dy() >= 60 {
t.Errorf("offset image: %v", got)
}
}
func TestCellAspectFromWindow(t *testing.T) {
// 140×50 cells of 9×20 px plus 4 and 11 px of padding: plain division
// would say 1264/140=9.03 and 1011/50=20.22 (aspect 2.24), the real shape is 20/9.
if got := cellAspectFromWindow(140, 50, 140*9+4, 50*20+11); math.Abs(got-20.0/9) > 1e-9 {
t.Errorf("aspect = %v, want %v", got, 20.0/9)
}
// Padding that grows with the window does not move it.
for _, pad := range []int{0, 3, 8} {
if got := cellAspectFromWindow(100, 30, 100*10+pad, 30*21+pad); math.Abs(got-2.1) > 1e-9 {
t.Errorf("padding %d: aspect = %v", pad, got)
}
}
for _, bad := range [][4]int{{0, 10, 100, 100}, {10, 0, 100, 100}, {10, 10, 0, 100}, {10, 10, 100, 0}, {100, 100, 50, 50}} {
if got := cellAspectFromWindow(bad[0], bad[1], bad[2], bad[3]); got != 2 {
t.Errorf("%v: aspect = %v, want the default 2", bad, got)
}
}
}
func TestPosterSource(t *testing.T) {
for in, want := range map[string]string{
"https://artworks.thetvdb.com/banners/posters/1-1.jpg": "artworks.thetvdb.com/banners/posters/1-1.jpg",
"http://radarr:7878|/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster.jpg",
} {
if got := posterSource(in); got != want {
t.Errorf("posterSource(%q) = %q, want %q", in, got, want)
}
}
}
func TestGraphicsAreHeldWhileTheWindowChanges(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 20)
screen := trueColor{sim}
rec := &recorder{}
k := newKittyGraphics(rec.write)
c := newCoverView(quadrantStyle, k, nil)
c.setImage(stripes(40, 60))
c.SetRect(0, 0, 30, 10)
k.hold()
if !k.settling() {
t.Fatal("hold must start the settling period")
}
c.Draw(screen)
if len(rec.cmds) != 0 {
t.Fatalf("graphics commands while the window changes: %q", rec.cmds)
}
if ch, _, _, _ := sim.GetContent(29, 9); ch == placeholderRune {
t.Error("placeholders drawn while settling; blocks are the stand-in")
}
k.holdUntil = time.Now().Add(-time.Millisecond) // the period is over
c.Draw(screen)
if len(rec.cmds) != 2 {
t.Errorf("after settling: %d commands, want upload + placement", len(rec.cmds))
}
if ch, _, _, _ := sim.GetContent(29, 9); ch != placeholderRune {
t.Error("real image not shown after settling")
}
}
func TestGraphicsDebugReportsUploads(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
var lines []string
k.debug = func(s string) { lines = append(lines, s) }
img := noisy(10, 15)
k.show(img, img.Bounds(), 12, 8)
if len(lines) != 1 || !strings.Contains(lines[0], "id 1") || !strings.Contains(lines[0], "12x8 cells") || !strings.Contains(lines[0], "10x15 px") {
t.Errorf("debug lines = %q", lines)
}
}
func TestGraphicsNeedTheTerminalsConfirmation(t *testing.T) {
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
ghostty := env(map[string]string{"TERM": "xterm-ghostty"})
kittyGraphicsSupported = func() bool { return false } // e.g. a terminal that claims kitty but prints APC as text
if _, gfx := resolveCovers("auto", ghostty); gfx {
t.Error("auto must not use real images when the terminal does not answer the query")
}
if _, gfx := resolveCovers("kitty", ghostty); gfx {
t.Error("even an explicit kitty setting must not send graphics a terminal does not understand")
}
asked := false
kittyGraphicsSupported = func() bool { asked = true; return true }
if _, gfx := resolveCovers("auto", env(map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"})); gfx || asked {
t.Error("a terminal not known for the protocol is not even asked (it might print the query)")
}
if _, gfx := resolveCovers("auto", ghostty); !gfx || !asked {
t.Error("a known terminal that answers gets real images")
}
}
func TestPostersAreSentAtTheBoxSize(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 333, 500)) // Radarr's "500" copy, smaller than the box
var msgs []string
k := newKittyGraphics(func([]byte) error { return nil })
k.debug = func(m string) { msgs = append(msgs, m) }
k.cellPixels = func() (int, int) { return 22, 48 } // a high-resolution screen
if _, err := k.show(img, img.Bounds(), 31, 21); err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 682x1008 px") {
t.Errorf("upload: %q", msgs)
}
// Without the cell size the image goes as it is.
k.cellPixels = func() (int, int) { return 0, 0 }
img2 := image.NewRGBA(image.Rect(0, 0, 426, 640))
msgs = nil
if _, err := k.show(img2, img2.Bounds(), 31, 21); err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 426x640 px") {
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))
}
}