@@ -0,0 +1,302 @@
1+package dialog
2+
3+import (
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.
24+const FilePickerID = "filepicker"
25+
26+// FilePicker is a dialog that allows users to select files or directories.
27+type 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+
49+var _ Dialog = (*FilePicker)(nil)
50+
51+// NewFilePicker creates a new [FilePicker] dialog.
52+func 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].
105+func (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].
115+func (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.
130+func (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.
139+func (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.
155+func (f *FilePicker) ID() string {
156+ return FilePickerID
157+}
158+
159+// HandleMsg updates the [FilePicker] dialog based on the given message.
160+func (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+
207+const (
208+ filePickerMinWidth = 70
209+ filePickerMinHeight = 10
210+)
211+
212+// Draw renders the [FilePicker] dialog as a string.
213+func (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+
248+var (
249+ imagePreviewCache = map[string]string{}
250+ imagePreviewMutex sync.RWMutex
251+)
252+
253+// imagePreview returns the image preview section of the [FilePicker] dialog.
254+func (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+
289+func 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+}