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
+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
}