@@ -0,0 +1,292 @@
1+package dialog
2+
3+import (
4+ "hash/fnv"
5+ "image"
6+ _ "image/jpeg" // register JPEG format
7+ _ "image/png" // register PNG format
8+ "io"
9+ "os"
10+ "strings"
11+ "sync"
12+
13+ "charm.land/bubbles/v2/filepicker"
14+ "charm.land/bubbles/v2/help"
15+ "charm.land/bubbles/v2/key"
16+ tea "charm.land/bubbletea/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+var (
24+ transmittedImages = map[uint64]struct{}{}
25+ transmittedMutex sync.RWMutex
26+)
27+
28+// FilePickerID is the identifier for the FilePicker dialog.
29+const FilePickerID = "filepicker"
30+
31+// FilePicker is a dialog that allows users to select files or directories.
32+type FilePicker struct {
33+ com *common.Common
34+
35+ width int
36+ imgPrevWidth, imgPrevHeight int
37+ imageCaps *fimage.Capabilities
38+
39+ img *fimage.Image
40+ fp filepicker.Model
41+ help help.Model
42+ previewingImage bool // indicates if an image is being previewed
43+
44+ km struct {
45+ Select,
46+ Down,
47+ Up,
48+ Forward,
49+ Backward,
50+ Navigate,
51+ Close key.Binding
52+ }
53+}
54+
55+var _ Dialog = (*FilePicker)(nil)
56+
57+// NewFilePicker creates a new [FilePicker] dialog.
58+func NewFilePicker(com *common.Common) (*FilePicker, Action) {
59+ f := new(FilePicker)
60+ f.com = com
61+
62+ help := help.New()
63+ help.Styles = com.Styles.DialogHelpStyles()
64+
65+ f.help = help
66+
67+ f.km.Select = key.NewBinding(
68+ key.WithKeys("enter"),
69+ key.WithHelp("enter", "accept"),
70+ )
71+ f.km.Down = key.NewBinding(
72+ key.WithKeys("down", "j"),
73+ key.WithHelp("down/j", "move down"),
74+ )
75+ f.km.Up = key.NewBinding(
76+ key.WithKeys("up", "k"),
77+ key.WithHelp("up/k", "move up"),
78+ )
79+ f.km.Forward = key.NewBinding(
80+ key.WithKeys("right", "l"),
81+ key.WithHelp("right/l", "move forward"),
82+ )
83+ f.km.Backward = key.NewBinding(
84+ key.WithKeys("left", "h"),
85+ key.WithHelp("left/h", "move backward"),
86+ )
87+ f.km.Navigate = key.NewBinding(
88+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
89+ key.WithHelp("↑↓←→", "navigate"),
90+ )
91+ f.km.Close = key.NewBinding(
92+ key.WithKeys("esc", "alt+esc"),
93+ key.WithHelp("esc", "close/exit"),
94+ )
95+
96+ fp := filepicker.New()
97+ fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
98+ fp.ShowPermissions = false
99+ fp.ShowSize = false
100+ fp.AutoHeight = false
101+ fp.Styles = com.Styles.FilePicker
102+ fp.Cursor = ""
103+ fp.CurrentDirectory = f.WorkingDir()
104+
105+ f.fp = fp
106+
107+ return f, ActionCmd{f.fp.Init()}
108+}
109+
110+// SetImageCapabilities sets the image capabilities for the [FilePicker].
111+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
112+ f.imageCaps = caps
113+}
114+
115+// WorkingDir returns the current working directory of the [FilePicker].
116+func (f *FilePicker) WorkingDir() string {
117+ wd := f.com.Config().WorkingDir()
118+ if len(wd) > 0 {
119+ return wd
120+ }
121+
122+ cwd, err := os.Getwd()
123+ if err != nil {
124+ return home.Dir()
125+ }
126+
127+ return cwd
128+}
129+
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.
147+func (f *FilePicker) ShortHelp() []key.Binding {
148+ return []key.Binding{
149+ f.km.Navigate,
150+ f.km.Select,
151+ f.km.Close,
152+ }
153+}
154+
155+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
156+func (f *FilePicker) FullHelp() [][]key.Binding {
157+ return [][]key.Binding{
158+ {
159+ f.km.Select,
160+ f.km.Down,
161+ f.km.Up,
162+ f.km.Forward,
163+ },
164+ {
165+ f.km.Backward,
166+ f.km.Close,
167+ },
168+ }
169+}
170+
171+// ID returns the identifier of the [FilePicker] dialog.
172+func (f *FilePicker) ID() string {
173+ return FilePickerID
174+}
175+
176+// Init implements the [Dialog] interface.
177+func (f *FilePicker) Init() tea.Cmd {
178+ return f.fp.Init()
179+}
180+
181+// HandleMsg updates the [FilePicker] dialog based on the given message.
182+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
183+ var cmds []tea.Cmd
184+ switch msg := msg.(type) {
185+ case tea.KeyPressMsg:
186+ switch {
187+ case key.Matches(msg, f.km.Close):
188+ return ActionClose{}
189+ }
190+ }
191+
192+ var cmd tea.Cmd
193+ f.fp, cmd = f.fp.Update(msg)
194+ if selFile := f.fp.HighlightedPath(); selFile != "" {
195+ var allowed bool
196+ for _, allowedExt := range f.fp.AllowedTypes {
197+ if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
198+ allowed = true
199+ break
200+ }
201+ }
202+
203+ 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)
217+ if err != nil {
218+ f.previewingImage = false
219+ }
220+
221+ f.img = timg
222+ if err == nil {
223+ cmds = append(cmds, f.img.Transmit())
224+ transmittedMutex.Lock()
225+ transmittedImages[id] = struct{}{}
226+ transmittedMutex.Unlock()
227+ }
228+ }
229+ }
230+ }
231+ if cmd != nil {
232+ cmds = append(cmds, cmd)
233+ }
234+
235+ return ActionCmd{tea.Batch(cmds...)}
236+}
237+
238+// Draw renders the [FilePicker] dialog as a string.
239+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
240+ t := f.com.Styles
241+ titleStyle := f.com.Styles.Dialog.Title
242+ dialogStyle := f.com.Styles.Dialog.View
243+ header := common.DialogTitle(t, "Add Image",
244+ max(0, f.width-dialogStyle.GetHorizontalFrameSize()-
245+ titleStyle.GetHorizontalFrameSize()))
246+ files := strings.TrimSpace(f.fp.View())
247+ filesHeight := f.fp.Height()
248+ imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview())
249+ view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f))
250+ DrawCenter(scr, area, view)
251+ return nil
252+}
253+
254+// imagePreview returns the image preview section of the [FilePicker] dialog.
255+func (f *FilePicker) imagePreview() string {
256+ if !f.previewingImage || f.img == nil {
257+ // TODO: Cache this?
258+ var sb strings.Builder
259+ for y := 0; y < f.imgPrevHeight; y++ {
260+ for x := 0; x < f.imgPrevWidth; x++ {
261+ sb.WriteRune('╱')
262+ }
263+ if y < f.imgPrevHeight-1 {
264+ sb.WriteRune('\n')
265+ }
266+ }
267+ return sb.String()
268+ }
269+
270+ return f.img.Render()
271+}
272+
273+func uniquePathID(path string) uint64 {
274+ h := fnv.New64a()
275+ _, _ = io.WriteString(h, path)
276+ return h.Sum64()
277+}
278+
279+func loadImage(path string) (img image.Image, err error) {
280+ file, err := os.Open(path)
281+ if err != nil {
282+ return nil, err
283+ }
284+ defer file.Close()
285+
286+ img, _, err = image.Decode(file)
287+ if err != nil {
288+ return nil, err
289+ }
290+
291+ return img, nil
292+}