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			img, err := loadImage(selFile)
189			if err != nil {
190				f.previewingImage = false
191			}
192
193			cmds = append(cmds, f.imgEnc.Transmit(
194				selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight))
195			f.previewingImage = true
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.Title = "Add Image"
235	rc.Help = f.help.View(f)
236
237	imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
238	rc.AddPart(imgPreview)
239
240	files := strings.TrimSpace(f.fp.View())
241	rc.AddPart(files)
242
243	view := rc.Render()
244
245	DrawCenter(scr, area, view)
246	return nil
247}
248
249var (
250	imagePreviewCache = map[string]string{}
251	imagePreviewMutex sync.RWMutex
252)
253
254// imagePreview returns the image preview section of the [FilePicker] dialog.
255func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
256	if !f.previewingImage {
257		key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
258		imagePreviewMutex.RLock()
259		cached, ok := imagePreviewCache[key]
260		imagePreviewMutex.RUnlock()
261		if ok {
262			return cached
263		}
264
265		var sb strings.Builder
266		for y := range imgPrevHeight {
267			for range imgPrevWidth {
268				sb.WriteRune('█')
269			}
270			if y < imgPrevHeight-1 {
271				sb.WriteRune('\n')
272			}
273		}
274
275		imagePreviewMutex.Lock()
276		imagePreviewCache[key] = sb.String()
277		imagePreviewMutex.Unlock()
278
279		return sb.String()
280	}
281
282	if id := f.fp.HighlightedPath(); id != "" {
283		r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
284		return r
285	}
286
287	return ""
288}
289
290func loadImage(path string) (img image.Image, err error) {
291	file, err := os.Open(path)
292	if err != nil {
293		return nil, err
294	}
295	defer file.Close()
296
297	img, _, err = image.Decode(file)
298	if err != nil {
299		return nil, err
300	}
301
302	return img, nil
303}