From 797acacbda09b38f0c34a0cb3eda47acc5087cbc Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 17 Jan 2025 02:42:22 +0300 Subject: [PATCH] wip --- go.mod | 8 +- go.sum | 16 +-- image/image.go | 279 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 image/image.go diff --git a/go.mod b/go.mod index 33ffc31..1c444eb 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index d327582..6249d3a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/image/image.go b/image/image.go new file mode 100644 index 0000000..3454191 --- /dev/null +++ b/image/image.go @@ -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 +}