filepicker.go

  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			f.previewingImage = false
189			img, err := loadImage(selFile)
190			if err == nil {
191				cmds = append(cmds, tea.Sequence(
192					f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight),
193					func() tea.Msg {
194						f.previewingImage = true
195						return nil
196					},
197				))
198			}
199		}
200	}
201	if cmd != nil {
202		cmds = append(cmds, cmd)
203	}
204
205	if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
206		return ActionFilePickerSelected{Path: path}
207	}
208
209	return ActionCmd{tea.Batch(cmds...)}
210}
211
212const (
213	filePickerMinWidth  = 70
214	filePickerMinHeight = 10
215)
216
217// Draw renders the [FilePicker] dialog as a string.
218func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
219	width := max(0, min(filePickerMinWidth, area.Dx()))
220	height := max(0, min(10, area.Dy()))
221	innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
222	imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
223	imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
224	f.imgPrevWidth = imgPrevWidth
225	f.imgPrevHeight = imgPrevHeight
226	f.fp.SetHeight(height)
227
228	styles := f.com.Styles.FilePicker
229	styles.File = styles.File.Width(innerWidth)
230	styles.Directory = styles.Directory.Width(innerWidth)
231	styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
232	styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
233	f.fp.Styles = styles
234
235	t := f.com.Styles
236	rc := NewRenderContext(t, width)
237	rc.Gap = 1
238	rc.Title = "Add Image"
239	rc.Help = f.help.View(f)
240
241	imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
242	rc.AddPart(imgPreview)
243
244	files := strings.TrimSpace(f.fp.View())
245	rc.AddPart(files)
246
247	view := rc.Render()
248
249	DrawCenter(scr, area, view)
250	return nil
251}
252
253var (
254	imagePreviewCache = map[string]string{}
255	imagePreviewMutex sync.RWMutex
256)
257
258// imagePreview returns the image preview section of the [FilePicker] dialog.
259func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
260	if !f.previewingImage {
261		key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
262		imagePreviewMutex.RLock()
263		cached, ok := imagePreviewCache[key]
264		imagePreviewMutex.RUnlock()
265		if ok {
266			return cached
267		}
268
269		var sb strings.Builder
270		for y := range imgPrevHeight {
271			for range imgPrevWidth {
272				sb.WriteRune('█')
273			}
274			if y < imgPrevHeight-1 {
275				sb.WriteRune('\n')
276			}
277		}
278
279		imagePreviewMutex.Lock()
280		imagePreviewCache[key] = sb.String()
281		imagePreviewMutex.Unlock()
282
283		return sb.String()
284	}
285
286	if id := f.fp.HighlightedPath(); id != "" {
287		r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
288		return r
289	}
290
291	return ""
292}
293
294func loadImage(path string) (img image.Image, err error) {
295	file, err := os.Open(path)
296	if err != nil {
297		return nil, err
298	}
299	defer file.Close()
300
301	img, _, err = image.Decode(file)
302	if err != nil {
303		return nil, err
304	}
305
306	return img, nil
307}