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