on main help sign in
feat(ui): filepicker: add image attachment support with preview
77ecb39Ayman Bagabas
@@ -34,6 +34,7 @@ require (
34 github.com/charmbracelet/x/term v0.2.2
34 github.com/charmbracelet/x/term v0.2.2
35 github.com/denisbrodbeck/machineid v1.0.1
35 github.com/denisbrodbeck/machineid v1.0.1
36 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
36 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
37+ github.com/disintegration/imaging v1.6.2
37 github.com/dustin/go-humanize v1.0.1
38 github.com/dustin/go-humanize v1.0.1
38 github.com/google/uuid v1.6.0
39 github.com/google/uuid v1.6.0
39 github.com/invopop/jsonschema v0.13.0
40 github.com/invopop/jsonschema v0.13.0
go.sum+3
@@ -148,6 +148,8 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
148github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
148github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
149github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
149github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
150github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
150github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
151+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
152+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
151github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
153github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
152github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
154github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
153github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
155github.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=
391golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
393golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
392golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
394golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
393golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
395golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
396+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
394golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
397golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
395golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
398golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
396golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
399golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
internal/ui/common/common.go+23
@@ -1,7 +1,9 @@
1package common
1package common
2
2
3import (
3import (
4+ "fmt"
4 "image"
5 "image"
6+ "os"
5
7
6 "github.com/charmbracelet/crush/internal/app"
8 "github.com/charmbracelet/crush/internal/app"
7 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
@@ -9,6 +11,12 @@ import (
9 uv "github.com/charmbracelet/ultraviolet"
11 uv "github.com/charmbracelet/ultraviolet"
10)
12)
11
13
14+// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
15+const MaxAttachmentSize = int64(5 * 1024 * 1024)
16+
17+// AllowedImageTypes defines the permitted image file types.
18+var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
19+
12// Common defines common UI options and configurations.
20// Common defines common UI options and configurations.
13type Common struct {
21type Common struct {
14 App *app.App
22 App *app.App
@@ -40,3 +48,18 @@ func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
40 maxY := minY + height
48 maxY := minY + height
41 return image.Rect(minX, minY, maxX, maxY)
49 return image.Rect(minX, minY, maxX, maxY)
42}
50}
51+
52+// IsFileTooBig checks if the file at the given path exceeds the specified size
53+// limit.
54+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
55+ fileInfo, err := os.Stat(filePath)
56+ if err != nil {
57+ return false, fmt.Errorf("error getting file info: %w", err)
58+ }
59+
60+ if fileInfo.Size() > sizeLimit {
61+ return true, nil
62+ }
63+
64+ return false, nil
65+}
internal/ui/dialog/actions.go+57
@@ -1,11 +1,19 @@
1package dialog
1package dialog
2
2
3import (
3import (
4+ "fmt"
5+ "net/http"
6+ "os"
7+ "path/filepath"
8+
4 tea "charm.land/bubbletea/v2"
9 tea "charm.land/bubbletea/v2"
5 "github.com/charmbracelet/catwalk/pkg/catwalk"
10 "github.com/charmbracelet/catwalk/pkg/catwalk"
6 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/message"
7 "github.com/charmbracelet/crush/internal/permission"
13 "github.com/charmbracelet/crush/internal/permission"
8 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/session"
15+ "github.com/charmbracelet/crush/internal/ui/common"
16+ "github.com/charmbracelet/crush/internal/uiutil"
9)
17)
10
18
11// ActionClose is a message to close the current dialog.
19// ActionClose is a message to close the current dialog.
@@ -60,3 +68,52 @@ type (
60type ActionCmd struct {
68type ActionCmd struct {
61 Cmd tea.Cmd
69 Cmd tea.Cmd
62}
70}
71+
72+// ActionFilePickerSelected is a message indicating a file has been selected in
73+// the file picker dialog.
74+type ActionFilePickerSelected struct {
75+ Path string
76+}
77+
78+// Cmd returns a command that reads the file at path and sends a
79+// [message.Attachement] to the program.
80+func (a ActionFilePickerSelected) Cmd() tea.Cmd {
81+ path := a.Path
82+ if path == "" {
83+ return nil
84+ }
85+ return func() tea.Msg {
86+ isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
87+ if err != nil {
88+ return uiutil.InfoMsg{
89+ Type: uiutil.InfoTypeError,
90+ Msg: fmt.Sprintf("unable to read the image: %v", err),
91+ }
92+ }
93+ if isFileLarge {
94+ return uiutil.InfoMsg{
95+ Type: uiutil.InfoTypeError,
96+ Msg: "file too large, max 5MB",
97+ }
98+ }
99+
100+ content, err := os.ReadFile(path)
101+ if err != nil {
102+ return uiutil.InfoMsg{
103+ Type: uiutil.InfoTypeError,
104+ Msg: fmt.Sprintf("unable to read the image: %v", err),
105+ }
106+ }
107+
108+ mimeBufferSize := min(512, len(content))
109+ mimeType := http.DetectContentType(content[:mimeBufferSize])
110+ fileName := filepath.Base(path)
111+
112+ return message.Attachment{
113+ FilePath: path,
114+ FileName: fileName,
115+ MimeType: mimeType,
116+ Content: content,
117+ }
118+ }
119+}
internal/ui/dialog/common.go+76−11
@@ -4,6 +4,7 @@ import (
4 "strings"
4 "strings"
5
5
6 tea "charm.land/bubbletea/v2"
6 tea "charm.land/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/ui/common"
7 "github.com/charmbracelet/crush/internal/ui/styles"
8 "github.com/charmbracelet/crush/internal/ui/styles"
8)
9)
9
10
@@ -34,32 +35,96 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
34 return cur
35 return cur
35}
36}
36
37
38+// RenderContext is a dialog rendering context that can be used to render
39+// common dialog layouts.
40+type RenderContext struct {
41+ // Styles is the styles to use for rendering.
42+ Styles *styles.Styles
43+ // Width is the total width of the dialog including any margins, borders,
44+ // and paddings.
45+ Width int
46+ // Title is the title of the dialog. This will be styled using the default
47+ // dialog title style and prepended to the content parts slice.
48+ Title string
49+ // Parts are the rendered parts of the dialog.
50+ Parts []string
51+ // Help is the help view content. This will be appended to the content parts
52+ // slice using the default dialog help style.
53+ Help string
54+}
55+
56+// NewRenderContext creates a new RenderContext with the provided styles and width.
57+func NewRenderContext(t *styles.Styles, width int) *RenderContext {
58+ return &RenderContext{
59+ Styles: t,
60+ Width: width,
61+ Parts: []string{},
62+ }
63+}
64+
65+// AddPart adds a rendered part to the dialog.
66+func (rc *RenderContext) AddPart(part string) {
67+ if len(part) > 0 {
68+ rc.Parts = append(rc.Parts, part)
69+ }
70+}
71+
72+// Render renders the dialog using the provided context.
73+func (rc *RenderContext) Render() string {
74+ titleStyle := rc.Styles.Dialog.Title
75+ dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
76+
77+ parts := []string{}
78+ if len(rc.Title) > 0 {
79+ title := common.DialogTitle(rc.Styles, rc.Title,
80+ max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
81+ titleStyle.GetHorizontalFrameSize()))
82+ parts = append(parts, titleStyle.Render(title), "")
83+ }
84+
85+ for i, p := range rc.Parts {
86+ if len(p) > 0 {
87+ parts = append(parts, p)
88+ }
89+ if i < len(rc.Parts)-1 {
90+ parts = append(parts, "")
91+ }
92+ }
93+
94+ if len(rc.Help) > 0 {
95+ parts = append(parts, "")
96+ helpStyle := rc.Styles.Dialog.HelpView
97+ helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
98+ parts = append(parts, helpStyle.Render(rc.Help))
99+ }
100+
101+ content := strings.Join(parts, "\n")
102+
103+ return dialogStyle.Render(content)
104+}
105+
37// HeaderInputListHelpView generates a view for dialogs with a header, input,
106// HeaderInputListHelpView generates a view for dialogs with a header, input,
38// list, and help sections.
107// list, and help sections.
39func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
108func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
109+ rc := NewRenderContext(t, width)
110+
40 titleStyle := t.Dialog.Title
111 titleStyle := t.Dialog.Title
41− helpStyle := t.Dialog.HelpView
42− dialogStyle := t.Dialog.View.Width(width)
43 inputStyle := t.Dialog.InputPrompt
112 inputStyle := t.Dialog.InputPrompt
44− helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize())
45 listStyle := t.Dialog.List.Height(listHeight)
113 listStyle := t.Dialog.List.Height(listHeight)
46 listContent := listStyle.Render(list)
114 listContent := listStyle.Render(list)
47
115
48− parts := []string{}
49 if len(header) > 0 {
116 if len(header) > 0 {
50− parts = append(parts, titleStyle.Render(header))
117+ rc.AddPart(titleStyle.Render(header))
51 }
118 }
52 if len(input) > 0 {
119 if len(input) > 0 {
53− parts = append(parts, inputStyle.Render(input))
120+ rc.AddPart(inputStyle.Render(input))
54 }
121 }
55 if len(list) > 0 {
122 if len(list) > 0 {
56− parts = append(parts, listContent)
123+ rc.AddPart(listContent)
57 }
124 }
58 if len(help) > 0 {
125 if len(help) > 0 {
59− parts = append(parts, helpStyle.Render(help))
126+ rc.Help = help
60 }
127 }
61
128
62− content := strings.Join(parts, "\n")
63−
64− return dialogStyle.Render(content)
129+ return rc.Render()
65}
130}
internal/ui/dialog/filepicker.go+84−73
@@ -1,11 +1,10 @@
1package dialog
1package dialog
2
2
3import (
3import (
4− "hash/fnv"
4+ "fmt"
5 "image"
5 "image"
6 _ "image/jpeg" // register JPEG format
6 _ "image/jpeg" // register JPEG format
7 _ "image/png" // register PNG format
7 _ "image/png" // register PNG format
8− "io"
9 "os"
8 "os"
10 "strings"
9 "strings"
11 "sync"
10 "sync"
@@ -14,17 +13,13 @@ import (
14 "charm.land/bubbles/v2/help"
13 "charm.land/bubbles/v2/help"
15 "charm.land/bubbles/v2/key"
14 "charm.land/bubbles/v2/key"
16 tea "charm.land/bubbletea/v2"
15 tea "charm.land/bubbletea/v2"
16+ "charm.land/lipgloss/v2"
17 "github.com/charmbracelet/crush/internal/home"
17 "github.com/charmbracelet/crush/internal/home"
18 "github.com/charmbracelet/crush/internal/ui/common"
18 "github.com/charmbracelet/crush/internal/ui/common"
19 fimage "github.com/charmbracelet/crush/internal/ui/image"
19 fimage "github.com/charmbracelet/crush/internal/ui/image"
20 uv "github.com/charmbracelet/ultraviolet"
20 uv "github.com/charmbracelet/ultraviolet"
21)
21)
22
22
23−var (
24− transmittedImages = map[uint64]struct{}{}
25− transmittedMutex sync.RWMutex
26−)
27−
28// FilePickerID is the identifier for the FilePicker dialog.
23// FilePickerID is the identifier for the FilePicker dialog.
29const FilePickerID = "filepicker"
24const FilePickerID = "filepicker"
30
25
@@ -32,11 +27,10 @@ const FilePickerID = "filepicker"
32type FilePicker struct {
27type FilePicker struct {
33 com *common.Common
28 com *common.Common
34
29
35− width int
30+ imgEnc fimage.Encoding
36 imgPrevWidth, imgPrevHeight int
31 imgPrevWidth, imgPrevHeight int
37− imageCaps *fimage.Capabilities
32+ cellSize fimage.CellSize
38
33
39− img *fimage.Image
40 fp filepicker.Model
34 fp filepicker.Model
41 help help.Model
35 help help.Model
42 previewingImage bool // indicates if an image is being previewed
36 previewingImage bool // indicates if an image is being previewed
@@ -94,7 +88,7 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) {
94 )
88 )
95
89
96 fp := filepicker.New()
90 fp := filepicker.New()
97− fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
91+ fp.AllowedTypes = common.AllowedImageTypes
98 fp.ShowPermissions = false
92 fp.ShowPermissions = false
99 fp.ShowSize = false
93 fp.ShowSize = false
100 fp.AutoHeight = false
94 fp.AutoHeight = false
@@ -109,7 +103,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, Action) {
109
103
110// SetImageCapabilities sets the image capabilities for the [FilePicker].
104// SetImageCapabilities sets the image capabilities for the [FilePicker].
111func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
105func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
112− f.imageCaps = caps
106+ if caps != nil {
107+ if caps.SupportsKittyGraphics {
108+ f.imgEnc = fimage.EncodingKitty
109+ }
110+ f.cellSize = caps.CellSize()
111+ }
113}
112}
114
113
115// WorkingDir returns the current working directory of the [FilePicker].
114// WorkingDir returns the current working directory of the [FilePicker].
@@ -127,22 +126,6 @@ func (f *FilePicker) WorkingDir() string {
127 return cwd
126 return cwd
128}
127}
129
128
130−// SetWindowSize sets the desired size of the [FilePicker] dialog window.
131−func (f *FilePicker) SetWindowSize(width, height int) {
132− f.width = width
133− f.imgPrevWidth = width/2 - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
134− // Use square preview for simplicity same size as width
135− f.imgPrevHeight = width/2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
136− f.fp.SetHeight(height)
137− innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
138− styles := f.com.Styles.FilePicker
139− styles.File = styles.File.Width(innerWidth)
140− styles.Directory = styles.Directory.Width(innerWidth)
141− styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
142− styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
143− f.fp.Styles = styles
144−}
145−
146// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
129// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
147func (f *FilePicker) ShortHelp() []key.Binding {
130func (f *FilePicker) ShortHelp() []key.Binding {
148 return []key.Binding{
131 return []key.Binding{
@@ -201,79 +184,107 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
201 }
184 }
202
185
203 f.previewingImage = allowed
186 f.previewingImage = allowed
204− if allowed {
205− id := uniquePathID(selFile)
206−
207− transmittedMutex.RLock()
208− _, transmitted := transmittedImages[id]
209− transmittedMutex.RUnlock()
210− if !transmitted {
211− img, err := loadImage(selFile)
212− if err != nil {
213− f.previewingImage = false
214− }
215−
216− timg, err := fimage.New(selFile, img, f.imgPrevWidth, f.imgPrevHeight)
187+ if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
217− if err != nil {
188+ img, err := loadImage(selFile)
218− f.previewingImage = false
219− }
220−
221− f.img = timg
222− if err == nil {
189+ if err != nil {
223− cmds = append(cmds, f.img.Transmit())
190+ f.previewingImage = false
224− transmittedMutex.Lock()
225− transmittedImages[id] = struct{}{}
226− transmittedMutex.Unlock()
227− }
228 }
191 }
192+
193+ cmds = append(cmds, f.imgEnc.Transmit(
194+ selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight))
195+ f.previewingImage = true
229 }
196 }
230 }
197 }
231 if cmd != nil {
198 if cmd != nil {
232 cmds = append(cmds, cmd)
199 cmds = append(cmds, cmd)
233 }
200 }
234
201
202+ if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
203+ return ActionFilePickerSelected{Path: path}
204+ }
205+
235 return ActionCmd{tea.Batch(cmds...)}
206 return ActionCmd{tea.Batch(cmds...)}
236}
207}
237
208
209+const (
210+ filePickerMinWidth = 70
211+ filePickerMinHeight = 10
212+)
213+
238// Draw renders the [FilePicker] dialog as a string.
214// Draw renders the [FilePicker] dialog as a string.
239func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
215func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
216+ width := max(0, min(filePickerMinWidth, area.Dx()))
217+ height := max(0, min(10, area.Dy()))
218+ innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
219+ imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
220+ imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
221+ f.imgPrevWidth = imgPrevWidth
222+ f.imgPrevHeight = imgPrevHeight
223+ f.fp.SetHeight(height)
224+
225+ styles := f.com.Styles.FilePicker
226+ styles.File = styles.File.Width(innerWidth)
227+ styles.Directory = styles.Directory.Width(innerWidth)
228+ styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
229+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
230+ f.fp.Styles = styles
231+
240 t := f.com.Styles
232 t := f.com.Styles
241− titleStyle := f.com.Styles.Dialog.Title
233+ rc := NewRenderContext(t, width)
242− dialogStyle := f.com.Styles.Dialog.View
234+ rc.Title = "Add Image"
243− header := common.DialogTitle(t, "Add Image",
235+ rc.Help = f.help.View(f)
244− max(0, f.width-dialogStyle.GetHorizontalFrameSize()-
236+
245− titleStyle.GetHorizontalFrameSize()))
237+ imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
238+ rc.AddPart(imgPreview)
239+
246 files := strings.TrimSpace(f.fp.View())
240 files := strings.TrimSpace(f.fp.View())
247− filesHeight := f.fp.Height()
241+ rc.AddPart(files)
248− imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview())
242+
249− view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f))
243+ view := rc.Render()
244+
250 DrawCenter(scr, area, view)
245 DrawCenter(scr, area, view)
251 return nil
246 return nil
252}
247}
253
248
249+var (
250+ imagePreviewCache = map[string]string{}
251+ imagePreviewMutex sync.RWMutex
252+)
253+
254// imagePreview returns the image preview section of the [FilePicker] dialog.
254// imagePreview returns the image preview section of the [FilePicker] dialog.
255−func (f *FilePicker) imagePreview() string {
255+func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
256− if !f.previewingImage || f.img == nil {
256+ if !f.previewingImage {
257− // TODO: Cache this?
257+ key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
258+ imagePreviewMutex.RLock()
259+ cached, ok := imagePreviewCache[key]
260+ imagePreviewMutex.RUnlock()
261+ if ok {
262+ return cached
263+ }
264+
258 var sb strings.Builder
265 var sb strings.Builder
259− for y := 0; y < f.imgPrevHeight; y++ {
266+ for y := range imgPrevHeight {
260− for x := 0; x < f.imgPrevWidth; x++ {
267+ for range imgPrevWidth {
261− sb.WriteRune('╱')
268+ sb.WriteRune('█')
262 }
269 }
263− if y < f.imgPrevHeight-1 {
270+ if y < imgPrevHeight-1 {
264 sb.WriteRune('\n')
271 sb.WriteRune('\n')
265 }
272 }
266 }
273 }
274+
275+ imagePreviewMutex.Lock()
276+ imagePreviewCache[key] = sb.String()
277+ imagePreviewMutex.Unlock()
278+
267 return sb.String()
279 return sb.String()
268 }
280 }
269
281
282+ if id := f.fp.HighlightedPath(); id != "" {
283+ r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
270− return f.img.Render()
284+ return r
271−}
285+ }
272
286
273−func uniquePathID(path string) uint64 {
274− h := fnv.New64a()
275− _, _ = io.WriteString(h, path)
276− return h.Sum64()
287+ return ""
277}
288}
278
289
279func loadImage(path string) (img image.Image, err error) {
290func loadImage(path string) (img image.Image, err error) {
internal/ui/image/image.go+184−64
@@ -8,31 +8,67 @@ import (
8 "image/color"
8 "image/color"
9 "io"
9 "io"
10 "log/slog"
10 "log/slog"
11+ "strings"
12+ "sync"
11
13
12 tea "charm.land/bubbletea/v2"
14 tea "charm.land/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/uiutil"
15 "github.com/charmbracelet/crush/internal/uiutil"
14 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/ansi"
15 "github.com/charmbracelet/x/ansi/kitty"
17 "github.com/charmbracelet/x/ansi/kitty"
16 "github.com/charmbracelet/x/mosaic"
18 "github.com/charmbracelet/x/mosaic"
19+ "github.com/disintegration/imaging"
17)
20)
18
21
19// Capabilities represents the capabilities of displaying images on the
22// Capabilities represents the capabilities of displaying images on the
20// terminal.
23// terminal.
21type Capabilities struct {
24type Capabilities struct {
25+ // Columns is the number of character columns in the terminal.
26+ Columns int
27+ // Rows is the number of character rows in the terminal.
28+ Rows int
29+ // PixelWidth is the width of the terminal in pixels.
30+ PixelWidth int
31+ // PixelHeight is the height of the terminal in pixels.
32+ PixelHeight int
22 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
33 // SupportsKittyGraphics indicates whether the terminal supports the Kitty
23 // graphics protocol.
34 // graphics protocol.
24 SupportsKittyGraphics bool
35 SupportsKittyGraphics bool
25}
36}
26
37
38+// CellSize returns the size of a single terminal cell in pixels.
39+func (c Capabilities) CellSize() CellSize {
40+ return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
41+}
42+
43+// CalculateCellSize calculates the size of a single terminal cell in pixels
44+// based on the terminal's pixel dimensions and character dimensions.
45+func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
46+ if charWidth == 0 || charHeight == 0 {
47+ return CellSize{}
48+ }
49+
50+ return CellSize{
51+ Width: pixelWidth / charWidth,
52+ Height: pixelHeight / charHeight,
53+ }
54+}
55+
27// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
56// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
28// its image related capabilities to the program.
57// its image related capabilities to the program.
29func RequestCapabilities() tea.Cmd {
58func RequestCapabilities() tea.Cmd {
30 return tea.Raw(
59 return tea.Raw(
60+ ansi.WindowOp(14) + // Window size in pixels
31− // ID 31 is just a random ID used to detect Kitty graphics support.
61+ // ID 31 is just a random ID used to detect Kitty graphics support.
32− ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
62+ ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"),
33 )
63 )
34}
64}
35
65
66+// TransmittedMsg is a message indicating that an image has been transmitted to
67+// the terminal.
68+type TransmittedMsg struct {
69+ ID string
70+}
71+
36// Encoding represents the encoding format of the image.
72// Encoding represents the encoding format of the image.
37type Encoding byte
73type Encoding byte
38
74
@@ -42,87 +78,171 @@ const (
42 EncodingKitty
78 EncodingKitty
43)
79)
44
80
45−// Image represents an image that can be displayed on the terminal.
81+type imageKey struct {
82+ id string
83+ cols int
84+ rows int
85+}
86+
87+// Hash returns a hash value for the image key.
88+// This uses FNV-32a for simplicity and speed.
89+func (k imageKey) Hash() uint32 {
90+ h := fnv.New32a()
91+ _, _ = io.WriteString(h, k.ID())
92+ return h.Sum32()
93+}
94+
95+// ID returns a unique string representation of the image key.
96+func (k imageKey) ID() string {
97+ return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
98+}
99+
100+// CellSize represents the size of a single terminal cell in pixels.
46−type Image struct {
101+type CellSize struct {
47− id int
102+ Width, Height int
103+}
104+
105+type cachedImage struct {
48 img image.Image
106 img image.Image
49− cols, rows int // in terminal cells
107+ cols, rows int
50− enc Encoding
51}
108}
52
109
53−// New creates a new [Image] instance with the given unique id, image, and
110+var (
111+ cachedImages = map[imageKey]cachedImage{}
112+ cachedMutex sync.RWMutex
113+)
114+
115+// fitImage resizes the image to fit within the specified dimensions in
54−// dimensions in terminal cells.
116+// terminal cells, maintaining the aspect ratio.
55−func New(id string, img image.Image, cols, rows int) (*Image, error) {
117+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
56− i := new(Image)
118+ if img == nil {
57− h := fnv.New64a()
119+ return nil
58− if _, err := io.WriteString(h, id); err != nil {
120+ }
121+
122+ key := imageKey{id: id, cols: cols, rows: rows}
123+
124+ cachedMutex.RLock()
125+ cached, ok := cachedImages[key]
126+ cachedMutex.RUnlock()
127+ if ok {
59− return nil, err
128+ return cached.img
60 }
129 }
61− i.id = int(h.Sum64())
130+
62− i.img = img
131+ if cs.Width == 0 || cs.Height == 0 {
132+ return img
133+ }
134+
135+ maxWidth := cols * cs.Width
136+ maxHeight := rows * cs.Height
137+
138+ img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
139+
140+ cachedMutex.Lock()
141+ cachedImages[key] = cachedImage{
142+ img: img,
63− i.cols = cols
143+ cols: cols,
64− i.rows = rows
144+ rows: rows,
145+ }
146+ cachedMutex.Unlock()
147+
65− return i, nil
148+ return img
66}
149}
67
150
68−// SetEncoding sets the encoding format for the image.
151+// HasTransmitted checks if the image with the given ID has already been
69−func (i *Image) SetEncoding(enc Encoding) {
152+// transmitted to the terminal.
70− i.enc = enc
153+func HasTransmitted(id string, cols, rows int) bool {
154+ key := imageKey{id: id, cols: cols, rows: rows}
155+
156+ cachedMutex.RLock()
157+ _, ok := cachedImages[key]
158+ cachedMutex.RUnlock()
159+ return ok
71}
160}
72
161
73−// Transmit returns a [tea.Cmd] that sends the image data to the terminal.
162+// Transmit transmits the image data to the terminal if needed. This is used to
74−// This is needed for the [EncodingKitty] protocol so that the terminal can
75−// cache the image for later rendering.
163+// cache the image on the terminal for later rendering.
76−//
164+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int) tea.Cmd {
77−// This should only happen once per image.
165+ if img == nil {
78−func (i *Image) Transmit() tea.Cmd {
79− if i.enc != EncodingKitty {
80 return nil
166 return nil
81 }
167 }
82
168
83− var buf bytes.Buffer
169+ key := imageKey{id: id, cols: cols, rows: rows}
84− bounds := i.img.Bounds()
170+
85− imgWidth := bounds.Dx()
171+ cachedMutex.RLock()
86− imgHeight := bounds.Dy()
172+ _, ok := cachedImages[key]
87−
173+ cachedMutex.RUnlock()
88− // RGBA is 4 bytes per pixel
174+ if ok {
89− imgSize := imgWidth * imgHeight * 4
90−
91− if err := kitty.EncodeGraphics(&buf, i.img, &kitty.Options{
92− ID: i.id,
93− Action: kitty.TransmitAndPut,
94− Transmission: kitty.Direct,
95− Format: kitty.RGBA,
96− Size: imgSize,
97− Width: imgWidth,
98− Height: imgHeight,
99− Columns: i.cols,
100− Rows: i.rows,
101− VirtualPlacement: true,
102− Quite: 2,
103− }); err != nil {
175+ return nil
104− slog.Error("failed to encode image for kitty graphics", "err", err)
105− return uiutil.ReportError(fmt.Errorf("failed to encode image"))
106 }
176 }
107
177
108− return tea.Raw(buf.String())
178+ cmd := func() tea.Msg {
179+ if e != EncodingKitty {
180+ cachedMutex.Lock()
181+ cachedImages[key] = cachedImage{
182+ img: img,
183+ cols: cols,
184+ rows: rows,
185+ }
186+ cachedMutex.Unlock()
187+ return TransmittedMsg{ID: key.ID()}
188+ }
189+
190+ var buf bytes.Buffer
191+ img := fitImage(id, img, cs, cols, rows)
192+ bounds := img.Bounds()
193+ imgWidth := bounds.Dx()
194+ imgHeight := bounds.Dy()
195+
196+ imgID := int(key.Hash())
197+ if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
198+ ID: imgID,
199+ Action: kitty.TransmitAndPut,
200+ Transmission: kitty.Direct,
201+ Format: kitty.RGBA,
202+ ImageWidth: imgWidth,
203+ ImageHeight: imgHeight,
204+ Columns: cols,
205+ Rows: rows,
206+ VirtualPlacement: true,
207+ Quite: 1,
208+ }); err != nil {
209+ slog.Error("failed to encode image for kitty graphics", "err", err)
210+ return uiutil.ReportError(fmt.Errorf("failed to encode image"))
211+ }
212+
213+ return tea.RawMsg{Msg: buf.String()}
214+ }
215+
216+ return cmd
109}
217}
110
218
111−// Render renders the image to a string that can be displayed on the terminal.
219+// Render renders the given image within the specified dimensions using the
220+// specified encoding.
112−func (i *Image) Render() string {
221+func (e Encoding) Render(id string, cols, rows int) string {
113− // Check cache first
222+ key := imageKey{id: id, cols: cols, rows: rows}
223+ cachedMutex.RLock()
224+ cached, ok := cachedImages[key]
225+ cachedMutex.RUnlock()
226+ if !ok {
227+ return ""
228+ }
229+
230+ img := cached.img
231+
114− switch i.enc {
232+ switch e {
115 case EncodingBlocks:
233 case EncodingBlocks:
116− m := mosaic.New().Width(i.cols).Height(i.rows).Scale(2)
234+ m := mosaic.New().Width(cols).Height(rows).Scale(1)
117− return m.Render(i.img)
235+ return strings.TrimSpace(m.Render(img))
118 case EncodingKitty:
236 case EncodingKitty:
119 // Build Kitty graphics unicode place holders
237 // Build Kitty graphics unicode place holders
120 var fg color.Color
238 var fg color.Color
121 var extra int
239 var extra int
122 var r, g, b int
240 var r, g, b int
241+ hashedID := key.Hash()
242+ id := int(hashedID)
123− extra, r, g, b = i.id>>24&0xff, i.id>>16&0xff, i.id>>8&0xff, i.id&0xff
243+ extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
124
244
125− if r == 0 && g == 0 {
245+ if id <= 255 {
126 fg = ansi.IndexedColor(b)
246 fg = ansi.IndexedColor(b)
127 } else {
247 } else {
128 fg = color.RGBA{
248 fg = color.RGBA{
@@ -136,7 +256,7 @@ func (i *Image) Render() string {
136 fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
256 fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
137
257
138 var buf bytes.Buffer
258 var buf bytes.Buffer
139− for y := 0; y < i.rows; y++ {
259+ for y := range rows {
140 // As an optimization, we only write the fg color sequence id, and
260 // As an optimization, we only write the fg color sequence id, and
141 // column-row data once on the first cell. The terminal will handle
261 // column-row data once on the first cell. The terminal will handle
142 // the rest.
262 // the rest.
@@ -147,11 +267,11 @@ func (i *Image) Render() string {
147 if extra > 0 {
267 if extra > 0 {
148 buf.WriteRune(kitty.Diacritic(extra))
268 buf.WriteRune(kitty.Diacritic(extra))
149 }
269 }
150− for x := 1; x < i.cols; x++ {
270+ for x := 1; x < cols; x++ {
151 buf.WriteString(fgStyle)
271 buf.WriteString(fgStyle)
152 buf.WriteRune(kitty.Placeholder)
272 buf.WriteRune(kitty.Placeholder)
153 }
273 }
154− if y < i.rows-1 {
274+ if y < rows-1 {
155 buf.WriteByte('\n')
275 buf.WriteByte('\n')
156 }
276 }
157 }
277 }
internal/ui/model/ui.go+24−15
@@ -46,12 +46,6 @@ import (
46 "github.com/charmbracelet/x/editor"
46 "github.com/charmbracelet/x/editor"
47)
47)
48
48
49−// Max file size set to 5M.
50−const maxAttachmentSize = int64(5 * 1024 * 1024)
51−
52−// Allowed image formats.
53−var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
54−
55// uiFocusState represents the current focus state of the UI.
49// uiFocusState represents the current focus state of the UI.
56type uiFocusState uint8
50type uiFocusState uint8
57
51
@@ -146,8 +140,8 @@ type UI struct {
146 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
140 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
147 sidebarLogo string
141 sidebarLogo string
148
142
149− // imageCaps stores the terminal image capabilities.
143+ // imgCaps stores the terminal image capabilities.
150− imageCaps timage.Capabilities
144+ imgCaps timage.Capabilities
151}
145}
152
146
153// New creates a new instance of the [UI] model.
147// New creates a new instance of the [UI] model.
@@ -314,6 +308,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
314 case tea.WindowSizeMsg:
308 case tea.WindowSizeMsg:
315 m.width, m.height = msg.Width, msg.Height
309 m.width, m.height = msg.Width, msg.Height
316 m.updateLayoutAndSize()
310 m.updateLayoutAndSize()
311+ // XXX: We need to store cell dimensions for image rendering.
312+ m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
317 case tea.KeyboardEnhancementsMsg:
313 case tea.KeyboardEnhancementsMsg:
318 m.keyenh = msg
314 m.keyenh = msg
319 if msg.SupportsKeyDisambiguation() {
315 if msg.SupportsKeyDisambiguation() {
@@ -435,11 +431,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
435 if m.completionsOpen {
431 if m.completionsOpen {
436 m.completions.SetFiles(msg.Files)
432 m.completions.SetFiles(msg.Files)
437 }
433 }
434+ case uv.WindowPixelSizeEvent:
435+ // [timage.RequestCapabilities] requests the terminal to send a window
436+ // size event to help determine pixel dimensions.
437+ m.imgCaps.PixelWidth = msg.Width
438+ m.imgCaps.PixelHeight = msg.Height
438 case uv.KittyGraphicsEvent:
439 case uv.KittyGraphicsEvent:
439 // [timage.RequestCapabilities] sends a Kitty graphics query and this
440 // [timage.RequestCapabilities] sends a Kitty graphics query and this
440 // captures the response. Any response means the terminal understands
441 // captures the response. Any response means the terminal understands
441 // the protocol.
442 // the protocol.
442− m.imageCaps.SupportsKittyGraphics = true
443+ m.imgCaps.SupportsKittyGraphics = true
443 default:
444 default:
444 if m.dialog.HasDialogs() {
445 if m.dialog.HasDialogs() {
445 if cmd := m.handleDialogMsg(msg); cmd != nil {
446 if cmd := m.handleDialogMsg(msg); cmd != nil {
@@ -833,6 +834,16 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
833 case dialog.PermissionDeny:
834 case dialog.PermissionDeny:
834 m.com.App.Permissions.Deny(msg.Permission)
835 m.com.App.Permissions.Deny(msg.Permission)
835 }
836 }
837+
838+ case dialog.ActionFilePickerSelected:
839+ cmds = append(cmds, tea.Sequence(
840+ msg.Cmd(),
841+ func() tea.Msg {
842+ m.dialog.CloseDialog(dialog.FilePickerID)
843+ return nil
844+ },
845+ ))
846+
836 default:
847 default:
837 cmds = append(cmds, uiutil.CmdHandler(msg))
848 cmds = append(cmds, uiutil.CmdHandler(msg))
838 }
849 }
@@ -2062,10 +2073,8 @@ func (m *UI) openFilesDialog() tea.Cmd {
2062 return nil
2073 return nil
2063 }
2074 }
2064
2075
2065− const desiredFilePickerHeight = 10
2066 filePicker, action := dialog.NewFilePicker(m.com)
2076 filePicker, action := dialog.NewFilePicker(m.com)
2067− filePicker.SetWindowSize(min(80, m.width-8), desiredFilePickerHeight)
2068− filePicker.SetImageCapabilities(&m.imageCaps)
2077+ filePicker.SetImageCapabilities(&m.imgCaps)
2069 m.dialog.OpenDialog(filePicker)
2078 m.dialog.OpenDialog(filePicker)
2070
2079
2071 switch action := action.(type) {
2080 switch action := action.(type) {
@@ -2138,7 +2147,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2138 if strings.Count(msg.Content, "\n") > 2 {
2147 if strings.Count(msg.Content, "\n") > 2 {
2139 return func() tea.Msg {
2148 return func() tea.Msg {
2140 content := []byte(msg.Content)
2149 content := []byte(msg.Content)
2141− if int64(len(content)) > maxAttachmentSize {
2150+ if int64(len(content)) > common.MaxAttachmentSize {
2142 return uiutil.ReportWarn("Paste is too big (>5mb)")
2151 return uiutil.ReportWarn("Paste is too big (>5mb)")
2143 }
2152 }
2144 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2153 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
@@ -2165,7 +2174,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2165 // Check if file has an allowed image extension.
2174 // Check if file has an allowed image extension.
2166 isAllowedType := false
2175 isAllowedType := false
2167 lowerPath := strings.ToLower(path)
2176 lowerPath := strings.ToLower(path)
2168− for _, ext := range allowedImageTypes {
2177+ for _, ext := range common.AllowedImageTypes {
2169 if strings.HasSuffix(lowerPath, ext) {
2178 if strings.HasSuffix(lowerPath, ext) {
2170 isAllowedType = true
2179 isAllowedType = true
2171 break
2180 break
@@ -2181,7 +2190,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2181 if err != nil {
2190 if err != nil {
2182 return uiutil.ReportError(err)
2191 return uiutil.ReportError(err)
2183 }
2192 }
2184− if fileInfo.Size() > maxAttachmentSize {
2193+ if fileInfo.Size() > common.MaxAttachmentSize {
2185 return uiutil.ReportWarn("File is too big (>5mb)")
2194 return uiutil.ReportWarn("File is too big (>5mb)")
2186 }
2195 }
2187
2196
internal/ui/styles/styles.go+1−1
@@ -1188,7 +1188,7 @@ func DefaultStyles() Styles {
1188 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1188 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1189 s.Dialog.ScrollbarTrack = base.Foreground(border)
1189 s.Dialog.ScrollbarTrack = base.Foreground(border)
1190
1190
1191− s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(1)
1191+ s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
1192
1192
1193 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1193 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1194 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1194 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")