From 5d2c533d9c2cea817506d8a8618c0859b469174b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 9 Jan 2026 11:39:15 -0500 Subject: [PATCH] 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