Skip to content

Commit 28316b5

Browse files
committed
wip
1 parent 0c83e6f commit 28316b5

File tree

3 files changed

+291
-18
lines changed

3 files changed

+291
-18
lines changed

go.mod

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ go 1.18
55
require (
66
github.com/MakeNowJust/heredoc v1.0.0
77
github.com/atotto/clipboard v0.1.4
8-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0
8+
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250116142418-57f505c01b98
99
github.com/charmbracelet/harmonica v0.2.0
1010
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607
11-
github.com/charmbracelet/x/ansi v0.7.0
11+
github.com/charmbracelet/x/ansi v0.7.1-0.20250116134054-e10c5c25afb9
1212
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f
13+
github.com/charmbracelet/x/input v0.3.1-0.20250117142827-dd310ffa7553
1314
github.com/dustin/go-humanize v1.0.1
1415
github.com/lucasb-eyer/go-colorful v1.2.0
1516
github.com/mattn/go-runewidth v0.0.16
@@ -20,15 +21,12 @@ require (
2021
require (
2122
github.com/aymanbagabas/go-udiff v0.2.0 // indirect
2223
github.com/charmbracelet/colorprofile v0.1.9 // indirect
23-
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 // indirect
24-
github.com/charmbracelet/x/input v0.3.0 // indirect
24+
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250117142827-dd310ffa7553 // indirect
2525
github.com/charmbracelet/x/term v0.2.1 // indirect
26-
github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 // indirect
2726
github.com/charmbracelet/x/windows v0.2.0 // indirect
2827
github.com/kylelemons/godebug v1.1.0 // indirect
2928
github.com/muesli/cancelreader v0.2.2 // indirect
3029
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3130
golang.org/x/sync v0.10.0 // indirect
3231
golang.org/x/sys v0.29.0 // indirect
33-
golang.org/x/text v0.20.0 // indirect
3432
)

go.sum

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,24 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
44
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
55
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
66
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
7-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0 h1:BWjXQRSwBjoCpLeNu8zT93n+NHhZZhkQQLveXMmnkYc=
8-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
7+
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250116142418-57f505c01b98 h1:3aU14i9Zqcz+IXwvlrB5pcx89YKSoDfzWQhk4xiI+V0=
8+
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250116142418-57f505c01b98/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
99
github.com/charmbracelet/colorprofile v0.1.9 h1:5JnfvX+I9D6rRNu8xK3pgIqknaBVTXHU9pGu1jkZxLw=
1010
github.com/charmbracelet/colorprofile v0.1.9/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60=
1111
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
1212
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
1313
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607 h1:lERE4ow371r5WMqQAt7Eqlg1A4tBNA8T4RLwdXnKyBo=
1414
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607/go.mod h1:MD7Vb+O1zFRgBo+F94JHHuME7df8XBByNKuX5k/L/qs=
15-
github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404=
16-
github.com/charmbracelet/x/ansi v0.7.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
17-
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 h1:P90NI2rZuBISjB1HIHdkBDE+riKtVzIOi6Xun3qjUn8=
18-
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72/go.mod h1:VXZSjC/QYH0t+9CG1qtcEx3XZubTDJb5ilWS6qJg4/0=
15+
github.com/charmbracelet/x/ansi v0.7.1-0.20250116134054-e10c5c25afb9 h1:j0FffnPgL7Xpafi0tqzUJyUIJjPMQbaj9m5l8lutbtk=
16+
github.com/charmbracelet/x/ansi v0.7.1-0.20250116134054-e10c5c25afb9/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
17+
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250117142827-dd310ffa7553 h1:jtUKbMdcL0X48Qq53Qkyt4LiuuxjJvE52OHBr2fxZhI=
18+
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250117142827-dd310ffa7553/go.mod h1:pwLWkALHlOKgfPiJ9Pf4dkRbs9IQkmN3ZI9QN/x05sY=
1919
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
2020
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
21-
github.com/charmbracelet/x/input v0.3.0 h1:lVzEz92E2u9jCU0mUwcyKeSOxkoeat+1eUkjzL0WCYI=
22-
github.com/charmbracelet/x/input v0.3.0/go.mod h1:M8CHPIYnmmiNHA17hqXmvSfeZLO2lj9pzJFX3aWvzgw=
21+
github.com/charmbracelet/x/input v0.3.1-0.20250117142827-dd310ffa7553 h1:3uyk+qMgFcD6bukpRMHDETkG84IBYpzuP/iyK9kWO7c=
22+
github.com/charmbracelet/x/input v0.3.1-0.20250117142827-dd310ffa7553/go.mod h1:qvZg4rmYhd5wslnkW3/zR9CzAKZ0sscFNRmCmhY5JE0=
2323
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
2424
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
25-
github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 h1:14czE6R5CgOlvONsJYa2B1uTyLvXzGXpBqw2AyZeTh4=
26-
github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:hyua5CY63kyl7IfyIxv1SjVEqoKze/XmDkEglItuVjA=
2725
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
2826
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
2927
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -48,5 +46,3 @@ golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
4846
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
4947
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
5048
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
51-
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
52-
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=

image/image.go

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
package image
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"image"
7+
"image/color"
8+
"log"
9+
"os"
10+
"strings"
11+
"sync/atomic"
12+
13+
tea "github.com/charmbracelet/bubbletea/v2"
14+
"github.com/charmbracelet/x/ansi"
15+
"github.com/charmbracelet/x/ansi/kitty"
16+
"github.com/charmbracelet/x/input"
17+
)
18+
19+
// number is a global number used to generate unique image numbers.
20+
var number int32
21+
22+
// nextImageNumber returns the next unique image number.
23+
func nextNumber() int32 {
24+
return atomic.AddInt32(&number, 1)
25+
}
26+
27+
// Protocol is the terminal graphics protocol used to render the image.
28+
type Protocol byte
29+
30+
// Graphic protocol constants.
31+
const (
32+
HalfBlocks Protocol = iota + 1
33+
Sixel
34+
ITerm2
35+
Kitty
36+
)
37+
38+
// Model represents a terminal graphics image.
39+
type Model struct {
40+
// The protocol used
41+
Protocol Protocol
42+
// The area covering the image in cells
43+
area image.Rectangle
44+
// The image data (exclusive with file)
45+
m image.Image
46+
// The image file path (exclusive with m)
47+
file string
48+
49+
// The image options
50+
opts kitty.Options
51+
52+
// The image unique id. A non-zero indicates the image was transmitted successfully.
53+
id int
54+
// The image number
55+
num int
56+
57+
// Whether the image was transmitted
58+
didTransmit bool
59+
60+
// The terminal width and height
61+
w, h int
62+
}
63+
64+
func newModel(area image.Rectangle) (m Model) {
65+
// We always use virtual placement for images
66+
m.opts.VirtualPlacement = true
67+
// Always chunk the image
68+
m.opts.Chunk = true
69+
// Transmit and put/display the image
70+
m.opts.Action = kitty.TransmitAndPut
71+
72+
num := int(nextNumber())
73+
m.num = num
74+
m.opts.Number = num
75+
76+
m.SetArea(area)
77+
78+
return
79+
}
80+
81+
// NewLocal creates a new image model from a local file.
82+
func NewLocal(file string, area image.Rectangle) (m Model, err error) {
83+
m = newModel(area)
84+
m.file = file
85+
m.area = area
86+
// TODO: Fix me! This currently doesn't work with PNG
87+
// ext := filepath.Ext(file)
88+
// if strings.Contains(ext, "png") {
89+
// // We're done here, there's no need to decode the image.
90+
// m.opts.Format = kitty.PNG
91+
// m.opts.File = file
92+
// return
93+
// }
94+
95+
f, err := os.Open(file)
96+
if err != nil {
97+
return m, fmt.Errorf("could not open image file: %w", err)
98+
}
99+
100+
defer f.Close() //nolint:errcheck
101+
102+
im, mtyp, err := image.Decode(f)
103+
if err != nil {
104+
return m, fmt.Errorf("could not decode image: %w", err)
105+
}
106+
107+
m.m = im
108+
109+
// Use a temporary file to store the image data
110+
m.opts.Transmission = kitty.TempFile
111+
112+
// TODO: Enable compression
113+
// m.opts.Compression = kitty.Zlib
114+
115+
// Set the image size
116+
bounds := im.Bounds()
117+
m.opts.ImageWidth = bounds.Dx()
118+
m.opts.ImageHeight = bounds.Dy()
119+
120+
// Optimize for JPEG images and alpha transparency
121+
switch mtyp {
122+
case "jpeg":
123+
m.opts.Format = kitty.RGB
124+
default:
125+
m.opts.Format = kitty.RGBA
126+
}
127+
128+
return
129+
}
130+
131+
// New creates a new image model given an image and an area in cells
132+
func New(im image.Image, area image.Rectangle) Model {
133+
m := newModel(area)
134+
m.opts.Transmission = kitty.Direct
135+
// m.opts.Compression = kitty.Zlib
136+
m.opts.Format = kitty.RGBA
137+
m.m = im
138+
// Set the image size
139+
bounds := im.Bounds()
140+
m.opts.ImageWidth = bounds.Dx()
141+
m.opts.ImageHeight = bounds.Dy()
142+
m.SetArea(area)
143+
return m
144+
}
145+
146+
// ID returns the image id unique with respect to the terminal.
147+
func (m Model) ID() int {
148+
return m.id
149+
}
150+
151+
// Number returns the image number unique with respect to the library.
152+
func (m Model) Number() int {
153+
return m.num
154+
}
155+
156+
// SetArea sets the image area in cells.
157+
func (m *Model) SetArea(area image.Rectangle) {
158+
m.area = area
159+
m.opts.Columns = m.area.Dx()
160+
m.opts.Rows = m.area.Dy()
161+
m.didTransmit = false
162+
}
163+
164+
// Area returns the image area in cells.
165+
func (m Model) Area() image.Rectangle {
166+
return m.area
167+
}
168+
169+
// transmit is a command that transmits the image to the terminal.
170+
func (m *Model) transmit() tea.Msg {
171+
var seq bytes.Buffer
172+
if err := ansi.WriteKittyGraphics(&seq, m.m, &m.opts); err != nil {
173+
// TODO: Error handling
174+
return nil
175+
}
176+
177+
m.didTransmit = true
178+
return tea.RawMsg{Msg: seq.String()}
179+
}
180+
181+
// Init initializes the image model.
182+
func (m Model) Init() (tea.Model, tea.Cmd) {
183+
return m, tea.Batch(
184+
// TODO: Query support
185+
)
186+
}
187+
188+
// Update updates the image model.
189+
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190+
var cmds []tea.Cmd
191+
switch msg := msg.(type) {
192+
case tea.KeyMsg:
193+
m.didTransmit = false
194+
case tea.WindowSizeMsg:
195+
m.w, m.h = msg.Width, msg.Height
196+
case input.KittyGraphicsEvent:
197+
if msg.Options.Number == m.num &&
198+
msg.Options.ID > 0 &&
199+
bytes.Equal(msg.Payload, []byte("OK")) {
200+
// Store the actual image id
201+
m.id = msg.Options.ID
202+
}
203+
}
204+
205+
if !m.didTransmit {
206+
cmds = append(cmds, m.transmit)
207+
m.didTransmit = true
208+
}
209+
210+
return m, tea.Batch(cmds...)
211+
}
212+
213+
// View returns a string representation to render the image.
214+
func (m Model) View() string {
215+
if m.id == 0 {
216+
// TODO: Maybe use a spinner?
217+
return "Loading image..."
218+
}
219+
220+
log.Printf("area: %v", m.area)
221+
222+
// Build Kitty graphics unicode place holders
223+
var fgSeq string
224+
var extra int
225+
var r, g, b int
226+
extra, r, g, b = m.id>>24&0xff, m.id>>16&0xff, m.id>>8&0xff, m.id&0xff
227+
228+
if r == 0 && g == 0 {
229+
fgSeq = ansi.Style{}.ForegroundColor(ansi.ExtendedColor(b)).String() //nolint:gosec
230+
} else {
231+
fgSeq = ansi.Style{}.ForegroundColor(color.RGBA{
232+
R: uint8(r), //nolint:gosec
233+
G: uint8(g), //nolint:gosec
234+
B: uint8(b), //nolint:gosec
235+
A: 0xff,
236+
}).String()
237+
}
238+
239+
var s strings.Builder
240+
width := min(m.area.Dx(), m.w)
241+
height := min(m.area.Dy(), m.h)
242+
s.WriteString(ansi.ResetStyle)
243+
244+
for y := 0; y < height; y++ {
245+
// As an optimization, we only write the fg color sequence id, and
246+
// column-row data once on the first cell. The terminal will handle
247+
// the rest.
248+
s.WriteString(fgSeq)
249+
s.WriteRune(kitty.Placeholder)
250+
s.WriteRune(kitty.Diacritic(y))
251+
s.WriteRune(kitty.Diacritic(0))
252+
if extra > 0 {
253+
s.WriteRune(kitty.Diacritic(extra))
254+
}
255+
256+
for x := 1; x < width; x++ {
257+
s.WriteRune(kitty.Placeholder)
258+
}
259+
260+
s.WriteString(ansi.ResetStyle)
261+
if y != m.area.Dy()-1 {
262+
s.WriteByte('\n')
263+
}
264+
}
265+
266+
return s.String()
267+
}
268+
269+
// Rect returns a rectangle from the given x, y, width, and height.
270+
func Rect(x, y, w, h int) image.Rectangle {
271+
return image.Rect(x, y, x+w, y+h)
272+
}
273+
274+
func min(a, b int) int {
275+
if a < b {
276+
return a
277+
}
278+
return b
279+
}

0 commit comments

Comments
 (0)