diff --git a/go.mod b/go.mod index 0ab5bd264dfdbab157a0fe38ba5308d84a4521a2..0b253c7052b9813f10b721d763ade79aba356624 100644 --- a/go.mod +++ b/go.mod @@ -30,10 +30,12 @@ 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-20260113142046-c1fa3de7983b 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 5a2b20e02e02085bf7f8559d946bced27a20cc27..6346b736a83815afe4daff8390a03b941539ad6d 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,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-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -148,6 +150,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= @@ -393,6 +397,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 1c7e9c2cdd9338cac4f28aee0d87ec7c08f5fa15..a4cfd66d695472e579fabf458ad5fc570af85b7e 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -1,13 +1,21 @@ 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/commands" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/oauth" "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. @@ -102,3 +110,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/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 48234281f304208b9e1a30c575fab342ceb5e57a..76b75064670935715f03e0d732b9df5070b9e9da 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -4,7 +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. @@ -34,23 +37,94 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { return cur } -// 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 { - 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) - - content := strings.Join([]string{ - titleStyle.Render(header), - inputStyle.Render(input), - listContent, - helpStyle.Render(help), - }, "\n") +// 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 + // 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 + // 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 { + 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()- + 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)...) + } + } + + 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 { + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } + helpStyle := rc.Styles.Dialog.HelpView + helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize()) + helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "") + parts = append(parts, helpView) + } + + 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..b7e7e49735d41136c4c9b9d54b8d8c4fa36a90ae --- /dev/null +++ b/internal/ui/dialog/filepicker.go @@ -0,0 +1,302 @@ +package dialog + +import ( + "fmt" + "image" + _ "image/jpeg" // register JPEG format + _ "image/png" // register PNG format + "os" + "strings" + "sync" + + "charm.land/bubbles/v2/filepicker" + "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" +) + +// 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 + + imgEnc fimage.Encoding + imgPrevWidth, imgPrevHeight int + cellSize fimage.CellSize + + 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, tea.Cmd) { + 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 = common.AllowedImageTypes + 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, f.fp.Init() +} + +// SetImageCapabilities sets the image capabilities for the [FilePicker]. +func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { + if caps != nil { + if caps.SupportsKittyGraphics { + f.imgEnc = fimage.EncodingKitty + } + f.cellSize = caps.CellSize() + } +} + +// 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 +} + +// 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 +} + +// 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 && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) { + f.previewingImage = false + img, err := loadImage(selFile) + 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 + }, + )) + } + } + } + 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 + rc := NewRenderContext(t, width) + rc.Gap = 1 + 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()) + 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(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 := range imgPrevHeight { + for range imgPrevWidth { + sb.WriteRune('█') + } + if y < imgPrevHeight-1 { + sb.WriteRune('\n') + } + } + + imagePreviewMutex.Lock() + imagePreviewCache[key] = sb.String() + imagePreviewMutex.Unlock() + + return sb.String() + } + + if id := f.fp.HighlightedPath(); id != "" { + r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight) + return r + } + + return "" +} + +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/dialog/models.go b/internal/ui/dialog/models.go index 2a40b8135f9041dd505672490d964257d57bc343..77aeab22380f89455f83760428fac128fd4fc28b 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) diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go new file mode 100644 index 0000000000000000000000000000000000000000..e7f51239f8fd5ebecec1f5855911fbe459b340ac --- /dev/null +++ b/internal/ui/image/image.go @@ -0,0 +1,284 @@ +package image + +import ( + "bytes" + "fmt" + "hash/fnv" + "image" + "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( + 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 + +// Image encodings. +const ( + EncodingBlocks Encoding = iota + EncodingKitty +) + +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 +} + +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 + } + + 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 +} + +// 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 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 + } + + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + _, ok := cachedImages[key] + cachedMutex.RUnlock() + if ok { + return nil + } + + 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 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(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 + hashedID := key.Hash() + id := int(hashedID) + extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff + + if id <= 255 { + 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 := 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. + 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 < cols; x++ { + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + } + if y < 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 de4b0e91fc38ec280ad9d9249ab17e88dedcf748..581152f70ca9e2998bac8d5a593b3dd622efeea4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -39,6 +39,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" @@ -48,12 +49,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"} - // Compact mode breakpoints. const ( compactModeWidthBreakpoint = 120 @@ -176,6 +171,9 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + // imgCaps stores the terminal image capabilities. + imgCaps timage.Capabilities + // custom commands & mcp commands customCommands []commands.CustomCommand mcpPrompts []commands.MCPPrompt @@ -272,6 +270,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()) } // load the user commands async cmds = append(cmds, m.loadCustomCommands()) @@ -414,6 +417,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height m.handleCompactMode(m.width, m.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() { @@ -543,6 +548,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.imgCaps.SupportsKittyGraphics = true default: if m.dialog.HasDialogs() { if cmd := m.handleDialogMsg(msg); cmd != nil { @@ -974,6 +989,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { 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 + }, + )) + case dialog.ActionRunCustomCommand: if len(msg.Arguments) > 0 && msg.Args == nil { m.dialog.CloseFrontDialog() @@ -1140,6 +1164,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 { @@ -2305,10 +2334,24 @@ 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 + } + + filePicker, cmd := dialog.NewFilePicker(m.com) + filePicker.SetImageCapabilities(&m.imgCaps) + m.dialog.OpenDialog(filePicker) + + return cmd +} + // openPermissionsDialog opens the permissions dialog for a permission request. func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd { // Close any existing permissions dialog first. @@ -2371,7 +2414,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()) @@ -2398,7 +2441,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 @@ -2414,7 +2457,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 bb39cc0a583cbaa834c4c59e139d97cc72a2de76..97711efaa7b951f13aa01a0a823ee7514919b136 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -355,6 +355,8 @@ type Styles struct { } Commands struct{} + + ImagePreview lipgloss.Style } // Status bar and help @@ -1227,6 +1229,8 @@ func DefaultStyles() Styles { s.Dialog.ScrollbarThumb = base.Foreground(secondary) s.Dialog.ScrollbarTrack = base.Foreground(border) + s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle) + s.Dialog.Arguments.Content = base.Padding(1) s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3) s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)