@@ -8,31 +8,67 @@ import (
8 "image/color"
8 "image/color"
10 "log/slog"
10 "log/slog"
12 tea "charm.land/bubbletea/v2"
14 tea "charm.land/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/uiutil"
15 "github.com/charmbracelet/crush/internal/uiutil"
14 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/ansi"
15 "github.com/charmbracelet/x/ansi/kitty"
17 "github.com/charmbracelet/x/ansi/kitty"
16 "github.com/charmbracelet/x/mosaic"
18 "github.com/charmbracelet/x/mosaic"
19+ "github.com/disintegration/imaging"
19// Capabilities represents the capabilities of displaying images on the
22// Capabilities represents the capabilities of displaying images on the
20// terminal.
23// terminal.
21type Capabilities struct {
24type Capabilities struct {
25+ // Columns is the number of character columns in the terminal.
27+ // Rows is the number of character rows in the terminal.
29+ // PixelWidth is the width of the terminal in pixels.
31+ // PixelHeight is the height of the terminal in pixels.
22 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
33 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
23 // graphics protocol.
34 // graphics protocol.
24 SupportsKittyGraphics bool
35 SupportsKittyGraphics bool
38+// CellSize returns the size of a single terminal cell in pixels.
39+func (c Capabilities) CellSize() CellSize {
40+ return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
43+// CalculateCellSize calculates the size of a single terminal cell in pixels
44+// based on the terminal's pixel dimensions and character dimensions.
45+func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
46+ if charWidth == 0 || charHeight == 0 {
51+ Width: pixelWidth / charWidth,
52+ Height: pixelHeight / charHeight,
27// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
56// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
28// its image related capabilities to the program.
57// its image related capabilities to the program.
29func RequestCapabilities() tea.Cmd {
58func RequestCapabilities() tea.Cmd {
30 return tea.Raw(
59 return tea.Raw(
60+ ansi.WindowOp(14) + // Window size in pixels
31− // ID 31 is just a random ID used to detect Kitty graphics support.
61+ // ID 31 is just a random ID used to detect Kitty graphics support.
32− ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
62+ ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
66+// TransmittedMsg is a message indicating that an image has been transmitted to
68+type TransmittedMsg struct {
36// Encoding represents the encoding format of the image.
72// Encoding represents the encoding format of the image.
37type Encoding byte
73type Encoding byte
@@ -42,87 +78,171 @@ const (
42 EncodingKitty
78 EncodingKitty
45−// Image represents an image that can be displayed on the terminal.
81+type imageKey struct {
87+// Hash returns a hash value for the image key.
88+// This uses FNV-32a for simplicity and speed.
89+func (k imageKey) Hash() uint32 {
91+ _, _ = io.WriteString(h, k.ID())
95+// ID returns a unique string representation of the image key.
96+func (k imageKey) ID() string {
97+ return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
100+// CellSize represents the size of a single terminal cell in pixels.
46−type Image struct {
101+type CellSize struct {
47− id int
102+ Width, Height int
105+type cachedImage struct {
48 img image.Image
106 img image.Image
49− cols, rows int // in terminal cells
107+ cols, rows int
53−// New creates a new [Image] instance with the given unique id, image, and
110+var (
111+ cachedImages = map[imageKey]cachedImage{}
112+ cachedMutex sync.RWMutex
115+// fitImage resizes the image to fit within the specified dimensions in
54−// dimensions in terminal cells.
116+// terminal cells, maintaining the aspect ratio.
55−func New(id string, img image.Image, cols, rows int) (*Image, error) {
117+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
56− i := new(Image)
118+ if img == nil {
57− h := fnv.New64a()
119+ return nil
58− if _, err := io.WriteString(h, id); err != nil {
120+ }
122+ key := imageKey{id: id, cols: cols, rows: rows}
125+ cached, ok := cachedImages[key]
126+ cachedMutex.RUnlock()
59− return nil, err
128+ return cached.img
61− i.id = int(h.Sum64())
130+
62− i.img = img
131+ if cs.Width == 0 || cs.Height == 0 {
135+ maxWidth := cols * cs.Width
136+ maxHeight := rows * cs.Height
138+ img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
141+ cachedImages[key] = cachedImage{
63− i.cols = cols
143+ cols: cols,
64− i.rows = rows
144+ rows: rows,
146+ cachedMutex.Unlock()
65− return i, nil
148+ return img
68−// SetEncoding sets the encoding format for the image.
151+// HasTransmitted checks if the image with the given ID has already been
69−func (i *Image) SetEncoding(enc Encoding) {
152+// transmitted to the terminal.
70− i.enc = enc
153+func HasTransmitted(id string, cols, rows int) bool {
154+ key := imageKey{id: id, cols: cols, rows: rows}
157+ _, ok := cachedImages[key]
158+ cachedMutex.RUnlock()
73−// Transmit returns a [tea.Cmd] that sends the image data to the terminal.
162+// Transmit transmits the image data to the terminal if needed. This is used to
74−// This is needed for the [EncodingKitty] protocol so that the terminal can
75−// cache the image for later rendering.
163+// cache the image on the terminal for later rendering.
76−//
164+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
77−// This should only happen once per image.
165+ if img == nil {
78−func (i *Image) Transmit() tea.Cmd {
79− if i.enc != EncodingKitty {
80 return nil
166 return nil
83− var buf bytes.Buffer
169+ key := imageKey{id: id, cols: cols, rows: rows}
84− bounds := i.img.Bounds()
170+
85− imgWidth := bounds.Dx()
171+ cachedMutex.RLock()
86− imgHeight := bounds.Dy()
172+ _, ok := cachedImages[key]
87−
173+ cachedMutex.RUnlock()
88− // RGBA is 4 bytes per pixel
174+ if ok {
89− imgSize := imgWidth * imgHeight * 4
91− if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{
93− Action: kitty.TransmitAndPut,
94− Transmission: kitty.Direct,
101− VirtualPlacement: true,
103− }); err != nil {
175+ return nil
104− slog.Error("failed to encode image for kitty graphics", "err", err)
105− return uiutil.ReportError(fmt.Errorf("failed to encode image"))
108− return tea.Raw(buf.String())
178+ cmd := func() tea.Msg {
179+ if e != EncodingKitty {
181+ cachedImages[key] = cachedImage{
186+ cachedMutex.Unlock()
187+ return TransmittedMsg{ID: key.ID()}
190+ var buf bytes.Buffer
191+ img := fitImage(id, img, cs, cols, rows)
192+ bounds := img.Bounds()
193+ imgWidth := bounds.Dx()
194+ imgHeight := bounds.Dy()
196+ imgID := int(key.Hash())
197+ if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
199+ Action: kitty.TransmitAndPut,
200+ Transmission: kitty.Direct,
202+ ImageWidth: imgWidth,
203+ ImageHeight: imgHeight,
206+ VirtualPlacement: true,
209+ slog.Error("failed to encode image for kitty graphics", "err", err)
210+ return uiutil.ReportError(fmt.Errorf("failed to encode image"))
213+ return tea.RawMsg{Msg: buf.String()}
111−// Render renders the image to a string that can be displayed on the terminal.
219+// Render renders the given image within the specified dimensions using the
220+// specified encoding.
112−func (i *Image) Render() string {
221+func (e Encoding) Render(id string, cols, rows int) string {
113− // Check cache first
222+ key := imageKey{id: id, cols: cols, rows: rows}
224+ cached, ok := cachedImages[key]
225+ cachedMutex.RUnlock()
114− switch i.enc {
232+ switch e {
115 case EncodingBlocks:
233 case EncodingBlocks:
116− m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2)
234+ m := mosaic.New().Width(cols).Height(rows).Scale(1)
117− return m.Render(i.img)
235+ return strings.TrimSpace(m.Render(img))
118 case EncodingKitty:
236 case EncodingKitty:
119 // Build Kitty graphics unicode place holders
237 // Build Kitty graphics unicode place holders
120 var fg color.Color
238 var fg color.Color
121 var extra int
239 var extra int
122 var r, g, b int
240 var r, g, b int
241+ hashedID := key.Hash()
123− extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff
243+ extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
125− if r == 0 && g == 0 {
245+ if id <= 255 {
126 fg = ansi.IndexedColor(b)
246 fg = ansi.IndexedColor(b)
128 fg = color.RGBA{
248 fg = color.RGBA{
@@ -136,7 +256,7 @@ func (i *Image) Render() string {
136 fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
256 fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
138 var buf bytes.Buffer
258 var buf bytes.Buffer
139− for y := 0; y < i.rows; y++ {
259+ for y := range rows {
140 // As an optimization, we only write the fg color sequence id, and
260 // As an optimization, we only write the fg color sequence id, and
141 // column-row data once on the first cell. The terminal will handle
261 // column-row data once on the first cell. The terminal will handle
142 // the rest.
262 // the rest.
@@ -147,11 +267,11 @@ func (i *Image) Render() string {
147 if extra > 0 {
267 if extra > 0 {
148 buf.WriteRune(kitty.Diacritic(extra))
268 buf.WriteRune(kitty.Diacritic(extra))
150− for x := 1; x < i.cols; x++ {
270+ for x := 1; x < cols; x++ {
151 buf.WriteString(fgStyle)
271 buf.WriteString(fgStyle)
152 buf.WriteRune(kitty.Placeholder)
272 buf.WriteRune(kitty.Placeholder)
154− if y < i.rows-1 {
274+ if y < rows-1 {
155 buf.WriteByte('\n')
275 buf.WriteByte('\n')