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
+49 -1
View File
@@ -52,6 +52,8 @@ type App struct {
bell bool
covers bool // poster thumbnails enabled
coverStyle coverStyle
gfx *kittyGraphics // real-image posters, nil when not used
cellAspect float64 // height of a terminal cell divided by its width
coverCache *coverCache
}
@@ -99,10 +101,16 @@ func New(cfg *config.Config) *App {
notifyDone: cfg.UI.ShouldNotifyDownloads(),
bell: cfg.UI.Bell,
covers: cfg.UI.ShowCovers(),
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv),
cellAspect: 2,
coverCache: newCoverCache(),
}
blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv)
a.coverStyle = blocks
if graphics {
a.gfx = newKittyGraphics(a.writeTerminal)
}
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.logs = newLogView(a)
@@ -149,6 +157,7 @@ func New(cfg *config.Config) *App {
a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool {
a.screen = s
a.cellAspect = cellAspectOf(s)
return false
})
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
@@ -158,6 +167,40 @@ func New(cfg *config.Config) *App {
return a
}
// writeTerminal sends raw bytes (graphics commands) to the terminal through the
// screen's own connection. UI goroutine only, so it stays in step with drawing.
func (a *App) writeTerminal(b []byte) error {
if a.screen == nil {
return fmt.Errorf("no screen yet")
}
tty, ok := a.screen.Tty()
if !ok {
return fmt.Errorf("the screen has no terminal")
}
_, err := tty.Write(b)
return err
}
// cellAspectOf measures the height of a terminal cell divided by its width in
// pixels; 2 when the terminal does not report pixel sizes.
func cellAspectOf(s tcell.Screen) float64 {
if tty, ok := s.Tty(); ok {
if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 && ws.PixelWidth > 0 && ws.PixelHeight > 0 {
return (float64(ws.PixelHeight) / float64(ws.Height)) / (float64(ws.PixelWidth) / float64(ws.Width))
}
}
return 2
}
// coverMaxHeight is how tall the stored poster copies are: real images keep
// more detail than block characters need.
func (a *App) coverMaxHeight() int {
if a.gfx != nil {
return graphicsCoverHeight
}
return storedCoverHeight
}
// SetService records the outcome of a request to a service and updates the
// connection indicators. UI goroutine only. A nil err means reachable.
func (a *App) SetService(name, version string, err error) {
@@ -246,6 +289,11 @@ func (a *App) post(f func()) {
func (a *App) Run() error {
stop := make(chan struct{})
defer close(stop)
defer func() {
if a.gfx != nil {
a.gfx.deleteAll()
}
}()
go a.watchServices(stop)
if a.notifyDone {
ctx, cancel := context.WithCancel(context.Background())
+85 -21
View File
@@ -18,15 +18,17 @@ import (
)
const (
maxCoverPixels = 25_000_000 // refuse decompression bombs
storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail
coverFailureTTL = 2 * time.Minute
maxCoverCache = 300
coverFetchLimit = 4
maxCoverPixels = 25_000_000 // refuse decompression bombs
storedCoverHeight = 160 // a small copy is plenty for block characters
graphicsCoverHeight = 640 // posters shown as real images keep more detail
coverFailureTTL = 2 * time.Minute
maxCoverCache = 120
coverFetchLimit = 4
)
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
func decodeCover(data []byte) (*image.RGBA, error) {
// decodeCover decodes a JPEG or PNG poster and keeps a copy at most
// maxHeight pixels tall.
func decodeCover(data []byte, maxHeight int) (*image.RGBA, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("not a supported image: %w", err)
@@ -39,10 +41,10 @@ func decodeCover(data []byte) (*image.RGBA, error) {
return nil, err
}
b := img.Bounds()
if b.Dy() <= storedCoverHeight {
if b.Dy() <= maxHeight {
return boxScale(img, b.Dx(), b.Dy()), nil
}
return boxScale(img, max(1, b.Dx()*storedCoverHeight/b.Dy()), storedCoverHeight), nil
return boxScale(img, max(1, b.Dx()*maxHeight/b.Dy()), maxHeight), nil
}
// boxScale resizes src to w×h by averaging the source pixels under every
@@ -117,8 +119,9 @@ func sextantGlyph(mask int) rune {
return rune(0x1FB00 + idx)
}
// coverStyleFor resolves the configured style. "auto" picks sextants on
// terminals known to have the glyphs and quadrants everywhere else.
// coverStyleFor resolves the block-character style for the setting. "auto"
// picks sextants on terminals known to have the glyphs and quadrants everywhere
// else.
func coverStyleFor(setting string, env func(string) string) coverStyle {
switch setting {
case "sextant":
@@ -138,6 +141,21 @@ func coverStyleFor(setting string, env func(string) string) coverStyle {
return quadrantStyle
}
// resolveCovers decides how posters are drawn: as real images through the
// Kitty graphics protocol (graphics) or with block characters in the given
// style. "kitty" asks for the protocol explicitly; "auto" uses it when the
// terminal is known to support it. The block style is also the fallback when
// the protocol cannot be used at draw time (e.g. no true color).
func resolveCovers(setting string, env func(string) string) (blocks coverStyle, graphics bool) {
switch setting {
case "kitty":
return coverStyleFor("auto", env), true
case "auto", "":
return coverStyleFor("auto", env), kittyTerminal(env)
}
return coverStyleFor(setting, env), false
}
// flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150
@@ -214,9 +232,11 @@ func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
// colors, also over SSH.
type coverView struct {
*tview.Box
style coverStyle
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
style coverStyle
gfx *kittyGraphics // real images; nil for block characters only
cellAspect func() float64 // height of a terminal cell divided by its width
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
// The cells for the last drawn size.
cols, rows int
@@ -228,7 +248,18 @@ type styledCell struct {
fg, bg tcell.Color
}
func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} }
func newCoverView(style coverStyle, gfx *kittyGraphics, cellAspect func() float64) *coverView {
return &coverView{Box: tview.NewBox(), style: style, gfx: gfx, cellAspect: cellAspect}
}
func (c *coverView) aspectOfCells() float64 {
if c.cellAspect != nil {
if a := c.cellAspect(); a > 0.5 && a < 8 {
return a
}
}
return 2
}
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
@@ -243,13 +274,14 @@ func (c *coverView) aspect() (w, h int) {
}
// coverCells returns how many columns and rows a poster of the given aspect
// needs inside w×h cells. A cell is about twice as tall as it is wide.
func coverCells(imgW, imgH, w, h int) (cols, rows int) {
// needs inside w×h cells. cellAspect is the height of a cell divided by its
// width, about 2 in most terminals.
func coverCells(imgW, imgH, w, h int, cellAspect float64) (cols, rows int) {
cols = w
rows = cols * imgH / imgW / 2
rows = int(float64(cols) * float64(imgH) / float64(imgW) / cellAspect)
if rows > h {
rows = h
cols = rows * 2 * imgW / imgH
cols = int(float64(rows) * cellAspect * float64(imgW) / float64(imgH))
}
return max(cols, 0), max(rows, 0)
}
@@ -268,10 +300,13 @@ func (c *coverView) Draw(screen tcell.Screen) {
}
b := c.img.Bounds()
cols, rows := coverCells(b.Dx(), b.Dy(), w, h)
cols, rows := coverCells(b.Dx(), b.Dy(), w, h, c.aspectOfCells())
if cols < 1 || rows < 1 {
return
}
if c.drawGraphics(screen, x+w-cols, y, cols, rows) {
return
}
if c.cells == nil || c.cols != cols || c.rows != rows {
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
}
@@ -285,6 +320,31 @@ func (c *coverView) Draw(screen tcell.Screen) {
}
}
// drawGraphics shows the poster as a real image through the Kitty graphics
// protocol: the image is uploaded once and the cells get placeholder
// characters. It reports false when that is not possible (no protocol, no true
// color, too many rows or columns, or the terminal could not be written to), so
// the caller can draw block characters instead.
func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool {
if c.gfx == nil || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders {
return false
}
id, err := c.gfx.imageID(c.img)
if err == nil {
err = c.gfx.place(id, cols, rows)
}
if err != nil {
return false
}
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
ch, comb, style := placeholderCell(id, row, col)
screen.SetContent(x+col, y+row, ch, comb, style)
}
}
return true
}
// build renders the poster into cols×rows cells.
func (c *coverView) build(cols, rows int) []styledCell {
st := c.style
@@ -358,6 +418,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
return
}
c.waiting[ref.key] = []func(*image.RGBA, error){done}
maxHeight := a.coverMaxHeight()
go func() {
c.slots <- struct{}{}
@@ -367,7 +428,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
var img *image.RGBA
data, err := ref.fetch(ctx)
if err == nil {
img, err = decodeCover(data)
img, err = decodeCover(data, maxHeight)
}
a.post(func() {
waiters := c.waiting[ref.key]
@@ -377,6 +438,9 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
} else {
if len(c.imgs) >= maxCoverCache {
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
if a.gfx != nil {
a.gfx.deleteAll() // the terminal's copies belong to the dropped images
}
}
c.imgs[ref.key] = img
}
+7 -7
View File
@@ -50,7 +50,7 @@ func TestDecodeCover(t *testing.T) {
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
t.Fatal(err)
}
img, err := decodeCover(buf.Bytes())
img, err := decodeCover(buf.Bytes(), storedCoverHeight)
if err != nil {
t.Fatal(err)
}
@@ -62,17 +62,17 @@ func TestDecodeCover(t *testing.T) {
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
t.Fatal(err)
}
if img, err := decodeCover(buf.Bytes()); err != nil || img.Bounds().Dy() != 30 {
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
t.Errorf("small png kept as is: %v %v", img, err)
}
if _, err := decodeCover([]byte("<html>not an image</html>")); err == nil {
if _, err := decodeCover([]byte("<html>not an image</html>"), storedCoverHeight); err == nil {
t.Error("garbage must be rejected")
}
// A header that promises a huge image is refused before decoding it.
var huge bytes.Buffer
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
if _, err := decodeCover(huge.Bytes()); err == nil {
if _, err := decodeCover(huge.Bytes(), storedCoverHeight); err == nil {
t.Error("oversized image must be rejected")
}
}
@@ -110,11 +110,11 @@ func TestQuadCell(t *testing.T) {
func TestCoverCells(t *testing.T) {
// A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
// width units tall, so 13 columns.
if c, r := coverCells(40, 60, 30, 10); c != 13 || r != 10 {
if c, r := coverCells(40, 60, 30, 10, 2); c != 13 || r != 10 {
t.Errorf("tall limit: %dx%d", c, r)
}
// In a narrow box the width limits it.
if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 {
if c, r := coverCells(40, 60, 10, 20, 2); c != 10 || r != 7 {
t.Errorf("narrow limit: %dx%d", c, r)
}
}
@@ -127,7 +127,7 @@ func TestCoverViewDraws(t *testing.T) {
defer screen.Fini()
screen.SetSize(30, 10)
c := newCoverView(quadrantStyle)
c := newCoverView(quadrantStyle, nil, nil)
c.setImage(stripes(40, 60)) // red above blue
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
+2 -2
View File
@@ -43,7 +43,7 @@ func newDetailPane(app *App) *detailPane {
Flex: tview.NewFlex(),
app: app,
text: tview.NewTextView().SetDynamicColors(true).SetWrap(false),
cover: newCoverView(app.coverStyle),
cover: newCoverView(app.coverStyle, app.gfx, func() float64 { return app.cellAspect }),
}
d.AddItem(d.text, 0, 1, false)
d.SetBorder(true).SetTitle(" Details ")
@@ -85,7 +85,7 @@ func (d *detailPane) Draw(screen tcell.Screen) {
if d.hasCover {
aw, ah := d.cover.aspect()
// At most 40% of the width, and the text keeps some rows below.
d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows)
d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows, d.cover.aspectOfCells())
if d.coverH < minCoverRows || d.coverW < minCoverCols {
d.coverW, d.coverH = 0, 0
}
+144
View File
@@ -0,0 +1,144 @@
package ui
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"strings"
"github.com/gdamore/tcell/v2"
)
// The Kitty graphics protocol with Unicode placeholders, as supported by
// kitty and Ghostty: the poster is uploaded to the terminal once, and then
// shown by writing special placeholder characters into ordinary text cells.
// Because the picture is made of text cells it behaves like text: popups and
// dialogs drawn over it cover it, it vanishes when the view changes, and the
// normal screen diffing keeps it in place. The poster is shown at the
// terminal's full resolution.
// placeholderRune marks a cell that shows a piece of an image. It is followed
// by combining characters that give the row and column in the image; the
// image id is the cell's foreground color.
const placeholderRune = '\U0010EEEE'
const kittyChunk = 4096 // bytes of base64 per escape sequence, the protocol maximum
// kittyGraphics uploads images to the terminal and keeps track of them.
type kittyGraphics struct {
write func([]byte) error // writes to the terminal, in step with the screen
nextID uint32
ids map[*image.RGBA]uint32
placed map[uint32][2]int // columns and rows each image is currently placed with
}
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
return &kittyGraphics{write: write, nextID: 1, ids: map[*image.RGBA]uint32{}, placed: map[uint32][2]int{}}
}
// kittyAPC builds one graphics command: ESC _ G <keys> ; <payload> ESC \.
func kittyAPC(keys string, payload []byte) []byte {
var b bytes.Buffer
b.WriteString("\x1b_G")
b.WriteString(keys)
if len(payload) > 0 {
b.WriteByte(';')
b.Write(payload)
}
b.WriteString("\x1b\\")
return b.Bytes()
}
// imageID returns the terminal's id for the image, uploading it on first use.
func (k *kittyGraphics) imageID(img *image.RGBA) (uint32, error) {
if id, ok := k.ids[img]; ok {
return id, nil
}
var data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, img); err != nil {
return 0, err
}
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
id := k.nextID
for first, rest := true, encoded; len(rest) > 0; first = false {
n := min(len(rest), kittyChunk)
more := 0
if n < len(rest) {
more = 1
}
keys := fmt.Sprintf("m=%d", more)
if first {
// Transmit only (a=t), PNG data (f=100) directly in the command (t=d),
// no replies (q=2): a reply would arrive as keyboard input.
keys = fmt.Sprintf("a=t,f=100,t=d,i=%d,q=2,m=%d", id, more)
}
if err := k.write(kittyAPC(keys, []byte(rest[:n]))); err != nil {
return 0, err
}
rest = rest[n:]
}
k.nextID++
k.ids[img] = id
return id, nil
}
// place makes the image cover cols×rows cells for the placeholders. A new size
// replaces the previous placement.
func (k *kittyGraphics) place(id uint32, cols, rows int) error {
if p, ok := k.placed[id]; ok {
if p == [2]int{cols, rows} {
return nil
}
// Remove the old placement (the uploaded data stays) before making a new one.
if err := k.write(kittyAPC(fmt.Sprintf("a=d,d=i,i=%d,q=2", id), nil)); err != nil {
return err
}
}
// U=1: a virtual placement, shown wherever placeholder cells refer to it.
if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil {
return err
}
k.placed[id] = [2]int{cols, rows}
return nil
}
// deleteAll removes every image uploaded so far from the terminal.
func (k *kittyGraphics) deleteAll() {
for _, id := range k.ids {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", id), nil))
}
k.ids = map[*image.RGBA]uint32{}
k.placed = map[uint32][2]int{}
}
// placeholderCell returns what to draw in the cell at (row, col) of the image
// with the given id: the placeholder character, its combining characters and
// the style that carries the id.
func placeholderCell(id uint32, row, col int) (main rune, comb []rune, style tcell.Style) {
comb = []rune{kittyDiacritics[row], kittyDiacritics[col]}
fg := tcell.NewRGBColor(int32(id>>16&0xFF), int32(id>>8&0xFF), int32(id&0xFF))
return placeholderRune, comb, tcell.StyleDefault.Foreground(fg)
}
// maxPlaceholders is how many rows and columns can be numbered.
const maxPlaceholders = len(kittyDiacritics)
// kittyTerminal reports whether the environment looks like a terminal that
// supports Kitty graphics with Unicode placeholders: kitty and Ghostty, not
// inside tmux or screen (which swallow the graphics commands).
func kittyTerminal(env func(string) string) bool {
term := strings.ToLower(env("TERM"))
if env("TMUX") != "" || env("STY") != "" || strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") {
return false
}
switch {
case strings.Contains(term, "kitty"), strings.Contains(term, "ghostty"):
return true
case strings.EqualFold(env("TERM_PROGRAM"), "ghostty"), env("GHOSTTY_RESOURCES_DIR") != "", env("KITTY_WINDOW_ID") != "":
return true
}
return false
}
+46
View File
@@ -0,0 +1,46 @@
package ui
// kittyDiacritics are the 297 combining characters the Kitty graphics
// protocol uses to number the rows and columns of an image drawn with Unicode
// placeholders (rowcolumn-diacritics.txt in the kitty repository, fixed to
// Unicode 6.0). Entry i stands for the number i.
var kittyDiacritics = [...]rune{
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F,
0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357,
0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484,
0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611,
0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658,
0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733,
0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE,
0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819,
0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C,
0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87,
0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D,
0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1,
0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9,
0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7,
0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA,
0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2,
0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D,
0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5,
0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7,
0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187,
0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243,
0x1D244,
}
+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:])
}
}