Files
arr-tui/internal/ui/kitty_test.go
T

321 lines
12 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"
"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
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)
}
}
}