mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-05 04:36:07 +00:00
wip
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user