package ui import ( "bytes" "encoding/base64" "image" "image/png" "math" "math/rand" "regexp" "strings" "testing" "github.com/gdamore/tcell/v2" ) // 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.imageID(img, img.Bounds()) if err != nil || id != 1 { t.Fatalf("id=%d err=%v", id, err) } if len(rec.cmds) < 3 { t.Fatalf("only %d commands for a large image", len(rec.cmds)) } re := regexp.MustCompile(`^\x1b_G([^;]*);(.*)\x1b\\$`) var payload strings.Builder for i, c := range rec.cmds { 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(rec.cmds)-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 image is not uploaded twice; another one gets the next id. n := len(rec.cmds) if again, _ := k.imageID(img, img.Bounds()); again != id || len(rec.cmds) != n { t.Error("an uploaded image must be reused") } if other, _ := k.imageID(noisy(4, 4), image.Rect(0, 0, 4, 4)); other != 2 { t.Errorf("second image id = %d", other) } } func TestKittyPlacement(t *testing.T) { rec := &recorder{} k := newKittyGraphics(rec.write) if err := k.place(7, 20, 15); err != nil { t.Fatal(err) } if len(rec.cmds) != 1 || rec.cmds[0] != "\x1b_Ga=p,U=1,i=7,c=20,r=15,q=2\x1b\\" { t.Fatalf("first placement: %q", rec.cmds) } k.place(7, 20, 15) // unchanged: nothing to send if len(rec.cmds) != 1 { t.Errorf("unchanged placement sent %d commands", len(rec.cmds)) } k.place(7, 10, 8) // a new size replaces the placement if len(rec.cmds) != 3 || rec.cmds[1] != "\x1b_Ga=d,d=i,i=7,q=2\x1b\\" || rec.cmds[2] != "\x1b_Ga=p,U=1,i=7,c=10,r=8,q=2\x1b\\" { t.Errorf("resize: %q", rec.cmds[1:]) } rec.cmds = nil small := noisy(2, 2) id, _ := k.imageID(small, small.Bounds()) k.deleteAll() if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=d,d=I,i="+itoa(int(id))+",q=2\x1b\\" { t.Errorf("deleteAll sent %q", last) } if len(k.ids) != 0 || len(k.placed) != 0 { t.Error("deleteAll must forget everything") } } 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, _, _ := 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) } } 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 crop is uploaded, the // old upload is removed, and the image is placed afresh. var placements []string for _, cmd := range rec.cmds[2:] { placements = append(placements, cmd[:min(len(cmd), 30)]) } last := rec.cmds[len(rec.cmds)-1] if !strings.Contains(last, "c=17,r=10") || !strings.Contains(strings.Join(rec.cmds[2:], ""), "a=d,d=I,i=1") { t.Errorf("after the cell aspect changed: %q", placements) } } 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) } }