diff --git a/go.mod b/go.mod index 1780ea04bf94224cfdbb867a4db61d8724cd3dc9..67c5089c7dd01fc3e4bf66e60ac50bdbe95b7767 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 335a7c23bd858794e1296d41c4c6c63615b377de..b18aab1b57cd3caf93fee69f1c73b565432ae37b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 68d01c77901a98263defa4d28fe4f80af4ac3cfc..21ab903c388adaa1f626bef46f09c3829f927086 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -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 +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index ecf81432410c31a523d221221e00c50d9862b9ac..f03e783ad5a0b7c52a90dbc8f1c94dfc65e647af 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -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, + } + } +} diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index 4c8d166ec7994813229cf0953aa9cc46fd3a27c0..7c812e4223fab44b38a9b4a41099055d737ec4c2 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -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() } diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index 6ccf6575a3a0ace7354f6eada3ceb8b1f1d7a904..e9705f23317c8ad9fab3874cfafafe3bc4d9eb72 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -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) { diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 5de8adfcb9d1bd4e6100243f5c6931bf2f663d35..e7f51239f8fd5ebecec1f5855911fbe459b340ac 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -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') } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0f96e43d2afcb1c41d91376bba8079e86a52b3bb..649a588056b9386af5568ff30b6db2bde0ba5638 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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)") } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 5e84e90e8d4b55f1dd3420e70b8e5a5126b62b3d..947b514a2ab0dd3f3737912c46288b100e1bc328 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -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!")