242 lines
8.0 KiB
Go
242 lines
8.0 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/base64"
|
||
"image"
|
||
"image/png"
|
||
"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)
|
||
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); again != id || len(rec.cmds) != n {
|
||
t.Error("an uploaded image must be reused")
|
||
}
|
||
if other, _ := k.imageID(noisy(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
|
||
id, _ := k.imageID(noisy(2, 2))
|
||
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) {
|
||
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)
|
||
if len(rec.cmds) != 4 || !strings.Contains(rec.cmds[3], "c=16,r=10") {
|
||
t.Errorf("after the cell aspect changed: %q", rec.cmds[2:])
|
||
}
|
||
}
|