okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
6e82811
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"bytes" 6"context" 7"encoding/base64" 8"image" 9"os" 10"os/exec" 11"strings" 12"sync" 13"sync/atomic" 14"time" 15 16"github.com/charmbracelet/x/ansi" 17"github.com/charmbracelet/x/ansi/kitty" 18) 19 20// A chip's thumbnail area, in cells. Cells are taken to be twice as tall as 21// they are wide. 22const ( 23thumbCols = 12 24thumbRows = 4 25// thumbPixels bounds the longer edge of what is sent to the terminal. 26thumbPixels = 240 27) 28 29// thumbnail is an image the terminal holds under id, shown in cols x rows 30// cells by writing its Unicode placeholders. 31type thumbnail struct { 32id ,cols ,rows int 33} 34 35// graphics is how kitty images reach this terminal: supported when it shows 36// them through Unicode placeholders, wrapped when tmux sits in between. 37type graphics struct { 38supported ,wrapped bool 39// hint explains why a terminal that could show thumbnails does not. 40hint string 41} 42 43var terminalGraphics = sync .OnceValue (func ()graphics { 44return detectGraphics (os .Getenv ,func (args ... string )string { 45ctx ,cancel := context .WithTimeout (context .Background (),time .Second ) 46defer cancel () 47out ,_ := exec .CommandContext (ctx ,"tmux" ,args ... ).Output () 48return strings .TrimSpace (string (out )) 49 }) 50}) 51 52// detectGraphics reads the terminal from the environment, and through tmux 53// the terminal of the client attached to it. 54func detectGraphics (getenv func (string )string ,tmux func (args ... string )string )graphics { 55if getenv ("TMUX" )== "" { 56return graphics {supported :placeholderTerminal (getenv ("TERM" ),getenv ("TERM_PROGRAM" ))|| getenv ("KITTY_WINDOW_ID" )!= "" } 57 } 58if ! placeholderTerminal (tmux ("display-message" ,"-p" ,"#{client_termname}" ),"" ) { 59return graphics {} 60 } 61switch tmux ("show-options" ,"-gv" ,"allow-passthrough" ) { 62case "on" ,"all" : 63return graphics {supported :true ,wrapped :true } 64 } 65return graphics {hint :"image thumbnails need tmux's allow-passthrough: set -g allow-passthrough on" } 66} 67 68// placeholderTerminal reports a terminal known to draw kitty images through 69// Unicode placeholders. 70func placeholderTerminal (term ,program string )bool { 71return strings .Contains (term ,"kitty" )|| strings .Contains (term ,"ghostty" )|| program == "ghostty" 72} 73 74// thumbnailIDs numbers transmitted images 16..255: the id travels as a 75// placeholder's 256-colour foreground, and the renderer may rewrite the first 76// sixteen as basic colours. A reused id replaces a long-gone image. 77var thumbnailIDs atomic.Uint32 78 79// transmitThumbnail decodes image, scales it down, and returns the escape 80// sequence that hands it to the terminal, with where it will be shown. It 81// returns false for a terminal without placeholders or an image it cannot 82// decode (WebP). 83func transmitThumbnail (g graphics ,attachment daemon.ImageAttachment ) (thumbnail ,string ,bool ) { 84if ! g .supported { 85return thumbnail {},"" ,false 86 } 87data ,err := base64 .StdEncoding .DecodeString (attachment .Data ) 88if err != nil { 89return thumbnail {},"" ,false 90 } 91decoded ,_ ,err := image .Decode (bytes .NewReader (data )) 92if err != nil { 93return thumbnail {},"" ,false 94 } 95cols ,rows := thumbnailCells (decoded .Bounds ().Dx (),decoded .Bounds ().Dy ()) 96thumb := thumbnail {id :16 + int (thumbnailIDs .Add (1 )- 1 )% 240 ,cols :cols ,rows :rows } 97options := & kitty.Options { 98Action :kitty .TransmitAndPut ,Transmission :kitty .Direct ,Format :kitty .PNG ,Quiet :2 , 99ID :thumb .id ,VirtualPlacement :true ,Columns :cols ,Rows :rows ,Chunk :true , 100 } 101if g .wrapped { 102options .ChunkFormatter = ansi .TmuxPassthrough 103 } 104var seq strings.Builder 105if err := kitty .EncodeGraphics (& seq ,shrink (decoded ,thumbPixels ),options );err != nil { 106return thumbnail {},"" ,false 107 } 108return thumb ,seq .String (),true 109} 110 111// thumbnailCells fits a width x height pixel image into the thumbnail area. 112func thumbnailCells (width ,height int ) (int ,int ) { 113if width <= 0 || height <= 0 { 114return thumbCols ,thumbRows 115 } 116// a cell is about twice as tall as wide: rows = cols * height / width / 2 117rows := max (1 , (thumbCols * height + width )/ (2 * width )) 118if rows <= thumbRows { 119return thumbCols ,rows 120 } 121return max (1 ,min (thumbCols , (2 * thumbRows * width + height / 2 )/ height )),thumbRows 122} 123 124// shrink scales img so its longer edge is at most edge pixels, sampling the 125// nearest source pixel. 126func shrink (img image.Image ,edge int ) image.Image { 127bounds := img .Bounds () 128width ,height := bounds .Dx (),bounds .Dy () 129if width <= edge && height <= edge { 130return img 131 } 132scaledW ,scaledH := edge ,max (1 ,height * edge / width ) 133if height > width { 134scaledW ,scaledH = max (1 ,width * edge / height ),edge 135 } 136out := image .NewRGBA (image .Rect (0 ,0 ,scaledW ,scaledH )) 137for y := range scaledH { 138for x := range scaledW { 139out .Set (x ,y ,img .At (bounds .Min .X + x * width / scaledW ,bounds .Min .Y + y * height / scaledH )) 140 } 141 } 142return out 143} 144 145// placeholderRows are the cells that draw thumb: each one the placeholder 146// character with its row and column as diacritics. Their foreground names 147// the image rather than a colour, so it comes from the id, not the theme. 148func placeholderRows (thumb thumbnail ) []string { 149image := ansi.Style {}.ForegroundColor (ansi .IndexedColor (thumb .id )).String () 150reset := ansi.Style {}.DefaultForegroundColor ().String () 151rows := make ([]string ,thumb .rows ) 152for r := range rows { 153var row strings.Builder 154row .WriteString (image ) 155for c := range thumb .cols { 156row .WriteRune (kitty .Placeholder ) 157row .WriteRune (kitty .Diacritic (r )) 158row .WriteRune (kitty .Diacritic (c )) 159 } 160row .WriteString (reset ) 161rows [r ]= row .String () 162 } 163return rows 164}