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	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}