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 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
50var _ Dialog = (*FilePicker)(nil)
51
52// NewFilePicker creates a new [FilePicker] dialog.
53func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
54 f := new(FilePicker)
55 f.com = com
56
57 help := help.New()
58 help.Styles = com.Styles.DialogHelpStyles()
59
60 f.help = help
61
62 f.km.Select = key.NewBinding(
63 key.WithKeys("enter"),
64 key.WithHelp("enter", "accept"),
65 )
66 f.km.Down = key.NewBinding(
67 key.WithKeys("down", "j"),
68 key.WithHelp("down/j", "move down"),
69 )
70 f.km.Up = key.NewBinding(
71 key.WithKeys("up", "k"),
72 key.WithHelp("up/k", "move up"),
73 )
74 f.km.Forward = key.NewBinding(
75 key.WithKeys("right", "l"),
76 key.WithHelp("right/l", "move forward"),
77 )
78 f.km.Backward = key.NewBinding(
79 key.WithKeys("left", "h"),
80 key.WithHelp("left/h", "move backward"),
81 )
82 f.km.Navigate = key.NewBinding(
83 key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
84 key.WithHelp("↑↓←→", "navigate"),
85 )
86 f.km.Close = key.NewBinding(
87 key.WithKeys("esc", "alt+esc"),
88 key.WithHelp("esc", "close/exit"),
89 )
90
91 fp := filepicker.New()
92 fp.AllowedTypes = common.AllowedImageTypes
93 fp.ShowPermissions = false
94 fp.ShowSize = false
95 fp.AutoHeight = false
96 fp.Styles = com.Styles.FilePicker
97 fp.Cursor = ""
98 fp.CurrentDirectory = f.WorkingDir()
99
100 f.fp = fp
101
102 return f, f.fp.Init()
103}
104
105// SetImageCapabilities sets the image capabilities for the [FilePicker].
106func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
107 if caps != nil {
108 if caps.SupportsKittyGraphics {
109 f.imgEnc = fimage.EncodingKitty
110 }
111 f.cellSize = caps.CellSize()
112 _, f.isTmux = caps.Env.LookupEnv("TMUX")
113 }
114}
115
116// WorkingDir returns the current working directory of the [FilePicker].
117func (f *FilePicker) WorkingDir() string {
118 wd := f.com.Config().WorkingDir()
119 if len(wd) > 0 {
120 return wd
121 }
122
123 cwd, err := os.Getwd()
124 if err != nil {
125 return home.Dir()
126 }
127
128 return cwd
129}
130
131// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
132func (f *FilePicker) ShortHelp() []key.Binding {
133 return []key.Binding{
134 f.km.Navigate,
135 f.km.Select,
136 f.km.Close,
137 }
138}
139
140// FullHelp returns the full help key bindings for the [FilePicker] dialog.
141func (f *FilePicker) FullHelp() [][]key.Binding {
142 return [][]key.Binding{
143 {
144 f.km.Select,
145 f.km.Down,
146 f.km.Up,
147 f.km.Forward,
148 },
149 {
150 f.km.Backward,
151 f.km.Close,
152 },
153 }
154}
155
156// ID returns the identifier of the [FilePicker] dialog.
157func (f *FilePicker) ID() string {
158 return FilePickerID
159}
160
161// HandleMsg updates the [FilePicker] dialog based on the given message.
162func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
163 var cmds []tea.Cmd
164 switch msg := msg.(type) {
165 case tea.KeyPressMsg:
166 switch {
167 case key.Matches(msg, f.km.Close):
168 return ActionClose{}
169 }
170 }
171
172 var cmd tea.Cmd
173 f.fp, cmd = f.fp.Update(msg)
174 if selFile := f.fp.HighlightedPath(); selFile != "" {
175 var allowed bool
176 for _, allowedExt := range f.fp.AllowedTypes {
177 if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
178 allowed = true
179 break
180 }
181 }
182
183 f.previewingImage = allowed
184 if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
185 f.previewingImage = false
186 img, err := loadImage(selFile)
187 if err == nil {
188 cmds = append(cmds, tea.Sequence(
189 f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
190 func() tea.Msg {
191 f.previewingImage = true
192 return nil
193 },
194 ))
195 }
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.Gap = 1
235 rc.Title = "Add Image"
236 rc.Help = f.help.View(f)
237
238 imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
239 rc.AddPart(imgPreview)
240
241 files := strings.TrimSpace(f.fp.View())
242 rc.AddPart(files)
243
244 view := rc.Render()
245
246 DrawCenter(scr, area, view)
247 return nil
248}
249
250var (
251 imagePreviewCache = map[string]string{}
252 imagePreviewMutex sync.RWMutex
253)
254
255// imagePreview returns the image preview section of the [FilePicker] dialog.
256func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
257 if !f.previewingImage {
258 key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
259 imagePreviewMutex.RLock()
260 cached, ok := imagePreviewCache[key]
261 imagePreviewMutex.RUnlock()
262 if ok {
263 return cached
264 }
265
266 var sb strings.Builder
267 for y := range imgPrevHeight {
268 for range imgPrevWidth {
269 sb.WriteRune('█')
270 }
271 if y < imgPrevHeight-1 {
272 sb.WriteRune('\n')
273 }
274 }
275
276 imagePreviewMutex.Lock()
277 imagePreviewCache[key] = sb.String()
278 imagePreviewMutex.Unlock()
279
280 return sb.String()
281 }
282
283 if id := f.fp.HighlightedPath(); id != "" {
284 r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
285 return r
286 }
287
288 return ""
289}
290
291func loadImage(path string) (img image.Image, err error) {
292 file, err := os.Open(path)
293 if err != nil {
294 return nil, err
295 }
296 defer file.Close()
297
298 img, _, err = image.Decode(file)
299 if err != nil {
300 return nil, err
301 }
302
303 return img, nil
304}