Detailed changes
@@ -34,6 +34,7 @@ require (
github.com/charmbracelet/x/term v0.2.2
github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
+ github.com/disintegration/imaging v1.6.2
github.com/dustin/go-humanize v1.0.1
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
@@ -148,6 +148,8 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -391,6 +393,7 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -1,7 +1,9 @@
package common
import (
+ "fmt"
"image"
+ "os"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
@@ -9,6 +11,12 @@ import (
uv "github.com/charmbracelet/ultraviolet"
)
+// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
+const MaxAttachmentSize = int64(5 * 1024 * 1024)
+
+// AllowedImageTypes defines the permitted image file types.
+var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
+
// Common defines common UI options and configurations.
type Common struct {
App *app.App
@@ -40,3 +48,18 @@ func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
maxY := minY + height
return image.Rect(minX, minY, maxX, maxY)
}
+
+// IsFileTooBig checks if the file at the given path exceeds the specified size
+// limit.
+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
+ fileInfo, err := os.Stat(filePath)
+ if err != nil {
+ return false, fmt.Errorf("error getting file info: %w", err)
+ }
+
+ if fileInfo.Size() > sizeLimit {
+ return true, nil
+ }
+
+ return false, nil
+}
@@ -1,11 +1,19 @@
package dialog
import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/uiutil"
)
// ActionClose is a message to close the current dialog.
@@ -60,3 +68,52 @@ type (
type ActionCmd struct {
Cmd tea.Cmd
}
+
+// ActionFilePickerSelected is a message indicating a file has been selected in
+// the file picker dialog.
+type ActionFilePickerSelected struct {
+ Path string
+}
+
+// Cmd returns a command that reads the file at path and sends a
+// [message.Attachement] to the program.
+func (a ActionFilePickerSelected) Cmd() tea.Cmd {
+ path := a.Path
+ if path == "" {
+ return nil
+ }
+ return func() tea.Msg {
+ isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
+ if err != nil {
+ return uiutil.InfoMsg{
+ Type: uiutil.InfoTypeError,
+ Msg: fmt.Sprintf("unable to read the image: %v", err),
+ }
+ }
+ if isFileLarge {
+ return uiutil.InfoMsg{
+ Type: uiutil.InfoTypeError,
+ Msg: "file too large, max 5MB",
+ }
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return uiutil.InfoMsg{
+ Type: uiutil.InfoTypeError,
+ Msg: fmt.Sprintf("unable to read the image: %v", err),
+ }
+ }
+
+ mimeBufferSize := min(512, len(content))
+ mimeType := http.DetectContentType(content[:mimeBufferSize])
+ fileName := filepath.Base(path)
+
+ return message.Attachment{
+ FilePath: path,
+ FileName: fileName,
+ MimeType: mimeType,
+ Content: content,
+ }
+ }
+}
@@ -4,6 +4,7 @@ import (
"strings"
tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
)
@@ -34,32 +35,96 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
return cur
}
+// RenderContext is a dialog rendering context that can be used to render
+// common dialog layouts.
+type RenderContext struct {
+ // Styles is the styles to use for rendering.
+ Styles *styles.Styles
+ // Width is the total width of the dialog including any margins, borders,
+ // and paddings.
+ Width int
+ // Title is the title of the dialog. This will be styled using the default
+ // dialog title style and prepended to the content parts slice.
+ Title string
+ // Parts are the rendered parts of the dialog.
+ Parts []string
+ // Help is the help view content. This will be appended to the content parts
+ // slice using the default dialog help style.
+ Help string
+}
+
+// NewRenderContext creates a new RenderContext with the provided styles and width.
+func NewRenderContext(t *styles.Styles, width int) *RenderContext {
+ return &RenderContext{
+ Styles: t,
+ Width: width,
+ Parts: []string{},
+ }
+}
+
+// AddPart adds a rendered part to the dialog.
+func (rc *RenderContext) AddPart(part string) {
+ if len(part) > 0 {
+ rc.Parts = append(rc.Parts, part)
+ }
+}
+
+// Render renders the dialog using the provided context.
+func (rc *RenderContext) Render() string {
+ titleStyle := rc.Styles.Dialog.Title
+ dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
+
+ parts := []string{}
+ if len(rc.Title) > 0 {
+ title := common.DialogTitle(rc.Styles, rc.Title,
+ max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
+ titleStyle.GetHorizontalFrameSize()))
+ parts = append(parts, titleStyle.Render(title), "")
+ }
+
+ for i, p := range rc.Parts {
+ if len(p) > 0 {
+ parts = append(parts, p)
+ }
+ if i < len(rc.Parts)-1 {
+ parts = append(parts, "")
+ }
+ }
+
+ if len(rc.Help) > 0 {
+ parts = append(parts, "")
+ helpStyle := rc.Styles.Dialog.HelpView
+ helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
+ parts = append(parts, helpStyle.Render(rc.Help))
+ }
+
+ content := strings.Join(parts, "\n")
+
+ return dialogStyle.Render(content)
+}
+
// HeaderInputListHelpView generates a view for dialogs with a header, input,
// list, and help sections.
func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
+ rc := NewRenderContext(t, width)
+
titleStyle := t.Dialog.Title
- helpStyle := t.Dialog.HelpView
- dialogStyle := t.Dialog.View.Width(width)
inputStyle := t.Dialog.InputPrompt
- helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize())
listStyle := t.Dialog.List.Height(listHeight)
listContent := listStyle.Render(list)
- parts := []string{}
if len(header) > 0 {
- parts = append(parts, titleStyle.Render(header))
+ rc.AddPart(titleStyle.Render(header))
}
if len(input) > 0 {
- parts = append(parts, inputStyle.Render(input))
+ rc.AddPart(inputStyle.Render(input))
}
if len(list) > 0 {
- parts = append(parts, listContent)
+ rc.AddPart(listContent)
}
if len(help) > 0 {
- parts = append(parts, helpStyle.Render(help))
+ rc.Help = help
}
- content := strings.Join(parts, "\n")
-
- return dialogStyle.Render(content)
+ return rc.Render()
}
@@ -1,11 +1,10 @@
package dialog
import (
- "hash/fnv"
+ "fmt"
"image"
_ "image/jpeg" // register JPEG format
_ "image/png" // register PNG format
- "io"
"os"
"strings"
"sync"
@@ -14,17 +13,13 @@ import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/ui/common"
fimage "github.com/charmbracelet/crush/internal/ui/image"
uv "github.com/charmbracelet/ultraviolet"
)
-var (
- transmittedImages = map[uint64]struct{}{}
- transmittedMutex sync.RWMutex
-)
-
// FilePickerID is the identifier for the FilePicker dialog.
const FilePickerID = "filepicker"
@@ -32,11 +27,10 @@ const FilePickerID = "filepicker"
type FilePicker struct {
com *common.Common
- width int
+ imgEnc fimage.Encoding
imgPrevWidth, imgPrevHeight int
- imageCaps *fimage.Capabilities
+ cellSize fimage.CellSize
- img *fimage.Image
fp filepicker.Model
help help.Model
previewingImage bool // indicates if an image is being previewed
@@ -94,7 +88,7 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) {
)
fp := filepicker.New()
- fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
+ fp.AllowedTypes = common.AllowedImageTypes
fp.ShowPermissions = false
fp.ShowSize = false
fp.AutoHeight = false
@@ -109,7 +103,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) {
// SetImageCapabilities sets the image capabilities for the [FilePicker].
func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
- f.imageCaps = caps
+ if caps != nil {
+ if caps.SupportsKittyGraphics {
+ f.imgEnc = fimage.EncodingKitty
+ }
+ f.cellSize = caps.CellSize()
+ }
}
// WorkingDir returns the current working directory of the [FilePicker].
@@ -127,22 +126,6 @@ func (f *FilePicker) WorkingDir() string {
return cwd
}
-// SetWindowSize sets the desired size of the [FilePicker] dialog window.
-func (f *FilePicker) SetWindowSize(width, height int) {
- f.width = width
- f.imgPrevWidth = width/2 - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
- // Use square preview for simplicity same size as width
- f.imgPrevHeight = width/2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
- f.fp.SetHeight(height)
- innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
- styles := f.com.Styles.FilePicker
- styles.File = styles.File.Width(innerWidth)
- styles.Directory = styles.Directory.Width(innerWidth)
- styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
- styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
- f.fp.Styles = styles
-}
-
// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
func (f *FilePicker) ShortHelp() []key.Binding {
return []key.Binding{
@@ -201,79 +184,107 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
}
f.previewingImage = allowed
- if allowed {
- id := uniquePathID(selFile)
-
- transmittedMutex.RLock()
- _, transmitted := transmittedImages[id]
- transmittedMutex.RUnlock()
- if !transmitted {
- img, err := loadImage(selFile)
- if err != nil {
- f.previewingImage = false
- }
-
- timg, err := fimage.New(selFile, img, f.imgPrevWidth, f.imgPrevHeight)
- if err != nil {
- f.previewingImage = false
- }
-
- f.img = timg
- if err == nil {
- cmds = append(cmds, f.img.Transmit())
- transmittedMutex.Lock()
- transmittedImages[id] = struct{}{}
- transmittedMutex.Unlock()
- }
+ if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
+ img, err := loadImage(selFile)
+ if err != nil {
+ f.previewingImage = false
}
+
+ cmds = append(cmds, f.imgEnc.Transmit(
+ selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight))
+ f.previewingImage = true
}
}
if cmd != nil {
cmds = append(cmds, cmd)
}
+ if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
+ return ActionFilePickerSelected{Path: path}
+ }
+
return ActionCmd{tea.Batch(cmds...)}
}
+const (
+ filePickerMinWidth = 70
+ filePickerMinHeight = 10
+)
+
// Draw renders the [FilePicker] dialog as a string.
func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ width := max(0, min(filePickerMinWidth, area.Dx()))
+ height := max(0, min(10, area.Dy()))
+ innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
+ imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
+ imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
+ f.imgPrevWidth = imgPrevWidth
+ f.imgPrevHeight = imgPrevHeight
+ f.fp.SetHeight(height)
+
+ styles := f.com.Styles.FilePicker
+ styles.File = styles.File.Width(innerWidth)
+ styles.Directory = styles.Directory.Width(innerWidth)
+ styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
+ f.fp.Styles = styles
+
t := f.com.Styles
- titleStyle := f.com.Styles.Dialog.Title
- dialogStyle := f.com.Styles.Dialog.View
- header := common.DialogTitle(t, "Add Image",
- max(0, f.width-dialogStyle.GetHorizontalFrameSize()-
- titleStyle.GetHorizontalFrameSize()))
+ rc := NewRenderContext(t, width)
+ rc.Title = "Add Image"
+ rc.Help = f.help.View(f)
+
+ imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
+ rc.AddPart(imgPreview)
+
files := strings.TrimSpace(f.fp.View())
- filesHeight := f.fp.Height()
- imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview())
- view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f))
+ rc.AddPart(files)
+
+ view := rc.Render()
+
DrawCenter(scr, area, view)
return nil
}
+var (
+ imagePreviewCache = map[string]string{}
+ imagePreviewMutex sync.RWMutex
+)
+
// imagePreview returns the image preview section of the [FilePicker] dialog.
-func (f *FilePicker) imagePreview() string {
- if !f.previewingImage || f.img == nil {
- // TODO: Cache this?
+func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
+ if !f.previewingImage {
+ key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
+ imagePreviewMutex.RLock()
+ cached, ok := imagePreviewCache[key]
+ imagePreviewMutex.RUnlock()
+ if ok {
+ return cached
+ }
+
var sb strings.Builder
- for y := 0; y < f.imgPrevHeight; y++ {
- for x := 0; x < f.imgPrevWidth; x++ {
- sb.WriteRune('╱')
+ for y := range imgPrevHeight {
+ for range imgPrevWidth {
+ sb.WriteRune('█')
}
- if y < f.imgPrevHeight-1 {
+ if y < imgPrevHeight-1 {
sb.WriteRune('\n')
}
}
+
+ imagePreviewMutex.Lock()
+ imagePreviewCache[key] = sb.String()
+ imagePreviewMutex.Unlock()
+
return sb.String()
}
- return f.img.Render()
-}
+ if id := f.fp.HighlightedPath(); id != "" {
+ r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
+ return r
+ }
-func uniquePathID(path string) uint64 {
- h := fnv.New64a()
- _, _ = io.WriteString(h, path)
- return h.Sum64()
+ return ""
}
func loadImage(path string) (img image.Image, err error) {
@@ -8,31 +8,67 @@ import (
"image/color"
"io"
"log/slog"
+ "strings"
+ "sync"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/uiutil"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/kitty"
"github.com/charmbracelet/x/mosaic"
+ "github.com/disintegration/imaging"
)
// Capabilities represents the capabilities of displaying images on the
// terminal.
type Capabilities struct {
+ // Columns is the number of character columns in the terminal.
+ Columns int
+ // Rows is the number of character rows in the terminal.
+ Rows int
+ // PixelWidth is the width of the terminal in pixels.
+ PixelWidth int
+ // PixelHeight is the height of the terminal in pixels.
+ PixelHeight int
// SupportsKittyGraphics indicates whether the terminal supports the Kitty
// graphics protocol.
SupportsKittyGraphics bool
}
+// CellSize returns the size of a single terminal cell in pixels.
+func (c Capabilities) CellSize() CellSize {
+ return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
+}
+
+// CalculateCellSize calculates the size of a single terminal cell in pixels
+// based on the terminal's pixel dimensions and character dimensions.
+func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
+ if charWidth == 0 || charHeight == 0 {
+ return CellSize{}
+ }
+
+ return CellSize{
+ Width: pixelWidth / charWidth,
+ Height: pixelHeight / charHeight,
+ }
+}
+
// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
// its image related capabilities to the program.
func RequestCapabilities() tea.Cmd {
return tea.Raw(
- // ID 31 is just a random ID used to detect Kitty graphics support.
- ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
+ ansi.WindowOp(14) + // Window size in pixels
+ // ID 31 is just a random ID used to detect Kitty graphics support.
+ ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
)
}
+// TransmittedMsg is a message indicating that an image has been transmitted to
+// the terminal.
+type TransmittedMsg struct {
+ ID string
+}
+
// Encoding represents the encoding format of the image.
type Encoding byte
@@ -42,87 +78,171 @@ const (
EncodingKitty
)
-// Image represents an image that can be displayed on the terminal.
-type Image struct {
- id int
+type imageKey struct {
+ id string
+ cols int
+ rows int
+}
+
+// Hash returns a hash value for the image key.
+// This uses FNV-32a for simplicity and speed.
+func (k imageKey) Hash() uint32 {
+ h := fnv.New32a()
+ _, _ = io.WriteString(h, k.ID())
+ return h.Sum32()
+}
+
+// ID returns a unique string representation of the image key.
+func (k imageKey) ID() string {
+ return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
+}
+
+// CellSize represents the size of a single terminal cell in pixels.
+type CellSize struct {
+ Width, Height int
+}
+
+type cachedImage struct {
img image.Image
- cols, rows int // in terminal cells
- enc Encoding
+ cols, rows int
}
-// New creates a new [Image] instance with the given unique id, image, and
-// dimensions in terminal cells.
-func New(id string, img image.Image, cols, rows int) (*Image, error) {
- i := new(Image)
- h := fnv.New64a()
- if _, err := io.WriteString(h, id); err != nil {
- return nil, err
+var (
+ cachedImages = map[imageKey]cachedImage{}
+ cachedMutex sync.RWMutex
+)
+
+// fitImage resizes the image to fit within the specified dimensions in
+// terminal cells, maintaining the aspect ratio.
+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
+ if img == nil {
+ return nil
+ }
+
+ key := imageKey{id: id, cols: cols, rows: rows}
+
+ cachedMutex.RLock()
+ cached, ok := cachedImages[key]
+ cachedMutex.RUnlock()
+ if ok {
+ return cached.img
}
- i.id = int(h.Sum64())
- i.img = img
- i.cols = cols
- i.rows = rows
- return i, nil
+
+ if cs.Width == 0 || cs.Height == 0 {
+ return img
+ }
+
+ maxWidth := cols * cs.Width
+ maxHeight := rows * cs.Height
+
+ img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
+
+ cachedMutex.Lock()
+ cachedImages[key] = cachedImage{
+ img: img,
+ cols: cols,
+ rows: rows,
+ }
+ cachedMutex.Unlock()
+
+ return img
}
-// SetEncoding sets the encoding format for the image.
-func (i *Image) SetEncoding(enc Encoding) {
- i.enc = enc
+// HasTransmitted checks if the image with the given ID has already been
+// transmitted to the terminal.
+func HasTransmitted(id string, cols, rows int) bool {
+ key := imageKey{id: id, cols: cols, rows: rows}
+
+ cachedMutex.RLock()
+ _, ok := cachedImages[key]
+ cachedMutex.RUnlock()
+ return ok
}
-// Transmit returns a [tea.Cmd] that sends the image data to the terminal.
-// This is needed for the [EncodingKitty] protocol so that the terminal can
-// cache the image for later rendering.
-//
-// This should only happen once per image.
-func (i *Image) Transmit() tea.Cmd {
- if i.enc != EncodingKitty {
+// Transmit transmits the image data to the terminal if needed. This is used to
+// cache the image on the terminal for later rendering.
+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
+ if img == nil {
return nil
}
- var buf bytes.Buffer
- bounds := i.img.Bounds()
- imgWidth := bounds.Dx()
- imgHeight := bounds.Dy()
-
- // RGBA is 4 bytes per pixel
- imgSize := imgWidth * imgHeight * 4
-
- if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{
- ID: i.id,
- Action: kitty.TransmitAndPut,
- Transmission: kitty.Direct,
- Format: kitty.RGBA,
- Size: imgSize,
- Width: imgWidth,
- Height: imgHeight,
- Columns: i.cols,
- Rows: i.rows,
- VirtualPlacement: true,
- Quite: 2,
- }); err != nil {
- slog.Error("failed to encode image for kitty graphics", "err", err)
- return uiutil.ReportError(fmt.Errorf("failed to encode image"))
+ key := imageKey{id: id, cols: cols, rows: rows}
+
+ cachedMutex.RLock()
+ _, ok := cachedImages[key]
+ cachedMutex.RUnlock()
+ if ok {
+ return nil
}
- return tea.Raw(buf.String())
+ cmd := func() tea.Msg {
+ if e != EncodingKitty {
+ cachedMutex.Lock()
+ cachedImages[key] = cachedImage{
+ img: img,
+ cols: cols,
+ rows: rows,
+ }
+ cachedMutex.Unlock()
+ return TransmittedMsg{ID: key.ID()}
+ }
+
+ var buf bytes.Buffer
+ img := fitImage(id, img, cs, cols, rows)
+ bounds := img.Bounds()
+ imgWidth := bounds.Dx()
+ imgHeight := bounds.Dy()
+
+ imgID := int(key.Hash())
+ if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
+ ID: imgID,
+ Action: kitty.TransmitAndPut,
+ Transmission: kitty.Direct,
+ Format: kitty.RGBA,
+ ImageWidth: imgWidth,
+ ImageHeight: imgHeight,
+ Columns: cols,
+ Rows: rows,
+ VirtualPlacement: true,
+ Quite: 1,
+ }); err != nil {
+ slog.Error("failed to encode image for kitty graphics", "err", err)
+ return uiutil.ReportError(fmt.Errorf("failed to encode image"))
+ }
+
+ return tea.RawMsg{Msg: buf.String()}
+ }
+
+ return cmd
}
-// Render renders the image to a string that can be displayed on the terminal.
-func (i *Image) Render() string {
- // Check cache first
- switch i.enc {
+// Render renders the given image within the specified dimensions using the
+// specified encoding.
+func (e Encoding) Render(id string, cols, rows int) string {
+ key := imageKey{id: id, cols: cols, rows: rows}
+ cachedMutex.RLock()
+ cached, ok := cachedImages[key]
+ cachedMutex.RUnlock()
+ if !ok {
+ return ""
+ }
+
+ img := cached.img
+
+ switch e {
case EncodingBlocks:
- m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2)
- return m.Render(i.img)
+ m := mosaic.New().Width(cols).Height(rows).Scale(1)
+ return strings.TrimSpace(m.Render(img))
case EncodingKitty:
// Build Kitty graphics unicode place holders
var fg color.Color
var extra int
var r, g, b int
- extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff
+ hashedID := key.Hash()
+ id := int(hashedID)
+ extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
- if r == 0 && g == 0 {
+ if id <= 255 {
fg = ansi.IndexedColor(b)
} else {
fg = color.RGBA{
@@ -136,7 +256,7 @@ func (i *Image) Render() string {
fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
var buf bytes.Buffer
- for y := 0; y < i.rows; y++ {
+ for y := range rows {
// 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.
@@ -147,11 +267,11 @@ func (i *Image) Render() string {
if extra > 0 {
buf.WriteRune(kitty.Diacritic(extra))
}
- for x := 1; x < i.cols; x++ {
+ for x := 1; x < cols; x++ {
buf.WriteString(fgStyle)
buf.WriteRune(kitty.Placeholder)
}
- if y < i.rows-1 {
+ if y < rows-1 {
buf.WriteByte('\n')
}
}
@@ -46,12 +46,6 @@ import (
"github.com/charmbracelet/x/editor"
)
-// Max file size set to 5M.
-const maxAttachmentSize = int64(5 * 1024 * 1024)
-
-// Allowed image formats.
-var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
-
// uiFocusState represents the current focus state of the UI.
type uiFocusState uint8
@@ -146,8 +140,8 @@ type UI struct {
// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
sidebarLogo string
- // imageCaps stores the terminal image capabilities.
- imageCaps timage.Capabilities
+ // imgCaps stores the terminal image capabilities.
+ imgCaps timage.Capabilities
}
// New creates a new instance of the [UI] model.
@@ -314,6 +308,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.updateLayoutAndSize()
+ // XXX: We need to store cell dimensions for image rendering.
+ m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
case tea.KeyboardEnhancementsMsg:
m.keyenh = msg
if msg.SupportsKeyDisambiguation() {
@@ -435,11 +431,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.completionsOpen {
m.completions.SetFiles(msg.Files)
}
+ case uv.WindowPixelSizeEvent:
+ // [timage.RequestCapabilities] requests the terminal to send a window
+ // size event to help determine pixel dimensions.
+ m.imgCaps.PixelWidth = msg.Width
+ m.imgCaps.PixelHeight = msg.Height
case uv.KittyGraphicsEvent:
// [timage.RequestCapabilities] sends a Kitty graphics query and this
// captures the response. Any response means the terminal understands
// the protocol.
- m.imageCaps.SupportsKittyGraphics = true
+ m.imgCaps.SupportsKittyGraphics = true
default:
if m.dialog.HasDialogs() {
if cmd := m.handleDialogMsg(msg); cmd != nil {
@@ -833,6 +834,16 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
case dialog.PermissionDeny:
m.com.App.Permissions.Deny(msg.Permission)
}
+
+ case dialog.ActionFilePickerSelected:
+ cmds = append(cmds, tea.Sequence(
+ msg.Cmd(),
+ func() tea.Msg {
+ m.dialog.CloseDialog(dialog.FilePickerID)
+ return nil
+ },
+ ))
+
default:
cmds = append(cmds, uiutil.CmdHandler(msg))
}
@@ -2062,10 +2073,8 @@ func (m *UI) openFilesDialog() tea.Cmd {
return nil
}
- const desiredFilePickerHeight = 10
filePicker, action := dialog.NewFilePicker(m.com)
- filePicker.SetWindowSize(min(80, m.width-8), desiredFilePickerHeight)
- filePicker.SetImageCapabilities(&m.imageCaps)
+ filePicker.SetImageCapabilities(&m.imgCaps)
m.dialog.OpenDialog(filePicker)
switch action := action.(type) {
@@ -2138,7 +2147,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
if strings.Count(msg.Content, "\n") > 2 {
return func() tea.Msg {
content := []byte(msg.Content)
- if int64(len(content)) > maxAttachmentSize {
+ if int64(len(content)) > common.MaxAttachmentSize {
return uiutil.ReportWarn("Paste is too big (>5mb)")
}
name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
@@ -2165,7 +2174,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
// Check if file has an allowed image extension.
isAllowedType := false
lowerPath := strings.ToLower(path)
- for _, ext := range allowedImageTypes {
+ for _, ext := range common.AllowedImageTypes {
if strings.HasSuffix(lowerPath, ext) {
isAllowedType = true
break
@@ -2181,7 +2190,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
if err != nil {
return uiutil.ReportError(err)
}
- if fileInfo.Size() > maxAttachmentSize {
+ if fileInfo.Size() > common.MaxAttachmentSize {
return uiutil.ReportWarn("File is too big (>5mb)")
}
@@ -1188,7 +1188,7 @@ func DefaultStyles() Styles {
s.Dialog.ScrollbarThumb = base.Foreground(secondary)
s.Dialog.ScrollbarTrack = base.Foreground(border)
- s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(1)
+ s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")