@@ -8,31 +8,67 @@ import (
88 "image/color"
99 "io"
1010 "log/slog"
11+ "strings"
12+ "sync"
1113
1214 tea "charm.land/bubbletea/v2"
1315 "github.com/charmbracelet/crush/internal/uiutil"
1416 "github.com/charmbracelet/x/ansi"
1517 "github.com/charmbracelet/x/ansi/kitty"
1618 "github.com/charmbracelet/x/mosaic"
19+ "github.com/disintegration/imaging"
1720)
1821
1922// Capabilities represents the capabilities of displaying images on the
2023// terminal.
2124type Capabilities struct {
25+ // Columns is the number of character columns in the terminal.
26+ Columns int
27+ // Rows is the number of character rows in the terminal.
28+ Rows int
29+ // PixelWidth is the width of the terminal in pixels.
30+ PixelWidth int
31+ // PixelHeight is the height of the terminal in pixels.
32+ PixelHeight int
2233 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
2334 // graphics protocol.
2435 SupportsKittyGraphics bool
2536}
2637
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)
41+}
42+
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 {
47+ return CellSize{}
48+ }
49+
50+ return CellSize{
51+ Width: pixelWidth / charWidth,
52+ Height: pixelHeight / charHeight,
53+ }
54+}
55+
2756// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
2857// its image related capabilities to the program.
2958func RequestCapabilities() tea.Cmd {
3059 return tea.Raw(
31− // 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"),
60+ ansi.WindowOp(14) + // Window size in pixels
61+ // ID 31 is just a random ID used to detect Kitty graphics support.
62+ ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
3363 )
3464}
3565
66+// TransmittedMsg is a message indicating that an image has been transmitted to
67+// the terminal.
68+type TransmittedMsg struct {
69+ ID string
70+}
71+
3672// Encoding represents the encoding format of the image.
3773type Encoding byte
3874
@@ -42,87 +78,171 @@ const (
4278 EncodingKitty
4379)
4480
45−// Image represents an image that can be displayed on the terminal.
46−type Image struct {
47− id int
81+type imageKey struct {
82+ id string
83+ cols int
84+ rows int
85+}
86+
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 {
90+ h := fnv.New32a()
91+ _, _ = io.WriteString(h, k.ID())
92+ return h.Sum32()
93+}
94+
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)
98+}
99+
100+// CellSize represents the size of a single terminal cell in pixels.
101+type CellSize struct {
102+ Width, Height int
103+}
104+
105+type cachedImage struct {
48106 img image.Image
49− cols, rows int // in terminal cells
50− enc Encoding
107+ cols, rows int
51108}
52109
53−// New creates a new [Image] instance with the given unique id, image, and
54−// dimensions in terminal cells.
55−func New(id string, img image.Image, cols, rows int) (*Image, error) {
56− i := new(Image)
57− h := fnv.New64a()
58− if _, err := io.WriteString(h, id); err != nil {
59− return nil, err
110+var (
111+ cachedImages = map[imageKey]cachedImage{}
112+ cachedMutex sync.RWMutex
113+)
114+
115+// fitImage resizes the image to fit within the specified dimensions in
116+// terminal cells, maintaining the aspect ratio.
117+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
118+ if img == nil {
119+ return nil
120+ }
121+
122+ key := imageKey{id: id, cols: cols, rows: rows}
123+
124+ cachedMutex.RLock()
125+ cached, ok := cachedImages[key]
126+ cachedMutex.RUnlock()
127+ if ok {
128+ return cached.img
60129 }
61− i.id = int(h.Sum64())
62− i.img = img
63− i.cols = cols
64− i.rows = rows
65− return i, nil
130+
131+ if cs.Width == 0 || cs.Height == 0 {
132+ return img
133+ }
134+
135+ maxWidth := cols * cs.Width
136+ maxHeight := rows * cs.Height
137+
138+ img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
139+
140+ cachedMutex.Lock()
141+ cachedImages[key] = cachedImage{
142+ img: img,
143+ cols: cols,
144+ rows: rows,
145+ }
146+ cachedMutex.Unlock()
147+
148+ return img
66149}
67150
68−// SetEncoding sets the encoding format for the image.
69−func (i *Image) SetEncoding(enc Encoding) {
70− i.enc = enc
151+// HasTransmitted checks if the image with the given ID has already been
152+// transmitted to the terminal.
153+func HasTransmitted(id string, cols, rows int) bool {
154+ key := imageKey{id: id, cols: cols, rows: rows}
155+
156+ cachedMutex.RLock()
157+ _, ok := cachedImages[key]
158+ cachedMutex.RUnlock()
159+ return ok
71160}
72161
73−// Transmit returns a [tea.Cmd] that sends the image data to the terminal.
74−// This is needed for the [EncodingKitty] protocol so that the terminal can
75−// cache the image for later rendering.
76−//
77−// This should only happen once per image.
78−func (i *Image) Transmit() tea.Cmd {
79− if i.enc != EncodingKitty {
162+// Transmit transmits the image data to the terminal if needed. This is used to
163+// cache the image on the terminal for later rendering.
164+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
165+ if img == nil {
80166 return nil
81167 }
82168
83− var buf bytes.Buffer
84− bounds := i.img.Bounds()
85− imgWidth := bounds.Dx()
86− imgHeight := bounds.Dy()
87−
88− // RGBA is 4 bytes per pixel
89− imgSize := imgWidth * imgHeight * 4
90−
91− if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{
92− ID: i.id,
93− Action: kitty.TransmitAndPut,
94− Transmission: kitty.Direct,
95− Format: kitty.RGBA,
96− Size: imgSize,
97− Width: imgWidth,
98− Height: imgHeight,
99− Columns: i.cols,
100− Rows: i.rows,
101− VirtualPlacement: true,
102− Quite: 2,
103− }); err != nil {
104− slog.Error("failed to encode image for kitty graphics", "err", err)
105− return uiutil.ReportError(fmt.Errorf("failed to encode image"))
169+ key := imageKey{id: id, cols: cols, rows: rows}
170+
171+ cachedMutex.RLock()
172+ _, ok := cachedImages[key]
173+ cachedMutex.RUnlock()
174+ if ok {
175+ return nil
106176 }
107177
108− return tea.Raw(buf.String())
178+ cmd := func() tea.Msg {
179+ if e != EncodingKitty {
180+ cachedMutex.Lock()
181+ cachedImages[key] = cachedImage{
182+ img: img,
183+ cols: cols,
184+ rows: rows,
185+ }
186+ cachedMutex.Unlock()
187+ return TransmittedMsg{ID: key.ID()}
188+ }
189+
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()
195+
196+ imgID := int(key.Hash())
197+ if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
198+ ID: imgID,
199+ Action: kitty.TransmitAndPut,
200+ Transmission: kitty.Direct,
201+ Format: kitty.RGBA,
202+ ImageWidth: imgWidth,
203+ ImageHeight: imgHeight,
204+ Columns: cols,
205+ Rows: rows,
206+ VirtualPlacement: true,
207+ Quite: 1,
208+ }); err != nil {
209+ slog.Error("failed to encode image for kitty graphics", "err", err)
210+ return uiutil.ReportError(fmt.Errorf("failed to encode image"))
211+ }
212+
213+ return tea.RawMsg{Msg: buf.String()}
214+ }
215+
216+ return cmd
109217}
110218
111−// Render renders the image to a string that can be displayed on the terminal.
112−func (i *Image) Render() string {
113− // Check cache first
114− switch i.enc {
219+// Render renders the given image within the specified dimensions using the
220+// specified encoding.
221+func (e Encoding) Render(id string, cols, rows int) string {
222+ key := imageKey{id: id, cols: cols, rows: rows}
223+ cachedMutex.RLock()
224+ cached, ok := cachedImages[key]
225+ cachedMutex.RUnlock()
226+ if !ok {
227+ return ""
228+ }
229+
230+ img := cached.img
231+
232+ switch e {
115233 case EncodingBlocks:
116− m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2)
117− return m.Render(i.img)
234+ m := mosaic.New().Width(cols).Height(rows).Scale(1)
235+ return strings.TrimSpace(m.Render(img))
118236 case EncodingKitty:
119237 // Build Kitty graphics unicode place holders
120238 var fg color.Color
121239 var extra int
122240 var r, g, b int
123− extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff
241+ hashedID := key.Hash()
242+ id := int(hashedID)
243+ extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
124244
125− if r == 0 && g == 0 {
245+ if id <= 255 {
126246 fg = ansi.IndexedColor(b)
127247 } else {
128248 fg = color.RGBA{
@@ -136,7 +256,7 @@ func (i *Image) Render() string {
136256 fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
137257
138258 var buf bytes.Buffer
139− for y := 0; y < i.rows; y++ {
259+ for y := range rows {
140260 // As an optimization, we only write the fg color sequence id, and
141261 // column-row data once on the first cell. The terminal will handle
142262 // the rest.
@@ -147,11 +267,11 @@ func (i *Image) Render() string {
147267 if extra > 0 {
148268 buf.WriteRune(kitty.Diacritic(extra))
149269 }
150− for x := 1; x < i.cols; x++ {
270+ for x := 1; x < cols; x++ {
151271 buf.WriteString(fgStyle)
152272 buf.WriteRune(kitty.Placeholder)
153273 }
154− if y < i.rows-1 {
274+ if y < rows-1 {
155275 buf.WriteByte('\n')
156276 }
157277 }