Show posters as real images in kitty and Ghostty (Kitty graphics with Unicode placeholders)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:03:29 +02:00
co-authored by Claude Sonnet 5.5
parent dc82b0b95d
commit 108541af91
12 changed files with 601 additions and 49 deletions
+241
View File
@@ -0,0 +1,241 @@
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:])
}
}