@@ -0,0 +1,292 @@
6+ _ "image/jpeg" // register JPEG format
7+ _ "image/png" // register PNG format
13+ "charm.land/bubbles/v2/filepicker"
14+ "charm.land/bubbles/v2/help"
15+ "charm.land/bubbles/v2/key"
16+ tea "charm.land/bubbletea/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"
24+ transmittedImages = map[uint64]struct{}{}
25+ transmittedMutex sync.RWMutex
28+// FilePickerID is the identifier for the FilePicker dialog.
29+const FilePickerID = "filepicker"
31+// FilePicker is a dialog that allows users to select files or directories.
32+type FilePicker struct {
36+ imgPrevWidth, imgPrevHeight int
37+ imageCaps *fimage.Capabilities
42+ previewingImage bool // indicates if an image is being previewed
55+var _ Dialog = (*FilePicker)(nil)
57+// NewFilePicker creates a new [FilePicker] dialog.
58+func NewFilePicker(com *common.Common) (*FilePicker, Action) {
63+ help.Styles = com.Styles.DialogHelpStyles()
67+ f.km.Select = key.NewBinding(
68+ key.WithKeys("enter"),
69+ key.WithHelp("enter", "accept"),
71+ f.km.Down = key.NewBinding(
72+ key.WithKeys("down", "j"),
73+ key.WithHelp("down/j", "move down"),
75+ f.km.Up = key.NewBinding(
76+ key.WithKeys("up", "k"),
77+ key.WithHelp("up/k", "move up"),
79+ f.km.Forward = key.NewBinding(
80+ key.WithKeys("right", "l"),
81+ key.WithHelp("right/l", "move forward"),
83+ f.km.Backward = key.NewBinding(
84+ key.WithKeys("left", "h"),
85+ key.WithHelp("left/h", "move backward"),
87+ f.km.Navigate = key.NewBinding(
88+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
89+ key.WithHelp("↑↓←→", "navigate"),
91+ f.km.Close = key.NewBinding(
92+ key.WithKeys("esc", "alt+esc"),
93+ key.WithHelp("esc", "close/exit"),
96+ fp := filepicker.New()
97+ fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
98+ fp.ShowPermissions = false
100+ fp.AutoHeight = false
101+ fp.Styles = com.Styles.FilePicker
103+ fp.CurrentDirectory = f.WorkingDir()
107+ return f, ActionCmd{f.fp.Init()}
110+// SetImageCapabilities sets the image capabilities for the [FilePicker].
111+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
115+// WorkingDir returns the current working directory of the [FilePicker].
116+func (f *FilePicker) WorkingDir() string {
117+ wd := f.com.Config().WorkingDir()
122+ cwd, err := os.Getwd()
130+// SetWindowSize sets the desired size of the [FilePicker] dialog window.
131+func (f *FilePicker) SetWindowSize(width, height int) {
133+ f.imgPrevWidth = width/2 - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
134+ // Use square preview for simplicity same size as width
135+ f.imgPrevHeight = width/2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
136+ f.fp.SetHeight(height)
137+ innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
138+ styles := f.com.Styles.FilePicker
139+ styles.File = styles.File.Width(innerWidth)
140+ styles.Directory = styles.Directory.Width(innerWidth)
141+ styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
142+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
143+ f.fp.Styles = styles
146+// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
147+func (f *FilePicker) ShortHelp() []key.Binding {
148+ return []key.Binding{
155+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
156+func (f *FilePicker) FullHelp() [][]key.Binding {
157+ return [][]key.Binding{
171+// ID returns the identifier of the [FilePicker] dialog.
172+func (f *FilePicker) ID() string {
176+// Init implements the [Dialog] interface.
177+func (f *FilePicker) Init() tea.Cmd {
181+// HandleMsg updates the [FilePicker] dialog based on the given message.
182+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
184+ switch msg := msg.(type) {
185+ case tea.KeyPressMsg:
187+ case key.Matches(msg, f.km.Close):
188+ return ActionClose{}
193+ f.fp, cmd = f.fp.Update(msg)
194+ if selFile := f.fp.HighlightedPath(); selFile != "" {
196+ for _, allowedExt := range f.fp.AllowedTypes {
197+ if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
203+ f.previewingImage = allowed
205+ id := uniquePathID(selFile)
207+ transmittedMutex.RLock()
208+ _, transmitted := transmittedImages[id]
209+ transmittedMutex.RUnlock()
211+ img, err := loadImage(selFile)
213+ f.previewingImage = false
216+ timg, err := fimage.New(selFile, img, f.imgPrevWidth, f.imgPrevHeight)
218+ f.previewingImage = false
223+ cmds = append(cmds, f.img.Transmit())
224+ transmittedMutex.Lock()
225+ transmittedImages[id] = struct{}{}
226+ transmittedMutex.Unlock()
232+ cmds = append(cmds, cmd)
235+ return ActionCmd{tea.Batch(cmds...)}
238+// Draw renders the [FilePicker] dialog as a string.
239+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
241+ titleStyle := f.com.Styles.Dialog.Title
242+ dialogStyle := f.com.Styles.Dialog.View
243+ header := common.DialogTitle(t, "Add Image",
244+ max(0, f.width-dialogStyle.GetHorizontalFrameSize()-
245+ titleStyle.GetHorizontalFrameSize()))
246+ files := strings.TrimSpace(f.fp.View())
247+ filesHeight := f.fp.Height()
248+ imgPreview := t.Dialog.ImagePreview.Render(f.imagePreview())
249+ view := HeaderInputListHelpView(t, f.width, filesHeight, header, imgPreview, files, f.help.View(f))
250+ DrawCenter(scr, area, view)
254+// imagePreview returns the image preview section of the [FilePicker] dialog.
255+func (f *FilePicker) imagePreview() string {
256+ if !f.previewingImage || f.img == nil {
257+ // TODO: Cache this?
258+ var sb strings.Builder
259+ for y := 0; y < f.imgPrevHeight; y++ {
260+ for x := 0; x < f.imgPrevWidth; x++ {
263+ if y < f.imgPrevHeight-1 {
270+ return f.img.Render()
273+func uniquePathID(path string) uint64 {
275+ _, _ = io.WriteString(h, path)
279+func loadImage(path string) (img image.Image, err error) {
280+ file, err := os.Open(path)
286+ img, _, err = image.Decode(file)