This commit is contained in:
Ayman Bagabas
2025-02-06 14:53:06 -05:00
parent 64c16e990e
commit 797acacbda
3 changed files with 291 additions and 12 deletions
+4 -4
View File
@@ -5,11 +5,12 @@ go 1.22
require (
github.com/MakeNowJust/heredoc v1.0.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250204175057-2a96bc438da6
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250206184610-cd30b7d2ac80
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d
github.com/charmbracelet/x/ansi v0.8.0
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f
github.com/charmbracelet/x/input v0.3.2
github.com/dustin/go-humanize v1.0.1
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.16
@@ -20,13 +21,12 @@ require (
require (
github.com/aymanbagabas/go-udiff v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 // indirect
github.com/charmbracelet/x/input v0.3.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250206182034-2ef4961df933 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/x/windows v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)
+8 -8
View File
@@ -6,8 +6,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250204175057-2a96bc438da6 h1:KbLqbYgHC5JZWks50WjBJr92Hs+GpiauwZaDObw9vxE=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250204175057-2a96bc438da6/go.mod h1:zaWGYfO6cBtu4dRgGbVN7yMYUz3j9sVIzHy2gn0jdo8=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250206184610-cd30b7d2ac80 h1:o3Vc/GL2me05kaciOp02/1UzyCY/0OlOzWPXL531oBI=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250206184610-cd30b7d2ac80/go.mod h1:Dr51sPGKwC4yfRA4Sqd9n4eD9EVxXrkmQFFYsQxP2S0=
github.com/charmbracelet/colorprofile v0.2.0 h1:iiIQlp3LSvoJPtR11KoDfIf9wqWm2mn/iU420rHOZ/A=
github.com/charmbracelet/colorprofile v0.2.0/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
@@ -16,12 +16,12 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d/go.mod h1:ZWl23X8o1vsQu8dpju10HKXepcMMlsHO8SwLl2OhmEU=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 h1:RFHEvURPMgGGd8epjjhi2UpXSKyFs39iRF4JTYCEdLg=
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY=
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250206182034-2ef4961df933 h1:zv5JXjtb6m+H5nzfAiWc/5gqCrNUATqv2voRFDT/0Tw=
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250206182034-2ef4961df933/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY=
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/input v0.3.1 h1:TE4s3fTRj+OUpJ86dKphrN99+NgBnto//EkWncMJQIg=
github.com/charmbracelet/x/input v0.3.1/go.mod h1:4w9jS/NW62WrHSdmjbpzydvnbqkd+mtyK8WOWbHCdvs=
github.com/charmbracelet/x/input v0.3.2 h1:6h/d/40Og6UP3a4VDjIbdoUhilEH4K+mxEuaSPIJwNQ=
github.com/charmbracelet/x/input v0.3.2/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
@@ -51,5 +51,5 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+279
View File
@@ -0,0 +1,279 @@
package image
import (
"bytes"
"fmt"
"image"
"image/color"
"log"
"os"
"strings"
"sync/atomic"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/kitty"
"github.com/charmbracelet/x/input"
)
// number is a global number used to generate unique image numbers.
var number int32
// nextImageNumber returns the next unique image number.
func nextNumber() int32 {
return atomic.AddInt32(&number, 1)
}
// Protocol is the terminal graphics protocol used to render the image.
type Protocol byte
// Graphic protocol constants.
const (
HalfBlocks Protocol = iota + 1
Sixel
ITerm2
Kitty
)
// Model represents a terminal graphics image.
type Model struct {
// The protocol used
Protocol Protocol
// The area covering the image in cells
area image.Rectangle
// The image data (exclusive with file)
m image.Image
// The image file path (exclusive with m)
file string
// The image options
opts kitty.Options
// The image unique id. A non-zero indicates the image was transmitted successfully.
id int
// The image number
num int
// Whether the image was transmitted
didTransmit bool
// The terminal width and height
w, h int
}
func newModel(area image.Rectangle) (m Model) {
// We always use virtual placement for images
m.opts.VirtualPlacement = true
// Always chunk the image
m.opts.Chunk = true
// Transmit and put/display the image
m.opts.Action = kitty.TransmitAndPut
num := int(nextNumber())
m.num = num
m.opts.Number = num
m.SetArea(area)
return
}
// NewLocal creates a new image model from a local file.
func NewLocal(file string, area image.Rectangle) (m Model, err error) {
m = newModel(area)
m.file = file
m.area = area
// TODO: Fix me! This currently doesn't work with PNG
// ext := filepath.Ext(file)
// if strings.Contains(ext, "png") {
// // We're done here, there's no need to decode the image.
// m.opts.Format = kitty.PNG
// m.opts.File = file
// return
// }
f, err := os.Open(file)
if err != nil {
return m, fmt.Errorf("could not open image file: %w", err)
}
defer f.Close() //nolint:errcheck
im, mtyp, err := image.Decode(f)
if err != nil {
return m, fmt.Errorf("could not decode image: %w", err)
}
m.m = im
// Use a temporary file to store the image data
m.opts.Transmission = kitty.TempFile
// TODO: Enable compression
// m.opts.Compression = kitty.Zlib
// Set the image size
bounds := im.Bounds()
m.opts.ImageWidth = bounds.Dx()
m.opts.ImageHeight = bounds.Dy()
// Optimize for JPEG images and alpha transparency
switch mtyp {
case "jpeg":
m.opts.Format = kitty.RGB
default:
m.opts.Format = kitty.RGBA
}
return
}
// New creates a new image model given an image and an area in cells
func New(im image.Image, area image.Rectangle) Model {
m := newModel(area)
m.opts.Transmission = kitty.Direct
// m.opts.Compression = kitty.Zlib
m.opts.Format = kitty.RGBA
m.m = im
// Set the image size
bounds := im.Bounds()
m.opts.ImageWidth = bounds.Dx()
m.opts.ImageHeight = bounds.Dy()
m.SetArea(area)
return m
}
// ID returns the image id unique with respect to the terminal.
func (m Model) ID() int {
return m.id
}
// Number returns the image number unique with respect to the library.
func (m Model) Number() int {
return m.num
}
// SetArea sets the image area in cells.
func (m *Model) SetArea(area image.Rectangle) {
m.area = area
m.opts.Columns = m.area.Dx()
m.opts.Rows = m.area.Dy()
m.didTransmit = false
}
// Area returns the image area in cells.
func (m Model) Area() image.Rectangle {
return m.area
}
// transmit is a command that transmits the image to the terminal.
func (m *Model) transmit() tea.Msg {
var seq bytes.Buffer
if err := ansi.WriteKittyGraphics(&seq, m.m, &m.opts); err != nil {
// TODO: Error handling
return nil
}
m.didTransmit = true
return tea.RawMsg{Msg: seq.String()}
}
// Init initializes the image model.
func (m Model) Init() (tea.Model, tea.Cmd) {
return m, tea.Batch(
// TODO: Query support
)
}
// Update updates the image model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
m.didTransmit = false
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
case input.KittyGraphicsEvent:
if msg.Options.Number == m.num &&
msg.Options.ID > 0 &&
bytes.Equal(msg.Payload, []byte("OK")) {
// Store the actual image id
m.id = msg.Options.ID
}
}
if !m.didTransmit {
cmds = append(cmds, m.transmit)
m.didTransmit = true
}
return m, tea.Batch(cmds...)
}
// View returns a string representation to render the image.
func (m Model) View() string {
if m.id == 0 {
// TODO: Maybe use a spinner?
return "Loading image..."
}
log.Printf("area: %v", m.area)
// Build Kitty graphics unicode place holders
var fgSeq string
var extra int
var r, g, b int
extra, r, g, b = m.id>>24&0xff, m.id>>16&0xff, m.id>>8&0xff, m.id&0xff
if r == 0 && g == 0 {
fgSeq = ansi.Style{}.ForegroundColor(ansi.ExtendedColor(b)).String() //nolint:gosec
} else {
fgSeq = ansi.Style{}.ForegroundColor(color.RGBA{
R: uint8(r), //nolint:gosec
G: uint8(g), //nolint:gosec
B: uint8(b), //nolint:gosec
A: 0xff,
}).String()
}
var s strings.Builder
width := min(m.area.Dx(), m.w)
height := min(m.area.Dy(), m.h)
s.WriteString(ansi.ResetStyle)
for y := 0; y < height; y++ {
// As an optimization, we only write the fg color sequence id, and
// column-row data once on the first cell. The terminal will handle
// the rest.
s.WriteString(fgSeq)
s.WriteRune(kitty.Placeholder)
s.WriteRune(kitty.Diacritic(y))
s.WriteRune(kitty.Diacritic(0))
if extra > 0 {
s.WriteRune(kitty.Diacritic(extra))
}
for x := 1; x < width; x++ {
s.WriteRune(kitty.Placeholder)
}
s.WriteString(ansi.ResetStyle)
if y != m.area.Dy()-1 {
s.WriteByte('\n')
}
}
return s.String()
}
// Rect returns a rectangle from the given x, y, width, and height.
func Rect(x, y, w, h int) image.Rectangle {
return image.Rect(x, y, x+w, y+h)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}