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, Action) {
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, ActionCmd{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// Init implements the [Dialog] interface.
160func (f *FilePicker) Init() tea.Cmd {
161 return f.fp.Init()
162}
163
164// HandleMsg updates the [FilePicker] dialog based on the given message.
165func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
166 var cmds []tea.Cmd
167 switch msg := msg.(type) {
168 case tea.KeyPressMsg:
169 switch {
170 case key.Matches(msg, f.km.Close):
171 return ActionClose{}
172 }
173 }
174
175 var cmd tea.Cmd
176 f.fp, cmd = f.fp.Update(msg)
177 if selFile := f.fp.HighlightedPath(); selFile != "" {
178 var allowed bool
179 for _, allowedExt := range f.fp.AllowedTypes {
180 if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
181 allowed = true
182 break
183 }
184 }
185
186 f.previewingImage = allowed
187 if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
188 f.previewingImage = false
189 img, err := loadImage(selFile)
190 if err == nil {
191 cmds = append(cmds, tea.Sequence(
192 f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
193 func() tea.Msg {
194 f.previewingImage = true
195 return nil
196 },
197 ))
198 }
199 }
200 }
201 if cmd != nil {
202 cmds = append(cmds, cmd)
203 }
204
205 if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
206 return ActionFilePickerSelected{Path: path}
207 }
208
209 return ActionCmd{tea.Batch(cmds...)}
210}
211
212const (
213 filePickerMinWidth = 70
214 filePickerMinHeight = 10
215)
216
217// Draw renders the [FilePicker] dialog as a string.
218func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
219 width := max(0, min(filePickerMinWidth, area.Dx()))
220 height := max(0, min(10, area.Dy()))
221 innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
222 imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
223 imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
224 f.imgPrevWidth = imgPrevWidth
225 f.imgPrevHeight = imgPrevHeight
226 f.fp.SetHeight(height)
227
228 styles := f.com.Styles.FilePicker
229 styles.File = styles.File.Width(innerWidth)
230 styles.Directory = styles.Directory.Width(innerWidth)
231 styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
232 styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
233 f.fp.Styles = styles
234
235 t := f.com.Styles
236 rc := NewRenderContext(t, width)
237 rc.Gap = 1
238 rc.Title = "Add Image"
239 rc.Help = f.help.View(f)
240
241 imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
242 rc.AddPart(imgPreview)
243
244 files := strings.TrimSpace(f.fp.View())
245 rc.AddPart(files)
246
247 view := rc.Render()
248
249 DrawCenter(scr, area, view)
250 return nil
251}
252
253var (
254 imagePreviewCache = map[string]string{}
255 imagePreviewMutex sync.RWMutex
256)
257
258// imagePreview returns the image preview section of the [FilePicker] dialog.
259func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
260 if !f.previewingImage {
261 key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
262 imagePreviewMutex.RLock()
263 cached, ok := imagePreviewCache[key]
264 imagePreviewMutex.RUnlock()
265 if ok {
266 return cached
267 }
268
269 var sb strings.Builder
270 for y := range imgPrevHeight {
271 for range imgPrevWidth {
272 sb.WriteRune('█')
273 }
274 if y < imgPrevHeight-1 {
275 sb.WriteRune('\n')
276 }
277 }
278
279 imagePreviewMutex.Lock()
280 imagePreviewCache[key] = sb.String()
281 imagePreviewMutex.Unlock()
282
283 return sb.String()
284 }
285
286 if id := f.fp.HighlightedPath(); id != "" {
287 r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
288 return r
289 }
290
291 return ""
292}
293
294func loadImage(path string) (img image.Image, err error) {
295 file, err := os.Open(path)
296 if err != nil {
297 return nil, err
298 }
299 defer file.Close()
300
301 img, _, err = image.Decode(file)
302 if err != nil {
303 return nil, err
304 }
305
306 return img, nil
307}