mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
203 lines
7.3 KiB
Go
203 lines
7.3 KiB
Go
package render
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
"image/png"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Native inline images via the kitty graphics protocol with Unicode
|
|
// placeholders (https://sw.kovidgoyal.net/kitty/graphics-protocol/): the image
|
|
// is transmitted once out of band with a virtual placement, and the chat trace
|
|
// renders placeholder cells that the terminal replaces with real pixels. The
|
|
// placeholder rows are plain styled text, so they scroll and diff like any
|
|
// other Bubble Tea content. Terminals without the protocol show no preview.
|
|
|
|
const (
|
|
imageMinCols = 20
|
|
imageMaxCols = 100
|
|
imageDefaultCols = 72
|
|
imageMaxRows = 28
|
|
kittyChunkSize = 4096
|
|
)
|
|
|
|
var imageCols = imageDefaultCols
|
|
|
|
// SetImageWidth sizes inline image placements to the chat content width in cells.
|
|
func SetImageWidth(cells int) {
|
|
imageCols = min(max(cells, imageMinCols), imageMaxCols)
|
|
}
|
|
|
|
// KittyGraphicsSupported reports whether the terminal supports the kitty
|
|
// graphics protocol; set at startup by DetectKittyGraphics via a live
|
|
// terminal query.
|
|
var KittyGraphicsSupported = func() bool { return false }
|
|
|
|
type kittyPlacement struct {
|
|
id uint32
|
|
cols int
|
|
rows int
|
|
placeholder string
|
|
}
|
|
|
|
var (
|
|
kittyMu sync.Mutex
|
|
kittyByHash = map[string]kittyPlacement{}
|
|
kittyQueue []string
|
|
kittyNextID uint32 = 1
|
|
)
|
|
|
|
// DrainImageTransmissions returns queued kitty transmit/placement sequences,
|
|
// to be written directly to the terminal exactly once per image.
|
|
func DrainImageTransmissions() []string {
|
|
kittyMu.Lock()
|
|
defer kittyMu.Unlock()
|
|
out := kittyQueue
|
|
kittyQueue = nil
|
|
return out
|
|
}
|
|
|
|
// payloadKey identifies an image payload without hashing megabytes of base64
|
|
// on every frame: its length plus both ends are enough to tell distinct
|
|
// images apart.
|
|
func payloadKey(payload string) string {
|
|
const edge = 64
|
|
if len(payload) <= 2*edge {
|
|
return payload
|
|
}
|
|
return fmt.Sprintf("%d:%s:%s", len(payload), payload[:edge], payload[len(payload)-edge:])
|
|
}
|
|
|
|
// kittyImageBlock registers the image payload (queueing its transmission on
|
|
// first sight) and returns the styled placeholder block for the chat trace.
|
|
func kittyImageBlock(mime, payload string) string {
|
|
kittyMu.Lock()
|
|
defer kittyMu.Unlock()
|
|
key := payloadKey(payload)
|
|
placement, ok := kittyByHash[key]
|
|
if !ok {
|
|
pngData, w, h := payloadToPNG(mime, payload)
|
|
if pngData == nil {
|
|
return ""
|
|
}
|
|
cols := min(imageCols, w)
|
|
rows := (h*cols + w - 1) / (w * 2)
|
|
rows = min(max(1, rows), imageMaxRows)
|
|
placement = kittyPlacement{id: kittyNextID, cols: cols, rows: rows}
|
|
placement.placeholder = kittyPlaceholder(placement)
|
|
kittyNextID++
|
|
kittyByHash[key] = placement
|
|
kittyQueue = append(kittyQueue, kittyTransmit(placement, pngData))
|
|
}
|
|
return placement.placeholder
|
|
}
|
|
|
|
func payloadToPNG(mime, payload string) (data []byte, w, h int) {
|
|
raw, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return nil, 0, 0
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, 0, 0
|
|
}
|
|
bounds := img.Bounds()
|
|
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
|
|
return nil, 0, 0
|
|
}
|
|
if mime == "png" {
|
|
return raw, bounds.Dx(), bounds.Dy()
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
return nil, 0, 0
|
|
}
|
|
return buf.Bytes(), bounds.Dx(), bounds.Dy()
|
|
}
|
|
|
|
// kittyTransmit builds the chunked APC sequences transmitting the PNG and
|
|
// creating a virtual (U=1) placement for Unicode placeholders.
|
|
func kittyTransmit(p kittyPlacement, pngData []byte) string {
|
|
encoded := base64.StdEncoding.EncodeToString(pngData)
|
|
var b strings.Builder
|
|
first := true
|
|
for len(encoded) > 0 {
|
|
chunk := encoded
|
|
if len(chunk) > kittyChunkSize {
|
|
chunk = chunk[:kittyChunkSize]
|
|
}
|
|
encoded = encoded[len(chunk):]
|
|
more := 0
|
|
if len(encoded) > 0 {
|
|
more = 1
|
|
}
|
|
if first {
|
|
fmt.Fprintf(&b, "\x1b_Ga=t,q=2,f=100,i=%d,m=%d;%s\x1b\\", p.id, more, chunk)
|
|
first = false
|
|
} else {
|
|
fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, chunk)
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "\x1b_Ga=p,q=2,U=1,i=%d,c=%d,r=%d\x1b\\", p.id, p.cols, p.rows)
|
|
return b.String()
|
|
}
|
|
|
|
// kittyPlaceholder renders the rows x cols grid of U+10EEEE placeholder cells
|
|
// carrying the image id in the foreground color and the cell position in
|
|
// row/column diacritics. The id must reach the terminal as an exact truecolor
|
|
// value, so the SGR sequence is emitted directly rather than through lipgloss
|
|
// (whose profile detection may downsample it).
|
|
func kittyPlaceholder(p kittyPlacement) string {
|
|
id := p.id & 0xffffff
|
|
var b strings.Builder
|
|
for row := range p.rows {
|
|
if row > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm", id>>16&0xff, id>>8&0xff, id&0xff)
|
|
for col := range p.cols {
|
|
b.WriteRune(0x10eeee)
|
|
b.WriteRune(rowColumnDiacritics[row])
|
|
b.WriteRune(rowColumnDiacritics[col])
|
|
}
|
|
b.WriteString("\x1b[39m")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// rowColumnDiacritics is kitty's canonical placeholder diacritic table
|
|
// (gen/rowcolumn-diacritics.txt); index n encodes row/column number n.
|
|
var rowColumnDiacritics = []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,
|
|
}
|