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:
@@ -40,8 +40,11 @@ type UI struct {
|
||||
// CoverStyle selects the block characters posters are drawn with:
|
||||
// "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant"
|
||||
// (2x3, sharper, needs a font with the Symbols for Legacy Computing
|
||||
// block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant
|
||||
// on terminals known to draw them, quadrant elsewhere).
|
||||
// block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through
|
||||
// the Kitty graphics protocol with Unicode placeholders: kitty and
|
||||
// Ghostty) or "auto" (default: the Kitty protocol where the terminal is
|
||||
// known to support it, else sextant on terminals known to draw them, else
|
||||
// quadrant). The block styles are also the fallback for "kitty".
|
||||
CoverStyle string `json:"cover_style,omitempty"`
|
||||
}
|
||||
|
||||
@@ -161,9 +164,9 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
switch c.UI.CoverStyle {
|
||||
case "", "auto", "quadrant", "sextant":
|
||||
case "", "auto", "quadrant", "sextant", "kitty":
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "quadrant" or "sextant"`, c.UI.CoverStyle))
|
||||
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant" or "quadrant"`, c.UI.CoverStyle))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -100,12 +100,12 @@ func TestShowCovers(t *testing.T) {
|
||||
|
||||
func TestCoverStyleValidation(t *testing.T) {
|
||||
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
|
||||
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `""`} {
|
||||
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `"kitty"`, `""`} {
|
||||
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
|
||||
t.Errorf("%s rejected: %v", ok, err)
|
||||
}
|
||||
}
|
||||
if _, err := Parse([]byte(base + `"braille"}}`)); err == nil {
|
||||
if _, err := Parse([]byte(base + `"sixel"}}`)); err == nil {
|
||||
t.Error("unknown style accepted")
|
||||
}
|
||||
}
|
||||
|
||||
+49
-1
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user