okami.mom/ultraviolet
git clone https://git.nkp.pet/okami.mom/ultraviolet
f72cc87
upstream
1package main 2 3import ( 4"bytes" 5"context" 6"encoding/base64" 7"flag" 8"fmt" 9"image" 10"image/color" 11"io" 12"log" 13"os" 14"slices" 15"strings" 16"time" 17 18 _"image/jpeg" // Register JPEG format 19 20 uv"github.com/charmbracelet/ultraviolet" 21"github.com/charmbracelet/ultraviolet/screen" 22"github.com/charmbracelet/x/ansi" 23"github.com/charmbracelet/x/ansi/iterm2" 24"github.com/charmbracelet/x/ansi/kitty" 25"github.com/charmbracelet/x/ansi/sixel" 26"github.com/charmbracelet/x/mosaic" 27) 28 29type imageEncoding uint8 30 31const ( 32blocksEncoding imageEncoding = iota + 1 33sixelEncoding 34itermEncoding 35kittyEncoding 36 37unknownEncoding = 0 38) 39 40func (e imageEncoding )String ()string { 41switch e { 42case blocksEncoding : 43return "blocks" 44case sixelEncoding : 45return "sixel" 46case itermEncoding : 47return "iterm" 48case kittyEncoding : 49return "kitty" 50default : 51return "unknown" 52 } 53} 54 55var desiredEnc int 56 57func init () { 58flag .IntVar (& desiredEnc ,"encoding" ,int (unknownEncoding ),"image encoding" ) 59 60f ,err := os .OpenFile ("image.log" ,os .O_CREATE | os .O_WRONLY | os .O_APPEND ,0o666 ) 61if err != nil { 62log .Fatalf ("failed to open log file: %v" ,err ) 63 } 64log .SetFlags (log .LstdFlags | log .Lshortfile ) 65log .SetOutput (f ) 66} 67 68func main () { 69flag .Parse () 70 71t := uv .DefaultTerminal () 72if err := t .Start ();err != nil { 73log .Fatalf ("failed to start terminal: %v" ,err ) 74 } 75 76defer t .Stop () 77 78scr := t .Screen () 79 80// Use altscreen buffer. 81scr .EnterAltScreen ()//nolint:errcheck 82 83// Enable mouse support. 84scr .SetMouseMode (uv .MouseModeClick ) 85 86// Get image info. 87charmImgFile ,err := os .Open ("./charm.jpg" ) 88if err != nil { 89log .Fatalf ("failed to open image file: %v" ,err ) 90 } 91 92defer charmImgFile .Close ()//nolint:errcheck 93charmImgStat ,err := charmImgFile .Stat () 94if err != nil { 95log .Fatalf ("failed to stat image file: %v" ,err ) 96 } 97 98charmImgFileSize := charmImgStat .Size () 99 100var charmImgBuf bytes.Buffer 101var charmImgB64 []byte 102imgTee := io .TeeReader (charmImgFile ,& charmImgBuf ) 103charmImg ,_ ,err := image .Decode (imgTee ) 104if err != nil { 105log .Fatalf ("failed to decode image: %v" ,err ) 106 } 107 108charmImgArea := charmImg .Bounds () 109 110// Image related variables. 111var ( 112winSize uv.WindowSizeEvent 113pixSize uv.PixelSizeEvent 114imgEnc = blocksEncoding 115 ) 116if desiredEnc > 0 { 117imgEnc = imageEncoding (desiredEnc ) 118 } 119 120scr .Resize (winSize .Width ,winSize .Height )//nolint:errcheck 121 122upgradeEnc := func (enc imageEncoding ) { 123if desiredEnc == unknownEncoding { 124if enc > imgEnc { 125imgEnc = enc 126 } 127 } 128 } 129 130// Check environment variables for supported encodings. 131var ( 132termType = os .Getenv ("TERM" ) 133termProg string 134lcTerm string 135termVersion string 136ok bool 137 ) 138if termProg ,ok = os .LookupEnv ("TERM_PROGRAM" );ok { 139if strings .Contains (termProg ,"iTerm" )|| 140strings .Contains (termProg ,"WezTerm" )|| 141strings .Contains (termProg ,"mintty" )|| 142strings .Contains (termProg ,"vscode" )|| 143strings .Contains (termProg ,"Tabby" )|| 144strings .Contains (termProg ,"Hyper" )|| 145strings .Contains (termProg ,"rio" ) { 146upgradeEnc (itermEncoding ) 147 } 148if lcTerm ,ok = os .LookupEnv ("LC_TERMINAL" );ok { 149if strings .Contains (lcTerm ,"iTerm" ) { 150upgradeEnc (itermEncoding ) 151 } 152 } 153 } 154 155// Display image methods. 156imgCellSize := func () (int ,int ) { 157if winSize .Width == 0 || winSize .Height == 0 || pixSize .Width == 0 || pixSize .Height == 0 { 158return 0 ,0 159 } 160 161cellW ,cellH := pixSize .Width / winSize .Width ,pixSize .Height / winSize .Height 162imgW ,imgH := charmImgArea .Dx (),charmImgArea .Dy () 163return imgW / cellW ,imgH / cellH 164 } 165 166var transmitKitty bool 167var imgCellW ,imgCellH int 168var imgOffsetX ,imgOffsetY int 169imgCellW ,imgCellH = imgCellSize () 170imgOffsetX = winSize .Width / 2 - imgCellW / 2 171imgOffsetY = winSize .Height / 2 - imgCellH / 2 172 173fillStyle := uv.Style {Fg :ansi .IndexedColor (240 )} 174displayImg := func () { 175img := charmImg 176imgArea := uv .Rect ( 177imgOffsetX , 178imgOffsetY , 179imgCellW , 180imgCellH , 181 ) 182if ! imgArea .In (winSize .Bounds ()) { 183imgArea = imgArea .Intersect (winSize .Bounds ()) 184// TODO: Crop image. 185 } 186 187log .Printf ("image area: %v" ,imgArea ) 188 189// Clear the screen. 190screen .Clear (scr ) 191fill := uv.Cell {Content :"/" ,Width :1 ,Style :fillStyle } 192screen .Fill (scr ,& fill ) 193 194// Draw the image on the screen. 195switch imgEnc { 196case blocksEncoding : 197blocks := mosaic .New ().Width (imgCellW ).Height (imgCellH ).Scale (2 ) 198ss := uv .NewStyledString (blocks .Render (img )) 199ss .Draw (scr ,imgArea ) 200 201case itermEncoding ,sixelEncoding : 202for y := imgArea .Min .Y ;y < imgArea .Max .Y ;y ++ { 203var content string 204if y == imgArea .Min .Y { 205switch imgEnc { 206case itermEncoding : 207if charmImgB64 == nil { 208// Encode the image to base64 for the first time. 209charmImgB64 = []byte (base64 .StdEncoding .EncodeToString (charmImgBuf .Bytes ())) 210 } 211content = ansi .ITerm2 (iterm2.File { 212Name :"charm.jpg" , 213Width :iterm2 .Cells (imgArea .Dx ()), 214Height :iterm2 .Cells (imgArea .Dy ()), 215Content :charmImgB64 , 216Inline :true , 217IgnoreAspectRatio :true , 218 })+ ansi .CursorPosition (imgArea .Min .X + imgArea .Dx ()+ 1 ,imgArea .Min .Y + 1 ) 219case sixelEncoding : 220var senc sixel.Encoder 221var buf bytes.Buffer 222senc .Encode (& buf ,img ) 223content = ansi .SixelGraphics (0 ,1 ,0 ,buf .Bytes ())+ 224ansi .CursorPosition (imgArea .Min .X + imgArea .Dx ()+ 1 ,imgArea .Min .Y + 1 ) 225 } 226 }else { 227content = ansi .CursorForward (imgArea .Dx ()) 228 } 229 230scr .SetCell (imgArea .Min .X ,y ,& uv.Cell { 231Content :content , 232Width :imgArea .Dx (), 233 }) 234 } 235 236case kittyEncoding : 237const imgId = 31 // random id for kitty graphics 238if ! transmitKitty { 239var buf bytes.Buffer 240if err := kitty .EncodeGraphics (& buf ,img ,& kitty.Options { 241ID :imgId , 242Action :kitty .TransmitAndPut , 243Transmission :kitty .Direct , 244Format :kitty .RGBA , 245Size :int (charmImgFileSize ), 246ImageWidth :charmImgArea .Dx (), 247ImageHeight :charmImgArea .Dy (), 248Columns :imgArea .Dx (), 249Rows :imgArea .Dy (), 250VirtualPlacement :true , 251Quite :2 , 252 });err != nil { 253log .Fatalf ("failed to encode image for Kitty Graphics: %v" ,err ) 254 } 255 256io .WriteString (scr ,buf .String ()) 257transmitKitty = true 258 } 259 260// Build Kitty graphics unicode place holders 261var fg color.Color 262var extra int 263var r ,g ,b int 264extra ,r ,g ,b = imgId >> 24 & 0xff ,imgId >> 16 & 0xff ,imgId >> 8 & 0xff ,imgId & 0xff 265 266if r == 0 && g == 0 { 267fg = ansi .IndexedColor (b ) 268 }else { 269fg = color.RGBA { 270R :uint8 (r ),//nolint:gosec 271G :uint8 (g ),//nolint:gosec 272B :uint8 (b ),//nolint:gosec 273A :0xff , 274 } 275 } 276 277for y := 0 ;y < imgArea .Dy ();y ++ { 278// As an optimization, we only write the fg color sequence id, and 279// column-row data once on the first cell. The terminal will handle 280// the rest. 281content := []rune {kitty .Placeholder ,kitty .Diacritic (y ),kitty .Diacritic (0 )} 282if extra > 0 { 283content = append (content ,kitty .Diacritic (extra )) 284 } 285scr .SetCell (imgArea .Min .X ,imgArea .Min .Y + y ,& uv.Cell { 286Style : uv.Style {Fg :fg }, 287Content :string (content ), 288Width :1 , 289 }) 290for x := 1 ;x < imgArea .Dx ();x ++ { 291scr .SetCell (imgArea .Min .X + x ,imgArea .Min .Y + y ,& uv.Cell { 292Style : uv.Style {Fg :fg }, 293Content :string (kitty .Placeholder ), 294Width :1 , 295 }) 296 } 297 } 298 299 } 300 301scr .Render ()//nolint:errcheck 302scr .Flush () 303 } 304 305// Query image encoding support. 306scr .WriteString (ansi .RequestPrimaryDeviceAttributes )// Query Sixel support. 307scr .WriteString (ansi .RequestNameVersion )// Query terminal version and name. 308scr .WriteString (ansi .WindowOp (ansi .RequestWindowSizeWinOp ))// Request window size. 309// Query Kitty Graphics support using random id=31. 310scr .WriteString (ansi .KittyGraphics ([]byte ("AAAA" ),"i=31" ,"s=1" ,"v=1" ,"a=q" ,"t=d" ,"f=24" )) 311 312ctx ,cancel := context .WithCancel (context .Background ()) 313defer cancel () 314 315// Listen for input events. 316LOOP: 317for { 318select { 319case <- ctx .Done (): 320break LOOP 321case ev := <- t .Events (): 322switch ev := ev .(type ) { 323case uv.PixelSizeEvent : 324// XXX: This is only emitted with traditional Unix systems. On 325// Windows, we would need to use [ansi.RequestWindowSizeWinOp] to 326// get the pixel size. 327pixSize = ev 328imgCellW ,imgCellH = imgCellSize () 329imgOffsetX = winSize .Width / 2 - imgCellW / 2 330imgOffsetY = winSize .Height / 2 - imgCellH / 2 331displayImg () 332case uv.WindowSizeEvent : 333winSize = ev 334imgCellW ,imgCellH = imgCellSize () 335imgOffsetX = winSize .Width / 2 - imgCellW / 2 336imgOffsetY = winSize .Height / 2 - imgCellH / 2 337log .Printf ("image cell size: %d x %d" ,imgCellW ,imgCellH ) 338scr .Resize (ev .Width ,ev .Height ) 339 340displayImg () 341case uv.KeyPressEvent : 342switch { 343case ev .MatchString ("q" ,"ctrl+c" ): 344cancel ()// This will stop the loop 345case ev .MatchString ("up" ,"k" ): 346imgOffsetY -- 347case ev .MatchString ("down" ,"j" ): 348imgOffsetY ++ 349case ev .MatchString ("left" ,"h" ): 350imgOffsetX -- 351case ev .MatchString ("right" ,"l" ): 352imgOffsetX ++ 353 } 354 355displayImg () 356case uv.MouseClickEvent : 357imgOffsetX = ev .X - (imgCellW / 2 ) 358imgOffsetY = ev .Y - (imgCellH / 2 ) 359 360displayImg () 361case uv.PrimaryDeviceAttributesEvent : 362if slices .Contains (ev ,4 ) { 363upgradeEnc (sixelEncoding ) 364displayImg () 365 } 366 367case uv.TerminalVersionEvent : 368if strings .Contains (ev .Name ,"iTerm" )|| strings .Contains (ev .Name ,"WezTerm" ) { 369upgradeEnc (itermEncoding ) 370displayImg () 371 } 372 373case uv.WindowOpEvent : 374// Here 4 corresponds to the window size response. 375if ev .Op == 4 && len (ev .Args )>= 2 { 376pixSize .Height = ev .Args [0 ] 377pixSize .Width = ev .Args [1 ] 378 } 379case uv.KittyGraphicsEvent : 380if strings .Contains (termType ,"wezterm" )|| 381strings .Contains (termVersion ,"WezTerm" )|| 382strings .Contains (termProg ,"WezTerm" ) { 383// WezTerm doesn't support Kitty Unicode Graphics 384break 385 } 386if ev .Options .ID == 31 { 387upgradeEnc (kittyEncoding ) 388 } 389 390displayImg () 391 } 392 } 393 } 394 395ctx ,cancel = context .WithTimeout (context .Background (),5 * time .Second ) 396defer cancel () 397 398// Disable mouse support. 399scr .WriteString (ansi .ResetMode ( 400ansi .ModeMouseButtonEvent , 401ansi .ModeMouseExtSgr , 402 )) 403 404if err := t .Stop ();err != nil { 405log .Fatalf ("failed to shutdown program: %v" ,err ) 406 } 407 408fmt .Println ("image encoding:" ,imgEnc ) 409} 410 411func init () { 412f ,err := os .OpenFile ("uv_debug.log" ,os .O_CREATE | os .O_WRONLY | os .O_APPEND ,0o666 ) 413if err != nil { 414log .Fatalf ("failed to open log file: %v" ,err ) 415 } 416 417log .SetOutput (f ) 418}