Skip to content

Commit

Permalink
feat: file picker field
Browse files Browse the repository at this point in the history
  • Loading branch information
maaslalani committed Jan 29, 2024
1 parent 2d4a91d commit 249124e
Show file tree
Hide file tree
Showing 9 changed files with 378 additions and 13 deletions.
22 changes: 22 additions & 0 deletions examples/filepicker/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package main

import (
"fmt"

"github.com/charmbracelet/huh"
)

func main() {
var file string

huh.NewForm(
huh.NewGroup(
huh.NewFile().
Title("Select a file:").
Description("This will be your profile image.").
AllowedTypes([]string{".png", ".jpeg", ".webp", ".gif"}).
Value(&file),
),
).WithShowHelp(true).Run()
fmt.Println(file)
}
7 changes: 5 additions & 2 deletions examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module examples
go 1.19

require (
github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6
github.com/charmbracelet/bubbles v0.17.2-0.20240129221336-07e7bd4ee418
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/huh v0.0.0-00010101000000-000000000000
github.com/charmbracelet/huh/spinner v0.0.0-00010101000000-000000000000
Expand All @@ -17,6 +17,7 @@ require (
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
Expand All @@ -25,7 +26,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
golang.org/x/sync v0.4.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.13.0 // indirect
Expand All @@ -35,3 +36,5 @@ require (
replace github.com/charmbracelet/huh => ../

replace github.com/charmbracelet/huh/spinner => ../spinner

replace github.com/charmbracelet/bubbles => ../../bubbles
8 changes: 4 additions & 4 deletions examples/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6 h1:6nVCV8pqGaeyxetur3gpX3AAaiyKgzjIoCPV3NXKZBE=
github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
Expand All @@ -16,6 +14,8 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20231213132142-f5e65255b0b9 h1:f8c
github.com/charmbracelet/x/exp/strings v0.0.0-20231213132142-f5e65255b0b9/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
Expand All @@ -35,8 +35,8 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
1 change: 1 addition & 0 deletions examples/theme/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func main() {
huh.NewGroup(
huh.NewInput().Title("Thoughts").Placeholder("What's on your mind?"),
huh.NewSelect[string]().Options(huh.NewOptions("A", "B", "C")...).Title("Colors"),
huh.NewFile().Title("File"),
huh.NewMultiSelect[string]().Options(huh.NewOptions("Red", "Green", "Yellow")...).Title("Letters"),
huh.NewConfirm().Title("Again?").Description("Try another theme").Value(&repeat),
),
Expand Down
286 changes: 286 additions & 0 deletions field_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
package huh

import (
"errors"
"fmt"
"path/filepath"
"strings"

"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

// File is a form file file field.
type File struct {
value *string
key string
picker filepicker.Model

// state
focused bool

// customization
title string
description string

// error handling
validate func(string) error
err error

// options
width int
accessible bool
theme *Theme
keymap FileKeyMap
}

const defaultHeight = 5

// NewFile returns a new file field.
func NewFile() *File {
fp := filepicker.New()
fp.ShowPermissions = false
fp.ShowSize = false
fp.Height = defaultHeight
fp.AutoHeight = false

cmd := fp.Init()
if cmd != nil {
fp, _ = fp.Update(cmd())
}

return &File{
value: new(string),
validate: func(string) error { return nil },
picker: fp,
theme: ThemeCharm(),
}
}

// CurrentDirectory sets the directory of the file field.
func (f *File) CurrentDirectory(directory string) *File {
f.picker.CurrentDirectory = directory
return f
}

// ShowHidden sets whether to show hidden files.
func (f *File) ShowHidden(v bool) *File {
f.picker.ShowHidden = v
return f
}

// Value sets the value of the file field.
func (f *File) Value(value *string) *File {
f.value = value
return f
}

// Key sets the key of the file field which can be used to retrieve the value
// after submission.
func (f *File) Key(key string) *File {
f.key = key
return f
}

// Title sets the title of the file field.
func (f *File) Title(title string) *File {
f.title = title
return f
}

// Description sets the description of the file field.
func (f *File) Description(description string) *File {
f.description = description
return f
}

// Height sets the height of the file field. If the number of options
// exceeds the height, the file field will become scrollable.
func (f *File) AllowedTypes(types []string) *File {
f.picker.AllowedTypes = types
return f
}

// Height sets the height of the file field. If the number of options
// exceeds the height, the file field will become scrollable.
func (f *File) Height(height int) *File {
f.picker.Height = height
f.picker.AutoHeight = false
return f
}

// Validate sets the validation function of the file field.
func (f *File) Validate(validate func(string) error) *File {
f.validate = validate
return f
}

// Error returns the error of the file field.
func (f *File) Error() error {
return f.err
}

// Skip returns whether the file should be skipped or should be blocking.
func (*File) Skip() bool {
return false
}

// Focus focuses the file field.
func (f *File) Focus() tea.Cmd {
f.focused = true
return f.picker.Init()
}

// Blur blurs the file field.
func (f *File) Blur() tea.Cmd {
f.focused = false
f.err = f.validate(*f.value)
return nil
}

// KeyBinds returns the help keybindings for the file field.
func (f *File) KeyBinds() []key.Binding {
return []key.Binding{f.keymap.Up, f.keymap.Down, f.keymap.Prev, f.keymap.Next, f.keymap.Submit}
}

// Init initializes the file field.
func (f *File) Init() tea.Cmd {
return f.picker.Init()
}

// Update updates the file field.
func (f *File) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.err = nil

var cmd tea.Cmd
f.picker, cmd = f.picker.Update(msg)
didSelect, file := f.picker.DidSelectFile(msg)
if didSelect {
*f.value = file
return f, nextField
}
didSelect, file = f.picker.DidSelectDisabledFile(msg)
if didSelect {
f.err = errors.New("cannot select " + filepath.Base(file))
return f, nil
}

switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, f.keymap.Next):
return f, nextField
case key.Matches(msg, f.keymap.Prev):
return f, prevField
}
}

return f, cmd
}

// View renders the file field.
func (f *File) View() string {
styles := f.theme.Blurred
if f.focused {
styles = f.theme.Focused
}
var sb strings.Builder
sb.WriteString(styles.Title.Render(f.title) + "\n")
if f.description != "" {
sb.WriteString(styles.Description.Render(f.description) + "\n")
}
sb.WriteString(strings.TrimSuffix(f.picker.View(), "\n"))
return styles.Base.Render(sb.String())
}

// Run runs the file field.
func (f *File) Run() error {
if f.accessible {
return f.runAccessible()
}
return Run(f)
}

// runAccessible runs an accessible file field.
func (f *File) runAccessible() error {
var sb strings.Builder
sb.WriteString(f.theme.Focused.Title.Render(f.title) + "\n")
fmt.Println(f.theme.Blurred.Base.Render(sb.String()))
return nil
}

// WithTheme sets the theme of the file field.
func (f *File) WithTheme(theme *Theme) Field {
f.theme = theme

// TODO: add specific themes
f.picker.Styles = filepicker.Styles{
DisabledCursor: lipgloss.Style{},
Cursor: theme.Focused.TextInput.Prompt,
Symlink: lipgloss.NewStyle(),
Directory: theme.Focused.Title,
File: lipgloss.NewStyle(),
DisabledFile: theme.Focused.Description,
Permission: theme.Focused.Description,
Selected: theme.Focused.SelectedOption,
DisabledSelected: theme.Focused.Description,
FileSize: theme.Focused.Description.Copy().Width(7).Align(lipgloss.Right).Inline(true),
EmptyDirectory: theme.Focused.Description.Copy().SetString("No files found."),
}

return f
}

// WithKeyMap sets the keymap on a file field.
func (f *File) WithKeyMap(k *KeyMap) Field {
f.keymap = k.File
f.picker.KeyMap = filepicker.KeyMap{
GoToTop: k.File.GoToTop,
GoToLast: k.File.GoToLast,
Down: k.File.Down,
Up: k.File.Up,
PageUp: k.File.PageUp,
PageDown: k.File.PageDown,
Back: k.File.Back,
Open: k.File.Open,
Select: k.File.Select,
}
return f
}

// WithAccessible sets the accessible mode of the file field.
func (f *File) WithAccessible(accessible bool) Field {
f.accessible = accessible
return f
}

// WithWidth sets the width of the file field.
func (f *File) WithWidth(width int) Field {
f.width = width
return f
}

// WithHeight sets the height of the file field.
func (f *File) WithHeight(height int) Field {
return f.Height(height)
}

// WithPosition sets the position of the file field.
func (f *File) WithPosition(p FieldPosition) Field {
f.keymap.Prev.SetEnabled(!p.IsFirst())
f.keymap.Next.SetEnabled(!p.IsLast())
f.keymap.Submit.SetEnabled(p.IsLast())
return f
}

// GetKey returns the key of the field.
func (f *File) GetKey() string {
return f.key
}

// GetValue returns the value of the field.
func (f *File) GetValue() any {
return *f.value
}
Loading

0 comments on commit 249124e

Please sign in to comment.