├── examples ├── .gitignore ├── filepicker │ ├── artichoke.hs │ ├── profile.jpeg │ ├── profile.png │ ├── demo.tape │ └── main.go ├── burger │ ├── taco.gif │ ├── accessible.gif │ ├── burger.gif │ ├── demo.tape │ └── main.go ├── hide │ ├── hide.gif │ ├── hide.tape │ └── main.go ├── readme │ ├── input │ │ ├── input.gif │ │ ├── suggestions.gif │ │ ├── input.tape │ │ ├── suggestions.tape │ │ └── main.go │ ├── text │ │ ├── text.gif │ │ ├── main.go │ │ └── text.tape │ ├── confirm │ │ ├── confirm.gif │ │ ├── main.go │ │ └── confirm.tape │ ├── select │ │ ├── select.gif │ │ ├── scroll │ │ │ ├── scroll.gif │ │ │ ├── scroll.tape │ │ │ └── scroll.go │ │ ├── main.go │ │ └── select.tape │ ├── multiselect │ │ ├── multiselect.gif │ │ ├── multiselect.tape │ │ └── main.go │ ├── note │ │ └── main.go │ └── main │ │ └── main.go ├── theme │ ├── charm-theme.png │ ├── basesixteen-theme.png │ ├── catppuccin-theme.png │ ├── default-theme.png │ ├── dracula-theme.png │ ├── theme.tape │ └── main.go ├── bubbletea │ ├── bubbletea.gif │ ├── demo.tape │ └── main.go ├── accessibility │ ├── accessible.gif │ ├── accessible.tape │ └── main.go ├── help │ └── main.go ├── filepicker-picking │ └── main.go ├── bubbletea-options │ └── main.go ├── dynamic │ ├── demo.tape │ ├── dynamic-markdown │ │ └── main.go │ ├── dynamic-increment │ │ └── main.go │ ├── dynamic-suggestions │ │ └── main.go │ ├── dynamic-name │ │ └── main.go │ ├── dynamic-count │ │ └── main.go │ ├── dynamic-all │ │ └── main.go │ ├── dynamic-country │ │ └── main.go │ └── dynamic-bubbletea │ │ └── main.go ├── scroll │ └── main.go ├── gum │ └── main.go ├── layout │ ├── default │ │ └── main.go │ ├── stack │ │ └── main.go │ ├── columns │ │ └── main.go │ └── grid │ │ └── main.go ├── skip │ └── main.go ├── git │ └── main.go ├── accessibility-secure-input │ └── main.go ├── multiple-groups │ └── main.go ├── conditional │ └── main.go ├── gh │ └── create.go ├── go.mod ├── ssh-form │ └── main.go ├── stickers │ └── main.go ├── timer │ └── main.go └── go.sum ├── .github ├── CODEOWNERS ├── workflows │ ├── lint-sync.yml │ ├── dependabot-sync.yml │ ├── lint.yml │ └── build.yml └── dependabot.yml ├── .gitattributes ├── huh.go ├── clamp.go ├── spinner ├── examples │ ├── loading │ │ ├── spinner.gif │ │ ├── demo.tape │ │ └── main.go │ ├── static │ │ └── main.go │ ├── context │ │ └── main.go │ ├── accessible │ │ └── main.go │ ├── context-and-action │ │ └── main.go │ └── context-and-action-and-error │ │ └── main.go ├── go.mod ├── go.sum ├── spinner_test.go └── spinner.go ├── wrap.go ├── run.go ├── Makefile ├── .gitignore ├── .golangci.yml ├── option.go ├── accessor.go ├── LICENSE ├── accessibility └── accessibility.go ├── validate.go ├── eval.go ├── go.mod ├── internal ├── selector │ └── selector.go └── accessibility │ └── accessibility.go ├── layout.go ├── go.sum ├── keymap.go ├── field_note.go ├── field_confirm.go ├── group.go └── field_filepicker.go /examples/.gitignore: -------------------------------------------------------------------------------- 1 | .ssh 2 | -------------------------------------------------------------------------------- /examples/filepicker/artichoke.hs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/filepicker/profile.jpeg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/filepicker/profile.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @charmbracelet/everyone 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.gif filter=lfs diff=lfs merge=lfs -text 2 | *.png filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /huh.go: -------------------------------------------------------------------------------- 1 | // Package huh provides components to build terminal-based forms and prompts. 2 | package huh 3 | -------------------------------------------------------------------------------- /clamp.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | func clamp(n, low, high int) int { 4 | if low > high { 5 | low, high = high, low 6 | } 7 | return min(high, max(low, n)) 8 | } 9 | -------------------------------------------------------------------------------- /examples/burger/taco.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:43d5130e04942106883513924ad435cdbe093c0c04113e222b6a7047d354076d 3 | size 219794 4 | -------------------------------------------------------------------------------- /examples/hide/hide.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a2e73f0a0c0088c0b8b0097a2856c95f3ff23690e7a840198c54bb9785eddc6d 3 | size 50749 4 | -------------------------------------------------------------------------------- /examples/burger/accessible.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3b1a4a5dd0552023215a144fc4585792d9df0b4898f4fa6421e02fc1dd98b07d 3 | size 296756 4 | -------------------------------------------------------------------------------- /examples/burger/burger.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8023e888007277abbeb33e06986a475eb12fafd142558be2338ae93552a368bb 3 | size 256361 4 | -------------------------------------------------------------------------------- /examples/readme/input/input.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b0522934f6e2262b3a7d7ab360f74e307204dd49d71729a92cd25a6e9b735c17 3 | size 29959 4 | -------------------------------------------------------------------------------- /examples/readme/text/text.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:88364052b099fc9f08e3d7008e21de6e0082890c50ec8752585f5e5d99caded6 3 | size 339650 4 | -------------------------------------------------------------------------------- /examples/theme/charm-theme.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:16a673c1e51a8940ccd5022c7aa1bd8e072df30bdf0e15c730d4683bb3217abb 3 | size 44897 4 | -------------------------------------------------------------------------------- /examples/bubbletea/bubbletea.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:7328ed89ce3e75019ae693e91a352d5b53167dd070ee74eae44eb8414e9a0dcc 3 | size 185865 4 | -------------------------------------------------------------------------------- /examples/readme/confirm/confirm.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c245e750b611f3577b1765eb02d09d90d4233cdb93d06f57c3c387ef7f87a712 3 | size 45020 4 | -------------------------------------------------------------------------------- /examples/readme/select/select.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:2e21cdfea2d9cf6d84b58b6cb4358822cc6e7b95474daafbbaf77639c3a05316 3 | size 69902 4 | -------------------------------------------------------------------------------- /examples/theme/basesixteen-theme.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:94ae0a2fda4d8e57710210345022fdc1ac930c59c100a0720096b7d78bdb6d20 3 | size 39832 4 | -------------------------------------------------------------------------------- /examples/theme/catppuccin-theme.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f48a647c651e81f96ade935630c38e51958e09e42f6e47886710a5a744196b98 3 | size 56497 4 | -------------------------------------------------------------------------------- /examples/theme/default-theme.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6a291f96fbf67c61fe382b71f66f1fad20a4eac22dc241f889e9c3b8bdf157bc 3 | size 40910 4 | -------------------------------------------------------------------------------- /examples/theme/dracula-theme.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:9663f27cb41ab961437bf2b3f87595710c89b594711c6de1f9b6a264b90b6f92 3 | size 53001 4 | -------------------------------------------------------------------------------- /spinner/examples/loading/spinner.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:51d09e731448f303ffddf399b560c2b9110c7e9f6de0ee7ef5fb284c4d4042e7 3 | size 25024 4 | -------------------------------------------------------------------------------- /wrap.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import "github.com/charmbracelet/x/cellbuf" 4 | 5 | func wrap(s string, limit int) string { 6 | return cellbuf.Wrap(s, limit, ",.-; ") 7 | } 8 | -------------------------------------------------------------------------------- /examples/accessibility/accessible.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:18968e486040bc87376e58ef21a898282aa0de2be3db8ead8df48f33e476712d 3 | size 35462 4 | -------------------------------------------------------------------------------- /examples/readme/input/suggestions.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:032c4c810ace1a4741b0affd0cd9364c6794d745091378018f97beb717d77a4c 3 | size 42564 4 | -------------------------------------------------------------------------------- /examples/readme/select/scroll/scroll.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c2e12453fb9429298812baeb554ee45c34da7c156c2d71f9863bf783d887cd6c 3 | size 158395 4 | -------------------------------------------------------------------------------- /examples/readme/multiselect/multiselect.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:93862ae6eb293c9c245d8d2241bef5ee83ca9b300a29d23d048f26e53ade373d 3 | size 43635 4 | -------------------------------------------------------------------------------- /run.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | // Run runs a single field by wrapping it within a group and a form. 4 | func Run(field Field) error { 5 | group := NewGroup(field) 6 | form := NewForm(group).WithShowHelp(false) 7 | return form.Run() 8 | } 9 | -------------------------------------------------------------------------------- /spinner/examples/static/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/huh/spinner" 7 | ) 8 | 9 | func main() { 10 | _ = spinner.New().Title("Loading").Accessible(true).Run() 11 | fmt.Println("Done!") 12 | } 13 | -------------------------------------------------------------------------------- /spinner/examples/loading/demo.tape: -------------------------------------------------------------------------------- 1 | Output spinner.gif 2 | 3 | Set FontSize 32 4 | Set Height 225 5 | Set Width 800 6 | 7 | Hide 8 | Type "go build -o spinner ." Enter 9 | Ctrl+L 10 | Sleep 1s 11 | Show 12 | 13 | Type "./spinner" 14 | Sleep 500ms 15 | Enter 16 | 17 | Sleep 4s 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: spinner 2 | 3 | $(V).SILENT: 4 | test: 5 | go test ./... 6 | 7 | spinner: 8 | cd spinner/examples/loading && go run . 9 | 10 | burger: 11 | cd examples/burger && go run . 12 | 13 | theme: 14 | cd examples/theme && go run . 15 | 16 | gh: 17 | cd examples/gh && go run . 18 | -------------------------------------------------------------------------------- /examples/help/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | f := huh.NewForm( 7 | huh.NewGroup( 8 | huh.NewInput().Title("Dynamic Help"), 9 | huh.NewInput().Title("Dynamic Help"), 10 | huh.NewInput().Title("Dynamic Help"), 11 | ), 12 | ) 13 | f.Run() 14 | } 15 | -------------------------------------------------------------------------------- /examples/readme/note/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | note := huh.NewNote().Description( 7 | "# Heading\n" + "This is _italic_, *bold*" + 8 | "\n\n# Heading\n" + "`This is _italic_, *bold*`", 9 | ) 10 | huh.NewForm( 11 | huh.NewGroup(note), 12 | ).Run() 13 | } 14 | -------------------------------------------------------------------------------- /examples/bubbletea/demo.tape: -------------------------------------------------------------------------------- 1 | Set Height 775 2 | Set Padding 60 3 | Set Width 1200 4 | Set FontSize 20 5 | 6 | Hide 7 | Type "clear && go run ." 8 | Enter 9 | Sleep 1s 10 | Show 11 | Sleep 2s 12 | 13 | Down Sleep 1s 14 | Enter Sleep 1s 15 | Down Sleep 1s 16 | Enter Sleep 1s 17 | Enter Sleep 1.5s 18 | Left Sleep 2s 19 | Enter Sleep 3s 20 | -------------------------------------------------------------------------------- /.github/workflows/lint-sync.yml: -------------------------------------------------------------------------------- 1 | name: lint-sync 2 | on: 3 | schedule: 4 | # every Sunday at midnight 5 | - cron: "0 0 * * 0" 6 | workflow_dispatch: # allows manual triggering 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | lint: 14 | uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main 15 | -------------------------------------------------------------------------------- /examples/readme/confirm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/charmbracelet/huh" 5 | ) 6 | 7 | func main() { 8 | var happy bool 9 | 10 | confirm := huh.NewConfirm(). 11 | Title("Are you sure? "). 12 | Description("Please confirm. "). 13 | Affirmative("Yes!"). 14 | Negative("No."). 15 | Value(&happy) 16 | 17 | huh.NewForm(huh.NewGroup(confirm)).Run() 18 | } 19 | -------------------------------------------------------------------------------- /examples/hide/hide.tape: -------------------------------------------------------------------------------- 1 | Output hide.gif 2 | 3 | Set Width 700 4 | Set Padding 40 5 | Set Height 350 6 | Set FontSize 28 7 | 8 | Hide 9 | Type "go build ." 10 | Sleep 500ms 11 | Enter 12 | Ctrl+L 13 | Sleep 500ms 14 | Type "clear && ./hide" 15 | Sleep 500ms 16 | Enter 17 | Sleep 500ms 18 | Show 19 | 20 | Type@500ms "llllllll" 21 | 22 | Hide 23 | Type "rm hide" Enter 24 | 25 | -------------------------------------------------------------------------------- /spinner/examples/context/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/charmbracelet/huh/spinner" 9 | ) 10 | 11 | func main() { 12 | action := func() { time.Sleep(5 * time.Second) } 13 | ctx, _ := context.WithTimeout(context.Background(), time.Second) 14 | go action() 15 | spinner.New().Context(ctx).Run() 16 | fmt.Println("Done!") 17 | } 18 | -------------------------------------------------------------------------------- /examples/readme/select/scroll/scroll.tape: -------------------------------------------------------------------------------- 1 | Output scroll.gif 2 | 3 | Set Width 800 4 | Set Padding 40 5 | Set Height 375 6 | Set FontSize 28 7 | 8 | Hide 9 | Type "go build scroll.go" 10 | Sleep 500ms 11 | Enter 12 | Ctrl+L 13 | Sleep 500ms 14 | Type "clear && ./scroll" 15 | Sleep 500ms 16 | Enter 17 | Sleep 500ms 18 | Show 19 | 20 | Down@250ms 20 21 | 22 | Hide 23 | Type "rm scroll" Enter 24 | -------------------------------------------------------------------------------- /examples/readme/input/input.tape: -------------------------------------------------------------------------------- 1 | Output input.gif 2 | 3 | Set Width 1000 4 | Set Padding 30 5 | Set Height 275 6 | Set FontSize 38 7 | 8 | Hide 9 | Type "go build ." 10 | Sleep 500ms 11 | Enter 12 | Ctrl+L 13 | Sleep 500ms 14 | Type "./input" 15 | Sleep 500ms 16 | Enter 17 | Sleep 500ms 18 | Show 19 | 20 | Sleep 1s 21 | Type "Spaghetti" 22 | Sleep 2s 23 | 24 | Hide 25 | Type "rm input" 26 | Enter 27 | -------------------------------------------------------------------------------- /spinner/examples/loading/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/huh/spinner" 8 | ) 9 | 10 | func main() { 11 | action := func() { 12 | time.Sleep(1 * time.Second) 13 | } 14 | if err := spinner.New().Title("Preparing your burger...").Action(action).Run(); err != nil { 15 | fmt.Println("Failed:", err) 16 | return 17 | } 18 | fmt.Println("Order up!") 19 | } 20 | 21 | -------------------------------------------------------------------------------- /spinner/examples/accessible/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/charmbracelet/huh/spinner" 9 | ) 10 | 11 | func main() { 12 | ctx, cancel := context.WithTimeout(context.Background(), time.Second/2) 13 | defer cancel() 14 | 15 | err := spinner.New(). 16 | Context(ctx). 17 | Accessible(true). 18 | Run() 19 | if err != nil { 20 | log.Fatalln(err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/readme/confirm/confirm.tape: -------------------------------------------------------------------------------- 1 | Output confirm.gif 2 | 3 | Set Width 1100 4 | Set Padding 40 5 | Set Height 375 6 | Set FontSize 36 7 | 8 | Hide 9 | Type "go build ." 10 | Sleep 500ms 11 | Enter 12 | Ctrl+L 13 | Sleep 500ms 14 | Type "clear && ./confirm" 15 | Sleep 500ms 16 | Enter 17 | Sleep 500ms 18 | Show 19 | 20 | Sleep 1s 21 | Left@500ms 6 22 | Sleep 2s 23 | 24 | Hide 25 | Type "rm confirm" 26 | Enter 27 | Sleep 500ms 28 | -------------------------------------------------------------------------------- /examples/filepicker-picking/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/huh" 7 | ) 8 | 9 | func main() { 10 | var file string 11 | huh.NewForm( 12 | huh.NewGroup( 13 | huh.NewFilePicker(). 14 | Picking(true). 15 | Title("Code"). 16 | Description("Select a .go file"). 17 | AllowedTypes([]string{".go"}). 18 | Value(&file), 19 | ), 20 | ).WithShowHelp(true).Run() 21 | fmt.Println(file) 22 | } 23 | -------------------------------------------------------------------------------- /examples/readme/select/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | var country string 7 | s := huh.NewSelect[string](). 8 | Title("Pick a country."). 9 | Options( 10 | huh.NewOption("United States", "US"), 11 | huh.NewOption("Germany", "DE"), 12 | huh.NewOption("Brazil", "BR"), 13 | huh.NewOption("Canada", "CA"), 14 | ). 15 | Value(&country) 16 | 17 | huh.NewForm(huh.NewGroup(s)).Run() 18 | } 19 | -------------------------------------------------------------------------------- /examples/readme/input/suggestions.tape: -------------------------------------------------------------------------------- 1 | Output suggestions.gif 2 | 3 | Set Width 1000 4 | Set Padding 30 5 | Set Height 275 6 | Set FontSize 38 7 | 8 | Hide 9 | Type "go build ." 10 | Sleep 500ms 11 | Enter 12 | Ctrl+L 13 | Sleep 500ms 14 | Type "./input" 15 | Sleep 500ms 16 | Enter 17 | Sleep 500ms 18 | Show 19 | 20 | Sleep 1s 21 | Type@300ms "Curryw" 22 | Sleep 1.5s 23 | Ctrl+E 24 | Sleep 3s 25 | 26 | Hide 27 | Type "rm input" 28 | Enter 29 | Sleep 0.5s 30 | 31 | -------------------------------------------------------------------------------- /examples/accessibility/accessible.tape: -------------------------------------------------------------------------------- 1 | Output accessible.gif 2 | 3 | Set Height 600 4 | Set Width 1000 5 | 6 | Hide 7 | Type "go build -o accessible ." Enter 8 | Type "export ACCESSIBLE=true" Enter 9 | Type "clear && ./accessible" 10 | Enter 11 | Sleep 1s 12 | Show 13 | 14 | Sleep 1s 15 | 16 | Type "2" 17 | Sleep 500ms 18 | Enter 19 | Sleep 1.5s 20 | 21 | Type "Souvlaki" 22 | Sleep 1.5s 23 | Enter 24 | Sleep 1.5s 25 | 26 | Hide 27 | Type "rm accessible" Enter 28 | Sleep 1s 29 | -------------------------------------------------------------------------------- /examples/filepicker/demo.tape: -------------------------------------------------------------------------------- 1 | Set Shell bash 2 | 3 | Set Width 800 4 | Set Height 725 5 | 6 | Hide 7 | Type "clear && go build -o file" 8 | Enter 9 | Show 10 | 11 | Sleep .5s 12 | Type "./file" Sleep .5s 13 | Enter 14 | 15 | Sleep 1s 16 | Type "Frank" Sleep 500ms 17 | Enter 18 | 19 | Sleep 1s 20 | Type "_frank" Sleep 500ms 21 | Enter 22 | 23 | Sleep 1s 24 | Enter 25 | Sleep 1s 26 | Type@200ms "jjjj" 27 | Sleep 1s 28 | Enter 29 | 30 | Sleep 1.5s 31 | Type "hunter2" 32 | Sleep 4s 33 | -------------------------------------------------------------------------------- /examples/readme/select/select.tape: -------------------------------------------------------------------------------- 1 | Output select.gif 2 | 3 | Set Width 1100 4 | Set Padding 40 5 | Set Height 375 6 | Set FontSize 28 7 | 8 | Hide 9 | Type "go build ." 10 | Sleep 500ms 11 | Enter 12 | Ctrl+L 13 | Sleep 500ms 14 | Type "clear && ./select" 15 | Sleep 500ms 16 | Enter 17 | Sleep 500ms 18 | Show 19 | 20 | Sleep 1s 21 | Down@500ms 3 22 | Sleep 1s 23 | Up@500ms 2 24 | Sleep 1s 25 | 26 | Type "/" 27 | Sleep 1s 28 | Type "cana" 29 | Sleep 2s 30 | 31 | Hide 32 | Type "rm select" Enter 33 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /examples/readme/text/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | // TODO: ensure input is not plagiarized. 6 | func checkForPlagiarism(s string) error { return nil } 7 | 8 | func main() { 9 | var story string 10 | 11 | text := huh.NewText(). 12 | Title("Tell me a story."). 13 | Validate(checkForPlagiarism). 14 | Placeholder("What's on your mind?"). 15 | Value(&story) 16 | 17 | // Create a form to show help. 18 | form := huh.NewForm(huh.NewGroup(text)) 19 | form.Run() 20 | } 21 | -------------------------------------------------------------------------------- /examples/bubbletea-options/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | var name string 12 | form := huh.NewForm( 13 | huh.NewGroup(huh.NewInput().Description("What should we call you?").Value(&name)), 14 | ).WithProgramOptions(tea.WithAltScreen()) 15 | 16 | err := form.Run() 17 | if err != nil { 18 | fmt.Println("error:", err) 19 | } 20 | 21 | fmt.Println("Welcome, " + name + "!") 22 | } 23 | -------------------------------------------------------------------------------- /examples/dynamic/demo.tape: -------------------------------------------------------------------------------- 1 | Output dynamic.gif 2 | 3 | Set Shell "bash" 4 | Set FontSize 28 5 | Set Width 1000 6 | Set Height 700 7 | 8 | Hide 9 | Type "clear && go build -o dynamic ./dynamic-country" 10 | Enter 11 | Sleep 1s 12 | Show 13 | 14 | Sleep 1s 15 | Type "./dynamic" Sleep 500ms Enter 16 | 17 | Sleep 3.5s 18 | Down 19 | Sleep 2.5s 20 | Down 21 | Sleep 2.5s 22 | Enter 23 | Sleep 1s 24 | Down@150ms 12 25 | Up@150ms 2 26 | 27 | Sleep 1s 28 | 29 | Enter 30 | 31 | Sleep 3s 32 | 33 | Hide 34 | Type "rm dynamic" 35 | Show 36 | -------------------------------------------------------------------------------- /examples/readme/multiselect/multiselect.tape: -------------------------------------------------------------------------------- 1 | Output multiselect.gif 2 | 3 | Set Width 1150 4 | Set Padding 40 5 | Set Height 480 6 | Set FontSize 28 7 | 8 | Hide 9 | Type "go build ." 10 | Sleep 500ms 11 | Enter 12 | Ctrl+L 13 | Sleep 500ms 14 | Type "clear && ./multiselect" 15 | Sleep 500ms 16 | Enter 17 | Sleep 500ms 18 | Show 19 | 20 | Sleep 1s 21 | Down@500ms 3 22 | Sleep 1s 23 | Type "x" 24 | Sleep 1s 25 | Up@500ms 2 26 | Sleep 1s 27 | 28 | Type "x" 29 | Sleep 2s 30 | 31 | Hide 32 | Type "rm multiselect" Enter 33 | Sleep 1s 34 | -------------------------------------------------------------------------------- /spinner/examples/context-and-action/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "time" 9 | 10 | "github.com/charmbracelet/huh/spinner" 11 | ) 12 | 13 | func main() { 14 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 15 | defer cancel() 16 | 17 | err := spinner.New(). 18 | Context(ctx). 19 | Action(func() { 20 | time.Sleep(time.Minute) 21 | }). 22 | Accessible(rand.Int()%2 == 0). 23 | Run() 24 | if err != nil { 25 | log.Fatalln(err) 26 | } 27 | fmt.Println("Done!") 28 | } 29 | -------------------------------------------------------------------------------- /examples/scroll/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | form := huh.NewForm( 7 | huh.NewGroup( 8 | huh.NewInput().Title("First"), 9 | huh.NewInput().Title("Second"), 10 | huh.NewInput().Title("Third"), 11 | huh.NewInput().Title("Fourth"), 12 | huh.NewInput().Title("Fifth"), 13 | huh.NewInput().Title("Sixth"), 14 | huh.NewInput().Title("Seventh"), 15 | huh.NewInput().Title("Eighth"), 16 | huh.NewInput().Title("Nineth"), 17 | huh.NewInput().Title("Tenth"), 18 | ), 19 | ).WithHeight(5) 20 | form.Run() 21 | } 22 | -------------------------------------------------------------------------------- /examples/accessibility/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/charmbracelet/huh" 7 | ) 8 | 9 | func main() { 10 | form := huh.NewForm( 11 | huh.NewGroup( 12 | huh.NewSelect[string](). 13 | Options(huh.NewOptions("Italian", "Greek", "Indian", "Japanese", "American")...). 14 | Title("Favorite Cuisine?"), 15 | ), 16 | 17 | huh.NewGroup( 18 | huh.NewInput(). 19 | Title("Favorite Meal?"). 20 | Placeholder("Breakfast"), 21 | ), 22 | ).WithAccessible(true) 23 | 24 | err := form.Run() 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/readme/text/text.tape: -------------------------------------------------------------------------------- 1 | Output text.gif 2 | 3 | Set Width 1000 4 | Set Padding 40 5 | Set Height 450 6 | Set FontSize 28 7 | 8 | Hide 9 | Type "go build ." Enter 10 | Ctrl+L 11 | Type "./text" Enter 12 | Sleep 500ms 13 | Show 14 | 15 | Sleep 1s 16 | Type "Once upon a time, in the heart of a lush, enchanted forest, there existed a peculiar village named Charm Dale. This village was unlike any other; its cobblestone streets were lined with houses crafted from the timber of ancient, towering trees that sparkled under the sunlight." 17 | Sleep 4s 18 | 19 | Hide 20 | Type "rm text" Enter 21 | -------------------------------------------------------------------------------- /spinner/examples/context-and-action-and-error/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/charmbracelet/huh/spinner" 10 | ) 11 | 12 | func main() { 13 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 14 | defer cancel() 15 | 16 | err := spinner.New(). 17 | Context(ctx). 18 | ActionWithErr(func(context.Context) error { 19 | time.Sleep(5 * time.Second) 20 | return nil 21 | }). 22 | Accessible(false). 23 | Run() 24 | if err != nil { 25 | log.Fatalln(err) 26 | } 27 | fmt.Println("Done!") 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # Debugging 24 | debug.log 25 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-markdown/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/charmbracelet/glamour" 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | var md string 12 | err := huh.NewForm( 13 | huh.NewGroup( 14 | huh.NewText().Title("Markdown").Value(&md), 15 | huh.NewNote().Height(20).Title("Preview"). 16 | DescriptionFunc(func() string { 17 | fmd, err := glamour.Render(md, "dark") 18 | if err != nil { 19 | return md 20 | } 21 | return fmd 22 | }, &md), 23 | ), 24 | ).Run() 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/gum/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | if len(os.Args) < 2 { 12 | fmt.Println("gum ") 13 | os.Exit(1) 14 | } 15 | switch os.Args[1] { 16 | case "input": 17 | huh.NewInput().Run() 18 | case "text": 19 | huh.NewText().Run() 20 | case "confirm": 21 | huh.NewConfirm().Run() 22 | case "select": 23 | huh.NewSelect[string]().Options(huh.NewOptions(os.Args[2:]...)...).Run() 24 | case "multiselect": 25 | huh.NewMultiSelect[string]().Options(huh.NewOptions(os.Args[2:]...)...).Run() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/layout/default/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | form := huh.NewForm( 7 | huh.NewGroup( 8 | huh.NewInput().Title("First"), 9 | huh.NewInput().Title("Second"), 10 | huh.NewInput().Title("Third"), 11 | ), 12 | huh.NewGroup( 13 | huh.NewInput().Title("Fourth"), 14 | huh.NewInput().Title("Fifth"), 15 | huh.NewInput().Title("Sixth"), 16 | ), 17 | huh.NewGroup( 18 | huh.NewInput().Title("Seventh"), 19 | huh.NewInput().Title("Eighth"), 20 | huh.NewInput().Title("Nineth"), 21 | huh.NewInput().Title("Tenth"), 22 | ), 23 | ) 24 | form.Run() 25 | } 26 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-increment/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | 12 | count := 0 13 | go func() { 14 | for { 15 | count++ 16 | time.Sleep(1 * time.Second) 17 | } 18 | }() 19 | 20 | descriptionFunc := func() string { 21 | return fmt.Sprintf("The count is: %d", count) 22 | } 23 | 24 | huh.NewForm(huh.NewGroup( 25 | huh.NewInput(). 26 | Title("Fill in the input"). 27 | DescriptionFunc(descriptionFunc, &count), 28 | huh.NewInput(). 29 | Title("Fill in the input"). 30 | DescriptionFunc(descriptionFunc, &count), 31 | )).Run() 32 | 33 | } 34 | -------------------------------------------------------------------------------- /examples/layout/stack/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | form := huh.NewForm( 7 | huh.NewGroup( 8 | huh.NewInput().Title("First"), 9 | huh.NewInput().Title("Second"), 10 | huh.NewInput().Title("Third"), 11 | ), 12 | huh.NewGroup( 13 | huh.NewInput().Title("Fourth"), 14 | huh.NewInput().Title("Fifth"), 15 | huh.NewInput().Title("Sixth"), 16 | ), 17 | huh.NewGroup( 18 | huh.NewInput().Title("Seventh"), 19 | huh.NewInput().Title("Eighth"), 20 | huh.NewInput().Title("Nineth"), 21 | huh.NewInput().Title("Tenth"), 22 | ), 23 | ).WithLayout(huh.LayoutStack) 24 | form.Run() 25 | } 26 | -------------------------------------------------------------------------------- /examples/layout/columns/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | form := huh.NewForm( 7 | huh.NewGroup( 8 | huh.NewInput().Title("First"), 9 | huh.NewInput().Title("Second"), 10 | huh.NewInput().Title("Third"), 11 | ), 12 | huh.NewGroup( 13 | huh.NewInput().Title("Fourth"), 14 | huh.NewInput().Title("Fifth"), 15 | huh.NewInput().Title("Sixth"), 16 | ), 17 | huh.NewGroup( 18 | huh.NewInput().Title("Seventh"), 19 | huh.NewInput().Title("Eighth"), 20 | huh.NewInput().Title("Nineth"), 21 | huh.NewInput().Title("Tenth"), 22 | ), 23 | ).WithLayout(huh.LayoutColumns(2)) 24 | form.Run() 25 | } 26 | -------------------------------------------------------------------------------- /examples/readme/multiselect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | var toppings []string 7 | s := huh.NewMultiSelect[string](). 8 | Options( 9 | huh.NewOption("Lettuce", "Lettuce").Selected(true), 10 | huh.NewOption("Tomatoes", "Tomatoes").Selected(true), 11 | huh.NewOption("Charm Sauce", "Charm Sauce"), 12 | huh.NewOption("Jalapeños", "Jalapeños"), 13 | huh.NewOption("Cheese", "Cheese"), 14 | huh.NewOption("Vegan Cheese", "Vegan Cheese"), 15 | huh.NewOption("Nutella", "Nutella"), 16 | ). 17 | Title("Toppings"). 18 | Limit(4). 19 | Value(&toppings) 20 | 21 | huh.NewForm(huh.NewGroup(s)).Run() 22 | } 23 | -------------------------------------------------------------------------------- /examples/theme/theme.tape: -------------------------------------------------------------------------------- 1 | Output theme.gif 2 | 3 | Set Width 800 4 | Set Height 740 5 | Set Padding 80 6 | 7 | Hide 8 | Type "go build -o theme ." 9 | Enter 10 | Ctrl+L 11 | Sleep 500ms 12 | Type "clear && ./theme" Enter 13 | Show 14 | 15 | Sleep 2s 16 | 17 | Enter 18 | 19 | Screenshot default-theme.png 20 | 21 | Tab 4 22 | Down 1 23 | Enter 24 | 25 | Screenshot dracula-theme.png 26 | 27 | Tab 4 28 | Down 2 29 | Enter 30 | 31 | Screenshot basesixteen-theme.png 32 | 33 | Tab 4 34 | Down 3 35 | Enter 36 | 37 | Screenshot charm-theme.png 38 | 39 | Tab 4 40 | Down 4 41 | Enter 42 | 43 | Screenshot catppuccin-theme.png 44 | 45 | Sleep 1s 46 | 47 | Hide 48 | Type "rm theme" 49 | Enter 50 | -------------------------------------------------------------------------------- /examples/filepicker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/charmbracelet/huh" 5 | ) 6 | 7 | func main() { 8 | var file string 9 | 10 | huh.NewForm( 11 | huh.NewGroup( 12 | huh.NewInput(). 13 | Title("Name"). 14 | Description("What's your name?"), 15 | 16 | huh.NewInput(). 17 | Title("Username"). 18 | Description("Select your username."), 19 | 20 | huh.NewFilePicker(). 21 | Title("Profile"). 22 | Description("Select your profile picture."). 23 | AllowedTypes([]string{".png", ".jpeg", ".webp", ".gif"}). 24 | Value(&file), 25 | 26 | huh.NewInput(). 27 | Title("Password"). 28 | EchoMode(huh.EchoModePassword). 29 | Description("Set your Password."), 30 | ), 31 | ).WithShowHelp(true).Run() 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 9 | pull-requests: read 10 | 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Install Go 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: ^1 20 | 21 | - uses: actions/checkout@v6 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v9 24 | with: 25 | # Optional: golangci-lint command line arguments. 26 | #args: 27 | # Optional: show only new issues if it's a pull request. The default value is `false`. 28 | only-new-issues: true 29 | -------------------------------------------------------------------------------- /examples/layout/grid/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | func main() { 6 | form := huh.NewForm( 7 | huh.NewGroup( 8 | huh.NewInput().Title("First"), 9 | huh.NewInput().Title("Second"), 10 | huh.NewInput().Title("Third"), 11 | ), 12 | huh.NewGroup( 13 | huh.NewInput().Title("Fourth"), 14 | huh.NewInput().Title("Fifth"), 15 | huh.NewInput().Title("Sixth"), 16 | ), 17 | huh.NewGroup( 18 | huh.NewInput().Title("Seventh"), 19 | huh.NewInput().Title("Eighth"), 20 | huh.NewInput().Title("Nineth"), 21 | huh.NewInput().Title("Tenth"), 22 | ), 23 | huh.NewGroup( 24 | huh.NewInput().Title("Eleventh"), 25 | huh.NewInput().Title("Twelveth"), 26 | huh.NewInput().Title("Thirteenth"), 27 | ), 28 | ).WithLayout(huh.LayoutGrid(2, 2)) 29 | form.Run() 30 | } 31 | -------------------------------------------------------------------------------- /examples/burger/demo.tape: -------------------------------------------------------------------------------- 1 | Output burger.gif 2 | 3 | Set Height 700 4 | Set Width 1000 5 | 6 | Hide 7 | Type "go build -o burger ." Enter 8 | Ctrl+L 9 | Sleep 1s 10 | 11 | Type "clear && ./burger" 12 | Sleep 500ms 13 | Enter 14 | Sleep 500ms 15 | 16 | Show 17 | 18 | Sleep 1s 19 | Type "n" 20 | Sleep 1s 21 | Down 2 22 | Sleep 500ms Enter 23 | Sleep 1s 24 | Up@500ms 25 | Sleep 500ms Enter 26 | Sleep 500ms 27 | Down@300ms 3 28 | Sleep 300ms 29 | Space 30 | Sleep 750ms Enter 31 | Sleep 500ms 32 | Down@300ms 2 33 | Sleep 500ms Enter 34 | Sleep 750ms 35 | Down@300ms 36 | Sleep 500ms Enter 37 | 38 | Sleep 1s 39 | 40 | Type "Hilda" 41 | Sleep 500ms Enter 42 | 43 | Sleep 1s 44 | Type "Extra spicy please!" 45 | Sleep 500ms Tab 46 | 47 | Sleep 750ms 48 | Left 49 | Sleep 750ms Enter 50 | 51 | Sleep 5s 52 | 53 | Hide 54 | Type "rm burger" Enter 55 | Sleep 1s 56 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: false 4 | linters: 5 | enable: 6 | - bodyclose 7 | - exhaustive 8 | - goconst 9 | - godot 10 | - gomoddirectives 11 | - goprintffuncname 12 | - gosec 13 | - misspell 14 | - nakedret 15 | - nestif 16 | - nilerr 17 | - noctx 18 | - nolintlint 19 | - prealloc 20 | - revive 21 | - rowserrcheck 22 | - sqlclosecheck 23 | - tparallel 24 | - unconvert 25 | - unparam 26 | - whitespace 27 | - wrapcheck 28 | exclusions: 29 | rules: 30 | - text: '(slog|log)\.\w+' 31 | linters: 32 | - noctx 33 | generated: lax 34 | presets: 35 | - common-false-positives 36 | issues: 37 | max-issues-per-linter: 0 38 | max-same-issues: 0 39 | formatters: 40 | enable: 41 | - gofumpt 42 | - goimports 43 | exclusions: 44 | generated: lax 45 | -------------------------------------------------------------------------------- /examples/hide/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/huh" 7 | ) 8 | 9 | func main() { 10 | var isAllergic bool 11 | var allergies string 12 | 13 | huh.NewForm( 14 | huh.NewGroup(huh.NewNote().Title("Just for fun!")).WithHideFunc(func() bool { return true }), 15 | huh.NewGroup(huh.NewNote().Title("Just for fun!")).WithHide(true), 16 | 17 | huh.NewGroup(huh.NewConfirm(). 18 | Title("Do you have any allergies?"). 19 | Description("If so, please list them."). 20 | Value(&isAllergic)), 21 | huh.NewGroup( 22 | huh.NewText(). 23 | Title("Allergies"). 24 | Description("Please list all your allergies..."). 25 | Value(&allergies), 26 | ).WithHideFunc(func() bool { 27 | return !isAllergic 28 | }), 29 | huh.NewGroup(huh.NewNote().Title("Invisible")).WithHide(true), 30 | ).Run() 31 | 32 | if isAllergic { 33 | fmt.Println(allergies) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/readme/input/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/huh" 7 | ) 8 | 9 | func isFood(_ string) error { 10 | return nil 11 | } 12 | 13 | func main() { 14 | var lunch string 15 | 16 | input := huh.NewInput(). 17 | Title("What's for lunch?"). 18 | Prompt("? "). 19 | Suggestions([]string{ 20 | "Artichoke", 21 | "Baking Flour", 22 | "Bananas", 23 | "Barley", 24 | "Bean Sprouts", 25 | "Bitter Melon", 26 | "Black Cod", 27 | "Blood Orange", 28 | "Brown Sugar", 29 | "Cashew Apple", 30 | "Cashews", 31 | "Cat Food", 32 | "Coconut Milk", 33 | "Cucumber", 34 | "Curry Paste", 35 | "Currywurst", 36 | "Dill", 37 | "Dragonfruit", 38 | "Dried Shrimp", 39 | "Eggs", 40 | "Fish Cake", 41 | "Furikake", 42 | "Garlic", 43 | }). 44 | Validate(isFood). 45 | Value(&lunch) 46 | 47 | huh.NewForm(huh.NewGroup(input)).Run() 48 | 49 | fmt.Printf("Yummy, %s!\n", lunch) 50 | } 51 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import "fmt" 4 | 5 | // Option is an option for select fields. 6 | type Option[T comparable] struct { 7 | Key string 8 | Value T 9 | selected bool 10 | } 11 | 12 | // NewOptions returns new options from a list of values. 13 | func NewOptions[T comparable](values ...T) []Option[T] { 14 | options := make([]Option[T], len(values)) 15 | for i, o := range values { 16 | options[i] = Option[T]{ 17 | Key: fmt.Sprint(o), 18 | Value: o, 19 | } 20 | } 21 | return options 22 | } 23 | 24 | // NewOption returns a new select option. 25 | func NewOption[T comparable](key string, value T) Option[T] { 26 | return Option[T]{Key: key, Value: value} 27 | } 28 | 29 | // Selected sets whether the option is currently selected. 30 | func (o Option[T]) Selected(selected bool) Option[T] { 31 | o.selected = selected 32 | return o 33 | } 34 | 35 | // String returns the key of the option. 36 | func (o Option[T]) String() string { 37 | return o.Key 38 | } 39 | -------------------------------------------------------------------------------- /examples/skip/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/charmbracelet/huh" 5 | ) 6 | 7 | func main() { 8 | f := huh.NewForm( 9 | huh.NewGroup( 10 | huh.NewNote(). 11 | Title("Charmburger"). 12 | Description("Welcome to _Charmburger™_."), 13 | 14 | huh.NewSelect[string](). 15 | Options(huh.NewOptions("Charmburger Classic", "Chickwich", "Fishburger", "Charmpossible™ Burger")...). 16 | Title("Choose your burger"). 17 | Description("At Charm we truly have a burger for everyone."), 18 | 19 | huh.NewNote(). 20 | Title("🍔"), 21 | ), 22 | 23 | huh.NewGroup( 24 | huh.NewNote(). 25 | Title("Buy 1 get 1 free"). 26 | Description("Welcome back to _Charmburger™_."), 27 | 28 | huh.NewSelect[string](). 29 | Options(huh.NewOptions("Charmburger Classic", "Chickwich", "Fishburger", "Charmpossible™ Burger")...). 30 | Title("Choose your burger"). 31 | Description("At Charm we truly have a burger for everyone."), 32 | 33 | huh.NewNote(). 34 | Title("🍔"), 35 | ), 36 | ) 37 | 38 | f.Run() 39 | } 40 | -------------------------------------------------------------------------------- /accessor.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | // Accessor give read/write access to field values. 4 | type Accessor[T any] interface { 5 | Get() T 6 | Set(value T) 7 | } 8 | 9 | // EmbeddedAccessor is a basic accessor, acting as the default one for fields. 10 | type EmbeddedAccessor[T any] struct { 11 | value T 12 | } 13 | 14 | // Get gets the value. 15 | func (a *EmbeddedAccessor[T]) Get() T { 16 | return a.value 17 | } 18 | 19 | // Set sets the value. 20 | func (a *EmbeddedAccessor[T]) Set(value T) { 21 | a.value = value 22 | } 23 | 24 | // PointerAccessor allows field value to be exposed as a pointed variable. 25 | type PointerAccessor[T any] struct { 26 | value *T 27 | } 28 | 29 | // NewPointerAccessor returns a new pointer accessor. 30 | func NewPointerAccessor[T any](value *T) *PointerAccessor[T] { 31 | return &PointerAccessor[T]{ 32 | value: value, 33 | } 34 | } 35 | 36 | // Get gets the value. 37 | func (a *PointerAccessor[T]) Get() T { 38 | return *a.value 39 | } 40 | 41 | // Set sets the value. 42 | func (a *PointerAccessor[T]) Set(value T) { 43 | *a.value = value 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Charm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/readme/select/scroll/scroll.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/huh" 4 | 5 | type Pokemon struct { 6 | id int 7 | name string 8 | } 9 | 10 | var pokemons = []Pokemon{ 11 | {1, "Bulbasaur"}, 12 | {2, "Ivysaur"}, 13 | {3, "Venusaur"}, 14 | {4, "Charmander"}, 15 | {5, "Charmeleon"}, 16 | {6, "Charizard"}, 17 | {7, "Squirtle"}, 18 | {8, "Wartortle"}, 19 | {9, "Blastoise"}, 20 | {10, "Caterpie"}, 21 | {11, "Metapod"}, 22 | {12, "Butterfree"}, 23 | {13, "Weedle"}, 24 | {14, "Kakuna"}, 25 | {15, "Beedrill"}, 26 | {16, "Pidgey"}, 27 | {17, "Pidgeotto"}, 28 | {18, "Pidgeot"}, 29 | {19, "Rattata"}, 30 | {20, "Raticate"}, 31 | {21, "Spearow"}, 32 | {22, "Fearow"}, 33 | {23, "Ekans"}, 34 | {24, "Arbok"}, 35 | {25, "Pikachu"}, 36 | {26, "Raichu"}, 37 | {27, "Sandshrew"}, 38 | {28, "Sandslash"}, 39 | } 40 | 41 | func (p Pokemon) String() string { 42 | return p.name 43 | } 44 | 45 | func main() { 46 | var pokemon Pokemon 47 | 48 | s := huh.NewSelect[Pokemon](). 49 | Title("Choose your starter"). 50 | Options(huh.NewOptions(pokemons...)...). 51 | Value(&pokemon). 52 | WithHeight(7) 53 | 54 | huh.NewForm(huh.NewGroup(s)).Run() 55 | } 56 | -------------------------------------------------------------------------------- /examples/git/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/charmbracelet/huh" 5 | ) 6 | 7 | // types is the possible commit types specified by the conventional commit spec. 8 | var types = []string{"fix", "feat", "docs", "style", "refactor", "test", "chore", "revert"} 9 | 10 | // This form is used to write a conventional commit message. It prompts the user 11 | // to choose the type of commit as specified in the conventional commit spec. 12 | // And then prompts for the summary and detailed description of the message and 13 | // uses the values provided as the summary and details of the message. 14 | func main() { 15 | var commit, scope string 16 | var summary, description string 17 | var confirm bool 18 | 19 | huh.NewForm( 20 | huh.NewGroup( 21 | huh.NewInput().Title("Type").Value(&commit).Placeholder("feat").Suggestions(types), 22 | huh.NewInput().Title("Scope").Value(&scope).Placeholder("scope"), 23 | ), 24 | huh.NewGroup( 25 | huh.NewInput().Title("Summary").Value(&summary).Placeholder("Summary of changes"), 26 | huh.NewText().Title("Description").Value(&description).Placeholder("Detailed description of changes"), 27 | ), 28 | huh.NewGroup(huh.NewConfirm().Title("Commit changes?").Value(&confirm)), 29 | ).Run() 30 | } 31 | -------------------------------------------------------------------------------- /examples/accessibility-secure-input/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func validate(s string) error { 11 | if s == "" { 12 | return errors.New("input cannot be empty") 13 | } 14 | return nil 15 | } 16 | 17 | func main() { 18 | form := huh.NewForm( 19 | huh.NewGroup( 20 | huh.NewNote(). 21 | Title("Welcome!"). 22 | Description("This is an accessible form example!"), 23 | huh.NewInput(). 24 | Validate(validate). 25 | Title("Name:"), 26 | huh.NewInput(). 27 | EchoMode(huh.EchoModePassword). 28 | Validate(validate). 29 | Title("Password:"), 30 | huh.NewMultiSelect[string](). 31 | Options(huh.NewOptions( 32 | "Red", 33 | "Green", 34 | "Yellow", 35 | )...). 36 | Limit(2). 37 | Title("Choose some colors:"), 38 | huh.NewSelect[string](). 39 | Options(huh.NewOptions( 40 | "Red", 41 | "Green", 42 | "Yellow", 43 | )...). 44 | Title("Choose the best color:"), 45 | huh.NewFilePicker(). 46 | Title("Which file?"), 47 | huh.NewConfirm(). 48 | Title("Send something?"), 49 | ), 50 | ).WithAccessible(true) 51 | 52 | err := form.Run() 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /spinner/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/huh/spinner 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.21.0 7 | github.com/charmbracelet/bubbletea v1.3.10 8 | github.com/charmbracelet/lipgloss v1.1.0 9 | github.com/muesli/termenv v0.16.0 10 | ) 11 | 12 | require ( 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 15 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 16 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 17 | github.com/charmbracelet/x/term v0.2.1 // indirect 18 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/mattn/go-localereader v0.0.1 // indirect 22 | github.com/mattn/go-runewidth v0.0.16 // indirect 23 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 24 | github.com/muesli/cancelreader v0.2.2 // indirect 25 | github.com/rivo/uniseg v0.4.7 // indirect 26 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 27 | golang.org/x/sys v0.36.0 // indirect 28 | golang.org/x/text v0.16.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-suggestions/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/charmbracelet/huh" 8 | "github.com/charmbracelet/huh/spinner" 9 | ) 10 | 11 | func main() { 12 | var org string 13 | var repo string 14 | 15 | err := huh.NewForm( 16 | huh.NewGroup( 17 | huh.NewInput(). 18 | Value(&org). 19 | Title("Organization"). 20 | Placeholder("charmbracelet"), 21 | huh.NewInput(). 22 | Value(&repo). 23 | Title("Repository"). 24 | PlaceholderFunc(func() string { 25 | switch org { 26 | case "hashicorp": 27 | return "terraform" 28 | case "golang": 29 | return "go" 30 | default: // charmbracelet 31 | return "bubbletea" 32 | } 33 | }, &org). 34 | SuggestionsFunc(func() []string { 35 | switch org { 36 | case "charmbracelet": 37 | return []string{"bubbletea", "huh", "mods", "melt", "freeze", "gum", "vhs", "pop", "lipgloss", "harmonica"} 38 | case "hashicorp": 39 | return []string{"terraform", "vault", "waypoint"} 40 | case "golang": 41 | return []string{"go", "net", "sys", "text", "tools"} 42 | default: 43 | return nil 44 | } 45 | }, &org), 46 | ), 47 | ).Run() 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | 52 | spinner.New().Title(fmt.Sprintf("Cloning %s/%s...", org, repo)).Run() 53 | } 54 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-name/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | var name string 12 | 13 | err := huh.NewForm( 14 | huh.NewGroup( 15 | huh.NewInput(). 16 | Title("What's your name?"). 17 | Placeholder("Frank"). 18 | Value(&name), 19 | huh.NewNote(). 20 | TitleFunc(func() string { 21 | if name == "" { 22 | return "Hello!" 23 | } 24 | return fmt.Sprintf("Hello, %s!", name) 25 | }, &name). 26 | DescriptionFunc(func() string { 27 | if name == "" { 28 | return "How are you?" 29 | } 30 | return fmt.Sprintf("Your name is %d characters long", len(name)) 31 | }, &name), 32 | huh.NewText(). 33 | Title("Biography."). 34 | PlaceholderFunc(func() string { 35 | placeholder := "Tell me about yourself" 36 | if name != "" { 37 | placeholder += ", " + name 38 | } 39 | placeholder += "." 40 | return placeholder 41 | }, &name), 42 | huh.NewConfirm(). 43 | TitleFunc(func() string { 44 | if name == "" { 45 | return "Continue?" 46 | } 47 | return fmt.Sprintf("Continue, %s?", name) 48 | }, &name). 49 | DescriptionFunc(func() string { 50 | if name == "" { 51 | return "Are you sure?" 52 | } 53 | return fmt.Sprintf("Last chance, %s.", name) 54 | }, &name), 55 | ), 56 | ).Run() 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | fmt.Println("Until next time, " + name + "!") 62 | } 63 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-count/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | 9 | "github.com/charmbracelet/huh" 10 | ) 11 | 12 | func main() { 13 | var value string 14 | defaultValue := 10 15 | var chosen int 16 | 17 | f := huh.NewForm( 18 | huh.NewGroup( 19 | huh.NewInput(). 20 | Value(&value). 21 | Title("Max"). 22 | Placeholder(strconv.Itoa(defaultValue)). 23 | Validate(func(s string) error { 24 | v, err := strconv.Atoi(value) 25 | if err != nil { 26 | return errors.New("max should be a number") 27 | } 28 | if v <= 0 { 29 | return errors.New("maximum must be positive") 30 | } 31 | return nil 32 | }). 33 | Description("Select a maximum"), 34 | 35 | huh.NewSelect[int](). 36 | Value(&chosen). 37 | Title("Pick a number"). 38 | DescriptionFunc(func() string { 39 | v, err := strconv.Atoi(value) 40 | if err != nil || v <= 0 { 41 | v = defaultValue 42 | } 43 | return "Between 1 and " + strconv.Itoa(v) 44 | }, &value). 45 | OptionsFunc(func() []huh.Option[int] { 46 | var options []huh.Option[int] 47 | v, err := strconv.Atoi(value) 48 | if err != nil { 49 | v = defaultValue 50 | } 51 | for i := range v { 52 | options = append(options, huh.NewOption(strconv.Itoa(i+1), i+1)) 53 | } 54 | return options 55 | }, &value), 56 | ), 57 | ) 58 | err := f.Run() 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | fmt.Println(chosen) 63 | } 64 | -------------------------------------------------------------------------------- /accessibility/accessibility.go: -------------------------------------------------------------------------------- 1 | // Package accessibility provides accessible functions to capture user input. 2 | // 3 | // Deprecated: use [internal/accessibility] instead. 4 | package accessibility 5 | 6 | import ( 7 | "os" 8 | 9 | "github.com/charmbracelet/huh/internal/accessibility" 10 | ) 11 | 12 | // PromptInt prompts a user for an integer between a certain range. 13 | // 14 | // Given invalid input (non-integers, integers outside of the range), the user 15 | // will continue to be reprompted until a valid input is given, ensuring that 16 | // the return value is always valid. 17 | // 18 | // Deprecated: use [accessibility.PromptInt] instead. 19 | func PromptInt(prompt string, low, high int) int { 20 | return accessibility.PromptInt(os.Stdout, os.Stdin, prompt, low, high, nil) 21 | } 22 | 23 | // PromptBool prompts a user for a boolean value. 24 | // 25 | // Given invalid input (non-boolean), the user will continue to be reprompted 26 | // until a valid input is given, ensuring that the return value is always valid. 27 | // 28 | // Deprecated: use [accessibility.PromptBool] instead. 29 | func PromptBool() bool { 30 | return accessibility.PromptBool(os.Stdout, os.Stdin, "Choose [y/N]: ", false) 31 | } 32 | 33 | // PromptString prompts a user for a string value and validates it against a 34 | // validator function. It re-prompts the user until a valid input is given. 35 | // 36 | // Deprecated: use [accessibility.PromptString] instead. 37 | func PromptString(prompt string, validator func(input string) error) string { 38 | return accessibility.PromptString(os.Stdout, os.Stdin, prompt, "", validator) 39 | } 40 | -------------------------------------------------------------------------------- /validate.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import ( 4 | "fmt" 5 | "unicode/utf8" 6 | ) 7 | 8 | // ValidateNotEmpty checks if the input is not empty. 9 | func ValidateNotEmpty() func(s string) error { 10 | return func(s string) error { 11 | if err := ValidateMinLength(1)(s); err != nil { 12 | return fmt.Errorf("input cannot be empty") 13 | } 14 | return nil 15 | } 16 | } 17 | 18 | // ValidateMinLength checks if the length of the input is at least min. 19 | func ValidateMinLength(v int) func(s string) error { 20 | return func(s string) error { 21 | if utf8.RuneCountInString(s) < v { 22 | return fmt.Errorf("input must be at least %d characters long", v) 23 | } 24 | return nil 25 | } 26 | } 27 | 28 | // ValidateMaxLength checks if the length of the input is at most max. 29 | func ValidateMaxLength(v int) func(s string) error { 30 | return func(s string) error { 31 | if utf8.RuneCountInString(s) > v { 32 | return fmt.Errorf("input must be at most %d characters long", v) 33 | } 34 | return nil 35 | } 36 | } 37 | 38 | // ValidateLength checks if the length of the input is within the specified range. 39 | func ValidateLength(minl, maxl int) func(s string) error { 40 | return func(s string) error { 41 | if err := ValidateMinLength(minl)(s); err != nil { 42 | return err 43 | } 44 | return ValidateMaxLength(maxl)(s) 45 | } 46 | } 47 | 48 | // ValidateOneOf checks if a string is one of the specified options. 49 | func ValidateOneOf(options ...string) func(string) error { 50 | validOptions := make(map[string]struct{}) 51 | for _, option := range options { 52 | validOptions[option] = struct{}{} 53 | } 54 | 55 | return func(value string) error { 56 | if _, ok := validOptions[value]; !ok { 57 | return fmt.Errorf("invalid option: %s", value) 58 | } 59 | return nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /examples/theme/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | var base *huh.Theme = huh.ThemeBase() 12 | var dracula *huh.Theme = huh.ThemeDracula() 13 | var base16 *huh.Theme = huh.ThemeBase16() 14 | var charm *huh.Theme = huh.ThemeCharm() 15 | var catppuccin *huh.Theme = huh.ThemeCatppuccin() 16 | var exit *huh.Theme = nil 17 | 18 | var theme *huh.Theme = base16 19 | 20 | repeat := true 21 | 22 | for { 23 | err := huh.NewSelect[*huh.Theme](). 24 | Title("Theme"). 25 | Value(&theme). 26 | Options( 27 | huh.NewOption("Default", base), 28 | huh.NewOption("Dracula", dracula), 29 | huh.NewOption("Base 16", base16), 30 | huh.NewOption("Charm", charm), 31 | huh.NewOption("Catppuccin", catppuccin), 32 | huh.NewOption("Exit", exit), 33 | ).Run() 34 | 35 | if err != nil { 36 | if err == huh.ErrUserAborted { 37 | os.Exit(130) 38 | } 39 | fmt.Println(err) 40 | os.Exit(1) 41 | } 42 | if theme == nil { 43 | break 44 | } 45 | 46 | // Display form with selected theme. 47 | err = huh.NewForm( 48 | huh.NewGroup( 49 | huh.NewInput().Title("Thoughts").Placeholder("What's on your mind?"), 50 | huh.NewSelect[string]().Options(huh.NewOptions("A", "B", "C")...).Title("Colors"), 51 | huh.NewFilePicker().Title("File"), 52 | huh.NewMultiSelect[string]().Options(huh.NewOptions("Red", "Green", "Yellow")...).Title("Letters"), 53 | huh.NewConfirm().Title("Again?").Description("Try another theme").Value(&repeat), 54 | ), 55 | ).WithTheme(theme).Run() 56 | if err != nil { 57 | if err == huh.ErrUserAborted { 58 | os.Exit(130) 59 | } 60 | fmt.Println(err) 61 | os.Exit(1) 62 | } 63 | 64 | if !repeat { 65 | break 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /eval.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mitchellh/hashstructure/v2" 7 | ) 8 | 9 | // Eval is an evaluatable value, it stores a cached value and a function to 10 | // recompute it. It's bindings are what we check to see if we need to recompute 11 | // the value. 12 | // 13 | // By default it is also cached. 14 | type Eval[T any] struct { 15 | val T 16 | fn func() T 17 | 18 | bindings any 19 | bindingsHash uint64 20 | cache map[uint64]T 21 | 22 | loading bool 23 | loadingStart time.Time 24 | } 25 | 26 | const spinnerShowThreshold = 25 * time.Millisecond 27 | 28 | func hash(val any) uint64 { 29 | hash, _ := hashstructure.Hash(val, hashstructure.FormatV2, nil) 30 | return hash 31 | } 32 | 33 | func (e *Eval[T]) shouldUpdate() (bool, uint64) { 34 | if e.fn == nil { 35 | return false, 0 36 | } 37 | newHash := hash(e.bindings) 38 | return e.bindingsHash != newHash, newHash 39 | } 40 | 41 | func (e *Eval[T]) loadFromCache() bool { 42 | val, ok := e.cache[e.bindingsHash] 43 | if ok { 44 | e.loading = false 45 | e.val = val 46 | } 47 | return ok 48 | } 49 | 50 | func (e *Eval[T]) update(val T) { 51 | e.val = val 52 | e.cache[e.bindingsHash] = val 53 | e.loading = false 54 | } 55 | 56 | type updateTitleMsg struct { 57 | id int 58 | hash uint64 59 | title string 60 | } 61 | 62 | type updateDescriptionMsg struct { 63 | id int 64 | hash uint64 65 | description string 66 | } 67 | 68 | type updatePlaceholderMsg struct { 69 | id int 70 | hash uint64 71 | placeholder string 72 | } 73 | 74 | type updateSuggestionsMsg struct { 75 | id int 76 | hash uint64 77 | suggestions []string 78 | } 79 | 80 | type updateOptionsMsg[T comparable] struct { 81 | id int 82 | hash uint64 83 | options []Option[T] 84 | } 85 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-all/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | var value string = "Dynamic" 12 | 13 | f := huh.NewForm( 14 | huh.NewGroup( 15 | huh.NewInput().Value(&value).Title("Dynamic").Description("Dynamic"), 16 | huh.NewNote(). 17 | TitleFunc(func() string { return value }, &value). 18 | DescriptionFunc(func() string { return value }, &value), 19 | huh.NewSelect[string](). 20 | Height(7). 21 | TitleFunc(func() string { return value }, &value). 22 | DescriptionFunc(func() string { return value }, &value). 23 | OptionsFunc(func() []huh.Option[string] { 24 | var options []huh.Option[string] 25 | for i := 1; i < 6; i++ { 26 | options = append(options, huh.NewOption(value+" "+strconv.Itoa(i), value+strconv.Itoa(i))) 27 | } 28 | return options 29 | }, &value), 30 | huh.NewMultiSelect[string](). 31 | Height(7). 32 | TitleFunc(func() string { return value }, &value). 33 | DescriptionFunc(func() string { return value }, &value). 34 | OptionsFunc(func() []huh.Option[string] { 35 | var options []huh.Option[string] 36 | for i := 1; i < 6; i++ { 37 | options = append(options, huh.NewOption(value+" "+strconv.Itoa(i), value+strconv.Itoa(i))) 38 | } 39 | return options 40 | }, &value), 41 | huh.NewConfirm(). 42 | TitleFunc(func() string { return value }, &value). 43 | DescriptionFunc(func() string { return value }, &value), 44 | huh.NewText(). 45 | TitleFunc(func() string { return value }, &value). 46 | DescriptionFunc(func() string { return value }, &value). 47 | PlaceholderFunc(func() string { return value }, &value), 48 | ), 49 | ) 50 | err := f.Run() 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/huh 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/catppuccin/go v0.3.0 9 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 10 | github.com/charmbracelet/bubbletea v1.3.6 11 | github.com/charmbracelet/lipgloss v1.1.0 12 | github.com/charmbracelet/x/ansi v0.9.3 13 | github.com/charmbracelet/x/cellbuf v0.0.13 14 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 15 | github.com/charmbracelet/x/term v0.2.1 16 | github.com/charmbracelet/x/xpty v0.1.2 17 | github.com/mitchellh/hashstructure/v2 v2.0.2 18 | ) 19 | 20 | require ( 21 | github.com/atotto/clipboard v0.1.4 // indirect 22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 23 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 24 | github.com/charmbracelet/x/conpty v0.1.0 // indirect 25 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect 26 | github.com/charmbracelet/x/termios v0.1.1 // indirect 27 | github.com/creack/pty v1.1.24 // indirect 28 | github.com/dustin/go-humanize v1.0.1 // indirect 29 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 30 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 31 | github.com/mattn/go-isatty v0.0.20 // indirect 32 | github.com/mattn/go-localereader v0.0.1 // indirect 33 | github.com/mattn/go-runewidth v0.0.16 // indirect 34 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 35 | github.com/muesli/cancelreader v0.2.2 // indirect 36 | github.com/muesli/termenv v0.16.0 // indirect 37 | github.com/rivo/uniseg v0.4.7 // indirect 38 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 39 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 40 | golang.org/x/sync v0.15.0 // indirect 41 | golang.org/x/sys v0.33.0 // indirect 42 | golang.org/x/text v0.23.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /examples/multiple-groups/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | func main() { 11 | f := huh.NewForm( 12 | huh.NewGroup( 13 | huh.NewSelect[string](). 14 | Options( 15 | huh.NewOption("A", "a"), 16 | huh.NewOption("B", "b"), 17 | huh.NewOption("C", "c"), 18 | huh.NewOption("D", "d"), 19 | huh.NewOption("E", "e"), 20 | huh.NewOption("F", "f"), 21 | huh.NewOption("G", "g"), 22 | huh.NewOption("H", "h"), 23 | huh.NewOption("I", "i"), 24 | huh.NewOption("J", "j"), 25 | huh.NewOption("K", "k").Selected(true), 26 | huh.NewOption("L", "l"), 27 | huh.NewOption("M", "m"), 28 | huh.NewOption("N", "n"), 29 | huh.NewOption("O", "o"), 30 | huh.NewOption("P", "p"), 31 | ), 32 | ).WithHeight(8), 33 | huh.NewGroup( 34 | huh.NewMultiSelect[string](). 35 | Options( 36 | huh.NewOption("A", "a"), 37 | huh.NewOption("B", "b"), 38 | huh.NewOption("C", "c"), 39 | huh.NewOption("D", "d"), 40 | huh.NewOption("E", "e"), 41 | huh.NewOption("F", "f"), 42 | huh.NewOption("G", "g"), 43 | huh.NewOption("H", "h"), 44 | huh.NewOption("I", "i"), 45 | huh.NewOption("K", "k").Selected(true), 46 | huh.NewOption("L", "l"), 47 | huh.NewOption("M", "m"), 48 | huh.NewOption("N", "n"), 49 | huh.NewOption("O", "o").Selected(true), 50 | huh.NewOption("P", "p"), 51 | ), 52 | ).WithHeight(10), 53 | huh.NewGroup( 54 | huh.NewSelect[string](). 55 | Options( 56 | huh.NewOption("A", "a"), 57 | huh.NewOption("B", "b"), 58 | huh.NewOption("C", "c"), 59 | huh.NewOption("D", "d"), 60 | huh.NewOption("E", "e"), 61 | huh.NewOption("F", "f"), 62 | huh.NewOption("G", "g"), 63 | huh.NewOption("H", "h"), 64 | huh.NewOption("I", "i"), 65 | huh.NewOption("J", "j"), 66 | huh.NewOption("K", "k").Selected(true), 67 | huh.NewOption("L", "l"), 68 | huh.NewOption("M", "m"), 69 | huh.NewOption("N", "n"), 70 | huh.NewOption("O", "o"), 71 | huh.NewOption("P", "p"), 72 | ), 73 | ).WithHeight(5), 74 | ) 75 | 76 | if err := f.Run(); err != nil { 77 | fmt.Fprintf(os.Stderr, "Oof: %v\n", err) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /examples/conditional/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | type consumable int 11 | 12 | const ( 13 | fruits consumable = iota 14 | vegetables 15 | drinks 16 | ) 17 | 18 | func (c consumable) String() string { 19 | return [...]string{"fruit", "vegetable", "drink"}[c] 20 | } 21 | 22 | func main() { 23 | 24 | var category consumable 25 | type opts []huh.Option[string] 26 | 27 | var choice string 28 | 29 | // Then ask for a specific food item based on the previous answer. 30 | err := 31 | huh.NewForm( 32 | huh.NewGroup( 33 | huh.NewSelect[consumable](). 34 | Title("What are you in the mood for?"). 35 | Value(&category). 36 | Options( 37 | huh.NewOption("Some fruit", fruits), 38 | huh.NewOption("A vegetable", vegetables), 39 | huh.NewOption("A drink", drinks), 40 | ), 41 | 42 | huh.NewSelect[string](). 43 | Value(&choice). 44 | Height(7). 45 | TitleFunc(func() string { 46 | return fmt.Sprintf("Okay, what kind of %s are you in the mood for?", category) 47 | }, &category). 48 | OptionsFunc(func() []huh.Option[string] { 49 | switch category { 50 | case fruits: 51 | return []huh.Option[string]{ 52 | huh.NewOption("Tangerine", "tangerine"), 53 | huh.NewOption("Canteloupe", "canteloupe"), 54 | huh.NewOption("Pomelo", "pomelo"), 55 | huh.NewOption("Grapefruit", "grapefruit"), 56 | } 57 | case vegetables: 58 | return []huh.Option[string]{ 59 | huh.NewOption("Carrot", "carrot"), 60 | huh.NewOption("Jicama", "jicama"), 61 | huh.NewOption("Kohlrabi", "kohlrabi"), 62 | huh.NewOption("Fennel", "fennel"), 63 | huh.NewOption("Ginger", "ginger"), 64 | } 65 | default: 66 | return []huh.Option[string]{ 67 | huh.NewOption("Coffee", "coffee"), 68 | huh.NewOption("Tea", "tea"), 69 | huh.NewOption("Bubble Tea", "bubble tea"), 70 | huh.NewOption("Agua Fresca", "agua-fresca"), 71 | } 72 | } 73 | }, &category), 74 | ), 75 | ).Run() 76 | 77 | if err != nil { 78 | fmt.Println("Trouble in food paradise:", err) 79 | os.Exit(1) 80 | } 81 | 82 | fmt.Printf("One %s coming right up!\n", choice) 83 | } 84 | -------------------------------------------------------------------------------- /examples/gh/create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/charmbracelet/huh" 9 | "github.com/charmbracelet/huh/spinner" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | type Action int 14 | 15 | const ( 16 | Cancel Action = iota 17 | Push 18 | Fork 19 | Skip 20 | ) 21 | 22 | var highlight = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D7D7")) 23 | 24 | func main() { 25 | var action Action 26 | spinnerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("4")) 27 | 28 | repo := "charmbracelet/huh" 29 | theme := huh.ThemeBase16() 30 | theme.FieldSeparator = lipgloss.NewStyle().SetString("\n") 31 | theme.Help.FullKey.MarginTop(1) 32 | 33 | f := huh.NewForm( 34 | huh.NewGroup( 35 | huh.NewSelect[Action](). 36 | Value(&action). 37 | Options( 38 | huh.NewOption(repo, Push), 39 | huh.NewOption("Create a fork of "+repo, Fork), 40 | huh.NewOption("Skip pushing the branch", Skip), 41 | huh.NewOption("Cancel", Cancel), 42 | ). 43 | Title("Where should we push the 'feature' branch?"), 44 | ), 45 | ).WithTheme(theme) 46 | 47 | err := f.Run() 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | 52 | switch action { 53 | case Push: 54 | _ = spinner.New().Title("Pushing to charmbracelet/huh").Style(spinnerStyle).Run() 55 | fmt.Println("Pushed to charmbracelet/huh") 56 | case Fork: 57 | fmt.Println("Creating a fork of charmbracelet/huh...") 58 | case Skip: 59 | fmt.Println("Skipping pushing the branch...") 60 | case Cancel: 61 | fmt.Println("Cancelling...") 62 | os.Exit(1) 63 | } 64 | 65 | fmt.Printf("Creating pull request for %s into %s in %s\n", highlight.Render("test"), highlight.Render("main"), repo) 66 | 67 | var nextAction string 68 | 69 | f = huh.NewForm( 70 | huh.NewGroup( 71 | huh.NewInput(). 72 | Title("Title "). 73 | Prompt(""). 74 | Inline(true), 75 | huh.NewText(). 76 | Title("Body"), 77 | ), 78 | huh.NewGroup( 79 | huh.NewSelect[string](). 80 | Options(huh.NewOptions("Submit", "Submit as draft", "Continue in browser", "Add metadata", "Cancel")...). 81 | Title("What's next?").Value(&nextAction), 82 | ), 83 | ).WithTheme(theme) 84 | 85 | err = f.Run() 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | if nextAction == "Submit" { 91 | _ = spinner.New().Title("Submitting...").Style(spinnerStyle).Run() 92 | fmt.Println("Pull request submitted!") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | groups: 17 | all: 18 | patterns: 19 | - "*" 20 | ignore: 21 | - dependency-name: github.com/charmbracelet/bubbletea/v2 22 | versions: 23 | - v2.0.0-beta1 24 | 25 | - package-ecosystem: "github-actions" 26 | directory: "/" 27 | schedule: 28 | interval: "weekly" 29 | day: "monday" 30 | time: "05:00" 31 | timezone: "America/New_York" 32 | labels: 33 | - "dependencies" 34 | commit-message: 35 | prefix: "chore" 36 | include: "scope" 37 | groups: 38 | all: 39 | patterns: 40 | - "*" 41 | 42 | - package-ecosystem: "docker" 43 | directory: "/" 44 | schedule: 45 | interval: "weekly" 46 | day: "monday" 47 | time: "05:00" 48 | timezone: "America/New_York" 49 | labels: 50 | - "dependencies" 51 | commit-message: 52 | prefix: "chore" 53 | include: "scope" 54 | groups: 55 | all: 56 | patterns: 57 | - "*" 58 | 59 | - package-ecosystem: "gomod" 60 | directory: "/examples" 61 | schedule: 62 | interval: "weekly" 63 | day: "monday" 64 | time: "05:00" 65 | timezone: "America/New_York" 66 | labels: 67 | - "dependencies" 68 | commit-message: 69 | prefix: "chore" 70 | include: "scope" 71 | groups: 72 | all: 73 | patterns: 74 | - "*" 75 | ignore: 76 | - dependency-name: github.com/charmbracelet/bubbletea/v2 77 | versions: 78 | - v2.0.0-beta1 79 | 80 | - package-ecosystem: "gomod" 81 | directory: "/spinner" 82 | schedule: 83 | interval: "weekly" 84 | day: "monday" 85 | time: "05:00" 86 | timezone: "America/New_York" 87 | labels: 88 | - "dependencies" 89 | commit-message: 90 | prefix: "chore" 91 | include: "scope" 92 | groups: 93 | all: 94 | patterns: 95 | - "*" 96 | ignore: 97 | - dependency-name: github.com/charmbracelet/bubbletea/v2 98 | versions: 99 | - v2.0.0-beta1 100 | -------------------------------------------------------------------------------- /internal/selector/selector.go: -------------------------------------------------------------------------------- 1 | // Package selector provides a helper type for selecting items. 2 | package selector 3 | 4 | // Selector is a helper type for selecting items. 5 | type Selector[T any] struct { 6 | items []T 7 | index int 8 | } 9 | 10 | // NewSelector creates a new item selector. 11 | func NewSelector[T any](items []T) *Selector[T] { 12 | return &Selector[T]{ 13 | items: items, 14 | } 15 | } 16 | 17 | // Append adds an item to the selector. 18 | func (s *Selector[T]) Append(item T) { 19 | s.items = append(s.items, item) 20 | } 21 | 22 | // Next moves the selector to the next item. 23 | func (s *Selector[T]) Next() { 24 | if s.index < len(s.items)-1 { 25 | s.index++ 26 | } 27 | } 28 | 29 | // Prev moves the selector to the previous item. 30 | func (s *Selector[T]) Prev() { 31 | if s.index > 0 { 32 | s.index-- 33 | } 34 | } 35 | 36 | // OnFirst returns true if the selector is on the first item. 37 | func (s *Selector[T]) OnFirst() bool { 38 | return s.index == 0 39 | } 40 | 41 | // OnLast returns true if the selector is on the last item. 42 | func (s *Selector[T]) OnLast() bool { 43 | return s.index == len(s.items)-1 44 | } 45 | 46 | // Selected returns the index of the current selected item. 47 | func (s *Selector[T]) Selected() T { 48 | return s.items[s.index] 49 | } 50 | 51 | // Index returns the index of the current selected item. 52 | func (s *Selector[T]) Index() int { 53 | return s.index 54 | } 55 | 56 | // Total returns the total number of items. 57 | func (s *Selector[T]) Total() int { 58 | return len(s.items) 59 | } 60 | 61 | // SetIndex sets the selected item. 62 | func (s *Selector[T]) SetIndex(i int) { 63 | if i < 0 || i >= len(s.items) { 64 | return 65 | } 66 | s.index = i 67 | } 68 | 69 | // Get returns the item at the given index. 70 | func (s *Selector[T]) Get(i int) T { 71 | return s.items[i] 72 | } 73 | 74 | // Set sets the item at the given index. 75 | func (s *Selector[T]) Set(i int, item T) { 76 | s.items[i] = item 77 | } 78 | 79 | // Range iterates over the items. 80 | // The callback function should return true to continue the iteration. 81 | func (s *Selector[T]) Range(f func(i int, item T) bool) { 82 | for i, item := range s.items { 83 | if !f(i, item) { 84 | break 85 | } 86 | } 87 | } 88 | 89 | // ReverseRange iterates over the items in reverse. 90 | // The callback function should return true to continue the iteration. 91 | func (s *Selector[T]) ReverseRange(f func(i int, item T) bool) { 92 | for i := len(s.items) - 1; i >= 0; i-- { 93 | if !f(i, s.items[i]) { 94 | break 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [oldstable, stable] 8 | os: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | env: 11 | GO111MODULE: "on" 12 | steps: 13 | - uses: actions/checkout@v6 14 | - uses: actions/setup-go@v6 15 | with: 16 | go-version: ${{ matrix.go-version }} 17 | cache: true 18 | - run: go mod download 19 | - run: go build -v ./... 20 | - run: go test -v -race ./... 21 | spinner: 22 | strategy: 23 | matrix: 24 | go-version: [oldstable, stable] 25 | os: [ubuntu-latest, macos-latest, windows-latest] 26 | runs-on: ${{ matrix.os }} 27 | env: 28 | GO111MODULE: "on" 29 | steps: 30 | - uses: actions/checkout@v6 31 | - uses: actions/setup-go@v6 32 | with: 33 | go-version: ${{ matrix.go-version }} 34 | cache: true 35 | - run: go mod download 36 | working-directory: ./spinner 37 | - run: go build -v ./... 38 | working-directory: ./spinner 39 | - run: go test -v -race ./... 40 | working-directory: ./spinner 41 | examples: 42 | strategy: 43 | matrix: 44 | go-version: [oldstable, stable] 45 | os: [ubuntu-latest, macos-latest, windows-latest] 46 | runs-on: ${{ matrix.os }} 47 | env: 48 | GO111MODULE: "on" 49 | steps: 50 | - uses: actions/checkout@v6 51 | - uses: actions/setup-go@v6 52 | with: 53 | go-version: ${{ matrix.go-version }} 54 | cache: true 55 | - run: go mod download 56 | working-directory: ./examples 57 | - run: go build -v ./... 58 | working-directory: ./examples 59 | - run: go test -v -race ./... 60 | working-directory: ./examples 61 | dependabot: 62 | needs: [test, examples, spinner] 63 | runs-on: ubuntu-latest 64 | permissions: 65 | pull-requests: write 66 | contents: write 67 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 68 | steps: 69 | - id: metadata 70 | uses: dependabot/fetch-metadata@v2 71 | with: 72 | github-token: "${{ secrets.GITHUB_TOKEN }}" 73 | - run: | 74 | gh pr review --approve "$PR_URL" 75 | gh pr merge --squash --auto "$PR_URL" 76 | env: 77 | PR_URL: ${{github.event.pull_request.html_url}} 78 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 79 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module examples 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 9 | github.com/charmbracelet/bubbletea v1.3.10 10 | github.com/charmbracelet/glamour v0.10.0 11 | github.com/charmbracelet/huh v0.0.0-00010101000000-000000000000 12 | github.com/charmbracelet/huh/spinner v0.0.0-00010101000000-000000000000 13 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 14 | github.com/charmbracelet/log v0.4.2 15 | github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 16 | github.com/charmbracelet/wish v1.4.7 17 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 18 | ) 19 | 20 | require ( 21 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 22 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 23 | github.com/atotto/clipboard v0.1.4 // indirect 24 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 25 | github.com/aymerick/douceur v0.2.0 // indirect 26 | github.com/catppuccin/go v0.3.0 // indirect 27 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 28 | github.com/charmbracelet/harmonica v0.2.0 // indirect 29 | github.com/charmbracelet/keygen v0.5.3 // indirect 30 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 31 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 32 | github.com/charmbracelet/x/conpty v0.1.0 // indirect 33 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect 34 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 35 | github.com/charmbracelet/x/input v0.3.4 // indirect 36 | github.com/charmbracelet/x/term v0.2.1 // indirect 37 | github.com/charmbracelet/x/termios v0.1.1 // indirect 38 | github.com/charmbracelet/x/windows v0.2.0 // indirect 39 | github.com/creack/pty v1.1.24 // indirect 40 | github.com/dlclark/regexp2 v1.11.0 // indirect 41 | github.com/dustin/go-humanize v1.0.1 // indirect 42 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 43 | github.com/go-logfmt/logfmt v0.6.0 // indirect 44 | github.com/gorilla/css v1.0.1 // indirect 45 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/mattn/go-localereader v0.0.1 // indirect 48 | github.com/mattn/go-runewidth v0.0.16 // indirect 49 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 50 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 52 | github.com/muesli/cancelreader v0.2.2 // indirect 53 | github.com/muesli/reflow v0.3.0 // indirect 54 | github.com/muesli/termenv v0.16.0 // indirect 55 | github.com/rivo/uniseg v0.4.7 // indirect 56 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 57 | github.com/yuin/goldmark v1.7.8 // indirect 58 | github.com/yuin/goldmark-emoji v1.0.5 // indirect 59 | golang.org/x/crypto v0.36.0 // indirect 60 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 61 | golang.org/x/net v0.36.0 // indirect 62 | golang.org/x/sys v0.36.0 // indirect 63 | golang.org/x/term v0.31.0 // indirect 64 | golang.org/x/text v0.24.0 // indirect 65 | ) 66 | 67 | replace github.com/charmbracelet/huh => ../ 68 | 69 | replace github.com/charmbracelet/huh/spinner => ../spinner 70 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-country/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/log" 8 | 9 | "github.com/charmbracelet/huh" 10 | ) 11 | 12 | func main() { 13 | log.SetReportTimestamp(false) 14 | 15 | var ( 16 | country string 17 | state string 18 | ) 19 | 20 | form := huh.NewForm( 21 | huh.NewGroup( 22 | huh.NewSelect[string](). 23 | Options(huh.NewOptions("United States", "Canada", "Mexico")...). 24 | Value(&country). 25 | Title("Country"). 26 | Height(5), 27 | huh.NewSelect[string](). 28 | Value(&state). 29 | Height(8). 30 | TitleFunc(func() string { 31 | switch country { 32 | case "United States": 33 | return "State" 34 | case "Canada": 35 | return "Province" 36 | default: 37 | return "Territory" 38 | } 39 | }, &country). 40 | OptionsFunc(func() []huh.Option[string] { 41 | s := states[country] 42 | // simulate API call 43 | time.Sleep(1000 * time.Millisecond) 44 | return huh.NewOptions(s...) 45 | }, &country /* only this function when `country` changes */), 46 | ), 47 | ) 48 | 49 | err := form.Run() 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | 54 | fmt.Printf("%s, %s\n", state, country) 55 | } 56 | 57 | var states = map[string][]string{ 58 | "Canada": { 59 | "Alberta", 60 | "British Columbia", 61 | "Manitoba", 62 | "New Brunswick", 63 | "Newfoundland and Labrador", 64 | "North West Territories", 65 | "Nova Scotia", 66 | "Nunavut", 67 | "Ontario", 68 | "Prince Edward Island", 69 | "Quebec", 70 | "Saskatchewan", 71 | "Yukon", 72 | }, 73 | "Mexico": { 74 | "Aguascalientes", 75 | "Baja California", 76 | "Baja California Sur", 77 | "Campeche", 78 | "Chiapas", 79 | "Chihuahua", 80 | "Coahuila", 81 | "Colima", 82 | "Durango", 83 | "Guanajuato", 84 | "Guerrero", 85 | "Hidalgo", 86 | "Jalisco", 87 | "México", 88 | "Mexico City", 89 | "Michoacán", 90 | "Morelos", 91 | "Nayarit", 92 | "Nuevo León", 93 | "Oaxaca", 94 | "Puebla", 95 | "Querétaro", 96 | "Quintana Roo", 97 | "San Luis Potosí", 98 | "Sinaloa", 99 | "Sonora", 100 | "Tabasco", 101 | "Tamaulipas", 102 | "Tlaxcala", 103 | "Veracruz", 104 | "Ignacio de la Llave", 105 | "Yucatán", 106 | "Zacatecas", 107 | }, 108 | "United States": { 109 | "Alabama", 110 | "Alaska", 111 | "Arizona", 112 | "Arkansas", 113 | "California", 114 | "Colorado", 115 | "Connecticut", 116 | "Delaware", 117 | "Florida", 118 | "Georgia", 119 | "Hawaii", 120 | "Idaho", 121 | "Illinois", 122 | "Indiana", 123 | "Iowa", 124 | "Kansas", 125 | "Kentucky", 126 | "Louisiana", 127 | "Maine", 128 | "Maryland", 129 | "Massachusetts", 130 | "Michigan", 131 | "Minnesota", 132 | "Mississippi", 133 | "Missouri", 134 | "Montana", 135 | "Nebraska", 136 | "Nevada", 137 | "New Hampshire", 138 | "New Jersey", 139 | "New Mexico", 140 | "New York", 141 | "North Carolina", 142 | "North Dakota", 143 | "Ohio", 144 | "Oklahoma", 145 | "Oregon", 146 | "Pennsylvania", 147 | "Rhode Island", 148 | "South Carolina", 149 | "South Dakota", 150 | "Tennessee", 151 | "Texas", 152 | "Utah", 153 | "Vermont", 154 | "Virginia", 155 | "Washington", 156 | "West Virginia", 157 | "Wisconsin", 158 | "Wyoming", 159 | }, 160 | } 161 | -------------------------------------------------------------------------------- /examples/readme/main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/charmbracelet/huh" 7 | ) 8 | 9 | // TODO: ensure input is not plagiarized. 10 | func checkForPlagiarism(s string) error { return nil } 11 | 12 | // TODO: ensure input is food. 13 | func isFood(s string) error { return nil } 14 | 15 | // TODO: ensure input is a valid name. 16 | func validateName(s string) error { return nil } 17 | 18 | func main() { 19 | var ( 20 | lunch string 21 | story string 22 | country string 23 | toppings []string 24 | discount bool 25 | ) 26 | 27 | // `Input`s are single line text fields. 28 | huh.NewInput(). 29 | Title("What's for lunch?"). 30 | Prompt("?"). 31 | Validate(isFood). 32 | Value(&lunch) 33 | 34 | // `Text`s are multi-line text fields. 35 | huh.NewText(). 36 | Title("Tell me a story."). 37 | Validate(checkForPlagiarism). 38 | Value(&story) 39 | 40 | // `Select`s are multiple choice questions. 41 | huh.NewSelect[string](). 42 | Title("Pick a country."). 43 | Options( 44 | huh.NewOption("United States", "US"), 45 | huh.NewOption("Germany", "DE"), 46 | huh.NewOption("Brazil", "BR"), 47 | huh.NewOption("Canada", "CA"), 48 | ). 49 | Value(&country) 50 | 51 | // `MultiSelect`s allow multiple selections from a list of options. 52 | huh.NewMultiSelect[string](). 53 | Options( 54 | huh.NewOption("Cheese", "cheese").Selected(true), 55 | huh.NewOption("Lettuce", "lettuce").Selected(true), 56 | huh.NewOption("Corn", "corn"), 57 | huh.NewOption("Salsa", "salsa"), 58 | huh.NewOption("Sour Cream", "sour cream"), 59 | huh.NewOption("Tomatoes", "tomatoes"), 60 | ). 61 | Title("Toppings"). 62 | Limit(4). 63 | Value(&toppings) 64 | 65 | // `Confirm`s are a confirmation prompt. 66 | huh.NewConfirm(). 67 | Title("Want a discount?"). 68 | Affirmative("Yes!"). 69 | Negative("No."). 70 | Value(&discount) 71 | 72 | // Form 73 | var ( 74 | burger string 75 | name string 76 | instructions string 77 | ) 78 | 79 | form := huh.NewForm( 80 | // Prompt the user to choose a burger. 81 | huh.NewGroup( 82 | huh.NewSelect[string](). 83 | Options( 84 | huh.NewOption("Charmburger Classic", "classic"), 85 | huh.NewOption("Chickwich", "chickwich"), 86 | huh.NewOption("Fishburger", "Fishburger"), 87 | huh.NewOption("Charmpossible™ Burger", "charmpossible"), 88 | ). 89 | Title("Choose your burger"). 90 | Value(&burger), 91 | ), 92 | 93 | // Prompt for toppings and special instructions. 94 | // The customer can ask for up to 4 toppings. 95 | huh.NewGroup( 96 | huh.NewMultiSelect[string](). 97 | Options( 98 | huh.NewOption("Lettuce", "Lettuce").Selected(true), 99 | huh.NewOption("Tomatoes", "Tomatoes").Selected(true), 100 | huh.NewOption("Charm Sauce", "Charm Sauce"), 101 | huh.NewOption("Jalapeños", "Jalapeños"), 102 | huh.NewOption("Cheese", "Cheese"), 103 | huh.NewOption("Vegan Cheese", "Vegan Cheese"), 104 | huh.NewOption("Nutella", "Nutella"), 105 | ). 106 | Title("Toppings"). 107 | Limit(4). 108 | Value(&toppings), 109 | ), 110 | 111 | // Gather final details for the order. 112 | huh.NewGroup( 113 | huh.NewInput(). 114 | Title("What's your name?"). 115 | Value(&name). 116 | Validate(validateName), 117 | 118 | huh.NewText(). 119 | Title("Special Instructions"). 120 | Value(&instructions). 121 | CharLimit(400), 122 | 123 | huh.NewConfirm(). 124 | Title("Would you like 15% off"). 125 | Value(&discount), 126 | ), 127 | ) 128 | 129 | err := form.Run() 130 | if err != nil { 131 | log.Fatal(err) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /examples/ssh-form/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/huh" 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/charmbracelet/log" 17 | "github.com/charmbracelet/ssh" 18 | "github.com/charmbracelet/wish" 19 | "github.com/charmbracelet/wish/activeterm" 20 | "github.com/charmbracelet/wish/bubbletea" 21 | ) 22 | 23 | const ( 24 | host = "localhost" 25 | port = "2222" 26 | ) 27 | 28 | func main() { 29 | s, err := wish.NewServer( 30 | wish.WithAddress(net.JoinHostPort(host, port)), 31 | wish.WithHostKeyPath(".ssh/id_ed25519"), 32 | wish.WithMiddleware( 33 | bubbletea.Middleware(teaHandler), 34 | activeterm.Middleware(), 35 | ), 36 | ) 37 | if err != nil { 38 | log.Error("Could not start server", "error", err) 39 | } 40 | 41 | done := make(chan os.Signal, 1) 42 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 43 | log.SetReportTimestamp(false) 44 | log.Infof("Running form over ssh, connect with:") 45 | fmt.Printf("\n ssh %s -p %s\n\n", host, port) 46 | go func() { 47 | if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { 48 | log.Error("Could not start server", "error", err) 49 | done <- nil 50 | } 51 | }() 52 | 53 | <-done 54 | log.Info("Stopping SSH server") 55 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 56 | defer cancel() 57 | if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { 58 | log.Error("Could not stop server", "error", err) 59 | } 60 | } 61 | 62 | func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { 63 | form := huh.NewForm( 64 | huh.NewGroup( 65 | huh.NewInput().Title("Username").Key("username"), 66 | huh.NewInput().Title("Password").EchoMode(huh.EchoModePassword), 67 | ), 68 | ) 69 | r := bubbletea.MakeRenderer(s) 70 | style := r.NewStyle(). 71 | Border(lipgloss.NormalBorder()). 72 | Padding(1, 2). 73 | BorderForeground(lipgloss.Color("#444444")). 74 | Foreground(lipgloss.Color("#7571F9")) 75 | 76 | custom := huh.ThemeBase() 77 | custom.Blurred.Title = r.NewStyle(). 78 | Foreground(lipgloss.Color("#444")) 79 | custom.Blurred.TextInput.Prompt = r.NewStyle(). 80 | Foreground(lipgloss.Color("#444")) 81 | custom.Blurred.TextInput.Text = r.NewStyle(). 82 | Foreground(lipgloss.Color("#444")) 83 | custom.Focused.TextInput.Cursor = r.NewStyle(). 84 | Foreground(lipgloss.Color("#7571F9")) 85 | custom.Focused.Base = r.NewStyle(). 86 | Padding(0, 1). 87 | Border(lipgloss.ThickBorder(), false). 88 | BorderLeft(true). 89 | BorderForeground(lipgloss.Color("#7571F9")) 90 | 91 | form.WithTheme(custom) 92 | 93 | m := model{form: form, style: style} 94 | return m, []tea.ProgramOption{tea.WithAltScreen()} 95 | } 96 | 97 | type model struct { 98 | form *huh.Form 99 | style lipgloss.Style 100 | loggedIn bool 101 | } 102 | 103 | func (m model) Init() tea.Cmd { 104 | if m.form == nil { 105 | return nil 106 | } 107 | return m.form.Init() 108 | } 109 | 110 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 111 | var cmds []tea.Cmd 112 | 113 | if m.form != nil { 114 | f, cmd := m.form.Update(msg) 115 | m.form = f.(*huh.Form) 116 | cmds = append(cmds, cmd) 117 | } 118 | 119 | m.loggedIn = m.form.State == huh.StateCompleted 120 | if m.form.State == huh.StateAborted { 121 | return m, tea.Quit 122 | } 123 | 124 | switch msg := msg.(type) { 125 | case tea.KeyMsg: 126 | switch msg.String() { 127 | case "ctrl+c": 128 | return m, tea.Interrupt 129 | case "q": 130 | return m, tea.Quit 131 | } 132 | } 133 | 134 | return m, tea.Batch(cmds...) 135 | } 136 | 137 | func (m model) View() string { 138 | if m.form == nil { 139 | return "Starting..." 140 | } 141 | if m.loggedIn { 142 | return m.style.Render("Welcome, " + m.form.GetString("username") + "!") 143 | } 144 | return m.form.View() 145 | } 146 | -------------------------------------------------------------------------------- /spinner/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 4 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 5 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 6 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 7 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 8 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 9 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 10 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 11 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 12 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 13 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 14 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 15 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 16 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 17 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 18 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 24 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 25 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 26 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 27 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 28 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 29 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 30 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 31 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 32 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 33 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 35 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 36 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 37 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 38 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 39 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 40 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 43 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 44 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 45 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 46 | -------------------------------------------------------------------------------- /layout.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | // A Layout is responsible for laying out groups in a form. 10 | type Layout interface { 11 | View(f *Form) string 12 | GroupWidth(f *Form, g *Group, w int) int 13 | } 14 | 15 | // LayoutDefault is the default layout shows a single group at a time. 16 | var LayoutDefault Layout = &layoutDefault{} 17 | 18 | // LayoutStack is a layout stacks all groups on top of each other. 19 | var LayoutStack Layout = &layoutStack{} 20 | 21 | // LayoutColumns layout distributes groups in even columns. 22 | func LayoutColumns(columns int) Layout { 23 | return &layoutColumns{columns: columns} 24 | } 25 | 26 | // LayoutGrid layout distributes groups in a grid. 27 | func LayoutGrid(rows int, columns int) Layout { 28 | return &layoutGrid{rows: rows, columns: columns} 29 | } 30 | 31 | type layoutDefault struct{} 32 | 33 | func (l *layoutDefault) View(f *Form) string { 34 | return f.selector.Selected().View() 35 | } 36 | 37 | func (l *layoutDefault) GroupWidth(_ *Form, _ *Group, w int) int { 38 | return w 39 | } 40 | 41 | type layoutColumns struct { 42 | columns int 43 | } 44 | 45 | func (l *layoutColumns) visibleGroups(f *Form) []*Group { 46 | segmentIndex := f.selector.Index() / l.columns 47 | start := segmentIndex * l.columns 48 | end := start + l.columns 49 | 50 | total := f.selector.Total() 51 | if end > total { 52 | end = total 53 | } 54 | 55 | var groups []*Group 56 | f.selector.Range(func(i int, group *Group) bool { 57 | if i >= start && i < end { 58 | groups = append(groups, group) 59 | return true 60 | } 61 | return true 62 | }) 63 | 64 | return groups 65 | } 66 | 67 | func (l *layoutColumns) View(f *Form) string { 68 | groups := l.visibleGroups(f) 69 | if len(groups) == 0 { 70 | return "" 71 | } 72 | 73 | columns := make([]string, 0, len(groups)) 74 | for _, group := range groups { 75 | columns = append(columns, group.Content()) 76 | } 77 | 78 | header := f.selector.Selected().Header() 79 | footer := f.selector.Selected().Footer() 80 | 81 | return strings.Join([]string{ 82 | header, 83 | lipgloss.JoinHorizontal(lipgloss.Left, columns...), 84 | footer, 85 | }, "\n") 86 | } 87 | 88 | func (l *layoutColumns) GroupWidth(_ *Form, _ *Group, w int) int { 89 | return w / l.columns 90 | } 91 | 92 | type layoutStack struct{} 93 | 94 | func (l *layoutStack) View(f *Form) string { 95 | var columns []string 96 | f.selector.Range(func(_ int, group *Group) bool { 97 | columns = append(columns, group.Content(), "") 98 | return true 99 | }) 100 | 101 | if footer := f.selector.Selected().Footer(); footer != "" { 102 | columns = append(columns, footer) 103 | } 104 | return strings.Join(columns, "\n") 105 | } 106 | 107 | func (l *layoutStack) GroupWidth(_ *Form, _ *Group, w int) int { 108 | return w 109 | } 110 | 111 | type layoutGrid struct { 112 | rows, columns int 113 | } 114 | 115 | func (l *layoutGrid) visibleGroups(f *Form) [][]*Group { 116 | total := l.rows * l.columns 117 | segmentIndex := f.selector.Index() / total 118 | start := segmentIndex * total 119 | end := start + total 120 | 121 | if glen := f.selector.Total(); end > glen { 122 | end = glen 123 | } 124 | 125 | var visible []*Group 126 | f.selector.Range(func(i int, group *Group) bool { 127 | if i >= start && i < end { 128 | visible = append(visible, group) 129 | return true 130 | } 131 | return true 132 | }) 133 | grid := make([][]*Group, l.rows) 134 | for i := 0; i < l.rows; i++ { 135 | startRow := i * l.columns 136 | endRow := startRow + l.columns 137 | if startRow >= len(visible) { 138 | break 139 | } 140 | if endRow > len(visible) { 141 | endRow = len(visible) 142 | } 143 | grid[i] = visible[startRow:endRow] 144 | } 145 | return grid 146 | } 147 | 148 | func (l *layoutGrid) View(f *Form) string { 149 | grid := l.visibleGroups(f) 150 | if len(grid) == 0 { 151 | return "" 152 | } 153 | 154 | rows := make([]string, 0, len(grid)) 155 | for _, row := range grid { 156 | var columns []string 157 | for _, group := range row { 158 | columns = append(columns, group.Content()) 159 | } 160 | rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, columns...), "") 161 | } 162 | footer := f.selector.Selected().Footer() 163 | 164 | return strings.Join(append(rows, footer), "\n") 165 | } 166 | 167 | func (l *layoutGrid) GroupWidth(_ *Form, _ *Group, w int) int { 168 | return w / l.columns 169 | } 170 | -------------------------------------------------------------------------------- /internal/accessibility/accessibility.go: -------------------------------------------------------------------------------- 1 | // Package accessibility provides accessible functions to capture user input. 2 | package accessibility 3 | 4 | import ( 5 | "bufio" 6 | "cmp" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "slices" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/charmbracelet/x/term" 15 | ) 16 | 17 | func atoi(s string) (int, error) { 18 | if strings.TrimSpace(s) == "" { 19 | return -1, nil 20 | } 21 | return strconv.Atoi(s) //nolint:wrapcheck 22 | } 23 | 24 | // PromptInt prompts a user for an integer between a certain range. 25 | // 26 | // Given invalid input (non-integers, integers outside of the range), the user 27 | // will continue to be reprompted until a valid input is given, ensuring that 28 | // the return value is always valid. 29 | func PromptInt( 30 | out io.Writer, 31 | in io.Reader, 32 | prompt string, 33 | low, high int, 34 | defaultValue *int, 35 | ) int { 36 | var choice int 37 | 38 | validInt := func(s string) error { 39 | if strings.TrimSpace(s) == "" && defaultValue != nil { 40 | return nil 41 | } 42 | i, err := atoi(s) 43 | if err != nil || i < low || i > high { 44 | if low == high { 45 | return fmt.Errorf("Invalid: must be %d", low) //nolint:staticcheck 46 | } 47 | return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) //nolint:staticcheck 48 | } 49 | return nil 50 | } 51 | 52 | input := PromptString( 53 | out, 54 | in, 55 | prompt, 56 | ptrToStr(defaultValue, strconv.Itoa), 57 | validInt, 58 | ) 59 | choice, _ = strconv.Atoi(input) 60 | return choice 61 | } 62 | 63 | func parseBool(s string) (bool, error) { 64 | s = strings.ToLower(s) 65 | 66 | if slices.Contains([]string{"y", "yes"}, s) { 67 | return true, nil 68 | } 69 | 70 | // As a special case, we default to "" to no since the usage of this 71 | // function suggests N is the default. 72 | if slices.Contains([]string{"n", "no"}, s) { 73 | return false, nil 74 | } 75 | 76 | return false, errors.New("invalid input. please try again") 77 | } 78 | 79 | // PromptBool prompts a user for a boolean value. 80 | // 81 | // Given invalid input (non-boolean), the user will continue to be reprompted 82 | // until a valid input is given, ensuring that the return value is always valid. 83 | func PromptBool( 84 | out io.Writer, 85 | in io.Reader, 86 | prompt string, 87 | defaultValue bool, 88 | ) bool { 89 | validBool := func(s string) error { 90 | if strings.TrimSpace(s) == "" { 91 | return nil 92 | } 93 | _, err := parseBool(s) 94 | return err 95 | } 96 | 97 | input := PromptString( 98 | out, in, prompt, 99 | boolToStr(defaultValue), 100 | validBool, 101 | ) 102 | b, _ := parseBool(input) 103 | return b 104 | } 105 | 106 | // PromptPassword allows to prompt for a password. 107 | // In must be the fd of a tty. 108 | func PromptPassword( 109 | out io.Writer, 110 | in uintptr, 111 | prompt string, 112 | validator func(input string) error, 113 | ) (string, error) { 114 | for { 115 | _, _ = fmt.Fprint(out, prompt) 116 | pwd, err := term.ReadPassword(in) 117 | if err != nil { 118 | return "", err //nolint:wrapcheck 119 | } 120 | _, _ = fmt.Fprintln(out) 121 | if err := validator(string(pwd)); err != nil { 122 | _, _ = fmt.Fprintln(out, err) 123 | continue 124 | } 125 | return string(pwd), nil 126 | } 127 | } 128 | 129 | // PromptString prompts a user for a string value and validates it against a 130 | // validator function. It re-prompts the user until a valid input is given. 131 | func PromptString( 132 | out io.Writer, 133 | in io.Reader, 134 | prompt string, 135 | defaultValue string, 136 | validator func(input string) error, 137 | ) string { 138 | scanner := bufio.NewScanner(in) 139 | 140 | var ( 141 | valid bool 142 | input string 143 | ) 144 | 145 | for !valid { 146 | _, _ = fmt.Fprint(out, prompt) 147 | if !scanner.Scan() { 148 | // no way to bubble up errors or signal cancellation 149 | // but the program is probably not continuing if 150 | // stdin sent EOF 151 | _, _ = fmt.Fprintln(out) 152 | break 153 | } 154 | input = scanner.Text() 155 | 156 | if err := validator(input); err != nil { 157 | _, _ = fmt.Fprintln(out, err) 158 | continue 159 | } 160 | 161 | break 162 | } 163 | 164 | return cmp.Or(strings.TrimSpace(input), defaultValue) 165 | } 166 | 167 | func ptrToStr[T any](t *T, fn func(t T) string) string { 168 | if t == nil { 169 | return "" 170 | } 171 | return fn(*t) 172 | } 173 | 174 | func boolToStr(b bool) string { 175 | if b { 176 | return "y" 177 | } 178 | return "N" 179 | } 180 | -------------------------------------------------------------------------------- /spinner/spinner_test.go: -------------------------------------------------------------------------------- 1 | package spinner 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/charmbracelet/bubbles/spinner" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | ) 16 | 17 | func TestNewSpinner(t *testing.T) { 18 | s := New() 19 | if s.title != "Loading..." { 20 | t.Errorf("Expected default title 'Loading...', got '%s'", s.title) 21 | } 22 | if !reflect.DeepEqual(s.spinner.Spinner, spinner.Dot) { 23 | t.Errorf("Expected default spinner type to be Dot, got %v", s.spinner.Spinner) 24 | } 25 | } 26 | 27 | func TestSpinnerType(t *testing.T) { 28 | s := New().Type(Dots) 29 | if !reflect.DeepEqual(s.spinner.Spinner, spinner.Dot) { 30 | t.Errorf("Expected spinner type to be Dot, got %v", s.spinner.Spinner) 31 | } 32 | } 33 | 34 | func TestSpinnerDifferentTypes(t *testing.T) { 35 | s := New().Type(Line) 36 | if !reflect.DeepEqual(s.spinner.Spinner, spinner.Line) { 37 | t.Errorf("Expected spinner type to be Line, got %v", s.spinner.Spinner) 38 | } 39 | } 40 | 41 | func TestSpinnerView(t *testing.T) { 42 | s := New().Title("Test") 43 | view := s.View() 44 | 45 | if !strings.Contains(view, "Test") { 46 | t.Errorf("Expected view to contain title 'Test', got '%s'", view) 47 | } 48 | } 49 | 50 | func TestSpinnerContextCancellation(t *testing.T) { 51 | exercise(t, func() *Spinner { 52 | ctx, cancel := context.WithCancel(context.Background()) 53 | s := New().Context(ctx) 54 | cancel() // Cancel before running 55 | return s 56 | }, requireContextCanceled) 57 | } 58 | 59 | func TestSpinnerContextCancellationWhileRunning(t *testing.T) { 60 | exercise(t, func() *Spinner { 61 | ctx, cancel := context.WithCancel(context.Background()) 62 | go func() { 63 | time.Sleep(250 * time.Millisecond) 64 | cancel() 65 | }() 66 | return New().Context(ctx) 67 | }, requireContextCanceled) 68 | } 69 | 70 | func TestSpinnerStyleMethods(t *testing.T) { 71 | s := New() 72 | style := lipgloss.NewStyle().Foreground(lipgloss.Color("red")) 73 | titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("blue")) 74 | 75 | s.Style(style) 76 | s.TitleStyle(titleStyle) 77 | 78 | if !reflect.DeepEqual(s.spinner.Style, style) { 79 | t.Errorf("Style was not set correctly") 80 | } 81 | 82 | if !reflect.DeepEqual(s.titleStyle, titleStyle) { 83 | t.Errorf("TitleStyle was not set correctly") 84 | } 85 | } 86 | 87 | func TestSpinnerInit(t *testing.T) { 88 | s := New() 89 | cmd := s.Init() 90 | 91 | if cmd == nil { 92 | t.Errorf("Init did not return a valid command") 93 | } 94 | } 95 | 96 | func TestSpinnerUpdate(t *testing.T) { 97 | s := New() 98 | cmd := s.Init() 99 | if cmd == nil { 100 | t.Errorf("Init did not return a valid command") 101 | } 102 | 103 | model, cmd := s.Update(spinner.TickMsg{}) 104 | if reflect.TypeOf(model) != reflect.TypeOf(&Spinner{}) { 105 | t.Errorf("Update did not return correct model type") 106 | } 107 | 108 | if cmd == nil { 109 | t.Errorf("Update should return a non-nil command in this scenario") 110 | } 111 | 112 | // Simulate key press 113 | _, cmd = s.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 114 | if cmd == nil { 115 | t.Errorf("Update did not handle key press correctly") 116 | } 117 | } 118 | 119 | func TestSpinnerSimple(t *testing.T) { 120 | exercise(t, func() *Spinner { 121 | return New().Action(func() {}) 122 | }, requireNoError) 123 | } 124 | 125 | func TestSpinnerWithContextAndAction(t *testing.T) { 126 | exercise(t, func() *Spinner { 127 | ctx := context.Background() 128 | return New().Context(ctx).Action(func() {}) 129 | }, requireNoError) 130 | } 131 | 132 | func TestSpinnerWithActionError(t *testing.T) { 133 | fake := errors.New("fake") 134 | exercise(t, func() *Spinner { 135 | return New().ActionWithErr(func(context.Context) error { return fake }) 136 | }, requireErrorIs(fake)) 137 | } 138 | 139 | func exercise(t *testing.T, factory func() *Spinner, checker func(tb testing.TB, err error)) { 140 | t.Helper() 141 | t.Run("accessible", func(t *testing.T) { 142 | err := factory(). 143 | Accessible(true). 144 | Output(io.Discard). 145 | Run() 146 | checker(t, err) 147 | }) 148 | t.Run("regular", func(t *testing.T) { 149 | err := factory(). 150 | Accessible(false). 151 | Output(io.Discard). 152 | Run() 153 | checker(t, err) 154 | }) 155 | } 156 | 157 | func requireNoError(tb testing.TB, err error) { 158 | tb.Helper() 159 | if err != nil { 160 | tb.Errorf("expected no error, got %v", err) 161 | } 162 | } 163 | 164 | func requireErrorIs(target error) func(tb testing.TB, err error) { 165 | return func(tb testing.TB, err error) { 166 | tb.Helper() 167 | if !errors.Is(err, target) { 168 | tb.Errorf("expected error to be %v, got %v", target, err) 169 | } 170 | } 171 | } 172 | 173 | func requireContextCanceled(tb testing.TB, err error) { 174 | tb.Helper() 175 | switch { 176 | case errors.Is(err, context.Canceled): 177 | case errors.Is(err, tea.ErrProgramKilled): 178 | default: 179 | tb.Errorf("expected to get a context canceled error, got %v", err) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /examples/burger/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/huh" 12 | "github.com/charmbracelet/huh/spinner" 13 | "github.com/charmbracelet/lipgloss" 14 | xstrings "github.com/charmbracelet/x/exp/strings" 15 | ) 16 | 17 | type Spice int 18 | 19 | const ( 20 | Mild Spice = iota + 1 21 | Medium 22 | Hot 23 | ) 24 | 25 | func (s Spice) String() string { 26 | switch s { 27 | case Mild: 28 | return "Mild " 29 | case Medium: 30 | return "Medium-Spicy " 31 | case Hot: 32 | return "Spicy-Hot " 33 | default: 34 | return "" 35 | } 36 | } 37 | 38 | type Order struct { 39 | Burger Burger 40 | Side string 41 | Name string 42 | Instructions string 43 | Discount bool 44 | } 45 | 46 | type Burger struct { 47 | Type string 48 | Toppings []string 49 | Spice Spice 50 | } 51 | 52 | func main() { 53 | var burger Burger 54 | order := Order{Burger: burger} 55 | 56 | // Should we run in accessible mode? 57 | accessible, _ := strconv.ParseBool(os.Getenv("ACCESSIBLE")) 58 | 59 | form := huh.NewForm( 60 | huh.NewGroup(huh.NewNote(). 61 | Title("Charmburger"). 62 | Description("Welcome to _Charmburger™_.\n\nHow may we take your order?"). 63 | Next(true). 64 | NextLabel("Next"), 65 | ), 66 | 67 | // Choose a burger. 68 | // We'll need to know what topping to add too. 69 | huh.NewGroup( 70 | huh.NewSelect[string](). 71 | Options(huh.NewOptions("Charmburger Classic", "Chickwich", "Fishburger", "Charmpossible™ Burger")...). 72 | Title("Choose your burger"). 73 | Description("At Charm we truly have a burger for everyone."). 74 | Validate(func(t string) error { 75 | if t == "Fishburger" { 76 | return fmt.Errorf("no fish today, sorry") 77 | } 78 | return nil 79 | }). 80 | Value(&order.Burger.Type), 81 | 82 | huh.NewMultiSelect[string](). 83 | Title("Toppings"). 84 | Description("Choose up to 4."). 85 | Options( 86 | huh.NewOption("Lettuce", "Lettuce").Selected(true), 87 | huh.NewOption("Tomatoes", "Tomatoes").Selected(true), 88 | huh.NewOption("Charm Sauce", "Charm Sauce"), 89 | huh.NewOption("Jalapeños", "Jalapeños"), 90 | huh.NewOption("Cheese", "Cheese"), 91 | huh.NewOption("Vegan Cheese", "Vegan Cheese"), 92 | huh.NewOption("Nutella", "Nutella"), 93 | ). 94 | Validate(func(t []string) error { 95 | if len(t) <= 0 { 96 | return fmt.Errorf("at least one topping is required") 97 | } 98 | return nil 99 | }). 100 | Value(&order.Burger.Toppings). 101 | Filterable(true). 102 | Limit(4), 103 | ), 104 | 105 | // Prompt for toppings and special instructions. 106 | // The customer can ask for up to 4 toppings. 107 | huh.NewGroup( 108 | huh.NewSelect[Spice](). 109 | Title("Spice level"). 110 | Options( 111 | huh.NewOption("Mild", Mild).Selected(true), 112 | huh.NewOption("Medium", Medium), 113 | huh.NewOption("Hot", Hot), 114 | ). 115 | Value(&order.Burger.Spice), 116 | 117 | huh.NewSelect[string](). 118 | Options(huh.NewOptions("Fries", "Disco Fries", "R&B Fries", "Carrots")...). 119 | Value(&order.Side). 120 | Title("Sides"). 121 | Description("You get one free side with this order."), 122 | ), 123 | 124 | // Gather final details for the order. 125 | huh.NewGroup( 126 | huh.NewInput(). 127 | Value(&order.Name). 128 | Title("What's your name?"). 129 | Placeholder("Margaret Thatcher"). 130 | Validate(func(s string) error { 131 | if s == "Frank" { 132 | return errors.New("no franks, sorry") 133 | } 134 | return nil 135 | }). 136 | Description("For when your order is ready."), 137 | 138 | huh.NewText(). 139 | Value(&order.Instructions). 140 | Placeholder("Just put it in the mailbox please"). 141 | Title("Special Instructions"). 142 | Description("Anything we should know?"). 143 | CharLimit(400). 144 | Lines(5), 145 | 146 | huh.NewConfirm(). 147 | Title("Would you like 15% off?"). 148 | Value(&order.Discount). 149 | Affirmative("Yes!"). 150 | Negative("No."), 151 | ), 152 | ).WithAccessible(accessible) 153 | 154 | err := form.Run() 155 | if err != nil { 156 | fmt.Println("Uh oh:", err) 157 | os.Exit(1) 158 | } 159 | 160 | prepareBurger := func() { 161 | time.Sleep(2 * time.Second) 162 | } 163 | 164 | _ = spinner.New().Title("Preparing your burger...").Accessible(accessible).Action(prepareBurger).Run() 165 | 166 | // Print order summary. 167 | { 168 | var sb strings.Builder 169 | keyword := func(s string) string { 170 | return lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Render(s) 171 | } 172 | fmt.Fprintf(&sb, 173 | "%s\n\nOne %s%s, topped with %s with %s on the side.", 174 | lipgloss.NewStyle().Bold(true).Render("BURGER RECEIPT"), 175 | keyword(order.Burger.Spice.String()), 176 | keyword(order.Burger.Type), 177 | keyword(xstrings.EnglishJoin(order.Burger.Toppings, true)), 178 | keyword(order.Side), 179 | ) 180 | 181 | name := order.Name 182 | if name != "" { 183 | name = ", " + name 184 | } 185 | fmt.Fprintf(&sb, "\n\nThanks for your order%s!", name) 186 | 187 | if order.Discount { 188 | fmt.Fprint(&sb, "\n\nEnjoy 15% off.") 189 | } 190 | 191 | fmt.Println( 192 | lipgloss.NewStyle(). 193 | Width(40). 194 | BorderStyle(lipgloss.RoundedBorder()). 195 | BorderForeground(lipgloss.Color("63")). 196 | Padding(1, 2). 197 | Render(sb.String()), 198 | ) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /spinner/spinner.go: -------------------------------------------------------------------------------- 1 | package spinner 2 | 3 | import ( 4 | "cmp" 5 | "context" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/muesli/termenv" 14 | ) 15 | 16 | // Spinner represents a loading spinner. 17 | // To get started simply create a new spinner and call `Run`. 18 | // 19 | // s := spinner.New() 20 | // s.Run() 21 | // 22 | // ⣾ Loading... 23 | type Spinner struct { 24 | spinner spinner.Model 25 | action func(ctx context.Context) error 26 | ctx context.Context 27 | accessible bool 28 | title string 29 | titleStyle lipgloss.Style 30 | output io.Writer 31 | err error 32 | } 33 | 34 | type Type spinner.Spinner 35 | 36 | var ( 37 | Line = Type(spinner.Line) 38 | Dots = Type(spinner.Dot) 39 | MiniDot = Type(spinner.MiniDot) 40 | Jump = Type(spinner.Jump) 41 | Points = Type(spinner.Points) 42 | Pulse = Type(spinner.Pulse) 43 | Globe = Type(spinner.Globe) 44 | Moon = Type(spinner.Moon) 45 | Monkey = Type(spinner.Monkey) 46 | Meter = Type(spinner.Meter) 47 | Hamburger = Type(spinner.Hamburger) 48 | Ellipsis = Type(spinner.Ellipsis) 49 | ) 50 | 51 | // Type sets the type of the spinner. 52 | func (s *Spinner) Type(t Type) *Spinner { 53 | s.spinner.Spinner = spinner.Spinner(t) 54 | return s 55 | } 56 | 57 | // Title sets the title of the spinner. 58 | func (s *Spinner) Title(title string) *Spinner { 59 | s.title = title 60 | return s 61 | } 62 | 63 | // Output set the output for the spinner. 64 | // Default is STDOUT when [Spinner.Accessible], STDERR otherwise. 65 | func (s *Spinner) Output(w io.Writer) *Spinner { 66 | s.output = w 67 | return s 68 | } 69 | 70 | // Action sets the action of the spinner. 71 | func (s *Spinner) Action(action func()) *Spinner { 72 | s.action = func(context.Context) error { 73 | action() 74 | return nil 75 | } 76 | return s 77 | } 78 | 79 | // ActionWithErr sets the action of the spinner. 80 | // 81 | // This is just like [Spinner.Action], but allows the action to use a `context.Context` 82 | // and to return an error. 83 | func (s *Spinner) ActionWithErr(action func(context.Context) error) *Spinner { 84 | s.action = action 85 | return s 86 | } 87 | 88 | // Context sets the context of the spinner. 89 | func (s *Spinner) Context(ctx context.Context) *Spinner { 90 | s.ctx = ctx 91 | return s 92 | } 93 | 94 | // Style sets the style of the spinner. 95 | func (s *Spinner) Style(style lipgloss.Style) *Spinner { 96 | s.spinner.Style = style 97 | return s 98 | } 99 | 100 | // TitleStyle sets the title style of the spinner. 101 | func (s *Spinner) TitleStyle(style lipgloss.Style) *Spinner { 102 | s.titleStyle = style 103 | return s 104 | } 105 | 106 | // Accessible sets the spinner to be static. 107 | func (s *Spinner) Accessible(accessible bool) *Spinner { 108 | s.accessible = accessible 109 | return s 110 | } 111 | 112 | // New creates a new spinner. 113 | func New() *Spinner { 114 | s := spinner.New() 115 | s.Spinner = spinner.Dot 116 | 117 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#F780E2")) 118 | 119 | return &Spinner{ 120 | spinner: s, 121 | title: "Loading...", 122 | titleStyle: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#00020A", Dark: "#FFFDF5"}), 123 | } 124 | } 125 | 126 | // Init initializes the spinner. 127 | func (s *Spinner) Init() tea.Cmd { 128 | return tea.Batch(s.spinner.Tick, func() tea.Msg { 129 | if s.action != nil { 130 | err := s.action(s.ctx) 131 | return doneMsg{err} 132 | } 133 | return nil 134 | }) 135 | } 136 | 137 | // Update updates the spinner. 138 | func (s *Spinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 139 | switch msg := msg.(type) { 140 | case doneMsg: 141 | s.err = msg.err 142 | return s, tea.Quit 143 | case tea.KeyMsg: 144 | switch msg.String() { 145 | case "ctrl+c": 146 | return s, tea.Interrupt 147 | } 148 | } 149 | 150 | var cmd tea.Cmd 151 | s.spinner, cmd = s.spinner.Update(msg) 152 | return s, cmd 153 | } 154 | 155 | // View returns the spinner view. 156 | func (s *Spinner) View() string { 157 | var title string 158 | if s.title != "" { 159 | title = s.titleStyle.Render(s.title) 160 | } 161 | return s.spinner.View() + title 162 | } 163 | 164 | // Run runs the spinner. 165 | func (s *Spinner) Run() error { 166 | if s.ctx == nil && s.action == nil { 167 | return nil 168 | } 169 | if s.ctx == nil { 170 | s.ctx = context.Background() 171 | } 172 | if err := s.ctx.Err(); err != nil { 173 | return err 174 | } 175 | 176 | if s.accessible { 177 | out := cmp.Or[io.Writer](s.output, os.Stdout) 178 | return s.runAccessible(out) 179 | } 180 | 181 | m, err := tea.NewProgram( 182 | s, 183 | tea.WithContext(s.ctx), 184 | tea.WithOutput(s.output), 185 | tea.WithInput(nil), 186 | ).Run() 187 | mm := m.(*Spinner) 188 | if mm.err != nil { 189 | return mm.err 190 | } 191 | return err 192 | } 193 | 194 | // runAccessible runs the spinner in an accessible mode (statically). 195 | func (s *Spinner) runAccessible(out io.Writer) error { 196 | output := termenv.NewOutput(out) 197 | output.HideCursor() 198 | frame := s.spinner.Style.Render("...") 199 | title := s.titleStyle.Render(strings.TrimSuffix(s.title, "...")) 200 | _, _ = io.WriteString(out, title+frame) 201 | 202 | defer func() { 203 | output.ShowCursor() 204 | output.CursorBack(len(frame) + len(title)) 205 | }() 206 | 207 | actionDone := make(chan error) 208 | if s.action != nil { 209 | go func() { 210 | actionDone <- s.action(s.ctx) 211 | }() 212 | } 213 | 214 | for { 215 | select { 216 | case <-s.ctx.Done(): 217 | return s.ctx.Err() 218 | case err := <-actionDone: 219 | return err 220 | } 221 | } 222 | } 223 | 224 | type doneMsg struct { 225 | err error 226 | } 227 | -------------------------------------------------------------------------------- /examples/stickers/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/charmbracelet/huh" 5 | ) 6 | 7 | func main() { 8 | var ( 9 | name string 10 | address string 11 | country string 12 | email string 13 | ) 14 | 15 | huh.NewForm( 16 | huh.NewGroup( 17 | huh.NewNote(). 18 | Title("\nStickers pls."). 19 | Description("Make sure to fill out the address exactly\nas it would appear on a parcel."), 20 | huh.NewInput(). 21 | Title("Full name"). 22 | Validate(huh.ValidateMinLength(1)). 23 | Value(&name), 24 | huh.NewSelect[string](). 25 | Title("Country "). 26 | Height(1). 27 | Value(&country). 28 | Inline(true). 29 | Options(countries...), 30 | huh.NewText(). 31 | Title("Address"). 32 | Lines(2). 33 | Description("Use your country's postal format."). 34 | Value(&address), 35 | huh.NewInput(). 36 | Title("Email"). 37 | Description("Optional: so we can send you updates."). 38 | Value(&email), 39 | ), 40 | ).Run() 41 | } 42 | 43 | var countries = huh.NewOptions( 44 | // common 45 | "United States", 46 | "Canada", 47 | "Germany", 48 | "Brazil", 49 | "Mexico", 50 | "China", 51 | "India", 52 | 53 | "Afghanistan", 54 | "Albania", 55 | "Algeria", 56 | "American Samoa", 57 | "Andorra", 58 | "Angola", 59 | "Anguilla", 60 | "Antarctica", 61 | "Antigua and Barbuda", 62 | "Argentina", 63 | "Armenia", 64 | "Aruba", 65 | "Australia", 66 | "Austria", 67 | "Azerbaijan", 68 | "Ã…land Islands", 69 | "Bahamas", 70 | "Bahrain", 71 | "Bangladesh", 72 | "Barbados", 73 | "Belarus", 74 | "Belgium", 75 | "Belize", 76 | "Benin", 77 | "Bermuda", 78 | "Bhutan", 79 | "Bolivia", 80 | "Bosnia and Herzegovina", 81 | "Botswana", 82 | "Bouvet Island", 83 | "British Indian Ocean Territory", 84 | "British Virgin Islands", 85 | "Brunei", 86 | "Bulgaria", 87 | "Burkina Faso", 88 | "Burundi", 89 | "Cambodia", 90 | "Cameroon", 91 | "Cape Verde", 92 | "Caribbean Netherlands", 93 | "Cayman Islands", 94 | "Central African Republic", 95 | "Chad", 96 | "Chile", 97 | "Christmas Island", 98 | "Cocos (Keeling) Islands", 99 | "Colombia", 100 | "Comoros", 101 | "Cook Islands", 102 | "Costa Rica", 103 | "Croatia", 104 | "Cuba", 105 | "Curaçao", 106 | "Cyprus", 107 | "Czechia", 108 | "DR Congo", 109 | "Denmark", 110 | "Djibouti", 111 | "Dominica", 112 | "Dominican Republic", 113 | "Ecuador", 114 | "Egypt", 115 | "El Salvador", 116 | "Equatorial Guinea", 117 | "Eritrea", 118 | "Estonia", 119 | "Eswatini", 120 | "Ethiopia", 121 | "Falkland Islands", 122 | "Faroe Islands", 123 | "Fiji", 124 | "Finland", 125 | "France", 126 | "French Guiana", 127 | "French Polynesia", 128 | "French Southern and Antarctic Lands", 129 | "Gabon", 130 | "Gambia", 131 | "Georgia", 132 | "Ghana", 133 | "Gibraltar", 134 | "Greece", 135 | "Greenland", 136 | "Grenada", 137 | "Guadeloupe", 138 | "Guam", 139 | "Guatemala", 140 | "Guernsey", 141 | "Guinea", 142 | "Guinea-Bissau", 143 | "Guyana", 144 | "Haiti", 145 | "Heard Island and McDonald Islands", 146 | "Honduras", 147 | "Hong Kong", 148 | "Hungary", 149 | "Iceland", 150 | "Indonesia", 151 | "Iran", 152 | "Iraq", 153 | "Ireland", 154 | "Isle of Man", 155 | "Israel", 156 | "Italy", 157 | "Ivory Coast", 158 | "Jamaica", 159 | "Japan", 160 | "Jersey", 161 | "Jordan", 162 | "Kazakhstan", 163 | "Kenya", 164 | "Kiribati", 165 | "Kosovo", 166 | "Kuwait", 167 | "Kyrgyzstan", 168 | "Laos", 169 | "Latvia", 170 | "Lebanon", 171 | "Lesotho", 172 | "Liberia", 173 | "Libya", 174 | "Liechtenstein", 175 | "Lithuania", 176 | "Luxembourg", 177 | "Macau", 178 | "Madagascar", 179 | "Malawi", 180 | "Malaysia", 181 | "Maldives", 182 | "Mali", 183 | "Malta", 184 | "Marshall Islands", 185 | "Martinique", 186 | "Mauritania", 187 | "Mauritius", 188 | "Mayotte", 189 | "Micronesia", 190 | "Moldova", 191 | "Monaco", 192 | "Mongolia", 193 | "Montenegro", 194 | "Montserrat", 195 | "Morocco", 196 | "Mozambique", 197 | "Myanmar", 198 | "Namibia", 199 | "Nauru", 200 | "Nepal", 201 | "Netherlands", 202 | "New Caledonia", 203 | "New Zealand", 204 | "Nicaragua", 205 | "Niger", 206 | "Nigeria", 207 | "Niue", 208 | "Norfolk Island", 209 | "North Korea", 210 | "North Macedonia", 211 | "Northern Mariana Islands", 212 | "Norway", 213 | "Oman", 214 | "Pakistan", 215 | "Palau", 216 | "Palestine", 217 | "Panama", 218 | "Papua New Guinea", 219 | "Paraguay", 220 | "Peru", 221 | "Philippines", 222 | "Pitcairn Islands", 223 | "Poland", 224 | "Portugal", 225 | "Puerto Rico", 226 | "Qatar", 227 | "Republic of the Congo", 228 | "Romania", 229 | "Russia", 230 | "Rwanda", 231 | "São Tomé and Príncipe", 232 | "Saint Barthélemy", 233 | "Saint Helena, Ascension and Tristan da Cunha", 234 | "Saint Kitts and Nevis", 235 | "Saint Lucia", 236 | "Saint Martin", 237 | "Saint Pierre and Miquelon", 238 | "Saint Vincent and the Grenadines", 239 | "Samoa", 240 | "San Marino", 241 | "Saudi Arabia", 242 | "Senegal", 243 | "Serbia", 244 | "Seychelles", 245 | "Sierra Leone", 246 | "Singapore", 247 | "Sint Maarten", 248 | "Slovakia", 249 | "Slovenia", 250 | "Solomon Islands", 251 | "Somalia", 252 | "South Africa", 253 | "South Georgia", 254 | "South Korea", 255 | "South Sudan", 256 | "Spain", 257 | "Sri Lanka", 258 | "Sudan", 259 | "Suriname", 260 | "Svalbard and Jan Mayen", 261 | "Sweden", 262 | "Switzerland", 263 | "Syria", 264 | "Taiwan", 265 | "Tajikistan", 266 | "Tanzania", 267 | "Thailand", 268 | "Timor-Leste", 269 | "Togo", 270 | "Tokelau", 271 | "Tonga", 272 | "Trinidad and Tobago", 273 | "Tunisia", 274 | "Turkey", 275 | "Turkmenistan", 276 | "Turks and Caicos Islands", 277 | "Tuvalu", 278 | "Uganda", 279 | "Ukraine", 280 | "United Arab Emirates", 281 | "United Kingdom", 282 | "United States Minor Outlying Islands", 283 | "United States Virgin Islands", 284 | "Uruguay", 285 | "Uzbekistan", 286 | "Vanuatu", 287 | "Vatican City", 288 | "Venezuela", 289 | "Vietnam", 290 | "Wallis and Futuna", 291 | "Western Sahara", 292 | "Yemen", 293 | "Zambia", 294 | "Zimbabwe", 295 | ) 296 | -------------------------------------------------------------------------------- /examples/timer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/progress" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/huh" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | const ( 15 | focusColor = "#2EF8BB" 16 | breakColor = "#FF5F87" 17 | ) 18 | 19 | var ( 20 | focusTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(focusColor)).MarginRight(1).SetString("Focus Mode") 21 | breakTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(breakColor)).MarginRight(1).SetString("Break Mode") 22 | pausedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(breakColor)).MarginRight(1).SetString("Continue?") 23 | helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginTop(2) 24 | sidebarStyle = lipgloss.NewStyle().MarginLeft(3).Padding(1, 3).Border(lipgloss.RoundedBorder()).BorderForeground(helpStyle.GetForeground()) 25 | ) 26 | 27 | var baseTimerStyle = lipgloss.NewStyle().Padding(1, 2) 28 | 29 | type mode int 30 | 31 | const ( 32 | Initial mode = iota 33 | Focusing 34 | Paused 35 | Breaking 36 | ) 37 | 38 | type Model struct { 39 | form *huh.Form 40 | quitting bool 41 | 42 | lastTick time.Time 43 | startTime time.Time 44 | 45 | mode mode 46 | 47 | focusTime time.Duration 48 | breakTime time.Duration 49 | 50 | progress progress.Model 51 | } 52 | 53 | func (m Model) Init() tea.Cmd { 54 | return m.form.Init() 55 | } 56 | 57 | const tickInterval = time.Second / 2 58 | 59 | type tickMsg time.Time 60 | 61 | func tickCmd(t time.Time) tea.Msg { 62 | return tickMsg(t) 63 | } 64 | 65 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 66 | var cmds []tea.Cmd 67 | 68 | switch msg := msg.(type) { 69 | case tickMsg: 70 | cmds = append(cmds, tea.Tick(tickInterval, tickCmd)) 71 | case tea.KeyMsg: 72 | switch msg.String() { 73 | case "q": 74 | switch m.mode { 75 | case Focusing: 76 | m.mode = Paused 77 | m.startTime = time.Now() 78 | m.progress.FullColor = breakColor 79 | case Paused: 80 | m.mode = Breaking 81 | m.startTime = time.Now() 82 | case Breaking: 83 | m.quitting = true 84 | return m, tea.Quit 85 | } 86 | case "ctrl+c": 87 | m.quitting = true 88 | return m, tea.Interrupt 89 | default: 90 | if m.mode == Paused { 91 | m.mode = Breaking 92 | m.startTime = time.Now() 93 | } 94 | } 95 | } 96 | 97 | // Update form 98 | f, cmd := m.form.Update(msg) 99 | m.form = f.(*huh.Form) 100 | cmds = append(cmds, cmd) 101 | if m.form.State != huh.StateCompleted { 102 | return m, tea.Batch(cmds...) 103 | } 104 | 105 | // Update timer 106 | if m.startTime.IsZero() { 107 | m.startTime = time.Now() 108 | m.focusTime = m.form.Get("focus").(time.Duration) 109 | m.breakTime = m.form.Get("break").(time.Duration) 110 | m.mode = Focusing 111 | cmds = append(cmds, tea.Tick(tickInterval, tickCmd)) 112 | } 113 | 114 | switch m.mode { 115 | case Focusing: 116 | if time.Now().After(m.startTime.Add(m.focusTime)) { 117 | m.mode = Paused 118 | m.startTime = time.Now() 119 | m.progress.FullColor = breakColor 120 | } 121 | case Breaking: 122 | if time.Now().After(m.startTime.Add(m.breakTime)) { 123 | m.quitting = true 124 | return m, tea.Quit 125 | } 126 | } 127 | 128 | return m, tea.Batch(cmds...) 129 | } 130 | 131 | func (m Model) View() string { 132 | if m.quitting { 133 | return "" 134 | } 135 | 136 | if m.form.State != huh.StateCompleted { 137 | return m.form.View() 138 | } 139 | 140 | var s strings.Builder 141 | 142 | elapsed := time.Now().Sub(m.startTime) 143 | var percent float64 144 | switch m.mode { 145 | case Focusing: 146 | percent = float64(elapsed) / float64(m.focusTime) 147 | s.WriteString(focusTitleStyle.String()) 148 | s.WriteString(elapsed.Round(time.Second).String()) 149 | s.WriteString("\n\n") 150 | s.WriteString(m.progress.ViewAs(percent)) 151 | s.WriteString(helpStyle.Render("Press 'q' to skip")) 152 | case Paused: 153 | s.WriteString(pausedStyle.String()) 154 | s.WriteString("\n\nFocus time is done, time to take a break.") 155 | s.WriteString(helpStyle.Render("press any key to continue.\n")) 156 | case Breaking: 157 | percent = float64(elapsed) / float64(m.breakTime) 158 | s.WriteString(breakTitleStyle.String()) 159 | s.WriteString(elapsed.Round(time.Second).String()) 160 | s.WriteString("\n\n") 161 | s.WriteString(m.progress.ViewAs(percent)) 162 | s.WriteString(helpStyle.Render("press 'q' to quit")) 163 | } 164 | 165 | return baseTimerStyle.Render(s.String()) 166 | } 167 | 168 | func NewModel() Model { 169 | theme := huh.ThemeCharm() 170 | theme.Focused.Base.Border(lipgloss.HiddenBorder()) 171 | theme.Focused.Title.Foreground(lipgloss.Color(focusColor)) 172 | theme.Focused.SelectSelector.Foreground(lipgloss.Color(focusColor)) 173 | theme.Focused.SelectedOption.Foreground(lipgloss.Color("15")) 174 | theme.Focused.Option.Foreground(lipgloss.Color("7")) 175 | 176 | form := huh.NewForm( 177 | huh.NewGroup( 178 | huh.NewSelect[time.Duration](). 179 | Title("Focus Time"). 180 | Key("focus"). 181 | Options( 182 | huh.NewOption("25 minutes", 25*time.Minute), 183 | huh.NewOption("30 minutes", 30*time.Minute), 184 | huh.NewOption("45 minutes", 45*time.Minute), 185 | huh.NewOption("1 hour", time.Hour), 186 | ), 187 | ), 188 | huh.NewGroup( 189 | huh.NewSelect[time.Duration](). 190 | Title("Break Time"). 191 | Key("break"). 192 | Options( 193 | huh.NewOption("5 minutes", 5*time.Minute), 194 | huh.NewOption("10 minutes", 10*time.Minute), 195 | huh.NewOption("15 minutes", 15*time.Minute), 196 | huh.NewOption("20 minutes", 20*time.Minute), 197 | ), 198 | ), 199 | ).WithShowHelp(false).WithTheme(theme) 200 | 201 | progress := progress.New() 202 | progress.FullColor = focusColor 203 | progress.SetSpringOptions(1, 1) 204 | 205 | return Model{ 206 | form: form, 207 | progress: progress, 208 | } 209 | } 210 | 211 | func main() { 212 | m := NewModel() 213 | mm, err := tea.NewProgram(&m).Run() 214 | m = mm.(Model) 215 | if err != nil { 216 | log.Fatal(err) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 8 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 9 | github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 10 | github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 11 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 12 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 13 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 14 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 15 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 16 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 17 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 18 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 19 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 20 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 21 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 22 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 23 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 24 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 25 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 26 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 27 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 28 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 29 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 30 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 31 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 32 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 33 | github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 34 | github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 35 | github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 36 | github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 37 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 38 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 39 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 40 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 41 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 42 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 43 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 44 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 45 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 46 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 47 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 48 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 49 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 50 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 51 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 52 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 54 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 55 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 56 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 57 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 58 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 59 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 60 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 61 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 62 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 63 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 64 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 65 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 66 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 67 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 68 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 71 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 72 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 73 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 74 | -------------------------------------------------------------------------------- /examples/bubbletea/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/huh" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | const maxWidth = 80 14 | 15 | var ( 16 | red = lipgloss.AdaptiveColor{Light: "#FE5F86", Dark: "#FE5F86"} 17 | indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"} 18 | green = lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"} 19 | ) 20 | 21 | type Styles struct { 22 | Base, 23 | HeaderText, 24 | Status, 25 | StatusHeader, 26 | Highlight, 27 | ErrorHeaderText, 28 | Help lipgloss.Style 29 | } 30 | 31 | func NewStyles(lg *lipgloss.Renderer) *Styles { 32 | s := Styles{} 33 | s.Base = lg.NewStyle(). 34 | Padding(1, 4, 0, 1) 35 | s.HeaderText = lg.NewStyle(). 36 | Foreground(indigo). 37 | Bold(true). 38 | Padding(0, 1, 0, 2) 39 | s.Status = lg.NewStyle(). 40 | Border(lipgloss.RoundedBorder()). 41 | BorderForeground(indigo). 42 | PaddingLeft(1). 43 | MarginTop(1) 44 | s.StatusHeader = lg.NewStyle(). 45 | Foreground(green). 46 | Bold(true) 47 | s.Highlight = lg.NewStyle(). 48 | Foreground(lipgloss.Color("212")) 49 | s.ErrorHeaderText = s.HeaderText. 50 | Foreground(red) 51 | s.Help = lg.NewStyle(). 52 | Foreground(lipgloss.Color("240")) 53 | return &s 54 | } 55 | 56 | type state int 57 | 58 | const ( 59 | statusNormal state = iota 60 | stateDone 61 | ) 62 | 63 | type Model struct { 64 | state state 65 | lg *lipgloss.Renderer 66 | styles *Styles 67 | form *huh.Form 68 | width int 69 | } 70 | 71 | func NewModel() Model { 72 | m := Model{width: maxWidth} 73 | m.lg = lipgloss.DefaultRenderer() 74 | m.styles = NewStyles(m.lg) 75 | 76 | m.form = huh.NewForm( 77 | huh.NewGroup( 78 | huh.NewSelect[string](). 79 | Key("class"). 80 | Options(huh.NewOptions("Warrior", "Mage", "Rogue")...). 81 | Title("Choose your class"). 82 | Description("This will determine your department"), 83 | 84 | huh.NewSelect[string](). 85 | Key("level"). 86 | Options(huh.NewOptions("1", "20", "9999")...). 87 | Title("Choose your level"). 88 | Description("This will determine your benefits package"), 89 | 90 | huh.NewConfirm(). 91 | Key("done"). 92 | Title("All done?"). 93 | Validate(func(v bool) error { 94 | if !v { 95 | return fmt.Errorf("Welp, finish up then") 96 | } 97 | return nil 98 | }). 99 | Affirmative("Yep"). 100 | Negative("Wait, no"), 101 | ), 102 | ). 103 | WithWidth(45). 104 | WithShowHelp(false). 105 | WithShowErrors(false) 106 | return m 107 | } 108 | 109 | func (m Model) Init() tea.Cmd { 110 | return m.form.Init() 111 | } 112 | 113 | func min(x, y int) int { 114 | if x > y { 115 | return y 116 | } 117 | return x 118 | } 119 | 120 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 121 | switch msg := msg.(type) { 122 | case tea.WindowSizeMsg: 123 | m.width = min(msg.Width, maxWidth) - m.styles.Base.GetHorizontalFrameSize() 124 | case tea.KeyMsg: 125 | switch msg.String() { 126 | case "ctrl+c": 127 | return m, tea.Interrupt 128 | case "esc", "q": 129 | return m, tea.Quit 130 | } 131 | } 132 | 133 | var cmds []tea.Cmd 134 | 135 | // Process the form 136 | form, cmd := m.form.Update(msg) 137 | if f, ok := form.(*huh.Form); ok { 138 | m.form = f 139 | cmds = append(cmds, cmd) 140 | } 141 | 142 | if m.form.State == huh.StateCompleted { 143 | // Quit when the form is done. 144 | cmds = append(cmds, tea.Quit) 145 | } 146 | 147 | return m, tea.Batch(cmds...) 148 | } 149 | 150 | func (m Model) View() string { 151 | s := m.styles 152 | 153 | switch m.form.State { 154 | case huh.StateCompleted: 155 | title, role := m.getRole() 156 | title = s.Highlight.Render(title) 157 | var b strings.Builder 158 | fmt.Fprintf(&b, "Congratulations, you’re Charm’s newest\n%s!\n\n", title) 159 | fmt.Fprintf(&b, "Your job description is as follows:\n\n%s\n\nPlease proceed to HR immediately.", role) 160 | return s.Status.Margin(0, 1).Padding(1, 2).Width(48).Render(b.String()) + "\n\n" 161 | default: 162 | 163 | var class string 164 | if m.form.GetString("class") != "" { 165 | class = "Class: " + m.form.GetString("class") 166 | } 167 | 168 | // Form (left side) 169 | v := strings.TrimSuffix(m.form.View(), "\n\n") 170 | form := m.lg.NewStyle().Margin(1, 0).Render(v) 171 | 172 | // Status (right side) 173 | var status string 174 | { 175 | var ( 176 | buildInfo = "(None)" 177 | role string 178 | jobDescription string 179 | level string 180 | ) 181 | 182 | if m.form.GetString("level") != "" { 183 | level = "Level: " + m.form.GetString("level") 184 | role, jobDescription = m.getRole() 185 | role = "\n\n" + s.StatusHeader.Render("Projected Role") + "\n" + role 186 | jobDescription = "\n\n" + s.StatusHeader.Render("Duties") + "\n" + jobDescription 187 | } 188 | if m.form.GetString("class") != "" { 189 | buildInfo = fmt.Sprintf("%s\n%s", class, level) 190 | } 191 | 192 | const statusWidth = 28 193 | statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight() 194 | status = s.Status. 195 | Height(lipgloss.Height(form)). 196 | Width(statusWidth). 197 | MarginLeft(statusMarginLeft). 198 | Render(s.StatusHeader.Render("Current Build") + "\n" + 199 | buildInfo + 200 | role + 201 | jobDescription) 202 | } 203 | 204 | errors := m.form.Errors() 205 | header := m.appBoundaryView("Charm Employment Application") 206 | if len(errors) > 0 { 207 | header = m.appErrorBoundaryView(m.errorView()) 208 | } 209 | body := lipgloss.JoinHorizontal(lipgloss.Left, form, status) 210 | 211 | footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds())) 212 | if len(errors) > 0 { 213 | footer = m.appErrorBoundaryView("") 214 | } 215 | 216 | return s.Base.Render(header + "\n" + body + "\n\n" + footer) 217 | } 218 | } 219 | 220 | func (m Model) errorView() string { 221 | var s string 222 | for _, err := range m.form.Errors() { 223 | s += err.Error() 224 | } 225 | return s 226 | } 227 | 228 | func (m Model) appBoundaryView(text string) string { 229 | return lipgloss.PlaceHorizontal( 230 | m.width, 231 | lipgloss.Left, 232 | m.styles.HeaderText.Render(text), 233 | lipgloss.WithWhitespaceChars("/"), 234 | lipgloss.WithWhitespaceForeground(indigo), 235 | ) 236 | } 237 | 238 | func (m Model) appErrorBoundaryView(text string) string { 239 | return lipgloss.PlaceHorizontal( 240 | m.width, 241 | lipgloss.Left, 242 | m.styles.ErrorHeaderText.Render(text), 243 | lipgloss.WithWhitespaceChars("/"), 244 | lipgloss.WithWhitespaceForeground(red), 245 | ) 246 | } 247 | 248 | func (m Model) getRole() (string, string) { 249 | level := m.form.GetString("level") 250 | switch m.form.GetString("class") { 251 | case "Warrior": 252 | switch level { 253 | case "1": 254 | return "Tank Intern", "Assists with tank-related activities. Paid position." 255 | case "9999": 256 | return "Tank Manager", "Manages tanks and tank-related activities." 257 | default: 258 | return "Tank", "General tank. Does damage, takes damage. Responsible for tanking." 259 | } 260 | case "Mage": 261 | switch level { 262 | case "1": 263 | return "DPS Associate", "Finds DPS deals and passes them on to DPS Manager." 264 | case "9999": 265 | return "DPS Operating Officer", "Oversees all DPS activities." 266 | default: 267 | return "DPS", "Does damage and ideally does not take damage. Logs hours in JIRA." 268 | } 269 | case "Rogue": 270 | switch level { 271 | case "1": 272 | return "Stealth Junior Designer", "Designs rogue-like activities. Reports to Stealth Lead." 273 | case "9999": 274 | return "Stealth Lead", "Lead designer for all things stealth. Some travel required." 275 | default: 276 | return "Sneaky Person", "Sneaks around and does sneaky things. Reports to Stealth Lead." 277 | } 278 | default: 279 | return "", "" 280 | } 281 | } 282 | 283 | func main() { 284 | _, err := tea.NewProgram(NewModel()).Run() 285 | if err != nil { 286 | fmt.Println("Oh no:", err) 287 | os.Exit(1) 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /examples/dynamic/dynamic-bubbletea/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/huh" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | const maxWidth = 80 14 | 15 | var ( 16 | red = lipgloss.AdaptiveColor{Light: "#FE5F86", Dark: "#FE5F86"} 17 | indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"} 18 | green = lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"} 19 | ) 20 | 21 | type Styles struct { 22 | Base, 23 | HeaderText, 24 | Status, 25 | StatusHeader, 26 | Highlight, 27 | ErrorHeaderText, 28 | Help lipgloss.Style 29 | } 30 | 31 | func NewStyles(lg *lipgloss.Renderer) *Styles { 32 | s := Styles{} 33 | s.Base = lg.NewStyle(). 34 | Padding(1, 4, 0, 1) 35 | s.HeaderText = lg.NewStyle(). 36 | Foreground(indigo). 37 | Bold(true). 38 | Padding(0, 1, 0, 2) 39 | s.Status = lg.NewStyle(). 40 | Border(lipgloss.RoundedBorder()). 41 | BorderForeground(indigo). 42 | PaddingLeft(1). 43 | MarginTop(1) 44 | s.StatusHeader = lg.NewStyle(). 45 | Foreground(green). 46 | Bold(true) 47 | s.Highlight = lg.NewStyle(). 48 | Foreground(lipgloss.Color("212")) 49 | s.ErrorHeaderText = s.HeaderText. 50 | Foreground(red) 51 | s.Help = lg.NewStyle(). 52 | Foreground(lipgloss.Color("240")) 53 | return &s 54 | } 55 | 56 | type state int 57 | 58 | const ( 59 | statusNormal state = iota 60 | stateDone 61 | ) 62 | 63 | type Model struct { 64 | state state 65 | lg *lipgloss.Renderer 66 | styles *Styles 67 | form *huh.Form 68 | width int 69 | } 70 | 71 | func NewModel() Model { 72 | m := Model{width: maxWidth} 73 | m.lg = lipgloss.DefaultRenderer() 74 | m.styles = NewStyles(m.lg) 75 | 76 | var class string 77 | 78 | m.form = huh.NewForm( 79 | huh.NewGroup( 80 | huh.NewSelect[string](). 81 | Key("class"). 82 | Value(&class). 83 | Options(huh.NewOptions("Warrior", "Mage", "Rogue")...). 84 | Title("Choose your class"). 85 | Description("This will determine your department"), 86 | 87 | huh.NewSelect[string](). 88 | Key("level"). 89 | OptionsFunc(func() []huh.Option[string] { 90 | switch class { 91 | case "Warrior": 92 | return huh.NewOptions("1", "20", "9999") 93 | case "Mage": 94 | return huh.NewOptions("10", "100", "1000") 95 | } 96 | return huh.NewOptions("1", "20", "9999") 97 | }, &class). 98 | Title("Choose your level"). 99 | Description("This will determine your benefits package"), 100 | 101 | huh.NewConfirm(). 102 | Key("done"). 103 | Title("All done?"). 104 | Validate(func(v bool) error { 105 | if !v { 106 | return fmt.Errorf("Welp, finish up then") 107 | } 108 | return nil 109 | }). 110 | Affirmative("Yep"). 111 | Negative("Wait, no"), 112 | ), 113 | ). 114 | WithWidth(45). 115 | WithShowHelp(false). 116 | WithShowErrors(false) 117 | return m 118 | } 119 | 120 | func (m Model) Init() tea.Cmd { 121 | return m.form.Init() 122 | } 123 | 124 | func min(x, y int) int { 125 | if x > y { 126 | return y 127 | } 128 | return x 129 | } 130 | 131 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 132 | switch msg := msg.(type) { 133 | case tea.WindowSizeMsg: 134 | m.width = min(msg.Width, maxWidth) - m.styles.Base.GetHorizontalFrameSize() 135 | case tea.KeyMsg: 136 | switch msg.String() { 137 | case "esc", "ctrl+c", "q": 138 | return m, tea.Quit 139 | } 140 | } 141 | 142 | var cmds []tea.Cmd 143 | 144 | // Process the form 145 | form, cmd := m.form.Update(msg) 146 | if f, ok := form.(*huh.Form); ok { 147 | m.form = f 148 | cmds = append(cmds, cmd) 149 | } 150 | 151 | if m.form.State == huh.StateCompleted { 152 | // Quit when the form is done. 153 | cmds = append(cmds, tea.Quit) 154 | } 155 | 156 | return m, tea.Batch(cmds...) 157 | } 158 | 159 | func (m Model) View() string { 160 | s := m.styles 161 | 162 | switch m.form.State { 163 | case huh.StateCompleted: 164 | title, role := m.getRole() 165 | title = s.Highlight.Render(title) 166 | var b strings.Builder 167 | fmt.Fprintf(&b, "Congratulations, you’re Charm’s newest\n%s!\n\n", title) 168 | fmt.Fprintf(&b, "Your job description is as follows:\n\n%s\n\nPlease proceed to HR immediately.", role) 169 | return s.Status.Margin(0, 1).Padding(1, 2).Width(48).Render(b.String()) + "\n\n" 170 | default: 171 | 172 | var class string 173 | if m.form.GetString("class") != "" { 174 | class = "Class: " + m.form.GetString("class") 175 | } 176 | 177 | // Form (left side) 178 | v := strings.TrimSuffix(m.form.View(), "\n\n") 179 | form := m.lg.NewStyle().Margin(1, 0).Render(v) 180 | 181 | // Status (right side) 182 | var status string 183 | { 184 | var ( 185 | buildInfo = "(None)" 186 | role string 187 | jobDescription string 188 | level string 189 | ) 190 | 191 | if m.form.GetString("level") != "" { 192 | level = "Level: " + m.form.GetString("level") 193 | role, jobDescription = m.getRole() 194 | role = "\n\n" + s.StatusHeader.Render("Projected Role") + "\n" + role 195 | jobDescription = "\n\n" + s.StatusHeader.Render("Duties") + "\n" + jobDescription 196 | } 197 | if m.form.GetString("class") != "" { 198 | buildInfo = fmt.Sprintf("%s\n%s", class, level) 199 | } 200 | 201 | const statusWidth = 28 202 | statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight() 203 | status = s.Status. 204 | Height(lipgloss.Height(form)). 205 | Width(statusWidth). 206 | MarginLeft(statusMarginLeft). 207 | Render(s.StatusHeader.Render("Current Build") + "\n" + 208 | buildInfo + 209 | role + 210 | jobDescription) 211 | } 212 | 213 | errors := m.form.Errors() 214 | header := m.appBoundaryView("Charm Employment Application") 215 | if len(errors) > 0 { 216 | header = m.appErrorBoundaryView(m.errorView()) 217 | } 218 | body := lipgloss.JoinHorizontal(lipgloss.Left, form, status) 219 | 220 | footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds())) 221 | if len(errors) > 0 { 222 | footer = m.appErrorBoundaryView("") 223 | } 224 | 225 | return s.Base.Render(header + "\n" + body + "\n\n" + footer) 226 | } 227 | } 228 | 229 | func (m Model) errorView() string { 230 | var s string 231 | for _, err := range m.form.Errors() { 232 | s += err.Error() 233 | } 234 | return s 235 | } 236 | 237 | func (m Model) appBoundaryView(text string) string { 238 | return lipgloss.PlaceHorizontal( 239 | m.width, 240 | lipgloss.Left, 241 | m.styles.HeaderText.Render(text), 242 | lipgloss.WithWhitespaceChars("/"), 243 | lipgloss.WithWhitespaceForeground(indigo), 244 | ) 245 | } 246 | 247 | func (m Model) appErrorBoundaryView(text string) string { 248 | return lipgloss.PlaceHorizontal( 249 | m.width, 250 | lipgloss.Left, 251 | m.styles.ErrorHeaderText.Render(text), 252 | lipgloss.WithWhitespaceChars("/"), 253 | lipgloss.WithWhitespaceForeground(red), 254 | ) 255 | } 256 | 257 | func (m Model) getRole() (string, string) { 258 | level := m.form.GetString("level") 259 | switch m.form.GetString("class") { 260 | case "Warrior": 261 | switch level { 262 | case "1": 263 | return "Tank Intern", "Assists with tank-related activities. Paid position." 264 | case "9999": 265 | return "Tank Manager", "Manages tanks and tank-related activities." 266 | default: 267 | return "Tank", "General tank. Does damage, takes damage. Responsible for tanking." 268 | } 269 | case "Mage": 270 | switch level { 271 | case "1": 272 | return "DPS Associate", "Finds DPS deals and passes them on to DPS Manager." 273 | case "9999": 274 | return "DPS Operating Officer", "Oversees all DPS activities." 275 | default: 276 | return "DPS", "Does damage and ideally does not take damage. Logs hours in JIRA." 277 | } 278 | case "Rogue": 279 | switch level { 280 | case "1": 281 | return "Stealth Junior Designer", "Designs rougue-like activities. Reports to Stealth Lead." 282 | case "9999": 283 | return "Stealth Lead", "Lead designer for all things stealth. Some travel required." 284 | default: 285 | return "Sneaky Person", "Sneaks around and does sneaky things. Reports to Stealth Lead." 286 | } 287 | default: 288 | return "", "" 289 | } 290 | } 291 | 292 | func main() { 293 | _, err := tea.NewProgram(NewModel()).Run() 294 | if err != nil { 295 | fmt.Println("Oh no:", err) 296 | os.Exit(1) 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /keymap.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | // KeyMap is the keybindings to navigate the form. 6 | type KeyMap struct { 7 | Quit key.Binding 8 | 9 | Confirm ConfirmKeyMap 10 | FilePicker FilePickerKeyMap 11 | Input InputKeyMap 12 | MultiSelect MultiSelectKeyMap 13 | Note NoteKeyMap 14 | Select SelectKeyMap 15 | Text TextKeyMap 16 | } 17 | 18 | // InputKeyMap is the keybindings for input fields. 19 | type InputKeyMap struct { 20 | AcceptSuggestion key.Binding 21 | Next key.Binding 22 | Prev key.Binding 23 | Submit key.Binding 24 | } 25 | 26 | // TextKeyMap is the keybindings for text fields. 27 | type TextKeyMap struct { 28 | Next key.Binding 29 | Prev key.Binding 30 | NewLine key.Binding 31 | Editor key.Binding 32 | Submit key.Binding 33 | } 34 | 35 | // SelectKeyMap is the keybindings for select fields. 36 | type SelectKeyMap struct { 37 | Next key.Binding 38 | Prev key.Binding 39 | Up key.Binding 40 | Down key.Binding 41 | HalfPageUp key.Binding 42 | HalfPageDown key.Binding 43 | GotoTop key.Binding 44 | GotoBottom key.Binding 45 | Left key.Binding 46 | Right key.Binding 47 | Filter key.Binding 48 | SetFilter key.Binding 49 | ClearFilter key.Binding 50 | Submit key.Binding 51 | } 52 | 53 | // MultiSelectKeyMap is the keybindings for multi-select fields. 54 | type MultiSelectKeyMap struct { 55 | Next key.Binding 56 | Prev key.Binding 57 | Up key.Binding 58 | Down key.Binding 59 | HalfPageUp key.Binding 60 | HalfPageDown key.Binding 61 | GotoTop key.Binding 62 | GotoBottom key.Binding 63 | Toggle key.Binding 64 | Filter key.Binding 65 | SetFilter key.Binding 66 | ClearFilter key.Binding 67 | Submit key.Binding 68 | SelectAll key.Binding 69 | SelectNone key.Binding 70 | } 71 | 72 | // FilePickerKeyMap is the keybindings for filepicker fields. 73 | type FilePickerKeyMap struct { 74 | Open key.Binding 75 | Close key.Binding 76 | GotoTop key.Binding 77 | GotoBottom key.Binding 78 | PageUp key.Binding 79 | PageDown key.Binding 80 | Back key.Binding 81 | Select key.Binding 82 | Up key.Binding 83 | Down key.Binding 84 | Prev key.Binding 85 | Next key.Binding 86 | Submit key.Binding 87 | } 88 | 89 | // NoteKeyMap is the keybindings for note fields. 90 | type NoteKeyMap struct { 91 | Next key.Binding 92 | Prev key.Binding 93 | Submit key.Binding 94 | } 95 | 96 | // ConfirmKeyMap is the keybindings for confirm fields. 97 | type ConfirmKeyMap struct { 98 | Next key.Binding 99 | Prev key.Binding 100 | Toggle key.Binding 101 | Submit key.Binding 102 | Accept key.Binding 103 | Reject key.Binding 104 | } 105 | 106 | // NewDefaultKeyMap returns a new default keymap. 107 | func NewDefaultKeyMap() *KeyMap { 108 | return &KeyMap{ 109 | Quit: key.NewBinding(key.WithKeys("ctrl+c")), 110 | Input: InputKeyMap{ 111 | AcceptSuggestion: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "complete")), 112 | Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), 113 | Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), 114 | Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 115 | }, 116 | FilePicker: FilePickerKeyMap{ 117 | GotoTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first"), key.WithDisabled()), 118 | GotoBottom: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last"), key.WithDisabled()), 119 | PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up"), key.WithDisabled()), 120 | PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down"), key.WithDisabled()), 121 | Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back"), key.WithDisabled()), 122 | Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select"), key.WithDisabled()), 123 | Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up"), key.WithDisabled()), 124 | Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down"), key.WithDisabled()), 125 | 126 | Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("enter", "open")), 127 | Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"), key.WithDisabled()), 128 | Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), 129 | Next: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next")), 130 | Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 131 | }, 132 | Text: TextKeyMap{ 133 | Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), 134 | Next: key.NewBinding(key.WithKeys("tab", "enter"), key.WithHelp("enter", "next")), 135 | Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 136 | NewLine: key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"), key.WithHelp("alt+enter / ctrl+j", "new line")), 137 | Editor: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "open editor")), 138 | }, 139 | Select: SelectKeyMap{ 140 | Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), 141 | Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "select")), 142 | Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 143 | Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up")), 144 | Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down")), 145 | Left: key.NewBinding(key.WithKeys("h", "left"), key.WithHelp("←", "left"), key.WithDisabled()), 146 | Right: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("→", "right"), key.WithDisabled()), 147 | Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), 148 | SetFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), 149 | ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), 150 | HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), 151 | HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), 152 | GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), 153 | GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), 154 | }, 155 | MultiSelect: MultiSelectKeyMap{ 156 | Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), 157 | Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "confirm")), 158 | Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 159 | Toggle: key.NewBinding(key.WithKeys(" ", "x"), key.WithHelp("x", "toggle")), 160 | Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+p"), key.WithHelp("↑", "up")), 161 | Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+n"), key.WithHelp("↓", "down")), 162 | Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), 163 | SetFilter: key.NewBinding(key.WithKeys("enter", "esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), 164 | ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), 165 | HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), 166 | HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), 167 | GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), 168 | GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), 169 | SelectAll: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "select all")), 170 | SelectNone: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "select none"), key.WithDisabled()), 171 | }, 172 | Note: NoteKeyMap{ 173 | Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), 174 | Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), 175 | Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 176 | }, 177 | Confirm: ConfirmKeyMap{ 178 | Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), 179 | Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), 180 | Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 181 | Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("←/→", "toggle")), 182 | Accept: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "Yes")), 183 | Reject: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "No")), 184 | }, 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /field_note.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/key" 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | // Note is a note field. 14 | // 15 | // A note is responsible for displaying information to the user. Use it to 16 | // provide context around a different field. Generally, the notes are not 17 | // interacted with unless the note has a next button `Next(true)`. 18 | type Note struct { 19 | id int 20 | 21 | title Eval[string] 22 | description Eval[string] 23 | nextLabel string 24 | 25 | focused bool 26 | showNextButton bool 27 | skip bool 28 | 29 | accessible bool // Deprecated: use RunAccessible instead. 30 | height int 31 | width int 32 | 33 | theme *Theme 34 | keymap NoteKeyMap 35 | } 36 | 37 | // NewNote creates a new note field. 38 | // 39 | // A note is responsible for displaying information to the user. Use it to 40 | // provide context around a different field. Generally, the notes are not 41 | // interacted with unless the note has a next button `Next(true)`. 42 | func NewNote() *Note { 43 | return &Note{ 44 | id: nextID(), 45 | showNextButton: false, 46 | skip: true, 47 | nextLabel: "Next", 48 | title: Eval[string]{cache: make(map[uint64]string)}, 49 | description: Eval[string]{cache: make(map[uint64]string)}, 50 | } 51 | } 52 | 53 | // Title sets the note field's title. 54 | // 55 | // This title will be static, for dynamic titles use `TitleFunc`. 56 | func (n *Note) Title(title string) *Note { 57 | n.title.val = title 58 | n.title.fn = nil 59 | return n 60 | } 61 | 62 | // TitleFunc sets the title func of the note field. 63 | // 64 | // The TitleFunc will be re-evaluated when the binding of the TitleFunc changes. 65 | // This is useful when you want to display dynamic content and update the title 66 | // of a note when another part of your form changes. 67 | // 68 | // See README.md#Dynamic for more usage information. 69 | func (n *Note) TitleFunc(f func() string, bindings any) *Note { 70 | n.title.fn = f 71 | n.title.bindings = bindings 72 | return n 73 | } 74 | 75 | // Description sets the note field's description. 76 | // 77 | // This description will be static, for dynamic descriptions use `DescriptionFunc`. 78 | func (n *Note) Description(description string) *Note { 79 | n.description.val = description 80 | n.description.fn = nil 81 | return n 82 | } 83 | 84 | // DescriptionFunc sets the description func of the note field. 85 | // 86 | // The DescriptionFunc will be re-evaluated when the binding of the 87 | // DescriptionFunc changes. This is useful when you want to display dynamic 88 | // content and update the description of a note when another part of your form 89 | // changes. 90 | // 91 | // For example, you can make a dynamic markdown preview with the following Form & Group. 92 | // 93 | // huh.NewText().Title("Markdown").Value(&md), 94 | // huh.NewNote().Height(20).Title("Preview"). 95 | // DescriptionFunc(func() string { 96 | // return md 97 | // }, &md), 98 | // 99 | // Notice the `binding` of the Note is the same as the `Value` of the Text field. 100 | // This binds the two values together, so that when the `Value` of the Text 101 | // field changes so does the Note description. 102 | func (n *Note) DescriptionFunc(f func() string, bindings any) *Note { 103 | n.description.fn = f 104 | n.description.bindings = bindings 105 | return n 106 | } 107 | 108 | // Height sets the note field's height. 109 | func (n *Note) Height(height int) *Note { 110 | n.height = height 111 | return n 112 | } 113 | 114 | // Next sets whether or not to show the next button. 115 | // 116 | // Title 117 | // Description 118 | // 119 | // [ Next ] 120 | func (n *Note) Next(show bool) *Note { 121 | n.showNextButton = show 122 | return n 123 | } 124 | 125 | // NextLabel sets the next button label. 126 | func (n *Note) NextLabel(label string) *Note { 127 | n.nextLabel = label 128 | return n 129 | } 130 | 131 | // Focus focuses the note field. 132 | func (n *Note) Focus() tea.Cmd { 133 | n.focused = true 134 | return nil 135 | } 136 | 137 | // Blur blurs the note field. 138 | func (n *Note) Blur() tea.Cmd { 139 | n.focused = false 140 | return nil 141 | } 142 | 143 | // Error returns the error of the note field. 144 | func (n *Note) Error() error { return nil } 145 | 146 | // Skip returns whether the note should be skipped or should be blocking. 147 | func (n *Note) Skip() bool { return n.skip } 148 | 149 | // Zoom returns whether the note should be zoomed. 150 | func (n *Note) Zoom() bool { return false } 151 | 152 | // KeyBinds returns the help message for the note field. 153 | func (n *Note) KeyBinds() []key.Binding { 154 | return []key.Binding{ 155 | n.keymap.Prev, 156 | n.keymap.Submit, 157 | n.keymap.Next, 158 | } 159 | } 160 | 161 | // Init initializes the note field. 162 | func (n *Note) Init() tea.Cmd { return nil } 163 | 164 | // Update updates the note field. 165 | func (n *Note) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 166 | switch msg := msg.(type) { 167 | case updateFieldMsg: 168 | var cmds []tea.Cmd 169 | if ok, hash := n.title.shouldUpdate(); ok { 170 | n.title.bindingsHash = hash 171 | if !n.title.loadFromCache() { 172 | n.title.loading = true 173 | cmds = append(cmds, func() tea.Msg { 174 | return updateTitleMsg{id: n.id, title: n.title.fn(), hash: hash} 175 | }) 176 | } 177 | } 178 | if ok, hash := n.description.shouldUpdate(); ok { 179 | n.description.bindingsHash = hash 180 | if !n.description.loadFromCache() { 181 | n.description.loading = true 182 | cmds = append(cmds, func() tea.Msg { 183 | return updateDescriptionMsg{id: n.id, description: n.description.fn(), hash: hash} 184 | }) 185 | } 186 | } 187 | return n, tea.Batch(cmds...) 188 | case updateTitleMsg: 189 | if msg.id == n.id && msg.hash == n.title.bindingsHash { 190 | n.title.update(msg.title) 191 | } 192 | case updateDescriptionMsg: 193 | if msg.id == n.id && msg.hash == n.description.bindingsHash { 194 | n.description.update(msg.description) 195 | } 196 | case tea.KeyMsg: 197 | switch { 198 | case key.Matches(msg, n.keymap.Prev): 199 | return n, PrevField 200 | case key.Matches(msg, n.keymap.Next, n.keymap.Submit): 201 | return n, NextField 202 | } 203 | return n, NextField 204 | } 205 | return n, nil 206 | } 207 | 208 | func (n *Note) activeStyles() *FieldStyles { 209 | theme := n.theme 210 | if theme == nil { 211 | theme = ThemeCharm() 212 | } 213 | if n.focused { 214 | return &theme.Focused 215 | } 216 | return &theme.Blurred 217 | } 218 | 219 | // View renders the note field. 220 | func (n *Note) View() string { 221 | styles := n.activeStyles() 222 | maxWidth := n.width - styles.Card.GetHorizontalFrameSize() 223 | sb := strings.Builder{} 224 | 225 | if n.title.val != "" || n.title.fn != nil { 226 | sb.WriteString(styles.NoteTitle.Render(wrap(n.title.val, maxWidth))) 227 | } 228 | if n.description.val != "" || n.description.fn != nil { 229 | sb.WriteRune('\n') 230 | sb.WriteString(wrap(render(n.description.val), maxWidth)) 231 | sb.WriteRune('\n') 232 | } 233 | if n.showNextButton { 234 | sb.WriteRune('\n') 235 | sb.WriteString(styles.Next.Render(n.nextLabel)) 236 | } 237 | return styles.Card. 238 | Height(n.height). 239 | Width(n.width). 240 | Render(sb.String()) 241 | } 242 | 243 | // Run runs the note field. 244 | func (n *Note) Run() error { 245 | if n.accessible { // TODO: remove in a future release. 246 | return n.RunAccessible(os.Stdout, os.Stdin) 247 | } 248 | return Run(n) 249 | } 250 | 251 | // RunAccessible runs an accessible note field. 252 | func (n *Note) RunAccessible(w io.Writer, _ io.Reader) error { 253 | styles := n.activeStyles() 254 | if n.title.val != "" { 255 | _, _ = fmt.Fprintln(w, styles.Title.Render(n.title.val)) 256 | } 257 | if n.description.val != "" { 258 | _, _ = fmt.Fprintln(w, n.description.val) 259 | } 260 | return nil 261 | } 262 | 263 | // WithTheme sets the theme on a note field. 264 | func (n *Note) WithTheme(theme *Theme) Field { 265 | if n.theme != nil { 266 | return n 267 | } 268 | n.theme = theme 269 | return n 270 | } 271 | 272 | // WithKeyMap sets the keymap on a note field. 273 | func (n *Note) WithKeyMap(k *KeyMap) Field { 274 | n.keymap = k.Note 275 | return n 276 | } 277 | 278 | // WithAccessible sets the accessible mode of the note field. 279 | // 280 | // Deprecated: you may now call [Note.RunAccessible] directly to run the 281 | // field in accessible mode. 282 | func (n *Note) WithAccessible(accessible bool) Field { 283 | n.accessible = accessible 284 | return n 285 | } 286 | 287 | // WithWidth sets the width of the note field. 288 | func (n *Note) WithWidth(width int) Field { 289 | n.width = width 290 | return n 291 | } 292 | 293 | // WithHeight sets the height of the note field. 294 | func (n *Note) WithHeight(height int) Field { 295 | n.Height(height) 296 | return n 297 | } 298 | 299 | // WithPosition sets the position information of the note field. 300 | func (n *Note) WithPosition(p FieldPosition) Field { 301 | // if the note is the only field on the screen, 302 | // we shouldn't skip the entire group. 303 | if p.Field == p.FirstField && p.Field == p.LastField { 304 | n.skip = false 305 | } 306 | n.keymap.Prev.SetEnabled(!p.IsFirst()) 307 | n.keymap.Next.SetEnabled(!p.IsLast()) 308 | n.keymap.Submit.SetEnabled(p.IsLast()) 309 | return n 310 | } 311 | 312 | // GetValue satisfies the Field interface, notes do not have values. 313 | func (n *Note) GetValue() any { return nil } 314 | 315 | // GetKey satisfies the Field interface, notes do not have keys. 316 | func (n *Note) GetKey() string { return "" } 317 | 318 | func render(input string) string { 319 | var result strings.Builder 320 | var italic, bold, codeblock bool 321 | var escape bool 322 | 323 | for _, char := range input { 324 | if escape || codeblock { 325 | result.WriteRune(char) 326 | escape = false 327 | continue 328 | } 329 | switch char { 330 | case '\\': 331 | escape = true 332 | case '_': 333 | if !italic { 334 | result.WriteString("\033[3m") 335 | italic = true 336 | } else { 337 | result.WriteString("\033[23m") 338 | italic = false 339 | } 340 | case '*': 341 | if !bold { 342 | result.WriteString("\033[1m") 343 | bold = true 344 | } else { 345 | result.WriteString("\033[22m") 346 | bold = false 347 | } 348 | case '`': 349 | if !codeblock { 350 | result.WriteString("\033[0;37;40m") 351 | result.WriteString(" ") 352 | codeblock = true 353 | } else { 354 | result.WriteString(" ") 355 | result.WriteString("\033[0m") 356 | codeblock = false 357 | 358 | if bold { 359 | result.WriteString("\033[1m") 360 | } 361 | if italic { 362 | result.WriteString("\033[3m") 363 | } 364 | } 365 | default: 366 | result.WriteRune(char) 367 | } 368 | } 369 | 370 | // Reset any open formatting 371 | result.WriteString("\033[0m") 372 | 373 | return result.String() 374 | } 375 | -------------------------------------------------------------------------------- /field_confirm.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import ( 4 | "cmp" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/key" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/huh/internal/accessibility" 12 | "github.com/charmbracelet/lipgloss" 13 | ) 14 | 15 | // Confirm is a form confirm field. 16 | type Confirm struct { 17 | accessor Accessor[bool] 18 | key string 19 | id int 20 | 21 | // customization 22 | title Eval[string] 23 | description Eval[string] 24 | affirmative string 25 | negative string 26 | 27 | // error handling 28 | validate func(bool) error 29 | err error 30 | 31 | // state 32 | focused bool 33 | 34 | // options 35 | width int 36 | height int 37 | inline bool 38 | accessible bool // Deprecated: use RunAccessible instead. 39 | theme *Theme 40 | keymap ConfirmKeyMap 41 | buttonAlignment lipgloss.Position 42 | } 43 | 44 | // NewConfirm returns a new confirm field. 45 | func NewConfirm() *Confirm { 46 | return &Confirm{ 47 | accessor: &EmbeddedAccessor[bool]{}, 48 | id: nextID(), 49 | title: Eval[string]{cache: make(map[uint64]string)}, 50 | description: Eval[string]{cache: make(map[uint64]string)}, 51 | affirmative: "Yes", 52 | negative: "No", 53 | validate: func(bool) error { return nil }, 54 | buttonAlignment: lipgloss.Center, 55 | } 56 | } 57 | 58 | // Validate sets the validation function of the confirm field. 59 | func (c *Confirm) Validate(validate func(bool) error) *Confirm { 60 | c.validate = validate 61 | return c 62 | } 63 | 64 | // Error returns the error of the confirm field. 65 | func (c *Confirm) Error() error { 66 | return c.err 67 | } 68 | 69 | // Skip returns whether the confirm should be skipped or should be blocking. 70 | func (*Confirm) Skip() bool { 71 | return false 72 | } 73 | 74 | // Zoom returns whether the input should be zoomed. 75 | func (*Confirm) Zoom() bool { 76 | return false 77 | } 78 | 79 | // Affirmative sets the affirmative value of the confirm field. 80 | func (c *Confirm) Affirmative(affirmative string) *Confirm { 81 | c.affirmative = affirmative 82 | return c 83 | } 84 | 85 | // Negative sets the negative value of the confirm field. 86 | func (c *Confirm) Negative(negative string) *Confirm { 87 | c.negative = negative 88 | return c 89 | } 90 | 91 | // Value sets the value of the confirm field. 92 | func (c *Confirm) Value(value *bool) *Confirm { 93 | return c.Accessor(NewPointerAccessor(value)) 94 | } 95 | 96 | // Accessor sets the accessor of the confirm field. 97 | func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm { 98 | c.accessor = accessor 99 | return c 100 | } 101 | 102 | // Key sets the key of the confirm field. 103 | func (c *Confirm) Key(key string) *Confirm { 104 | c.key = key 105 | return c 106 | } 107 | 108 | // Title sets the title of the confirm field. 109 | func (c *Confirm) Title(title string) *Confirm { 110 | c.title.val = title 111 | c.title.fn = nil 112 | return c 113 | } 114 | 115 | // TitleFunc sets the title func of the confirm field. 116 | func (c *Confirm) TitleFunc(f func() string, bindings any) *Confirm { 117 | c.title.fn = f 118 | c.title.bindings = bindings 119 | return c 120 | } 121 | 122 | // Description sets the description of the confirm field. 123 | func (c *Confirm) Description(description string) *Confirm { 124 | c.description.val = description 125 | c.description.fn = nil 126 | return c 127 | } 128 | 129 | // DescriptionFunc sets the description function of the confirm field. 130 | func (c *Confirm) DescriptionFunc(f func() string, bindings any) *Confirm { 131 | c.description.fn = f 132 | c.description.bindings = bindings 133 | return c 134 | } 135 | 136 | // Inline sets whether the field should be inline. 137 | func (c *Confirm) Inline(inline bool) *Confirm { 138 | c.inline = inline 139 | return c 140 | } 141 | 142 | // Focus focuses the confirm field. 143 | func (c *Confirm) Focus() tea.Cmd { 144 | c.focused = true 145 | return nil 146 | } 147 | 148 | // Blur blurs the confirm field. 149 | func (c *Confirm) Blur() tea.Cmd { 150 | c.focused = false 151 | c.err = c.validate(c.accessor.Get()) 152 | return nil 153 | } 154 | 155 | // KeyBinds returns the help message for the confirm field. 156 | func (c *Confirm) KeyBinds() []key.Binding { 157 | return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next, c.keymap.Accept, c.keymap.Reject} 158 | } 159 | 160 | // Init initializes the confirm field. 161 | func (c *Confirm) Init() tea.Cmd { 162 | return nil 163 | } 164 | 165 | // Update updates the confirm field. 166 | func (c *Confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 167 | var cmds []tea.Cmd 168 | 169 | switch msg := msg.(type) { 170 | case updateFieldMsg: 171 | if ok, hash := c.title.shouldUpdate(); ok { 172 | c.title.bindingsHash = hash 173 | if !c.title.loadFromCache() { 174 | c.title.loading = true 175 | cmds = append(cmds, func() tea.Msg { 176 | return updateTitleMsg{id: c.id, title: c.title.fn(), hash: hash} 177 | }) 178 | } 179 | } 180 | if ok, hash := c.description.shouldUpdate(); ok { 181 | c.description.bindingsHash = hash 182 | if !c.description.loadFromCache() { 183 | c.description.loading = true 184 | cmds = append(cmds, func() tea.Msg { 185 | return updateDescriptionMsg{id: c.id, description: c.description.fn(), hash: hash} 186 | }) 187 | } 188 | } 189 | 190 | case updateTitleMsg: 191 | if msg.id == c.id && msg.hash == c.title.bindingsHash { 192 | c.title.val = msg.title 193 | c.title.loading = false 194 | } 195 | case updateDescriptionMsg: 196 | if msg.id == c.id && msg.hash == c.description.bindingsHash { 197 | c.description.val = msg.description 198 | c.description.loading = false 199 | } 200 | case tea.KeyMsg: 201 | c.err = nil 202 | switch { 203 | case key.Matches(msg, c.keymap.Toggle): 204 | if c.negative == "" { 205 | break 206 | } 207 | c.accessor.Set(!c.accessor.Get()) 208 | case key.Matches(msg, c.keymap.Prev): 209 | cmds = append(cmds, PrevField) 210 | case key.Matches(msg, c.keymap.Next, c.keymap.Submit): 211 | cmds = append(cmds, NextField) 212 | case key.Matches(msg, c.keymap.Accept): 213 | c.accessor.Set(true) 214 | cmds = append(cmds, NextField) 215 | case key.Matches(msg, c.keymap.Reject): 216 | c.accessor.Set(false) 217 | cmds = append(cmds, NextField) 218 | } 219 | } 220 | 221 | return c, tea.Batch(cmds...) 222 | } 223 | 224 | func (c *Confirm) activeStyles() *FieldStyles { 225 | theme := c.theme 226 | if theme == nil { 227 | theme = ThemeCharm() 228 | } 229 | if c.focused { 230 | return &theme.Focused 231 | } 232 | return &theme.Blurred 233 | } 234 | 235 | // View renders the confirm field. 236 | func (c *Confirm) View() string { 237 | styles := c.activeStyles() 238 | maxWidth := c.width - styles.Base.GetHorizontalFrameSize() 239 | 240 | var wroteHeader bool 241 | var sb strings.Builder 242 | if c.title.val != "" { 243 | sb.WriteString(styles.Title.Render(wrap(c.title.val, maxWidth))) 244 | wroteHeader = true 245 | } 246 | if c.err != nil { 247 | sb.WriteString(styles.ErrorIndicator.String()) 248 | wroteHeader = true 249 | } 250 | 251 | if c.description.val != "" { 252 | description := styles.Description.Render(wrap(c.description.val, maxWidth)) 253 | if !c.inline && (c.description.val != "" || c.description.fn != nil) { 254 | sb.WriteString("\n") 255 | } 256 | sb.WriteString(description) 257 | wroteHeader = true 258 | } 259 | 260 | if !c.inline && wroteHeader { 261 | sb.WriteString("\n") 262 | sb.WriteString("\n") 263 | } 264 | 265 | var negative string 266 | var affirmative string 267 | if c.negative != "" { 268 | if c.accessor.Get() { 269 | affirmative = styles.FocusedButton.Render(c.affirmative) 270 | negative = styles.BlurredButton.Render(c.negative) 271 | } else { 272 | affirmative = styles.BlurredButton.Render(c.affirmative) 273 | negative = styles.FocusedButton.Render(c.negative) 274 | } 275 | c.keymap.Reject.SetHelp("n", c.negative) 276 | } else { 277 | affirmative = styles.FocusedButton.Render(c.affirmative) 278 | c.keymap.Reject.SetEnabled(false) 279 | } 280 | 281 | c.keymap.Accept.SetHelp("y", c.affirmative) 282 | 283 | buttonsRow := lipgloss.JoinHorizontal(c.buttonAlignment, affirmative, negative) 284 | 285 | promptWidth := lipgloss.Width(sb.String()) 286 | buttonsWidth := lipgloss.Width(buttonsRow) 287 | 288 | renderWidth := max(buttonsWidth, promptWidth) 289 | 290 | style := lipgloss.NewStyle().Width(renderWidth).Align(c.buttonAlignment) 291 | 292 | sb.WriteString(style.Render(buttonsRow)) 293 | return styles.Base.Width(c.width).Height(c.height). 294 | Render(sb.String()) 295 | } 296 | 297 | // Run runs the confirm field in accessible mode. 298 | func (c *Confirm) Run() error { 299 | if c.accessible { // TODO: remove in a future release. 300 | return c.RunAccessible(os.Stdout, os.Stdin) 301 | } 302 | return Run(c) 303 | } 304 | 305 | // RunAccessible runs the confirm field in accessible mode. 306 | func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { 307 | styles := c.activeStyles() 308 | defaultValue := c.GetValue().(bool) 309 | opts := "[y/N]" 310 | if defaultValue { 311 | opts = "[Y/n]" 312 | } 313 | prompt := styles.Title. 314 | PaddingRight(1). 315 | Render(cmp.Or(c.title.val, "Choose"), opts) 316 | c.accessor.Set(accessibility.PromptBool(w, r, prompt, defaultValue)) 317 | return nil 318 | } 319 | 320 | func (c *Confirm) String() string { 321 | if c.accessor.Get() { 322 | return c.affirmative 323 | } 324 | return c.negative 325 | } 326 | 327 | // WithTheme sets the theme of the confirm field. 328 | func (c *Confirm) WithTheme(theme *Theme) Field { 329 | if c.theme != nil { 330 | return c 331 | } 332 | c.theme = theme 333 | return c 334 | } 335 | 336 | // WithKeyMap sets the keymap of the confirm field. 337 | func (c *Confirm) WithKeyMap(k *KeyMap) Field { 338 | c.keymap = k.Confirm 339 | return c 340 | } 341 | 342 | // WithAccessible sets the accessible mode of the confirm field. 343 | // 344 | // Deprecated: you may now call [Confirm.RunAccessible] directly to run the 345 | // field in accessible mode. 346 | func (c *Confirm) WithAccessible(accessible bool) Field { 347 | c.accessible = accessible 348 | return c 349 | } 350 | 351 | // WithWidth sets the width of the confirm field. 352 | func (c *Confirm) WithWidth(width int) Field { 353 | c.width = width 354 | return c 355 | } 356 | 357 | // WithHeight sets the height of the confirm field. 358 | func (c *Confirm) WithHeight(height int) Field { 359 | c.height = height 360 | return c 361 | } 362 | 363 | // WithPosition sets the position of the confirm field. 364 | func (c *Confirm) WithPosition(p FieldPosition) Field { 365 | c.keymap.Prev.SetEnabled(!p.IsFirst()) 366 | c.keymap.Next.SetEnabled(!p.IsLast()) 367 | c.keymap.Submit.SetEnabled(p.IsLast()) 368 | return c 369 | } 370 | 371 | // WithButtonAlignment sets the button position of the confirm field. 372 | func (c *Confirm) WithButtonAlignment(p lipgloss.Position) *Confirm { 373 | c.buttonAlignment = p 374 | return c 375 | } 376 | 377 | // GetKey returns the key of the field. 378 | func (c *Confirm) GetKey() string { 379 | return c.key 380 | } 381 | 382 | // GetValue returns the value of the field. 383 | func (c *Confirm) GetValue() any { 384 | return c.accessor.Get() 385 | } 386 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/huh/internal/selector" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | // Group is a collection of fields that are displayed together with a page of 14 | // the form. While a group is displayed the form completer can switch between 15 | // fields in the group. 16 | // 17 | // If any of the fields in a group have errors, the form will not be able to 18 | // progress to the next group. 19 | type Group struct { 20 | // collection of fields 21 | selector *selector.Selector[Field] 22 | 23 | // information 24 | title string 25 | description string 26 | 27 | // navigation 28 | viewport viewport.Model 29 | 30 | // help 31 | showHelp bool 32 | help help.Model 33 | 34 | // errors 35 | showErrors bool 36 | 37 | // group options 38 | width int 39 | height int 40 | theme *Theme 41 | keymap *KeyMap 42 | hide func() bool 43 | active bool 44 | } 45 | 46 | // NewGroup returns a new group with the given fields. 47 | func NewGroup(fields ...Field) *Group { 48 | selector := selector.NewSelector(fields) 49 | group := &Group{ 50 | selector: selector, 51 | help: help.New(), 52 | showHelp: true, 53 | showErrors: true, 54 | active: false, 55 | } 56 | 57 | group.width = 80 58 | height := group.rawHeight() 59 | v := viewport.New(group.width, height) //nolint:mnd 60 | group.viewport = v 61 | group.height = height 62 | 63 | return group 64 | } 65 | 66 | // Title sets the group's title. 67 | func (g *Group) Title(title string) *Group { 68 | g.title = title 69 | return g 70 | } 71 | 72 | // Description sets the group's description. 73 | func (g *Group) Description(description string) *Group { 74 | g.description = description 75 | return g 76 | } 77 | 78 | // WithShowHelp sets whether or not the group's help should be shown. 79 | func (g *Group) WithShowHelp(show bool) *Group { 80 | g.showHelp = show 81 | return g 82 | } 83 | 84 | // WithShowErrors sets whether or not the group's errors should be shown. 85 | func (g *Group) WithShowErrors(show bool) *Group { 86 | g.showErrors = show 87 | return g 88 | } 89 | 90 | // WithTheme sets the theme on a group. 91 | func (g *Group) WithTheme(t *Theme) *Group { 92 | g.theme = t 93 | g.help.Styles = t.Help 94 | g.selector.Range(func(_ int, field Field) bool { 95 | field.WithTheme(t) 96 | return true 97 | }) 98 | if g.height <= 0 { 99 | g.WithHeight(g.rawHeight()) 100 | } 101 | return g 102 | } 103 | 104 | // WithKeyMap sets the keymap on a group. 105 | func (g *Group) WithKeyMap(k *KeyMap) *Group { 106 | g.keymap = k 107 | g.selector.Range(func(_ int, field Field) bool { 108 | field.WithKeyMap(k) 109 | return true 110 | }) 111 | return g 112 | } 113 | 114 | // WithWidth sets the width on a group. 115 | func (g *Group) WithWidth(width int) *Group { 116 | g.width = width 117 | g.viewport.Width = width 118 | g.help.Width = width 119 | g.selector.Range(func(_ int, field Field) bool { 120 | field.WithWidth(width) 121 | return true 122 | }) 123 | return g 124 | } 125 | 126 | // WithHeight sets the height on a group. 127 | func (g *Group) WithHeight(height int) *Group { 128 | g.height = height 129 | h := height - g.titleFooterHeight() 130 | g.viewport.Height = h 131 | g.selector.Range(func(_ int, field Field) bool { 132 | // A field height must not exceed the form height. 133 | if h < lipgloss.Height(field.View()) { 134 | field.WithHeight(h) 135 | } 136 | return true 137 | }) 138 | return g 139 | } 140 | 141 | // WithHide sets whether this group should be skipped. 142 | func (g *Group) WithHide(hide bool) *Group { 143 | g.WithHideFunc(func() bool { return hide }) 144 | return g 145 | } 146 | 147 | // WithHideFunc sets the function that checks if this group should be skipped. 148 | func (g *Group) WithHideFunc(hideFunc func() bool) *Group { 149 | g.hide = hideFunc 150 | return g 151 | } 152 | 153 | // Errors returns the groups' fields' errors. 154 | func (g *Group) Errors() []error { 155 | var errs []error 156 | g.selector.Range(func(_ int, field Field) bool { 157 | if err := field.Error(); err != nil { 158 | errs = append(errs, err) 159 | } 160 | return true 161 | }) 162 | return errs 163 | } 164 | 165 | // updateFieldMsg is a message to update the fields of a group that is currently 166 | // displayed. 167 | // 168 | // This is used to update all TitleFunc, DescriptionFunc, and ...Func update 169 | // methods to make all fields dynamically update based on user input. 170 | type updateFieldMsg struct{} 171 | 172 | // nextFieldMsg is a message to move to the next field, 173 | // 174 | // each field controls when to send this message such that it is able to use 175 | // different key bindings or events to trigger group progression. 176 | type nextFieldMsg struct{} 177 | 178 | // prevFieldMsg is a message to move to the previous field. 179 | // 180 | // each field controls when to send this message such that it is able to use 181 | // different key bindings or events to trigger group progression. 182 | type prevFieldMsg struct{} 183 | 184 | // NextField is the command to move to the next field. 185 | func NextField() tea.Msg { 186 | return nextFieldMsg{} 187 | } 188 | 189 | // PrevField is the command to move to the previous field. 190 | func PrevField() tea.Msg { 191 | return prevFieldMsg{} 192 | } 193 | 194 | // Init initializes the group. 195 | func (g *Group) Init() tea.Cmd { 196 | var cmds []tea.Cmd 197 | 198 | cmds = append(cmds, func() tea.Msg { return updateFieldMsg{} }) 199 | 200 | if g.selector.Selected().Skip() { 201 | if g.selector.OnLast() { 202 | cmds = append(cmds, g.prevField()...) 203 | } else if g.selector.OnFirst() { 204 | cmds = append(cmds, g.nextField()...) 205 | } 206 | return tea.Batch(cmds...) 207 | } 208 | 209 | if g.active { 210 | cmd := g.selector.Selected().Focus() 211 | cmds = append(cmds, cmd) 212 | } 213 | g.buildView() 214 | return tea.Batch(cmds...) 215 | } 216 | 217 | // nextField moves to the next field. 218 | func (g *Group) nextField() []tea.Cmd { 219 | blurCmd := g.selector.Selected().Blur() 220 | if g.selector.OnLast() { 221 | return []tea.Cmd{blurCmd, nextGroup} 222 | } 223 | g.selector.Next() 224 | for g.selector.Selected().Skip() { 225 | if g.selector.OnLast() { 226 | return []tea.Cmd{blurCmd, nextGroup} 227 | } 228 | g.selector.Next() 229 | } 230 | focusCmd := g.selector.Selected().Focus() 231 | return []tea.Cmd{blurCmd, focusCmd} 232 | } 233 | 234 | // prevField moves to the previous field. 235 | func (g *Group) prevField() []tea.Cmd { 236 | blurCmd := g.selector.Selected().Blur() 237 | if g.selector.OnFirst() { 238 | return []tea.Cmd{blurCmd, prevGroup} 239 | } 240 | g.selector.Prev() 241 | for g.selector.Selected().Skip() { 242 | if g.selector.OnFirst() { 243 | return []tea.Cmd{blurCmd, prevGroup} 244 | } 245 | g.selector.Prev() 246 | } 247 | focusCmd := g.selector.Selected().Focus() 248 | return []tea.Cmd{blurCmd, focusCmd} 249 | } 250 | 251 | // Update updates the group. 252 | func (g *Group) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 253 | var cmds []tea.Cmd 254 | 255 | // Update all the fields in the group. 256 | g.selector.Range(func(i int, field Field) bool { 257 | switch msg := msg.(type) { 258 | case tea.KeyMsg: 259 | break 260 | default: 261 | m, cmd := field.Update(msg) 262 | g.selector.Set(i, m.(Field)) 263 | cmds = append(cmds, cmd) 264 | } 265 | if g.selector.Index() == i { 266 | m, cmd := field.Update(msg) 267 | g.selector.Set(i, m.(Field)) 268 | cmds = append(cmds, cmd) 269 | } 270 | m, cmd := field.Update(updateFieldMsg{}) 271 | g.selector.Set(i, m.(Field)) 272 | cmds = append(cmds, cmd) 273 | return true 274 | }) 275 | 276 | switch msg.(type) { 277 | case nextFieldMsg: 278 | cmds = append(cmds, g.nextField()...) 279 | case prevFieldMsg: 280 | cmds = append(cmds, g.prevField()...) 281 | } 282 | 283 | g.buildView() 284 | 285 | return g, tea.Batch(cmds...) 286 | } 287 | 288 | func (g *Group) getTheme() *Theme { 289 | if theme := g.theme; theme != nil { 290 | return theme 291 | } 292 | return ThemeCharm() 293 | } 294 | 295 | func (g *Group) styles() GroupStyles { return g.getTheme().Group } 296 | 297 | func (g *Group) getContent() (int, string) { 298 | var fields strings.Builder 299 | offset := 0 300 | 301 | gap := g.getTheme().FieldSeparator.Render() 302 | 303 | // if the focused field is requesting it be zoomed, only show that field. 304 | if g.selector.Selected().Zoom() { 305 | g.selector.Selected().WithHeight(g.height) 306 | fields.WriteString(g.selector.Selected().View()) 307 | } else { 308 | g.selector.Range(func(i int, field Field) bool { 309 | fields.WriteString(field.View()) 310 | if i == g.selector.Index() { 311 | offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) 312 | } 313 | if i < g.selector.Total()-1 { 314 | fields.WriteString(gap) 315 | } 316 | return true 317 | }) 318 | } 319 | 320 | return offset, fields.String() 321 | } 322 | 323 | func (g *Group) buildView() { 324 | offset, content := g.getContent() 325 | g.viewport.SetContent(content) 326 | g.viewport.SetYOffset(offset) 327 | } 328 | 329 | // Header renders the group's header only (no content). 330 | func (g *Group) Header() string { 331 | styles := g.styles() 332 | var parts []string 333 | if g.title != "" { 334 | parts = append(parts, styles.Title.Render(wrap(g.title, g.width))) 335 | } 336 | if g.description != "" { 337 | parts = append(parts, styles.Description.Render(wrap(g.description, g.width))) 338 | } 339 | return strings.Join(parts, "\n") 340 | } 341 | 342 | // titleFooterHeight returns the height of the footer + header. 343 | func (g *Group) titleFooterHeight() int { 344 | h := 0 345 | if s := g.Header(); s != "" { 346 | h += lipgloss.Height(s) 347 | } 348 | if s := g.Footer(); s != "" { 349 | h += lipgloss.Height(s) 350 | } 351 | return h 352 | } 353 | 354 | // rawHeight returns the full height of the group, without using a viewport. 355 | func (g *Group) rawHeight() int { 356 | return lipgloss.Height(g.Content()) + g.titleFooterHeight() 357 | } 358 | 359 | // View renders the group. 360 | func (g *Group) View() string { 361 | var parts []string 362 | if s := g.Header(); s != "" { 363 | parts = append(parts, s) 364 | } 365 | parts = append(parts, g.viewport.View()) 366 | if s := g.Footer(); s != "" { 367 | // append an empty line, and the footer (usually the help). 368 | parts = append(parts, "", s) 369 | } 370 | if len(parts) > 0 { 371 | // Trim suffix spaces from the last part as it can accidentally 372 | // scroll the view up on some terminals (like Apple's Terminal.app) 373 | // when we right to the bottom rightmost corner cell. 374 | lastIdx := len(parts) - 1 375 | parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], " ") 376 | } 377 | return strings.Join(parts, "\n") 378 | } 379 | 380 | // Content renders the group's content only (no footer). 381 | func (g *Group) Content() string { 382 | _, content := g.getContent() 383 | return content 384 | } 385 | 386 | // Footer renders the group's footer only (no content). 387 | func (g *Group) Footer() string { 388 | var parts []string 389 | errors := g.Errors() 390 | if g.showHelp && len(errors) <= 0 { 391 | parts = append(parts, g.help.ShortHelpView(g.selector.Selected().KeyBinds())) 392 | } 393 | if g.showErrors { 394 | for _, err := range errors { 395 | parts = append(parts, wrap( 396 | g.getTheme().Focused.ErrorMessage.Render(err.Error()), 397 | g.width, 398 | )) 399 | } 400 | } 401 | return g.styles().Base. 402 | Render(strings.Join(parts, "\n")) 403 | } 404 | -------------------------------------------------------------------------------- /examples/go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 4 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 6 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 7 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 8 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 10 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 11 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 12 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 14 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 15 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 16 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 17 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 18 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 19 | github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 20 | github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 21 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 22 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 23 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 24 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 25 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 26 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 27 | github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 28 | github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 29 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= 30 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 31 | github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= 32 | github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= 33 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 34 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 35 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 36 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 37 | github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw= 38 | github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko= 39 | github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= 40 | github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= 41 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 42 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 43 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 44 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 45 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 46 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 47 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 48 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 49 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 50 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 51 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= 52 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 53 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 54 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 55 | github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= 56 | github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= 57 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 58 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 59 | github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 60 | github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 61 | github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= 62 | github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= 63 | github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 64 | github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 65 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 66 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 67 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 68 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 69 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 70 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 71 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 72 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 73 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 74 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 75 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 76 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 77 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 78 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 79 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 80 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 81 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 82 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 83 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 84 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 85 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 86 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 87 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 88 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 89 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 90 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 91 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 92 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 93 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 94 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 95 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 96 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 97 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 98 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 99 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 100 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 101 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 102 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 103 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 104 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 105 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 106 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 107 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 108 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 109 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 110 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 111 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 112 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 113 | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 114 | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 115 | github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 116 | github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 117 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 118 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 119 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 120 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 121 | golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= 122 | golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= 123 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 126 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 127 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 128 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 129 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 130 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 131 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 132 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 133 | -------------------------------------------------------------------------------- /field_filepicker.go: -------------------------------------------------------------------------------- 1 | package huh 2 | 3 | import ( 4 | "cmp" 5 | "errors" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/charmbracelet/bubbles/filepicker" 11 | "github.com/charmbracelet/bubbles/key" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/huh/internal/accessibility" 14 | "github.com/charmbracelet/lipgloss" 15 | xstrings "github.com/charmbracelet/x/exp/strings" 16 | ) 17 | 18 | // FilePicker is a form file file field. 19 | type FilePicker struct { 20 | accessor Accessor[string] 21 | key string 22 | picker filepicker.Model 23 | 24 | // state 25 | focused bool 26 | picking bool 27 | 28 | // customization 29 | title string 30 | description string 31 | 32 | // error handling 33 | validate func(string) error 34 | err error 35 | 36 | // options 37 | width int 38 | height int 39 | accessible bool // Deprecated: use RunAccessible instead. 40 | theme *Theme 41 | keymap FilePickerKeyMap 42 | } 43 | 44 | // NewFilePicker returns a new file field. 45 | func NewFilePicker() *FilePicker { 46 | fp := filepicker.New() 47 | fp.ShowSize = false 48 | 49 | if cmd := fp.Init(); cmd != nil { 50 | fp, _ = fp.Update(cmd()) 51 | } 52 | 53 | return &FilePicker{ 54 | accessor: &EmbeddedAccessor[string]{}, 55 | validate: func(string) error { return nil }, 56 | picker: fp, 57 | } 58 | } 59 | 60 | // CurrentDirectory sets the directory of the file field. 61 | func (f *FilePicker) CurrentDirectory(directory string) *FilePicker { 62 | f.picker.CurrentDirectory = directory 63 | if cmd := f.picker.Init(); cmd != nil { 64 | f.picker, _ = f.picker.Update(cmd()) 65 | } 66 | return f 67 | } 68 | 69 | // Cursor sets the cursor of the file field. 70 | func (f *FilePicker) Cursor(cursor string) *FilePicker { 71 | f.picker.Cursor = cursor 72 | return f 73 | } 74 | 75 | // Picking sets whether the file picker should be in the picking files state. 76 | func (f *FilePicker) Picking(v bool) *FilePicker { 77 | f.setPicking(v) 78 | return f 79 | } 80 | 81 | // ShowHidden sets whether to show hidden files. 82 | func (f *FilePicker) ShowHidden(v bool) *FilePicker { 83 | f.picker.ShowHidden = v 84 | return f 85 | } 86 | 87 | // ShowSize sets whether to show file sizes. 88 | func (f *FilePicker) ShowSize(v bool) *FilePicker { 89 | f.picker.ShowSize = v 90 | return f 91 | } 92 | 93 | // ShowPermissions sets whether to show file permissions. 94 | func (f *FilePicker) ShowPermissions(v bool) *FilePicker { 95 | f.picker.ShowPermissions = v 96 | return f 97 | } 98 | 99 | // FileAllowed sets whether to allow files to be selected. 100 | func (f *FilePicker) FileAllowed(v bool) *FilePicker { 101 | f.picker.FileAllowed = v 102 | return f 103 | } 104 | 105 | // DirAllowed sets whether to allow directories to be selected. 106 | func (f *FilePicker) DirAllowed(v bool) *FilePicker { 107 | f.picker.DirAllowed = v 108 | return f 109 | } 110 | 111 | // Value sets the value of the file field. 112 | func (f *FilePicker) Value(value *string) *FilePicker { 113 | return f.Accessor(NewPointerAccessor(value)) 114 | } 115 | 116 | // Accessor sets the accessor of the file field. 117 | func (f *FilePicker) Accessor(accessor Accessor[string]) *FilePicker { 118 | f.accessor = accessor 119 | return f 120 | } 121 | 122 | // Key sets the key of the file field which can be used to retrieve the value 123 | // after submission. 124 | func (f *FilePicker) Key(key string) *FilePicker { 125 | f.key = key 126 | return f 127 | } 128 | 129 | // Title sets the title of the file field. 130 | func (f *FilePicker) Title(title string) *FilePicker { 131 | f.title = title 132 | return f 133 | } 134 | 135 | // Description sets the description of the file field. 136 | func (f *FilePicker) Description(description string) *FilePicker { 137 | f.description = description 138 | return f 139 | } 140 | 141 | // AllowedTypes sets the allowed types of the file field. These will be the only 142 | // valid file types accepted, other files will show as disabled. 143 | func (f *FilePicker) AllowedTypes(types []string) *FilePicker { 144 | f.picker.AllowedTypes = types 145 | return f 146 | } 147 | 148 | // Height sets the height of the file field. If the number of options 149 | // exceeds the height, the file field will become scrollable. 150 | func (f *FilePicker) Height(height int) *FilePicker { 151 | f.WithHeight(height) 152 | return f 153 | } 154 | 155 | // Validate sets the validation function of the file field. 156 | func (f *FilePicker) Validate(validate func(string) error) *FilePicker { 157 | f.validate = validate 158 | return f 159 | } 160 | 161 | // Error returns the error of the file field. 162 | func (f *FilePicker) Error() error { 163 | return f.err 164 | } 165 | 166 | // Skip returns whether the file should be skipped or should be blocking. 167 | func (*FilePicker) Skip() bool { 168 | return false 169 | } 170 | 171 | // Zoom returns whether the input should be zoomed. 172 | func (f *FilePicker) Zoom() bool { 173 | return f.picking 174 | } 175 | 176 | // Focus focuses the file field. 177 | func (f *FilePicker) Focus() tea.Cmd { 178 | f.focused = true 179 | return f.picker.Init() 180 | } 181 | 182 | // Blur blurs the file field. 183 | func (f *FilePicker) Blur() tea.Cmd { 184 | f.focused = false 185 | f.setPicking(false) 186 | f.err = f.validate(f.accessor.Get()) 187 | return nil 188 | } 189 | 190 | // KeyBinds returns the help keybindings for the file field. 191 | func (f *FilePicker) KeyBinds() []key.Binding { 192 | return []key.Binding{f.keymap.Up, f.keymap.Down, f.keymap.Close, f.keymap.Open, f.keymap.Prev, f.keymap.Next, f.keymap.Submit} 193 | } 194 | 195 | // Init initializes the file field. 196 | func (f *FilePicker) Init() tea.Cmd { 197 | return f.picker.Init() 198 | } 199 | 200 | // Update updates the file field. 201 | func (f *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 202 | f.err = nil 203 | 204 | switch msg := msg.(type) { 205 | case tea.KeyMsg: 206 | switch { 207 | case key.Matches(msg, f.keymap.Open): 208 | if f.picking { 209 | break 210 | } 211 | f.setPicking(true) 212 | return f, f.picker.Init() 213 | case key.Matches(msg, f.keymap.Close): 214 | f.setPicking(false) 215 | return f, NextField 216 | case key.Matches(msg, f.keymap.Next): 217 | f.setPicking(false) 218 | return f, NextField 219 | case key.Matches(msg, f.keymap.Prev): 220 | f.setPicking(false) 221 | return f, PrevField 222 | } 223 | } 224 | 225 | var cmd tea.Cmd 226 | f.picker, cmd = f.picker.Update(msg) 227 | didSelect, file := f.picker.DidSelectFile(msg) 228 | if didSelect { 229 | f.accessor.Set(file) 230 | f.setPicking(false) 231 | return f, NextField 232 | } 233 | didSelect, _ = f.picker.DidSelectDisabledFile(msg) 234 | if didSelect { 235 | f.err = errors.New(xstrings.EnglishJoin(f.picker.AllowedTypes, true) + " files only") 236 | return f, nil 237 | } 238 | 239 | return f, cmd 240 | } 241 | 242 | func (f *FilePicker) activeStyles() *FieldStyles { 243 | theme := f.theme 244 | if theme == nil { 245 | theme = ThemeCharm() 246 | } 247 | if f.focused { 248 | return &theme.Focused 249 | } 250 | return &theme.Blurred 251 | } 252 | 253 | func (f *FilePicker) renderTitle() string { 254 | styles := f.activeStyles() 255 | maxWidth := f.width - styles.Base.GetHorizontalFrameSize() 256 | return styles.Title.Render(wrap(f.title, maxWidth)) 257 | } 258 | 259 | func (f FilePicker) renderDescription() string { 260 | styles := f.activeStyles() 261 | maxWidth := f.width - styles.Base.GetHorizontalFrameSize() 262 | return styles.Description.Render(wrap(f.description, maxWidth)) 263 | } 264 | 265 | // View renders the file field. 266 | func (f *FilePicker) View() string { 267 | styles := f.activeStyles() 268 | var parts []string 269 | if f.title != "" { 270 | parts = append(parts, f.renderTitle()) 271 | } 272 | if f.description != "" { 273 | parts = append(parts, f.renderDescription()) 274 | } 275 | parts = append(parts, f.pickerView()) 276 | return styles.Base.Width(f.width).Height(f.height). 277 | Render(strings.Join(parts, "\n")) 278 | } 279 | 280 | func (f *FilePicker) pickerView() string { 281 | if f.picking { 282 | return f.picker.View() 283 | } 284 | styles := f.activeStyles() 285 | if f.accessor.Get() != "" { 286 | return styles.SelectedOption.Render(f.accessor.Get()) 287 | } 288 | return styles.TextInput.Placeholder.Render("No file selected.") 289 | } 290 | 291 | func (f *FilePicker) setPicking(v bool) { 292 | f.picking = v 293 | 294 | f.keymap.Close.SetEnabled(v) 295 | f.keymap.Up.SetEnabled(v) 296 | f.keymap.Down.SetEnabled(v) 297 | f.keymap.Select.SetEnabled(v) 298 | f.keymap.Back.SetEnabled(v) 299 | 300 | f.picker.KeyMap.Up.SetEnabled(v) 301 | f.picker.KeyMap.Down.SetEnabled(v) 302 | f.picker.KeyMap.GoToTop.SetEnabled(v) 303 | f.picker.KeyMap.GoToLast.SetEnabled(v) 304 | f.picker.KeyMap.Select.SetEnabled(v) 305 | f.picker.KeyMap.Open.SetEnabled(v) 306 | f.picker.KeyMap.Back.SetEnabled(v) 307 | } 308 | 309 | // Run runs the file field. 310 | func (f *FilePicker) Run() error { 311 | if f.accessible { // TODO: remove in a future release. 312 | return f.RunAccessible(os.Stdout, os.Stdin) 313 | } 314 | return Run(f) 315 | } 316 | 317 | // RunAccessible runs an accessible file field. 318 | func (f *FilePicker) RunAccessible(w io.Writer, r io.Reader) error { 319 | styles := f.activeStyles() 320 | prompt := styles.Title. 321 | PaddingRight(1). 322 | Render(cmp.Or(f.title, "Choose a file:")) 323 | 324 | validateFile := func(s string) error { 325 | // is the string a file? 326 | if _, err := os.Open(s); err != nil { 327 | return errors.New("not a file") 328 | } 329 | 330 | // is it one of the allowed types? 331 | valid := len(f.picker.AllowedTypes) == 0 332 | for _, ext := range f.picker.AllowedTypes { 333 | if strings.HasSuffix(s, ext) { 334 | valid = true 335 | break 336 | } 337 | } 338 | if !valid { 339 | return errors.New("cannot select: " + s) 340 | } 341 | 342 | // does it pass user validation? 343 | return f.validate(s) 344 | } 345 | 346 | f.accessor.Set(accessibility.PromptString( 347 | w, 348 | r, 349 | prompt, 350 | f.GetValue().(string), 351 | validateFile, 352 | )) 353 | return nil 354 | } 355 | 356 | // copied from bubbles' filepicker. 357 | const ( 358 | fileSizeWidth = 7 359 | paddingLeft = 2 360 | ) 361 | 362 | // WithTheme sets the theme of the file field. 363 | func (f *FilePicker) WithTheme(theme *Theme) Field { 364 | if f.theme != nil || theme == nil { 365 | return f 366 | } 367 | f.theme = theme 368 | 369 | // XXX: add specific themes 370 | f.picker.Styles = filepicker.Styles{ 371 | DisabledCursor: lipgloss.Style{}, 372 | Cursor: theme.Focused.TextInput.Prompt, 373 | Symlink: lipgloss.NewStyle(), 374 | Directory: theme.Focused.Directory, 375 | File: theme.Focused.File, 376 | DisabledFile: theme.Focused.TextInput.Placeholder, 377 | Permission: theme.Focused.TextInput.Placeholder, 378 | Selected: theme.Focused.SelectedOption, 379 | DisabledSelected: theme.Focused.TextInput.Placeholder, 380 | FileSize: theme.Focused.TextInput.Placeholder.Width(fileSizeWidth).Align(lipgloss.Right), 381 | EmptyDirectory: theme.Focused.TextInput.Placeholder.PaddingLeft(paddingLeft).SetString("No files found."), 382 | } 383 | 384 | return f 385 | } 386 | 387 | // WithKeyMap sets the keymap on a file field. 388 | func (f *FilePicker) WithKeyMap(k *KeyMap) Field { 389 | f.keymap = k.FilePicker 390 | f.picker.KeyMap = filepicker.KeyMap{ 391 | GoToTop: k.FilePicker.GotoTop, 392 | GoToLast: k.FilePicker.GotoBottom, 393 | Down: k.FilePicker.Down, 394 | Up: k.FilePicker.Up, 395 | PageUp: k.FilePicker.PageUp, 396 | PageDown: k.FilePicker.PageDown, 397 | Back: k.FilePicker.Back, 398 | Open: k.FilePicker.Open, 399 | Select: k.FilePicker.Select, 400 | } 401 | f.setPicking(f.picking) 402 | return f 403 | } 404 | 405 | // WithAccessible sets the accessible mode of the file field. 406 | // 407 | // Deprecated: you may now call [FilePicker.RunAccessible] directly to run the 408 | // field in accessible mode. 409 | func (f *FilePicker) WithAccessible(accessible bool) Field { 410 | f.accessible = accessible 411 | return f 412 | } 413 | 414 | // WithWidth sets the width of the file field. 415 | func (f *FilePicker) WithWidth(width int) Field { 416 | f.width = width 417 | return f 418 | } 419 | 420 | // WithHeight sets the height of the file field. 421 | func (f *FilePicker) WithHeight(height int) Field { 422 | if height == 0 { 423 | return f 424 | } 425 | adjust := 0 426 | if f.title != "" { 427 | adjust += lipgloss.Height(f.renderTitle()) 428 | } 429 | if f.description != "" { 430 | adjust += lipgloss.Height(f.renderDescription()) 431 | } 432 | adjust++ // picker's own help height 433 | f.picker.SetHeight(height - adjust) 434 | return f 435 | } 436 | 437 | // WithPosition sets the position of the file field. 438 | func (f *FilePicker) WithPosition(p FieldPosition) Field { 439 | f.keymap.Prev.SetEnabled(!p.IsFirst()) 440 | f.keymap.Next.SetEnabled(!p.IsLast()) 441 | f.keymap.Submit.SetEnabled(p.IsLast()) 442 | return f 443 | } 444 | 445 | // GetKey returns the key of the field. 446 | func (f *FilePicker) GetKey() string { 447 | return f.key 448 | } 449 | 450 | // GetValue returns the value of the field. 451 | func (f *FilePicker) GetValue() any { 452 | return f.accessor.Get() 453 | } 454 | --------------------------------------------------------------------------------