From 5d2c533d9c2cea817506d8a8618c0859b469174b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 9 Jan 2026 11:39:15 -0500 Subject: [PATCH 1/6] feat(ui): dialog: add file picker dialog with image preview --- go.mod | 3 +- go.sum | 6 +- internal/ui/dialog/common.go | 21 ++- internal/ui/dialog/filepicker.go | 292 +++++++++++++++++++++++++++++++ internal/ui/image/image.go | 164 +++++++++++++++++ internal/ui/model/ui.go | 40 +++++ internal/ui/styles/styles.go | 4 + 7 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 internal/ui/dialog/filepicker.go create mode 100644 internal/ui/image/image.go diff --git a/go.mod b/go.mod index b607a70975a383b3c9ba5e2c945fcada2f27c125..1780ea04bf94224cfdbb867a4db61d8724cd3dc9 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff + github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 @@ -171,7 +172,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/image v0.27.0 // indirect + golang.org/x/image v0.34.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect diff --git a/go.sum b/go.sum index 70582b7c92f86af89a03d9f9a43382e27235d2ca..335a7c23bd858794e1296d41c4c6c63615b377de 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= +github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y= +github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY= github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc= github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -389,8 +391,8 @@ 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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= -golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +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= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index 48234281f304208b9e1a30c575fab342ceb5e57a..4c8d166ec7994813229cf0953aa9cc46fd3a27c0 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -45,12 +45,21 @@ func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, in listStyle := t.Dialog.List.Height(listHeight) listContent := listStyle.Render(list) - content := strings.Join([]string{ - titleStyle.Render(header), - inputStyle.Render(input), - listContent, - helpStyle.Render(help), - }, "\n") + parts := []string{} + if len(header) > 0 { + parts = append(parts, titleStyle.Render(header)) + } + if len(input) > 0 { + parts = append(parts, inputStyle.Render(input)) + } + if len(list) > 0 { + parts = append(parts, listContent) + } + if len(help) > 0 { + parts = append(parts, helpStyle.Render(help)) + } + + content := strings.Join(parts, "\n") return dialogStyle.Render(content) } diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..6ccf6575a3a0ace7354f6eada3ceb8b1f1d7a904 --- /dev/null +++ b/internal/ui/dialog/filepicker.go @@ -0,0 +1,292 @@ +package dialog + +import ( + "hash/fnv" + "image" + _ "image/jpeg" // register JPEG format + _ "image/png" // register PNG format + "io" + "os" + "strings" + "sync" + + "charm.land/bubbles/v2/filepicker" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/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" + +// FilePicker is a dialog that allows users to select files or directories. +type FilePicker struct { + com *common.Common + + width int + imgPrevWidth, imgPrevHeight int + imageCaps *fimage.Capabilities + + img *fimage.Image + fp filepicker.Model + help help.Model + previewingImage bool // indicates if an image is being previewed + + km struct { + Select, + Down, + Up, + Forward, + Backward, + Navigate, + Close key.Binding + } +} + +var _ Dialog = (*FilePicker)(nil) + +// NewFilePicker creates a new [FilePicker] dialog. +func NewFilePicker(com *common.Common) (*FilePicker, Action) { + f := new(FilePicker) + f.com = com + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + f.help = help + + f.km.Select = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "accept"), + ) + f.km.Down = key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("down/j", "move down"), + ) + f.km.Up = key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("up/k", "move up"), + ) + f.km.Forward = key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("right/l", "move forward"), + ) + f.km.Backward = key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("left/h", "move backward"), + ) + f.km.Navigate = key.NewBinding( + key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"), + key.WithHelp("↑↓←→", "navigate"), + ) + f.km.Close = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "close/exit"), + ) + + fp := filepicker.New() + fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"} + fp.ShowPermissions = false + fp.ShowSize = false + fp.AutoHeight = false + fp.Styles = com.Styles.FilePicker + fp.Cursor = "" + fp.CurrentDirectory = f.WorkingDir() + + f.fp = fp + + return f, ActionCmd{f.fp.Init()} +} + +// SetImageCapabilities sets the image capabilities for the [FilePicker]. +func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { + f.imageCaps = caps +} + +// WorkingDir returns the current working directory of the [FilePicker]. +func (f *FilePicker) WorkingDir() string { + wd := f.com.Config().WorkingDir() + if len(wd) > 0 { + return wd + } + + cwd, err := os.Getwd() + if err != nil { + return home.Dir() + } + + 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{ + f.km.Navigate, + f.km.Select, + f.km.Close, + } +} + +// FullHelp returns the full help key bindings for the [FilePicker] dialog. +func (f *FilePicker) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + f.km.Select, + f.km.Down, + f.km.Up, + f.km.Forward, + }, + { + f.km.Backward, + f.km.Close, + }, + } +} + +// ID returns the identifier of the [FilePicker] dialog. +func (f *FilePicker) ID() string { + return FilePickerID +} + +// Init implements the [Dialog] interface. +func (f *FilePicker) Init() tea.Cmd { + return f.fp.Init() +} + +// HandleMsg updates the [FilePicker] dialog based on the given message. +func (f *FilePicker) HandleMsg(msg tea.Msg) Action { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, f.km.Close): + return ActionClose{} + } + } + + var cmd tea.Cmd + f.fp, cmd = f.fp.Update(msg) + if selFile := f.fp.HighlightedPath(); selFile != "" { + var allowed bool + for _, allowedExt := range f.fp.AllowedTypes { + if strings.HasSuffix(strings.ToLower(selFile), allowedExt) { + allowed = true + break + } + } + + 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 cmd != nil { + cmds = append(cmds, cmd) + } + + return ActionCmd{tea.Batch(cmds...)} +} + +// Draw renders the [FilePicker] dialog as a string. +func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + 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())) + 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)) + DrawCenter(scr, area, view) + return nil +} + +// imagePreview returns the image preview section of the [FilePicker] dialog. +func (f *FilePicker) imagePreview() string { + if !f.previewingImage || f.img == nil { + // TODO: Cache this? + var sb strings.Builder + for y := 0; y < f.imgPrevHeight; y++ { + for x := 0; x < f.imgPrevWidth; x++ { + sb.WriteRune('╱') + } + if y < f.imgPrevHeight-1 { + sb.WriteRune('\n') + } + } + return sb.String() + } + + return f.img.Render() +} + +func uniquePathID(path string) uint64 { + h := fnv.New64a() + _, _ = io.WriteString(h, path) + return h.Sum64() +} + +func loadImage(path string) (img image.Image, err error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, _, err = image.Decode(file) + if err != nil { + return nil, err + } + + return img, nil +} diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go new file mode 100644 index 0000000000000000000000000000000000000000..5de8adfcb9d1bd4e6100243f5c6931bf2f663d35 --- /dev/null +++ b/internal/ui/image/image.go @@ -0,0 +1,164 @@ +package image + +import ( + "bytes" + "fmt" + "hash/fnv" + "image" + "image/color" + "io" + "log/slog" + + 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" +) + +// Capabilities represents the capabilities of displaying images on the +// terminal. +type Capabilities struct { + // SupportsKittyGraphics indicates whether the terminal supports the Kitty + // graphics protocol. + SupportsKittyGraphics bool +} + +// 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"), + ) +} + +// Encoding represents the encoding format of the image. +type Encoding byte + +// Image encodings. +const ( + EncodingBlocks Encoding = iota + EncodingKitty +) + +// Image represents an image that can be displayed on the terminal. +type Image struct { + id int + img image.Image + cols, rows int // in terminal cells + enc Encoding +} + +// 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 + } + i.id = int(h.Sum64()) + i.img = img + i.cols = cols + i.rows = rows + return i, nil +} + +// SetEncoding sets the encoding format for the image. +func (i *Image) SetEncoding(enc Encoding) { + i.enc = enc +} + +// 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 { + 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")) + } + + return tea.Raw(buf.String()) +} + +// 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 { + case EncodingBlocks: + m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2) + return m.Render(i.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 + + if r == 0 && g == 0 { + fg = ansi.IndexedColor(b) + } else { + fg = color.RGBA{ + R: uint8(r), //nolint:gosec + G: uint8(g), //nolint:gosec + B: uint8(b), //nolint:gosec + A: 0xff, + } + } + + fgStyle := ansi.NewStyle().ForegroundColor(fg).String() + + var buf bytes.Buffer + for y := 0; y < i.rows; y++ { + // 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. + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + buf.WriteRune(kitty.Diacritic(y)) + buf.WriteRune(kitty.Diacritic(0)) + if extra > 0 { + buf.WriteRune(kitty.Diacritic(extra)) + } + for x := 1; x < i.cols; x++ { + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + } + if y < i.rows-1 { + buf.WriteByte('\n') + } + } + + return buf.String() + + default: + return "" + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 620636ad717100db01e3524c500147f7bd8576ee..0f96e43d2afcb1c41d91376bba8079e86a52b3bb 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -36,6 +36,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" + timage "github.com/charmbracelet/crush/internal/ui/image" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uiutil" @@ -144,6 +145,9 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + + // imageCaps stores the terminal image capabilities. + imageCaps timage.Capabilities } // New creates a new instance of the [UI] model. @@ -224,6 +228,11 @@ func (m *UI) Init() tea.Cmd { var cmds []tea.Cmd if m.QueryVersion { cmds = append(cmds, tea.RequestTerminalVersion) + // XXX: Right now, we're using the same logic to determine image + // support. Terminals like Apple Terminal and possibly others might + // bleed characters when querying for Kitty graphics via APC escape + // sequences. + cmds = append(cmds, timage.RequestCapabilities()) } return tea.Batch(cmds...) } @@ -426,6 +435,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.completionsOpen { m.completions.SetFiles(msg.Files) } + 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 default: if m.dialog.HasDialogs() { if cmd := m.handleDialogMsg(msg); cmd != nil { @@ -926,6 +940,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } switch { + case key.Matches(msg, m.keyMap.Editor.AddImage): + if cmd := m.openFilesDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Editor.SendMessage): value := m.textarea.Value() if before, ok := strings.CutSuffix(value, "\\"); ok { @@ -2032,6 +2051,27 @@ func (m *UI) openSessionsDialog() tea.Cmd { } m.dialog.OpenDialog(dialog) + return nil +} + +// openFilesDialog opens the file picker dialog. +func (m *UI) openFilesDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.FilePickerID) { + // Bring to front + m.dialog.BringToFront(dialog.FilePickerID) + return nil + } + + const desiredFilePickerHeight = 10 + filePicker, action := dialog.NewFilePicker(m.com) + filePicker.SetWindowSize(min(80, m.width-8), desiredFilePickerHeight) + filePicker.SetImageCapabilities(&m.imageCaps) + m.dialog.OpenDialog(filePicker) + + switch action := action.(type) { + case dialog.ActionCmd: + return action.Cmd + } return nil } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 1bb6648117e1413950fb8a68dc9a9b3f3b90b89d..5e84e90e8d4b55f1dd3420e70b8e5a5126b62b3d 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -328,6 +328,8 @@ type Styles struct { ScrollbarTrack lipgloss.Style Commands struct{} + + ImagePreview lipgloss.Style } // Status bar and help @@ -1186,6 +1188,8 @@ func DefaultStyles() Styles { s.Dialog.ScrollbarThumb = base.Foreground(secondary) s.Dialog.ScrollbarTrack = base.Foreground(border) + s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(1) + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") s.Status.InfoIndicator = s.Status.SuccessIndicator From 77ecb3924f3378c6a494984ba190a48f99a44a56 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Jan 2026 13:35:23 -0500 Subject: [PATCH 2/6] feat(ui): filepicker: add image attachment support with preview --- go.mod | 1 + go.sum | 3 + internal/ui/common/common.go | 23 +++ internal/ui/dialog/actions.go | 57 +++++++ internal/ui/dialog/common.go | 87 +++++++++-- internal/ui/dialog/filepicker.go | 157 ++++++++++--------- internal/ui/image/image.go | 248 +++++++++++++++++++++++-------- internal/ui/model/ui.go | 39 +++-- internal/ui/styles/styles.go | 2 +- 9 files changed, 453 insertions(+), 164 deletions(-) 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!") From 9f5284f56cebf49d8a13d5bbfb294c0499bb820d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Jan 2026 13:50:45 -0500 Subject: [PATCH 3/6] fix(ui): filepicker: defer image preview until after transmission --- internal/ui/dialog/filepicker.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index e9705f23317c8ad9fab3874cfafafe3bc4d9eb72..8bfdbabd12f54911a7da85842152a17f0ade275a 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -185,14 +185,17 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action { f.previewingImage = allowed if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) { + f.previewingImage = false img, err := loadImage(selFile) - if err != nil { - f.previewingImage = false + if err == nil { + cmds = append(cmds, tea.Sequence( + f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight), + func() tea.Msg { + f.previewingImage = true + return nil + }, + )) } - - cmds = append(cmds, f.imgEnc.Transmit( - selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight)) - f.previewingImage = true } } if cmd != nil { From 1f0605ae33299a887971130f120dc7fca5cf635f Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 15 Jan 2026 09:46:03 -0500 Subject: [PATCH 4/6] refactor(ui): dialog: cleanup render logic and use RenderContext (#1871) --- internal/ui/dialog/commands.go | 23 +++++----- internal/ui/dialog/common.go | 72 ++++++++++++++++---------------- internal/ui/dialog/filepicker.go | 1 + internal/ui/dialog/models.go | 23 ++++------ internal/ui/dialog/sessions.go | 17 ++++---- 5 files changed, 66 insertions(+), 70 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index a6861a5c87707d7c0717ec4d3c50c1d995a528af..97addbba1036781840951f2503b8b7813a16d9eb 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -9,7 +9,6 @@ import ( "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" @@ -17,7 +16,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" ) // CommandsID is the identifier for the commands dialog. @@ -247,6 +245,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { // we need to reset the command items when width changes c.setCommandItems(c.selected) } + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + @@ -257,18 +256,20 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { c.list.SetSize(innerWidth, height-heightOffset) c.help.SetWidth(innerWidth) - radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0) - titleStyle := t.Dialog.Title - dialogStyle := t.Dialog.View.Width(width) - headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() - helpView := ansi.Truncate(c.help.View(c), innerWidth, "") - header := common.DialogTitle(t, "Commands", width-headerOffset) + radio + rc := NewRenderContext(t, width) + rc.Title = "Commands" + rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0) + inputView := t.Dialog.InputPrompt.Render(c.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render()) + rc.AddPart(listView) + rc.Help = c.help.View(c) if c.loading { - helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...") + rc.Help = c.spinner.View() + " Generating Prompt..." } - view := HeaderInputListHelpView(t, width, c.list.Height(), header, - c.input.View(), c.list.Render(), helpView) + + view := rc.Render() cur := c.Cursor() DrawCenterCursor(scr, area, view, cur) diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index 7c812e4223fab44b38a9b4a41099055d737ec4c2..76b75064670935715f03e0d732b9df5070b9e9da 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -4,8 +4,10 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" ) // InputCursor adjusts the cursor position for an input field within a dialog. @@ -43,9 +45,15 @@ type RenderContext struct { // Width is the total width of the dialog including any margins, borders, // and paddings. Width int + // Gap is the gap between content parts. Zero means no gap. + Gap 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 + // TitleInfo is additional information to display next to the title. This + // part is displayed as is, any styling must be applied before setting this + // field. + TitleInfo 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 @@ -76,55 +84,47 @@ func (rc *RenderContext) Render() string { parts := []string{} if len(rc.Title) > 0 { + var titleInfoWidth int + if len(rc.TitleInfo) > 0 { + titleInfoWidth = lipgloss.Width(rc.TitleInfo) + } title := common.DialogTitle(rc.Styles, rc.Title, max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()- - titleStyle.GetHorizontalFrameSize())) - parts = append(parts, titleStyle.Render(title), "") + titleStyle.GetHorizontalFrameSize()- + titleInfoWidth)) + if len(rc.TitleInfo) > 0 { + title += rc.TitleInfo + } + parts = append(parts, titleStyle.Render(title)) + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } } - for i, p := range rc.Parts { - if len(p) > 0 { - parts = append(parts, p) - } - if i < len(rc.Parts)-1 { - parts = append(parts, "") + if rc.Gap <= 0 { + parts = append(parts, rc.Parts...) + } else { + for i, p := range rc.Parts { + if len(p) > 0 { + parts = append(parts, p) + } + if i < len(rc.Parts)-1 { + parts = append(parts, make([]string, rc.Gap)...) + } } } if len(rc.Help) > 0 { - parts = append(parts, "") + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } helpStyle := rc.Styles.Dialog.HelpView helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize()) - parts = append(parts, helpStyle.Render(rc.Help)) + helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "") + parts = append(parts, helpView) } 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 - inputStyle := t.Dialog.InputPrompt - listStyle := t.Dialog.List.Height(listHeight) - listContent := listStyle.Render(list) - - if len(header) > 0 { - rc.AddPart(titleStyle.Render(header)) - } - if len(input) > 0 { - rc.AddPart(inputStyle.Render(input)) - } - if len(list) > 0 { - rc.AddPart(listContent) - } - if len(help) > 0 { - rc.Help = help - } - - return rc.Render() -} diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index 8bfdbabd12f54911a7da85842152a17f0ade275a..a099cd2ff9fe6f707ac1e18c8470dba55794889c 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -234,6 +234,7 @@ func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := f.com.Styles rc := NewRenderContext(t, width) + rc.Gap = 1 rc.Title = "Add Image" rc.Help = f.help.View(f) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index c12c78e1f4753c01f80653bb6ee5e5013fc9ea09..543e610d013e58a71447814aedd22841aaa6bf2a 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -10,13 +10,11 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/uiutil" uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" ) // ModelType represents the type of model to select. @@ -253,19 +251,16 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { m.list.SetSize(innerWidth, height-heightOffset) m.help.SetWidth(innerWidth) - titleStyle := t.Dialog.Title - dialogStyle := t.Dialog.View + rc := NewRenderContext(t, width) + rc.Title = "Switch Model" + rc.TitleInfo = m.modelTypeRadioView() + inputView := t.Dialog.InputPrompt.Render(m.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render()) + rc.AddPart(listView) + rc.Help = m.help.View(m) - radios := m.modelTypeRadioView() - - headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() + - dialogStyle.GetHorizontalFrameSize() - - header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios - - helpView := ansi.Truncate(m.help.View(m), innerWidth, "") - view := HeaderInputListHelpView(t, width, m.list.Height(), header, - m.input.View(), m.list.Render(), helpView) + view := rc.Render() cur := m.Cursor() DrawCenterCursor(scr, area, view, cur) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 7a4725fcb9fac33d349dd5d6d7812e8f70c00eaa..a70d13ce58fed2ddf1b292d30e405362cf093569 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" ) // SessionsID is the identifier for the session selector dialog. @@ -154,15 +153,15 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { s.list.SetSize(innerWidth, height-heightOffset) s.help.SetWidth(innerWidth) - titleStyle := s.com.Styles.Dialog.Title - dialogStyle := s.com.Styles.Dialog.View.Width(width) - header := common.DialogTitle(s.com.Styles, "Switch Session", - max(0, width-dialogStyle.GetHorizontalFrameSize()- - titleStyle.GetHorizontalFrameSize())) + rc := NewRenderContext(t, width) + rc.Title = "Switch Session" + inputView := t.Dialog.InputPrompt.Render(s.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render()) + rc.AddPart(listView) + rc.Help = s.help.View(s) - helpView := ansi.Truncate(s.help.View(s), innerWidth, "") - view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header, - s.input.View(), s.list.Render(), helpView) + view := rc.Render() cur := s.Cursor() DrawCenterCursor(scr, area, view, cur) From cb8ddcb99c013dabc7de902e5b8ae74ba16e0abc Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 14:38:23 -0500 Subject: [PATCH 5/6] fix(ui): filepicker: remove redundant Init method and Action type --- internal/ui/dialog/filepicker.go | 9 ++------- internal/ui/model/ui.go | 7 +++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index a099cd2ff9fe6f707ac1e18c8470dba55794889c..b7e7e49735d41136c4c9b9d54b8d8c4fa36a90ae 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -49,7 +49,7 @@ type FilePicker struct { var _ Dialog = (*FilePicker)(nil) // NewFilePicker creates a new [FilePicker] dialog. -func NewFilePicker(com *common.Common) (*FilePicker, Action) { +func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) { f := new(FilePicker) f.com = com @@ -98,7 +98,7 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) { f.fp = fp - return f, ActionCmd{f.fp.Init()} + return f, f.fp.Init() } // SetImageCapabilities sets the image capabilities for the [FilePicker]. @@ -156,11 +156,6 @@ func (f *FilePicker) ID() string { return FilePickerID } -// Init implements the [Dialog] interface. -func (f *FilePicker) Init() tea.Cmd { - return f.fp.Init() -} - // HandleMsg updates the [FilePicker] dialog based on the given message. func (f *FilePicker) HandleMsg(msg tea.Msg) Action { var cmds []tea.Cmd diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f40a25204ce167029ee09dc1dbd0fcf012c2a94e..98f0cf7feb875597c04edd5518630736e3756ff2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2345,13 +2345,12 @@ func (m *UI) openFilesDialog() tea.Cmd { return nil } - filePicker, action := dialog.NewFilePicker(m.com) + filePicker, cmd := dialog.NewFilePicker(m.com) filePicker.SetImageCapabilities(&m.imgCaps) m.dialog.OpenDialog(filePicker) - switch action := action.(type) { - case dialog.ActionCmd: - return action.Cmd + if cmd != nil { + return cmd } return nil From f97faa180bd3274539158fe14dbc6457af579af5 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 15:14:35 -0500 Subject: [PATCH 6/6] fix(ui): filepicker: simplify cmd return Co-authored-by: Andrey Nering --- internal/ui/model/ui.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 98f0cf7feb875597c04edd5518630736e3756ff2..581152f70ca9e2998bac8d5a593b3dd622efeea4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2349,11 +2349,7 @@ func (m *UI) openFilesDialog() tea.Cmd { filePicker.SetImageCapabilities(&m.imgCaps) m.dialog.OpenDialog(filePicker) - if cmd != nil { - return cmd - } - - return nil + return cmd } // openPermissionsDialog opens the permissions dialog for a permission request.