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