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)) } }