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 img, err := loadImage(selFile)
189 if err != nil {
190 f.previewingImage = false
191 }
192
193 cmds = append(cmds, f.imgEnc.Transmit(
194 selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight))
195 f.previewingImage = true
196 }
197 }
198 if cmd != nil {
199 cmds = append(cmds, cmd)
200 }
201
202 if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
203 return ActionFilePickerSelected{Path: path}
204 }
205
206 return ActionCmd{tea.Batch(cmds...)}
207}
208
209const (
210 filePickerMinWidth = 70
211 filePickerMinHeight = 10
212)
213
214// Draw renders the [FilePicker] dialog as a string.
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
232 t := f.com.Styles
233 rc := NewRenderContext(t, width)
234 rc.Title = "Add Image"
235 rc.Help = f.help.View(f)
236
237 imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
238 rc.AddPart(imgPreview)
239
240 files := strings.TrimSpace(f.fp.View())
241 rc.AddPart(files)
242
243 view := rc.Render()
244
245 DrawCenter(scr, area, view)
246 return nil
247}
248
249var (
250 imagePreviewCache = map[string]string{}
251 imagePreviewMutex sync.RWMutex
252)
253
254// imagePreview returns the image preview section of the [FilePicker] dialog.
255func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
256 if !f.previewingImage {
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
265 var sb strings.Builder
266 for y := range imgPrevHeight {
267 for range imgPrevWidth {
268 sb.WriteRune('█')
269 }
270 if y < imgPrevHeight-1 {
271 sb.WriteRune('\n')
272 }
273 }
274
275 imagePreviewMutex.Lock()
276 imagePreviewCache[key] = sb.String()
277 imagePreviewMutex.Unlock()
278
279 return sb.String()
280 }
281
282 if id := f.fp.HighlightedPath(); id != "" {
283 r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
284 return r
285 }
286
287 return ""
288}
289
290func loadImage(path string) (img image.Image, err error) {
291 file, err := os.Open(path)
292 if err != nil {
293 return nil, err
294 }
295 defer file.Close()
296
297 img, _, err = image.Decode(file)
298 if err != nil {
299 return nil, err
300 }
301
302 return img, nil
303}