1package dialog
2
3import (
4 "fmt"
5 "image"
6 _ "image/jpeg" // register JPEG format
7 _ "image/png" // register PNG format
8 "os"
9 "strings"
10 "sync"
11
12 "charm.land/bubbles/v2/filepicker"
13 "charm.land/bubbles/v2/help"
14 "charm.land/bubbles/v2/key"
15 tea "charm.land/bubbletea/v2"
16 "charm.land/lipgloss/v2"
17 "github.com/charmbracelet/crush/internal/home"
18 "github.com/charmbracelet/crush/internal/ui/common"
19 fimage "github.com/charmbracelet/crush/internal/ui/image"
20 uv "github.com/charmbracelet/ultraviolet"
21)
22
23// FilePickerID is the identifier for the FilePicker dialog.
24const FilePickerID = "filepicker"
25
26// FilePicker is a dialog that allows users to select files or directories.
27type FilePicker struct {
28 com *common.Common
29
30 imgEnc fimage.Encoding
31 imgPrevWidth, imgPrevHeight int
32 cellSize fimage.CellSize
33
34 fp filepicker.Model
35 help help.Model
36 previewingImage bool // indicates if an image is being previewed
37
38 km struct {
39 Select,
40 Down,
41 Up,
42 Forward,
43 Backward,
44 Navigate,
45 Close key.Binding
46 }
47}
48
49var _ Dialog = (*FilePicker)(nil)
50
51// NewFilePicker creates a new [FilePicker] dialog.
52func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
53 f := new(FilePicker)
54 f.com = com
55
56 help := help.New()
57 help.Styles = com.Styles.DialogHelpStyles()
58
59 f.help = help
60
61 f.km.Select = key.NewBinding(
62 key.WithKeys("enter"),
63 key.WithHelp("enter", "accept"),
64 )
65 f.km.Down = key.NewBinding(
66 key.WithKeys("down", "j"),
67 key.WithHelp("down/j", "move down"),
68 )
69 f.km.Up = key.NewBinding(
70 key.WithKeys("up", "k"),
71 key.WithHelp("up/k", "move up"),
72 )
73 f.km.Forward = key.NewBinding(
74 key.WithKeys("right", "l"),
75 key.WithHelp("right/l", "move forward"),
76 )
77 f.km.Backward = key.NewBinding(
78 key.WithKeys("left", "h"),
79 key.WithHelp("left/h", "move backward"),
80 )
81 f.km.Navigate = key.NewBinding(
82 key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
83 key.WithHelp("↑↓←→", "navigate"),
84 )
85 f.km.Close = key.NewBinding(
86 key.WithKeys("esc", "alt+esc"),
87 key.WithHelp("esc", "close/exit"),
88 )
89
90 fp := filepicker.New()
91 fp.AllowedTypes = common.AllowedImageTypes
92 fp.ShowPermissions = false
93 fp.ShowSize = false
94 fp.AutoHeight = false
95 fp.Styles = com.Styles.FilePicker
96 fp.Cursor = ""
97 fp.CurrentDirectory = f.WorkingDir()
98
99 f.fp = fp
100
101 return f, f.fp.Init()
102}
103
104// SetImageCapabilities sets the image capabilities for the [FilePicker].
105func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
106 if caps != nil {
107 if caps.SupportsKittyGraphics {
108 f.imgEnc = fimage.EncodingKitty
109 }
110 f.cellSize = caps.CellSize()
111 }
112}
113
114// WorkingDir returns the current working directory of the [FilePicker].
115func (f *FilePicker) WorkingDir() string {
116 wd := f.com.Config().WorkingDir()
117 if len(wd) > 0 {
118 return wd
119 }
120
121 cwd, err := os.Getwd()
122 if err != nil {
123 return home.Dir()
124 }
125
126 return cwd
127}
128
129// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
130func (f *FilePicker) ShortHelp() []key.Binding {
131 return []key.Binding{
132 f.km.Navigate,
133 f.km.Select,
134 f.km.Close,
135 }
136}
137
138// FullHelp returns the full help key bindings for the [FilePicker] dialog.
139func (f *FilePicker) FullHelp() [][]key.Binding {
140 return [][]key.Binding{
141 {
142 f.km.Select,
143 f.km.Down,
144 f.km.Up,
145 f.km.Forward,
146 },
147 {
148 f.km.Backward,
149 f.km.Close,
150 },
151 }
152}
153
154// ID returns the identifier of the [FilePicker] dialog.
155func (f *FilePicker) ID() string {
156 return FilePickerID
157}
158
159// HandleMsg updates the [FilePicker] dialog based on the given message.
160func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
161 var cmds []tea.Cmd
162 switch msg := msg.(type) {
163 case tea.KeyPressMsg:
164 switch {
165 case key.Matches(msg, f.km.Close):
166 return ActionClose{}
167 }
168 }
169
170 var cmd tea.Cmd
171 f.fp, cmd = f.fp.Update(msg)
172 if selFile := f.fp.HighlightedPath(); selFile != "" {
173 var allowed bool
174 for _, allowedExt := range f.fp.AllowedTypes {
175 if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
176 allowed = true
177 break
178 }
179 }
180
181 f.previewingImage = allowed
182 if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
183 f.previewingImage = false
184 img, err := loadImage(selFile)
185 if err == nil {
186 cmds = append(cmds, tea.Sequence(
187 f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
188 func() tea.Msg {
189 f.previewingImage = true
190 return nil
191 },
192 ))
193 }
194 }
195 }
196 if cmd != nil {
197 cmds = append(cmds, cmd)
198 }
199
200 if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
201 return ActionFilePickerSelected{Path: path}
202 }
203
204 return ActionCmd{tea.Batch(cmds...)}
205}
206
207const (
208 filePickerMinWidth = 70
209 filePickerMinHeight = 10
210)
211
212// Draw renders the [FilePicker] dialog as a string.
213func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
214 width := max(0, min(filePickerMinWidth, area.Dx()))
215 height := max(0, min(10, area.Dy()))
216 innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
217 imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
218 imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
219 f.imgPrevWidth = imgPrevWidth
220 f.imgPrevHeight = imgPrevHeight
221 f.fp.SetHeight(height)
222
223 styles := f.com.Styles.FilePicker
224 styles.File = styles.File.Width(innerWidth)
225 styles.Directory = styles.Directory.Width(innerWidth)
226 styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
227 styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
228 f.fp.Styles = styles
229
230 t := f.com.Styles
231 rc := NewRenderContext(t, width)
232 rc.Gap = 1
233 rc.Title = "Add Image"
234 rc.Help = f.help.View(f)
235
236 imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
237 rc.AddPart(imgPreview)
238
239 files := strings.TrimSpace(f.fp.View())
240 rc.AddPart(files)
241
242 view := rc.Render()
243
244 DrawCenter(scr, area, view)
245 return nil
246}
247
248var (
249 imagePreviewCache = map[string]string{}
250 imagePreviewMutex sync.RWMutex
251)
252
253// imagePreview returns the image preview section of the [FilePicker] dialog.
254func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
255 if !f.previewingImage {
256 key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
257 imagePreviewMutex.RLock()
258 cached, ok := imagePreviewCache[key]
259 imagePreviewMutex.RUnlock()
260 if ok {
261 return cached
262 }
263
264 var sb strings.Builder
265 for y := range imgPrevHeight {
266 for range imgPrevWidth {
267 sb.WriteRune('█')
268 }
269 if y < imgPrevHeight-1 {
270 sb.WriteRune('\n')
271 }
272 }
273
274 imagePreviewMutex.Lock()
275 imagePreviewCache[key] = sb.String()
276 imagePreviewMutex.Unlock()
277
278 return sb.String()
279 }
280
281 if id := f.fp.HighlightedPath(); id != "" {
282 r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
283 return r
284 }
285
286 return ""
287}
288
289func loadImage(path string) (img image.Image, err error) {
290 file, err := os.Open(path)
291 if err != nil {
292 return nil, err
293 }
294 defer file.Close()
295
296 img, _, err = image.Decode(file)
297 if err != nil {
298 return nil, err
299 }
300
301 return img, nil
302}