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:
+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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user