Repository: charmbracelet/huh Branch: main Commit: 3b90d9d74396 Files: 108 Total size: 331.5 KB Directory structure: gitextract_o580ujl4/ ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ ├── dependabot-sync.yml │ ├── lint-sync.yml │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── UPGRADE_GUIDE_V2.md ├── accessor.go ├── eval.go ├── examples/ │ ├── .gitignore │ ├── accessibility/ │ │ ├── accessible.tape │ │ └── main.go │ ├── accessibility-secure-input/ │ │ └── main.go │ ├── bubbletea/ │ │ ├── demo.tape │ │ └── main.go │ ├── bubbletea-options/ │ │ └── main.go │ ├── burger/ │ │ ├── demo.tape │ │ └── main.go │ ├── conditional/ │ │ └── main.go │ ├── dynamic/ │ │ ├── demo.tape │ │ ├── dynamic-all/ │ │ │ └── main.go │ │ ├── dynamic-bubbletea/ │ │ │ └── main.go │ │ ├── dynamic-count/ │ │ │ └── main.go │ │ ├── dynamic-country/ │ │ │ └── main.go │ │ ├── dynamic-increment/ │ │ │ └── main.go │ │ ├── dynamic-markdown/ │ │ │ └── main.go │ │ ├── dynamic-name/ │ │ │ └── main.go │ │ └── dynamic-suggestions/ │ │ └── main.go │ ├── filepicker/ │ │ ├── artichoke.hs │ │ ├── demo.tape │ │ └── main.go │ ├── filepicker-picking/ │ │ └── main.go │ ├── gh/ │ │ └── create.go │ ├── git/ │ │ └── main.go │ ├── go.mod │ ├── go.sum │ ├── gum/ │ │ └── main.go │ ├── help/ │ │ └── main.go │ ├── hide/ │ │ ├── hide.tape │ │ └── main.go │ ├── layout/ │ │ ├── columns/ │ │ │ └── main.go │ │ ├── default/ │ │ │ └── main.go │ │ ├── grid/ │ │ │ └── main.go │ │ └── stack/ │ │ └── main.go │ ├── multiple-groups/ │ │ └── main.go │ ├── readme/ │ │ ├── confirm/ │ │ │ ├── confirm.tape │ │ │ └── main.go │ │ ├── input/ │ │ │ ├── input.tape │ │ │ ├── main.go │ │ │ └── suggestions.tape │ │ ├── main/ │ │ │ └── main.go │ │ ├── multiselect/ │ │ │ ├── main.go │ │ │ └── multiselect.tape │ │ ├── note/ │ │ │ └── main.go │ │ ├── select/ │ │ │ ├── main.go │ │ │ ├── scroll/ │ │ │ │ ├── scroll.go │ │ │ │ └── scroll.tape │ │ │ └── select.tape │ │ └── text/ │ │ ├── main.go │ │ └── text.tape │ ├── scroll/ │ │ └── main.go │ ├── skip/ │ │ └── main.go │ ├── spinner/ │ │ ├── accessible/ │ │ │ └── main.go │ │ ├── context/ │ │ │ └── main.go │ │ ├── context-and-action/ │ │ │ └── main.go │ │ ├── context-and-action-and-error/ │ │ │ └── main.go │ │ ├── loading/ │ │ │ ├── demo.tape │ │ │ └── main.go │ │ └── static/ │ │ └── main.go │ ├── ssh-form/ │ │ └── main.go │ ├── stickers/ │ │ └── main.go │ ├── theme/ │ │ ├── main.go │ │ └── theme.tape │ └── timer/ │ └── main.go ├── field_confirm.go ├── field_filepicker.go ├── field_input.go ├── field_multiselect.go ├── field_note.go ├── field_select.go ├── field_text.go ├── form.go ├── go.mod ├── go.sum ├── group.go ├── huh.go ├── huh_test.go ├── internal/ │ ├── accessibility/ │ │ └── accessibility.go │ ├── compat/ │ │ └── model.go │ └── selector/ │ └── selector.go ├── keymap.go ├── layout.go ├── option.go ├── run.go ├── spinner/ │ ├── spinner.go │ └── spinner_test.go ├── theme.go ├── validate.go ├── wrap.go └── zz_resize_width_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ *.gif filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text ================================================ FILE: .github/CODEOWNERS ================================================ * @charmbracelet/everyone ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" ignore: - dependency-name: github.com/charmbracelet/bubbletea/v2 versions: - v2.0.0-beta1 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" - package-ecosystem: "gomod" directory: "/examples" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" ignore: - dependency-name: github.com/charmbracelet/bubbletea/v2 versions: - v2.0.0-beta1 - package-ecosystem: "gomod" directory: "/spinner" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" ignore: - dependency-name: github.com/charmbracelet/bubbletea/v2 versions: - v2.0.0-beta1 ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: [push, pull_request] jobs: build: uses: charmbracelet/meta/.github/workflows/build.yml@main build-go-mod: uses: charmbracelet/meta/.github/workflows/build.yml@main with: go-version: "" go-version-file: ./go.mod build-examples: uses: charmbracelet/meta/.github/workflows/build.yml@main with: go-version: "" go-version-file: ./examples/go.mod working-directory: ./examples ================================================ FILE: .github/workflows/dependabot-sync.yml ================================================ name: dependabot-sync on: schedule: - cron: "0 0 * * 0" # every Sunday at midnight workflow_dispatch: # allows manual triggering permissions: contents: write pull-requests: write jobs: dependabot-sync: uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main with: repo_name: ${{ github.event.repository.name }} secrets: gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} ================================================ FILE: .github/workflows/lint-sync.yml ================================================ name: lint-sync on: schedule: # every Sunday at midnight - cron: "0 0 * * 0" workflow_dispatch: # allows manual triggering permissions: contents: write pull-requests: write jobs: lint: uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main ================================================ FILE: .github/workflows/lint.yml ================================================ name: lint on: push: pull_request: jobs: lint: uses: charmbracelet/meta/.github/workflows/lint.yml@main with: golangci_path: .golangci.yml timeout: 10m ================================================ FILE: .github/workflows/release.yml ================================================ name: goreleaser on: push: tags: - v*.*.* concurrency: group: goreleaser cancel-in-progress: true jobs: goreleaser: uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main secrets: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_token: ${{ secrets.DOCKERHUB_TOKEN }} gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} goreleaser_key: ${{ secrets.GORELEASER_KEY }} twitter_consumer_key: ${{ secrets.TWITTER_CONSUMER_KEY }} twitter_consumer_secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} twitter_access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }} twitter_access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} mastodon_client_id: ${{ secrets.MASTODON_CLIENT_ID }} mastodon_client_secret: ${{ secrets.MASTODON_CLIENT_SECRET }} mastodon_access_token: ${{ secrets.MASTODON_ACCESS_TOKEN }} discord_webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} discord_webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json ================================================ FILE: .gitignore ================================================ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work # Debugging debug.log ================================================ FILE: .golangci.yml ================================================ version: "2" run: tests: false linters: enable: - bodyclose - exhaustive - goconst - godot - gomoddirectives - goprintffuncname - gosec - misspell - nakedret - nestif - nilerr - noctx - nolintlint - prealloc - revive - rowserrcheck - sqlclosecheck - tparallel - unconvert - unparam - whitespace - wrapcheck exclusions: rules: - text: '(slog|log)\.\w+' linters: - noctx generated: lax presets: - common-false-positives settings: exhaustive: default-signifies-exhaustive: true issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gofumpt - goimports exclusions: generated: lax ================================================ FILE: .goreleaser.yml ================================================ includes: - from_url: url: charmbracelet/meta/main/goreleaser-lib.yaml # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023–2026 Charm Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: spinner $(V).SILENT: test: go test ./... spinner: cd spinner/examples/loading && go run . burger: cd examples/burger && go run . theme: cd examples/theme && go run . gh: cd examples/gh && go run . ================================================ FILE: README.md ================================================ # Huh?



Latest Release Go Docs Build Status

A simple, powerful library for building interactive forms and prompts in the terminal. Running a burger form `huh?` is easy to use in a standalone fashion, can be [integrated into a Bubble Tea application](#what-about-bubble-tea), and contains a first-class [accessible mode](#accessibility) for screen readers. The above example is running from a single Go program ([source](./examples/burger/main.go)). ## Tutorial Let’s build a form for ordering burgers. To start, we’ll import the library and define a few variables where we'll store answers. ```go package main import "charm.land/huh/v2" var ( burger string toppings []string sauceLevel int name string instructions string discount bool ) ``` `huh?` separates forms into groups (you can think of groups as pages). Groups are made of fields (e.g. `Select`, `Input`, `Text`). We will set up three groups for the customer to fill out. ```go form := huh.NewForm( huh.NewGroup( // Ask the user for a base burger and toppings. huh.NewSelect[string](). Title("Choose your burger"). Options( huh.NewOption("Charmburger Classic", "classic"), huh.NewOption("Chickwich", "chickwich"), huh.NewOption("Fishburger", "fishburger"), huh.NewOption("Charmpossible™ Burger", "charmpossible"), ). Value(&burger), // store the chosen option in the "burger" variable // Let the user select multiple toppings. huh.NewMultiSelect[string](). Title("Toppings"). Options( huh.NewOption("Lettuce", "lettuce").Selected(true), huh.NewOption("Tomatoes", "tomatoes").Selected(true), huh.NewOption("Jalapeños", "jalapeños"), huh.NewOption("Cheese", "cheese"), huh.NewOption("Vegan Cheese", "vegan cheese"), huh.NewOption("Nutella", "nutella"), ). Limit(4). // there’s a 4 topping limit! Value(&toppings), // Option values in selects and multi selects can be any type you // want. We’ve been recording strings above, but here we’ll store // answers as integers. Note the generic "[int]" directive below. huh.NewSelect[int](). Title("How much Charm Sauce do you want?"). Options( huh.NewOption("None", 0), huh.NewOption("A little", 1), huh.NewOption("A lot", 2), ). Value(&sauceLevel), ), // Gather some final details about the order. huh.NewGroup( huh.NewInput(). Title("What’s your name?"). Value(&name). // Validating fields is easy. The form will mark erroneous fields // and display error messages accordingly. Validate(func(str string) error { if str == "Frank" { return errors.New("Sorry, we don’t serve customers named Frank.") } return nil }), huh.NewText(). Title("Special Instructions"). CharLimit(400). Value(&instructions), huh.NewConfirm(). Title("Would you like 15% off?"). Value(&discount), ), ) ``` Finally, run the form: ```go err := form.Run() if err != nil { log.Fatal(err) } if !discount { fmt.Println("What? You didn’t take the discount?!") } ``` And that’s it! For more info see [the full source][burgersource] for this example as well as [the docs][docs]. If you need more dynamic forms that change based on input from previous fields, check out the [dynamic forms](#dynamic-forms) example. [burgersource]: ./examples/burger/main.go [docs]: https://pkg.go.dev/charm.land/huh/v2?tab=doc ## Field Reference - [`Input`](#input): single line text input - [`Text`](#text): multi-line text input - [`Select`](#select): select an option from a list - [`MultiSelect`](#multiple-select): select multiple options from a list - [`Confirm`](#confirm): confirm an action (yes or no) > [!TIP] > Just want to prompt the user with a single field? Each field has a `Run` > method that can be used as a shorthand for gathering quick and easy input. ```go var name string huh.NewInput(). Title("What’s your name?"). Value(&name). Run() // this is blocking... fmt.Printf("Hey, %s!\n", name) ``` ### Input Prompt the user for a single line of text. Input field ```go huh.NewInput(). Title("What’s for lunch?"). Prompt("?"). Validate(isFood). Value(&lunch) ``` ### Text Prompt the user for multiple lines of text. Text field ```go huh.NewText(). Title("Tell me a story."). Validate(checkForPlagiarism). Value(&story) ``` ### Select Prompt the user to select a single option from a list. Select field ```go huh.NewSelect[string](). Title("Pick a country."). Options( huh.NewOption("United States", "US"), huh.NewOption("Germany", "DE"), huh.NewOption("Brazil", "BR"), huh.NewOption("Canada", "CA"), ). Value(&country) ``` ### Multiple Select Prompt the user to select multiple (zero or more) options from a list. Multiselect field ```go huh.NewMultiSelect[string](). Options( huh.NewOption("Lettuce", "Lettuce").Selected(true), huh.NewOption("Tomatoes", "Tomatoes").Selected(true), huh.NewOption("Charm Sauce", "Charm Sauce"), huh.NewOption("Jalapeños", "Jalapeños"), huh.NewOption("Cheese", "Cheese"), huh.NewOption("Vegan Cheese", "Vegan Cheese"), huh.NewOption("Nutella", "Nutella"), ). Title("Toppings"). Limit(4). Value(&toppings) ``` ### Confirm Prompt the user to confirm (Yes or No). Confirm field ```go huh.NewConfirm(). Title("Are you sure?"). Affirmative("Yes!"). Negative("No."). Value(&confirm) ``` ## Accessibility `huh?` has a special rendering option designed specifically for screen readers. You can enable it with `form.WithAccessible(true)`. > [!TIP] > We recommend setting this through an environment variable or configuration > option to allow the user to control accessibility. ```go accessibleMode := os.Getenv("ACCESSIBLE") != "" form.WithAccessible(accessibleMode) ``` Accessible forms will drop TUIs in favor of standard prompts, providing better dictation and feedback of the information on screen for the visually impaired. Accessible cuisine form ## Themes `huh?` contains a powerful theme abstraction. Supply your own custom theme or choose from one of the five predefined themes: - `Charm` - `Dracula` - `Catppuccin` - `Base 16` - `Default`

Charm-themed form Dracula-themed form Catppuccin-themed form Base 16-themed form Default-themed form

Themes can take advantage of the full range of [Lip Gloss][lipgloss] style options. For a high level theme reference see [the docs](https://pkg.go.dev/charm.land/huh/v2#Theme). [lipgloss]: https://github.com/charmbracelet/lipgloss ## Dynamic Forms `huh?` forms can be as dynamic as your heart desires. Simply replace properties with their equivalent `Func` to recompute the properties value every time a different part of your form changes. Here’s how you would build a simple country + state / province picker. First, define some variables that we’ll use to store the user selection. ```go var country string var state string ``` Define your country select as you normally would: ```go huh.NewSelect[string](). Options(huh.NewOptions("United States", "Canada", "Mexico")...). Value(&country). Title("Country"). ``` Define your state select with `TitleFunc` and `OptionsFunc` instead of `Title` and `Options`. This will allow you to change the title and options based on the selection of the previous field, i.e. `country`. To do this, we provide a `func() string` and a `binding any` to `TitleFunc`. The function defines what to show for the title and the binding specifies what value needs to change for the function to recompute. So if `country` changes (e.g. the user changes the selection) we will recompute the function. For `OptionsFunc`, we provide a `func() []Option[string]` and a `binding any`. We’ll fetch the country’s states, provinces, or territories from an API. `huh` will automatically handle caching for you. > [!IMPORTANT] > We have to pass `&country` as the binding to recompute the function only when > `country` changes, otherwise we will hit the API too often. ```go huh.NewSelect[string](). Value(&state). Height(8). TitleFunc(func() string { switch country { case "United States": return "State" case "Canada": return "Province" default: return "Territory" } }, &country). OptionsFunc(func() []huh.Option[string] { opts := fetchStatesForCountry(country) return huh.NewOptions(opts...) }, &country), ``` Lastly, run the `form` with these inputs. ```go err := form.Run() if err != nil { log.Fatal(err) } ``` Country / State form with dynamic inputs running. ## Bonus: Spinner `huh?` ships with a standalone spinner package. It’s useful for indicating background activity after a form is submitted. Spinner while making a burger Create a new spinner, set a title, set the action (or provide a `Context`), and run the spinner:
Action Style Context Style
```go err := spinner.New(). Title("Making your burger..."). Action(makeBurger). Run() fmt.Println("Order up!") ``` ```go go makeBurger() err := spinner.New(). Type(spinner.Line). Title("Making your burger..."). Context(ctx). Run() fmt.Println("Order up!") ```
For more on Spinners see the [spinner examples](./spinner/examples) and [the spinner docs](https://pkg.go.dev/charm.land/huh/v2/spinner). ## What about Bubble Tea? Bubbletea + Huh? Huh is built on [Bubble Tea][tea] and, in addition to its standalone mode, `huh?` has first-class support and can be easily integrated into Bubble Tea applications. It’s very useful in portions of your Bubble Tea application that need form-like input, and for times when you need more flexibility than `huh?` alone can offer. Bubble Tea embedded form example A `huh.Form` is just a `tea.Model`, so you can use it just as you would any other [Bubble](https://github.com/charmbracelet/bubbles). ```go type Model struct { form *huh.Form // huh.Form is just a tea.Model } func NewModel() Model { return Model{ form: huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Key("class"). Options(huh.NewOptions("Warrior", "Mage", "Rogue")...). Title("Choose your class"), huh.NewSelect[int](). Key("level"). Options(huh.NewOptions(1, 20, 9999)...). Title("Choose your level"), ), ) } } func (m Model) Init() tea.Cmd { return m.form.Init() } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // ... form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } return m, cmd } func (m Model) View() string { if m.form.State == huh.StateCompleted { class := m.form.GetString("class") level := m.form.GetInt("level") return fmt.Sprintf("You selected: %s, Lvl. %d", class, level) } return m.form.View() } ``` For more info in using `huh?` in Bubble Tea applications see [the full Bubble Tea example][example]. [tea]: https://github.com/charmbracelet/bubbletea [bubbles]: https://github.com/charmbracelet/bubbles [example]: https://github.com/charmbracelet/huh/blob/main/examples/bubbletea/main.go ## `Huh?` in the Wild For some `Huh?` programs in production, see: * [glyphs](https://github.com/maaslalani/glyphs): a unicode symbol picker * [meteor](https://github.com/stefanlogue/meteor): a highly customisable conventional commit message tool * [freeze](https://github.com/charmbracelet/freeze): a tool for generating images of code and terminal output * [savvy](https://github.com/getsavvyinc/savvy-cli): the easiest way to create, share, and run runbooks in the terminal ## Contributing See [contributing][contribute]. [contribute]: https://github.com/charmbracelet/huh/contribute ## Feedback We’d love to hear your thoughts on this project. Feel free to drop us a note! - [Twitter](https://twitter.com/charmcli) - [The Fediverse](https://mastodon.social/@charmcli) - [Discord](https://charm.sh/chat) ## Acknowledgments `huh?` is inspired by the wonderful [Survey][survey] library by Alec Aivazis. [survey]: https://github.com/AlecAivazis/survey ## License [MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE) --- Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة ================================================ FILE: UPGRADE_GUIDE_V2.md ================================================ # Huh v2 Upgrade Guide This guide will help you migrate from Huh v1 to v2. Most changes are straightforward, and many are handled automatically by your IDE or `gofmt`. > [!TIP] > For a high-level overview of what's new, check out [What's New in Huh v2](WHATS_NEW_V2.md). ## Quick Start Update your imports and dependencies, and you're 90% done: ```bash # Update your go.mod go get charm.land/huh/v2@latest go get charm.land/bubbletea/v2@latest go get charm.land/lipgloss/v2@latest go get charm.land/bubbles/v2@latest ``` Then update your import paths: ```go // Before import ( "github.com/charmbracelet/huh" "github.com/charmbracelet/huh/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/bubbles/key" ) // After import ( "charm.land/huh/v2" "charm.land/huh/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "charm.land/bubbles/v2/key" ) ``` ## Breaking Changes ### Import Paths All Charm imports now use the `charm.land` vanity domain with a `/v2` version suffix. | v1 | v2 | |----|-----| | `github.com/charmbracelet/huh` | `charm.land/huh/v2` | | `github.com/charmbracelet/huh/spinner` | `charm.land/huh/v2/spinner` | | `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` | | `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` | | `github.com/charmbracelet/bubbles` | `charm.land/bubbles/v2` | ### Theme Changes Themes are now passed by value and take a `bool` parameter for dark mode detection. **Before:** ```go form := huh.NewForm( // ... ).WithTheme(huh.ThemeCharm()) ``` **After:** ```go isDark := lipgloss.HasDarkBackground() // or detect however you prefer form := huh.NewForm( // ... ).WithTheme(huh.ThemeCharm(isDark)) ``` All built-in themes now follow this pattern: ```go huh.ThemeCharm(isDark bool) *Styles huh.ThemeDracula(isDark bool) *Styles huh.ThemeCatppuccin(isDark bool) *Styles huh.ThemeBase(isDark bool) *Styles huh.ThemeBase16(isDark bool) *Styles ``` ### Theme Type Changes The `Theme` type has changed from a struct to an interface: **Before:** ```go type Theme struct { Form FormStyles Group GroupStyles FieldSeparator lipgloss.Style Blurred FieldStyles Focused FieldStyles Help help.Styles } ``` **After:** ```go type ThemeFunc func(isDark bool) *Styles ``` If you created custom themes, you'll need to update them to this new function signature: ```go func MyCustomTheme(isDark bool) *Styles { styles := &huh.Styles{ // Your custom styles... } return styles } ``` ### Field-Level WithAccessible Removed Individual fields no longer have `WithAccessible()` methods. Accessible mode is now controlled exclusively at the form level, making it simpler and more consistent. **Before:** ```go // v1 - each field could have its own accessible setting input := huh.NewInput(). Title("Name"). WithAccessible(true) // ❌ No longer exists select := huh.NewSelect[string](). Title("Country"). Options(huh.NewOptions("US", "CA", "MX")...). WithAccessible(true) // ❌ Removed confirm := huh.NewConfirm(). Title("Continue?"). WithAccessible(true) // ❌ Gone from all field types form := huh.NewForm( huh.NewGroup(input, select, confirm), ).WithAccessible(true) ``` **After:** ```go // v2 - only the form controls accessible mode input := huh.NewInput(). Title("Name") select := huh.NewSelect[string](). Title("Country"). Options(huh.NewOptions("US", "CA", "MX")...) confirm := huh.NewConfirm(). Title("Continue?") form := huh.NewForm( huh.NewGroup(input, select, confirm), ).WithAccessible(true) // ✅ One setting for all fields ``` **Fields affected:** - `Input.WithAccessible()` - removed - `Text.WithAccessible()` - removed - `Select.WithAccessible()` - removed - `MultiSelect.WithAccessible()` - removed - `Confirm.WithAccessible()` - removed - `Note.WithAccessible()` - removed - `FilePicker.WithAccessible()` - removed The separate `github.com/charmbracelet/huh/accessibility` package is also gone. Just use `Form.WithAccessible()` directly. ### Bubble Tea v2 Integration All methods that returned or accepted Bubble Tea types have been updated to v2: **Field Methods:** - `Blur() tea.Cmd` (now returns `charm.land/bubbletea/v2.Cmd`) - `Focus() tea.Cmd` (now returns `charm.land/bubbletea/v2.Cmd`) - `Init() tea.Cmd` (now returns `charm.land/bubbletea/v2.Cmd`) - `Update(tea.Msg) (tea.Model, tea.Cmd)` (now uses v2 types) **Form Methods:** - `Init() tea.Cmd` (now returns `charm.land/bubbletea/v2.Cmd`) - `Update(tea.Msg) (tea.Model, tea.Cmd)` (now uses v2 types) - `WithProgramOptions(...tea.ProgramOption)` (now uses v2 types) **Key Bindings:** - `KeyBinds() []key.Binding` (now returns `charm.land/bubbles/v2/key.Binding`) These changes are mostly mechanical. Your IDE should help you update these automatically. ### Lip Gloss v2 Types All Lip Gloss types have been updated to v2. This affects style definitions in custom themes: **Before:** ```go import "github.com/charmbracelet/lipgloss" style := lipgloss.NewStyle(). Foreground(lipgloss.Color("205")) ``` **After:** ```go import "charm.land/lipgloss/v2" style := lipgloss.NewStyle(). Foreground(lipgloss.Color("205")) ``` The API is largely the same, but the import path and internal types have changed. ### Position Type Button alignment now uses Lip Gloss v2's `Position` type: **Before:** ```go import "github.com/charmbracelet/lipgloss" field.WithButtonAlignment(lipgloss.Left) ``` **After:** ```go import "charm.land/lipgloss/v2" field.WithButtonAlignment(lipgloss.Left) ``` ## New Features ### View Hooks You can now modify the view before it's rendered: ```go form.WithViewHook(func(v tea.View) tea.View { // Modify view properties like alt screen, mouse mode, etc. v.AltScreen = true return v }) ``` ### Width Method Select and MultiSelect fields now expose a `Width()` method for getting the field's width: ```go width := multiSelect.Width() ``` ### Model Type The `Model` type is now exported, improving type safety when working with forms in Bubble Tea applications: ```go var _ tea.Model = (*huh.Model)(nil) ``` ## Migration Checklist - [ ] Update `go.mod` dependencies to v2 - [ ] Update all import paths from `github.com/charmbracelet/` to `charm.land/` with `/v2` suffix - [ ] Update theme calls to pass `isDark bool` parameter - [ ] Remove field-level `WithAccessible()` calls (e.g., from `Input`, `Select`, etc.) - [ ] Keep form-level `WithAccessible()` calls (those still work) - [ ] Remove imports from `github.com/charmbracelet/huh/accessibility` package - [ ] Update custom themes to `ThemeFunc` signature if applicable - [ ] Run `go mod tidy` - [ ] Run tests - [ ] Update any documentation or examples ## Common Issues ### Import Cycles If you encounter import cycle issues, make sure all Charm dependencies are on v2: ```bash go list -m all | grep charmbracelet go list -m all | grep charm.land ``` Ensure nothing is still referencing v1 versions. ### Type Mismatches If you see type errors with `tea.Model`, `tea.Msg`, or `tea.Cmd`, double-check your Bubble Tea import: ```go import tea "charm.land/bubbletea/v2" // Make sure it's v2! ``` ### Theme Signature Errors If you get errors about theme functions, remember all built-in themes now require a `bool` parameter: ```go // ✅ Correct form.WithTheme(huh.ThemeCharm(true)) // ❌ Wrong form.WithTheme(huh.ThemeCharm()) ``` ## Getting Help If you run into issues: - Check the [examples](./examples) directory for reference implementations - Read the [Bubble Tea v2 Upgrade Guide](https://github.com/charmbracelet/bubbletea/blob/main/UPGRADE_GUIDE_V2.md) - Ask in [Discord](https://charm.land/chat) or [Matrix](https://charm.land/matrix) - Open an issue on [GitHub](https://github.com/charmbracelet/huh/issues) --- Part of [Charm](https://charm.land). The Charm logo Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة ================================================ FILE: accessor.go ================================================ package huh // Accessor give read/write access to field values. type Accessor[T any] interface { Get() T Set(value T) } // EmbeddedAccessor is a basic accessor, acting as the default one for fields. type EmbeddedAccessor[T any] struct { value T } // Get gets the value. func (a *EmbeddedAccessor[T]) Get() T { return a.value } // Set sets the value. func (a *EmbeddedAccessor[T]) Set(value T) { a.value = value } // PointerAccessor allows field value to be exposed as a pointed variable. type PointerAccessor[T any] struct { value *T } // NewPointerAccessor returns a new pointer accessor. func NewPointerAccessor[T any](value *T) *PointerAccessor[T] { return &PointerAccessor[T]{ value: value, } } // Get gets the value. func (a *PointerAccessor[T]) Get() T { return *a.value } // Set sets the value. func (a *PointerAccessor[T]) Set(value T) { *a.value = value } ================================================ FILE: eval.go ================================================ package huh import ( "time" "github.com/mitchellh/hashstructure/v2" ) // Eval is an evaluatable value, it stores a cached value and a function to // recompute it. It's bindings are what we check to see if we need to recompute // the value. // // By default it is also cached. type Eval[T any] struct { val T fn func() T bindings any bindingsHash uint64 cache map[uint64]T loading bool loadingStart time.Time } const spinnerShowThreshold = 25 * time.Millisecond func hash(val any) uint64 { hash, _ := hashstructure.Hash(val, hashstructure.FormatV2, nil) return hash } func (e *Eval[T]) shouldUpdate() (bool, uint64) { if e.fn == nil { return false, 0 } newHash := hash(e.bindings) return e.bindingsHash != newHash, newHash } func (e *Eval[T]) loadFromCache() bool { val, ok := e.cache[e.bindingsHash] if ok { e.loading = false e.val = val } return ok } func (e *Eval[T]) update(val T) { e.val = val e.cache[e.bindingsHash] = val e.loading = false } type updateTitleMsg struct { id int hash uint64 title string } type updateDescriptionMsg struct { id int hash uint64 description string } type updatePlaceholderMsg struct { id int hash uint64 placeholder string } type updateSuggestionsMsg struct { id int hash uint64 suggestions []string } type updateOptionsMsg[T comparable] struct { id int hash uint64 options []Option[T] } ================================================ FILE: examples/.gitignore ================================================ .ssh ================================================ FILE: examples/accessibility/accessible.tape ================================================ Output accessible.gif Set Height 600 Set Width 1000 Hide Type "go build -o accessible ." Enter Type "export ACCESSIBLE=true" Enter Type "clear && ./accessible" Enter Sleep 1s Show Sleep 1s Type "2" Sleep 500ms Enter Sleep 1.5s Type "Souvlaki" Sleep 1.5s Enter Sleep 1.5s Hide Type "rm accessible" Enter Sleep 1s ================================================ FILE: examples/accessibility/main.go ================================================ package main import ( "log" "charm.land/huh/v2" ) func main() { form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Options(huh.NewOptions("Italian", "Greek", "Indian", "Japanese", "American")...). Title("Favorite Cuisine?"), ), huh.NewGroup( huh.NewInput(). Title("Favorite Meal?"). Placeholder("Breakfast"), ), ).WithAccessible(true) err := form.Run() if err != nil { log.Fatal(err) } } ================================================ FILE: examples/accessibility-secure-input/main.go ================================================ package main import ( "errors" "log" "charm.land/huh/v2" ) func validate(s string) error { if s == "" { return errors.New("input cannot be empty") } return nil } func main() { form := huh.NewForm( huh.NewGroup( huh.NewNote(). Title("Welcome!"). Description("This is an accessible form example!"), huh.NewInput(). Validate(validate). Title("Name:"), huh.NewInput(). EchoMode(huh.EchoModePassword). Validate(validate). Title("Password:"), huh.NewMultiSelect[string](). Options(huh.NewOptions( "Red", "Green", "Yellow", )...). Limit(2). Title("Choose some colors:"), huh.NewSelect[string](). Options(huh.NewOptions( "Red", "Green", "Yellow", )...). Title("Choose the best color:"), huh.NewFilePicker(). Title("Which file?"), huh.NewConfirm(). Title("Send something?"), ), ).WithAccessible(true) err := form.Run() if err != nil { log.Fatal(err) } } ================================================ FILE: examples/bubbletea/demo.tape ================================================ Set Height 775 Set Padding 60 Set Width 1200 Set FontSize 20 Hide Type "clear && go run ." Enter Sleep 1s Show Sleep 2s Down Sleep 1s Enter Sleep 1s Down Sleep 1s Enter Sleep 1s Enter Sleep 1.5s Left Sleep 2s Enter Sleep 3s ================================================ FILE: examples/bubbletea/main.go ================================================ package main import ( "fmt" "image/color" "os" "strings" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" "charm.land/lipgloss/v2" ) const maxWidth = 80 type Styles struct { Base, HeaderText, Status, StatusHeader, Highlight, ErrorHeaderText, Help lipgloss.Style Red, Indigo, Green color.Color } func NewStyles(hasDarkBg bool) *Styles { var ( s = Styles{} lightDark = lipgloss.LightDark(hasDarkBg) ) s.Red = lightDark(lipgloss.Color("#FE5F86"), lipgloss.Color("#FE5F86")) s.Indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) s.Green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) s.Base = lipgloss.NewStyle(). Padding(1, 4, 0, 1) s.HeaderText = lipgloss.NewStyle(). Foreground(s.Indigo). Bold(true). Padding(0, 1, 0, 2) s.Status = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(s.Indigo). PaddingLeft(1). MarginTop(1) s.StatusHeader = lipgloss.NewStyle(). Foreground(s.Green). Bold(true) s.Highlight = lipgloss.NewStyle(). Foreground(lipgloss.Color("212")) s.ErrorHeaderText = s.HeaderText. Foreground(s.Red) s.Help = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) return &s } type state int const ( statusNormal state = iota stateDone ) type Model struct { state state styles func(bool) *Styles form *huh.Form hasDarkBg bool width int } func NewModel() Model { m := Model{ width: maxWidth, styles: NewStyles, } m.form = huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Key("class"). Options(huh.NewOptions("Warrior", "Mage", "Rogue")...). Title("Choose your class"). Description("This will determine your department"), huh.NewSelect[string](). Key("level"). Options(huh.NewOptions("1", "20", "9999")...). Title("Choose your level"). Description("This will determine your benefits package"), huh.NewConfirm(). Key("done"). Title("All done?"). Validate(func(v bool) error { if !v { return fmt.Errorf("Welp, finish up then") } return nil }). Affirmative("Yep"). Negative("Wait, no"), ), ). WithWidth(45). WithShowHelp(false). WithShowErrors(false) return m } func (m Model) Init() tea.Cmd { return m.form.Init() } func min(x, y int) int { if x > y { return y } return x } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { styles := m.styles(m.hasDarkBg) switch msg := msg.(type) { case tea.BackgroundColorMsg: m.hasDarkBg = msg.IsDark() case tea.WindowSizeMsg: m.width = min(msg.Width, maxWidth) - styles.Base.GetHorizontalFrameSize() case tea.KeyPressMsg: switch msg.String() { case "ctrl+c": return m, tea.Interrupt case "esc", "q": return m, tea.Quit } } var cmds []tea.Cmd // Process the form form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f cmds = append(cmds, cmd) } if m.form.State == huh.StateCompleted { // Quit when the form is done. cmds = append(cmds, tea.Quit) } return m, tea.Batch(cmds...) } func (m Model) View() tea.View { s := m.styles(m.hasDarkBg) switch m.form.State { case huh.StateCompleted: title, role := m.getRole() title = s.Highlight.Render(title) var b strings.Builder fmt.Fprintf(&b, "Congratulations, you’re Charm’s newest\n%s!\n\n", title) fmt.Fprintf(&b, "Your job description is as follows:\n\n%s\n\nPlease proceed to HR immediately.", role) return tea.NewView(s.Status.Margin(0, 1).Padding(1, 2).Width(48).Render(b.String()) + "\n\n") default: var class string if m.form.GetString("class") != "" { class = "Class: " + m.form.GetString("class") } // Form (left side) v := strings.TrimSuffix(m.form.View(), "\n\n") form := lipgloss.NewStyle().Margin(1, 0).Render(v) // Status (right side) var status string { var ( buildInfo = "(None)" role string jobDescription string level string ) if m.form.GetString("level") != "" { level = "Level: " + m.form.GetString("level") role, jobDescription = m.getRole() role = "\n\n" + s.StatusHeader.Render("Projected Role") + "\n" + role jobDescription = "\n\n" + s.StatusHeader.Render("Duties") + "\n" + jobDescription } if m.form.GetString("class") != "" { buildInfo = fmt.Sprintf("%s\n%s", class, level) } const statusWidth = 28 statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight() status = s.Status. Height(lipgloss.Height(form)). Width(statusWidth). MarginLeft(statusMarginLeft). Render(s.StatusHeader.Render("Current Build") + "\n" + buildInfo + role + jobDescription) } errors := m.form.Errors() header := m.appBoundaryView("Charm Employment Application") if len(errors) > 0 { header = m.appErrorBoundaryView(m.errorView()) } body := lipgloss.JoinHorizontal(lipgloss.Left, form, status) footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds())) if len(errors) > 0 { footer = m.appErrorBoundaryView("") } return tea.NewView(s.Base.Render(header + "\n" + body + "\n\n" + footer)) } } func (m Model) errorView() string { var s string for _, err := range m.form.Errors() { s += err.Error() } return s } func (m Model) appBoundaryView(text string) string { s := m.styles(m.hasDarkBg) return lipgloss.PlaceHorizontal( m.width, lipgloss.Left, s.HeaderText.Render(text), lipgloss.WithWhitespaceChars("/"), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(s.Indigo)), ) } func (m Model) appErrorBoundaryView(text string) string { s := m.styles(m.hasDarkBg) return lipgloss.PlaceHorizontal( m.width, lipgloss.Left, s.ErrorHeaderText.Render(text), lipgloss.WithWhitespaceChars("/"), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(s.Red)), ) } func (m Model) getRole() (string, string) { level := m.form.GetString("level") switch m.form.GetString("class") { case "Warrior": switch level { case "1": return "Tank Intern", "Assists with tank-related activities. Paid position." case "9999": return "Tank Manager", "Manages tanks and tank-related activities." default: return "Tank", "General tank. Does damage, takes damage. Responsible for tanking." } case "Mage": switch level { case "1": return "DPS Associate", "Finds DPS deals and passes them on to DPS Manager." case "9999": return "DPS Operating Officer", "Oversees all DPS activities." default: return "DPS", "Does damage and ideally does not take damage. Logs hours in JIRA." } case "Rogue": switch level { case "1": return "Stealth Junior Designer", "Designs rogue-like activities. Reports to Stealth Lead." case "9999": return "Stealth Lead", "Lead designer for all things stealth. Some travel required." default: return "Sneaky Person", "Sneaks around and does sneaky things. Reports to Stealth Lead." } default: return "", "" } } func main() { _, err := tea.NewProgram(NewModel()).Run() if err != nil { fmt.Println("Oh no:", err) os.Exit(1) } } ================================================ FILE: examples/bubbletea-options/main.go ================================================ package main import ( "fmt" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" ) func main() { var name string form := huh.NewForm( huh.NewGroup(huh.NewInput().Description("What should we call you?").Value(&name)), ).WithViewHook(func(v tea.View) tea.View { v.AltScreen = true return v }) err := form.Run() if err != nil { fmt.Println("error:", err) } fmt.Println("Welcome, " + name + "!") } ================================================ FILE: examples/burger/demo.tape ================================================ Output burger.gif Set Height 700 Set Width 1000 Hide Type "go build -o burger ." Enter Ctrl+L Sleep 1s Type "clear && ./burger" Sleep 500ms Enter Sleep 500ms Show Sleep 1s Type "n" Sleep 1s Down 2 Sleep 500ms Enter Sleep 1s Up@500ms Sleep 500ms Enter Sleep 500ms Down@300ms 3 Sleep 300ms Space Sleep 750ms Enter Sleep 500ms Down@300ms 2 Sleep 500ms Enter Sleep 750ms Down@300ms Sleep 500ms Enter Sleep 1s Type "Hilda" Sleep 500ms Enter Sleep 1s Type "Extra spicy please!" Sleep 500ms Tab Sleep 750ms Left Sleep 750ms Enter Sleep 5s Hide Type "rm burger" Enter Sleep 1s ================================================ FILE: examples/burger/main.go ================================================ package main import ( "errors" "fmt" "os" "strconv" "strings" "time" "charm.land/huh/v2" "charm.land/huh/v2/spinner" "charm.land/lipgloss/v2" xstrings "github.com/charmbracelet/x/exp/strings" ) type Spice int const ( Mild Spice = iota + 1 Medium Hot ) func (s Spice) String() string { switch s { case Mild: return "Mild " case Medium: return "Medium-Spicy " case Hot: return "Spicy-Hot " default: return "" } } type Order struct { Burger Burger Side string Name string Instructions string Discount bool } type Burger struct { Type string Toppings []string Spice Spice } func main() { var burger Burger order := Order{Burger: burger} // Should we run in accessible mode? accessible, _ := strconv.ParseBool(os.Getenv("ACCESSIBLE")) form := huh.NewForm( huh.NewGroup(huh.NewNote(). Title("Charmburger"). Description("Welcome to _Charmburger™_.\n\nHow may we take your order?"). Next(true). NextLabel("Next"), ), // Choose a burger. // We'll need to know what topping to add too. huh.NewGroup( huh.NewSelect[string](). Options(huh.NewOptions("Charmburger Classic", "Chickwich", "Fishburger", "Charmpossible™ Burger")...). Title("Choose your burger"). Description("At Charm we truly have a burger for everyone."). Validate(func(t string) error { if t == "Fishburger" { return fmt.Errorf("no fish today, sorry") } return nil }). Value(&order.Burger.Type), huh.NewMultiSelect[string](). Title("Toppings"). Description("Choose up to 4."). Options( huh.NewOption("Lettuce", "Lettuce").Selected(true), huh.NewOption("Tomatoes", "Tomatoes").Selected(true), huh.NewOption("Charm Sauce", "Charm Sauce"), huh.NewOption("Jalapeños", "Jalapeños"), huh.NewOption("Cheese", "Cheese"), huh.NewOption("Vegan Cheese", "Vegan Cheese"), huh.NewOption("Nutella", "Nutella"), ). Validate(func(t []string) error { if len(t) <= 0 { return fmt.Errorf("at least one topping is required") } return nil }). Value(&order.Burger.Toppings). Filterable(true). Limit(4), ), // Prompt for toppings and special instructions. // The customer can ask for up to 4 toppings. huh.NewGroup( huh.NewSelect[Spice](). Title("Spice level"). Options( huh.NewOption("Mild", Mild).Selected(true), huh.NewOption("Medium", Medium), huh.NewOption("Hot", Hot), ). Value(&order.Burger.Spice), huh.NewSelect[string](). Options(huh.NewOptions("Fries", "Disco Fries", "R&B Fries", "Carrots")...). Value(&order.Side). Title("Sides"). Description("You get one free side with this order."), ), // Gather final details for the order. huh.NewGroup( huh.NewInput(). Value(&order.Name). Title("What's your name?"). Placeholder("Margaret Thatcher"). Validate(func(s string) error { if s == "Frank" { return errors.New("no franks, sorry") } return nil }). Description("For when your order is ready."), huh.NewText(). Value(&order.Instructions). Placeholder("Just put it in the mailbox please"). Title("Special Instructions"). Description("Anything we should know?"). CharLimit(400). Lines(5), huh.NewConfirm(). Title("Would you like 15% off?"). Value(&order.Discount). Affirmative("Yes!"). Negative("No."), ), ).WithAccessible(accessible) err := form.Run() if err != nil { fmt.Println("Uh oh:", err) os.Exit(1) } prepareBurger := func() { time.Sleep(2 * time.Second) } _ = spinner.New().Title("Preparing your burger...").WithAccessible(accessible).Action(prepareBurger).Run() // Print order summary. { var sb strings.Builder keyword := func(s string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Render(s) } fmt.Fprintf(&sb, "%s\n\nOne %s%s, topped with %s with %s on the side.", lipgloss.NewStyle().Bold(true).Render("BURGER RECEIPT"), keyword(order.Burger.Spice.String()), keyword(order.Burger.Type), keyword(xstrings.EnglishJoin(order.Burger.Toppings, true)), keyword(order.Side), ) name := order.Name if name != "" { name = ", " + name } fmt.Fprintf(&sb, "\n\nThanks for your order%s!", name) if order.Discount { fmt.Fprint(&sb, "\n\nEnjoy 15% off.") } fmt.Println( lipgloss.NewStyle(). Width(40). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(1, 2). Render(sb.String()), ) } } ================================================ FILE: examples/conditional/main.go ================================================ package main import ( "fmt" "os" "charm.land/huh/v2" ) type consumable int const ( fruits consumable = iota vegetables drinks ) func (c consumable) String() string { return [...]string{"fruit", "vegetable", "drink"}[c] } func main() { var category consumable type opts []huh.Option[string] var choice string // Then ask for a specific food item based on the previous answer. err := huh.NewForm( huh.NewGroup( huh.NewSelect[consumable](). Title("What are you in the mood for?"). Value(&category). Options( huh.NewOption("Some fruit", fruits), huh.NewOption("A vegetable", vegetables), huh.NewOption("A drink", drinks), ), huh.NewSelect[string](). Value(&choice). Height(7). TitleFunc(func() string { return fmt.Sprintf("Okay, what kind of %s are you in the mood for?", category) }, &category). OptionsFunc(func() []huh.Option[string] { switch category { case fruits: return []huh.Option[string]{ huh.NewOption("Tangerine", "tangerine"), huh.NewOption("Canteloupe", "canteloupe"), huh.NewOption("Pomelo", "pomelo"), huh.NewOption("Grapefruit", "grapefruit"), } case vegetables: return []huh.Option[string]{ huh.NewOption("Carrot", "carrot"), huh.NewOption("Jicama", "jicama"), huh.NewOption("Kohlrabi", "kohlrabi"), huh.NewOption("Fennel", "fennel"), huh.NewOption("Ginger", "ginger"), } default: return []huh.Option[string]{ huh.NewOption("Coffee", "coffee"), huh.NewOption("Tea", "tea"), huh.NewOption("Bubble Tea", "bubble tea"), huh.NewOption("Agua Fresca", "agua-fresca"), } } }, &category), ), ).Run() if err != nil { fmt.Println("Trouble in food paradise:", err) os.Exit(1) } fmt.Printf("One %s coming right up!\n", choice) } ================================================ FILE: examples/dynamic/demo.tape ================================================ Output dynamic.gif Set Shell "bash" Set FontSize 28 Set Width 1000 Set Height 700 Hide Type "clear && go build -o dynamic ./dynamic-country" Enter Sleep 1s Show Sleep 1s Type "./dynamic" Sleep 500ms Enter Sleep 3.5s Down Sleep 2.5s Down Sleep 2.5s Enter Sleep 1s Down@150ms 12 Up@150ms 2 Sleep 1s Enter Sleep 3s Hide Type "rm dynamic" Show ================================================ FILE: examples/dynamic/dynamic-all/main.go ================================================ package main import ( "log" "strconv" "charm.land/huh/v2" ) func main() { var value string = "Dynamic" f := huh.NewForm( huh.NewGroup( huh.NewInput().Value(&value).Title("Dynamic").Description("Dynamic"), huh.NewNote(). TitleFunc(func() string { return value }, &value). DescriptionFunc(func() string { return value }, &value), huh.NewSelect[string](). Height(7). TitleFunc(func() string { return value }, &value). DescriptionFunc(func() string { return value }, &value). OptionsFunc(func() []huh.Option[string] { var options []huh.Option[string] for i := 1; i < 6; i++ { options = append(options, huh.NewOption(value+" "+strconv.Itoa(i), value+strconv.Itoa(i))) } return options }, &value), huh.NewMultiSelect[string](). Height(7). TitleFunc(func() string { return value }, &value). DescriptionFunc(func() string { return value }, &value). OptionsFunc(func() []huh.Option[string] { var options []huh.Option[string] for i := 1; i < 6; i++ { options = append(options, huh.NewOption(value+" "+strconv.Itoa(i), value+strconv.Itoa(i))) } return options }, &value), huh.NewConfirm(). TitleFunc(func() string { return value }, &value). DescriptionFunc(func() string { return value }, &value), huh.NewText(). TitleFunc(func() string { return value }, &value). DescriptionFunc(func() string { return value }, &value). PlaceholderFunc(func() string { return value }, &value), ), ) err := f.Run() if err != nil { log.Fatal(err) } } ================================================ FILE: examples/dynamic/dynamic-bubbletea/main.go ================================================ package main import ( "fmt" "image/color" "os" "strings" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" "charm.land/lipgloss/v2" ) const maxWidth = 80 type Styles struct { Base, HeaderText, Status, StatusHeader, Highlight, ErrorHeaderText, Help lipgloss.Style Red, Indigo, Green color.Color } func NewStyles(hasDarkBg bool) *Styles { var ( s = Styles{} lightDark = lipgloss.LightDark(hasDarkBg) ) s.Red = lightDark(lipgloss.Color("#FE5F86"), lipgloss.Color("#FE5F86")) s.Indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) s.Green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) s.Base = lipgloss.NewStyle(). Padding(1, 4, 0, 1) s.HeaderText = lipgloss.NewStyle(). Foreground(s.Indigo). Bold(true). Padding(0, 1, 0, 2) s.Status = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(s.Indigo). PaddingLeft(1). MarginTop(1) s.StatusHeader = lipgloss.NewStyle(). Foreground(s.Green). Bold(true) s.Highlight = lipgloss.NewStyle(). Foreground(lipgloss.Color("212")) s.ErrorHeaderText = s.HeaderText. Foreground(s.Red) s.Help = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) return &s } type state int const ( statusNormal state = iota stateDone ) type Model struct { state state styles func(bool) *Styles hasDarkBg bool form *huh.Form width int } func NewModel() Model { m := Model{width: maxWidth, styles: NewStyles} var class string m.form = huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Key("class"). Value(&class). Options(huh.NewOptions("Warrior", "Mage", "Rogue")...). Title("Choose your class"). Description("This will determine your department"), huh.NewSelect[string](). Key("level"). OptionsFunc(func() []huh.Option[string] { switch class { case "Warrior": return huh.NewOptions("1", "20", "9999") case "Mage": return huh.NewOptions("10", "100", "1000") } return huh.NewOptions("1", "20", "9999") }, &class). Title("Choose your level"). Description("This will determine your benefits package"), huh.NewConfirm(). Key("done"). Title("All done?"). Validate(func(v bool) error { if !v { return fmt.Errorf("Welp, finish up then") } return nil }). Affirmative("Yep"). Negative("Wait, no"), ), ). WithWidth(45). WithShowHelp(false). WithShowErrors(false) return m } func (m Model) Init() tea.Cmd { return m.form.Init() } func min(x, y int) int { if x > y { return y } return x } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.BackgroundColorMsg: m.hasDarkBg = msg.IsDark() case tea.WindowSizeMsg: s := m.styles(m.hasDarkBg) m.width = min(msg.Width, maxWidth) - s.Base.GetHorizontalFrameSize() case tea.KeyPressMsg: switch msg.String() { case "esc", "ctrl+c", "q": return m, tea.Quit } } var cmds []tea.Cmd // Process the form form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f cmds = append(cmds, cmd) } if m.form.State == huh.StateCompleted { // Quit when the form is done. cmds = append(cmds, tea.Quit) } return m, tea.Batch(cmds...) } func (m Model) View() tea.View { s := m.styles(m.hasDarkBg) switch m.form.State { case huh.StateCompleted: title, role := m.getRole() title = s.Highlight.Render(title) var b strings.Builder fmt.Fprintf(&b, "Congratulations, you’re Charm’s newest\n%s!\n\n", title) fmt.Fprintf(&b, "Your job description is as follows:\n\n%s\n\nPlease proceed to HR immediately.", role) return tea.NewView(s.Status.Margin(0, 1).Padding(1, 2).Width(48).Render(b.String()) + "\n\n") default: var class string if m.form.GetString("class") != "" { class = "Class: " + m.form.GetString("class") } // Form (left side) v := strings.TrimSuffix(m.form.View(), "\n\n") form := lipgloss.NewStyle().Margin(1, 0).Render(v) // Status (right side) var status string { var ( buildInfo = "(None)" role string jobDescription string level string ) if m.form.GetString("level") != "" { level = "Level: " + m.form.GetString("level") role, jobDescription = m.getRole() role = "\n\n" + s.StatusHeader.Render("Projected Role") + "\n" + role jobDescription = "\n\n" + s.StatusHeader.Render("Duties") + "\n" + jobDescription } if m.form.GetString("class") != "" { buildInfo = fmt.Sprintf("%s\n%s", class, level) } const statusWidth = 28 statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight() status = s.Status. Height(lipgloss.Height(form)). Width(statusWidth). MarginLeft(statusMarginLeft). Render(s.StatusHeader.Render("Current Build") + "\n" + buildInfo + role + jobDescription) } errors := m.form.Errors() header := m.appBoundaryView("Charm Employment Application") if len(errors) > 0 { header = m.appErrorBoundaryView(m.errorView()) } body := lipgloss.JoinHorizontal(lipgloss.Left, form, status) footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds())) if len(errors) > 0 { footer = m.appErrorBoundaryView("") } return tea.NewView(s.Base.Render(header + "\n" + body + "\n\n" + footer)) } } func (m Model) errorView() string { var s string for _, err := range m.form.Errors() { s += err.Error() } return s } func (m Model) appBoundaryView(text string) string { s := m.styles(m.hasDarkBg) return lipgloss.PlaceHorizontal( m.width, lipgloss.Left, s.HeaderText.Render(text), lipgloss.WithWhitespaceChars("/"), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(s.Indigo)), ) } func (m Model) appErrorBoundaryView(text string) string { s := m.styles(m.hasDarkBg) return lipgloss.PlaceHorizontal( m.width, lipgloss.Left, s.ErrorHeaderText.Render(text), lipgloss.WithWhitespaceChars("/"), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(s.Red)), ) } func (m Model) getRole() (string, string) { level := m.form.GetString("level") switch m.form.GetString("class") { case "Warrior": switch level { case "1": return "Tank Intern", "Assists with tank-related activities. Paid position." case "9999": return "Tank Manager", "Manages tanks and tank-related activities." default: return "Tank", "General tank. Does damage, takes damage. Responsible for tanking." } case "Mage": switch level { case "1": return "DPS Associate", "Finds DPS deals and passes them on to DPS Manager." case "9999": return "DPS Operating Officer", "Oversees all DPS activities." default: return "DPS", "Does damage and ideally does not take damage. Logs hours in JIRA." } case "Rogue": switch level { case "1": return "Stealth Junior Designer", "Designs rougue-like activities. Reports to Stealth Lead." case "9999": return "Stealth Lead", "Lead designer for all things stealth. Some travel required." default: return "Sneaky Person", "Sneaks around and does sneaky things. Reports to Stealth Lead." } default: return "", "" } } func main() { _, err := tea.NewProgram(NewModel()).Run() if err != nil { fmt.Println("Oh no:", err) os.Exit(1) } } ================================================ FILE: examples/dynamic/dynamic-count/main.go ================================================ package main import ( "errors" "fmt" "log" "strconv" "charm.land/huh/v2" ) func main() { var value string defaultValue := 10 var chosen int f := huh.NewForm( huh.NewGroup( huh.NewInput(). Value(&value). Title("Max"). Placeholder(strconv.Itoa(defaultValue)). Validate(func(s string) error { v, err := strconv.Atoi(value) if err != nil { return errors.New("max should be a number") } if v <= 0 { return errors.New("maximum must be positive") } return nil }). Description("Select a maximum"), huh.NewSelect[int](). Value(&chosen). Title("Pick a number"). DescriptionFunc(func() string { v, err := strconv.Atoi(value) if err != nil || v <= 0 { v = defaultValue } return "Between 1 and " + strconv.Itoa(v) }, &value). OptionsFunc(func() []huh.Option[int] { var options []huh.Option[int] v, err := strconv.Atoi(value) if err != nil { v = defaultValue } for i := range v { options = append(options, huh.NewOption(strconv.Itoa(i+1), i+1)) } return options }, &value), ), ) err := f.Run() if err != nil { log.Fatal(err) } fmt.Println(chosen) } ================================================ FILE: examples/dynamic/dynamic-country/main.go ================================================ package main import ( "fmt" "time" "charm.land/huh/v2" "charm.land/log/v2" ) func main() { log.SetReportTimestamp(false) var ( country string state string ) form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Options(huh.NewOptions("United States", "Canada", "Mexico")...). Value(&country). Title("Country"). Height(5), huh.NewSelect[string](). Value(&state). Height(8). TitleFunc(func() string { switch country { case "United States": return "State" case "Canada": return "Province" default: return "Territory" } }, &country). OptionsFunc(func() []huh.Option[string] { s := states[country] // simulate API call time.Sleep(1000 * time.Millisecond) return huh.NewOptions(s...) }, &country /* only this function when `country` changes */), ), ) err := form.Run() if err != nil { log.Fatal(err) } fmt.Printf("%s, %s\n", state, country) } var states = map[string][]string{ "Canada": { "Alberta", "British Columbia", "Manitoba", "New Brunswick", "Newfoundland and Labrador", "North West Territories", "Nova Scotia", "Nunavut", "Ontario", "Prince Edward Island", "Quebec", "Saskatchewan", "Yukon", }, "Mexico": { "Aguascalientes", "Baja California", "Baja California Sur", "Campeche", "Chiapas", "Chihuahua", "Coahuila", "Colima", "Durango", "Guanajuato", "Guerrero", "Hidalgo", "Jalisco", "México", "Mexico City", "Michoacán", "Morelos", "Nayarit", "Nuevo León", "Oaxaca", "Puebla", "Querétaro", "Quintana Roo", "San Luis Potosí", "Sinaloa", "Sonora", "Tabasco", "Tamaulipas", "Tlaxcala", "Veracruz", "Ignacio de la Llave", "Yucatán", "Zacatecas", }, "United States": { "Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico", "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming", }, } ================================================ FILE: examples/dynamic/dynamic-increment/main.go ================================================ package main import ( "fmt" "time" "charm.land/huh/v2" ) func main() { count := 0 go func() { for { count++ time.Sleep(1 * time.Second) } }() descriptionFunc := func() string { return fmt.Sprintf("The count is: %d", count) } huh.NewForm(huh.NewGroup( huh.NewInput(). Title("Fill in the input"). DescriptionFunc(descriptionFunc, &count), huh.NewInput(). Title("Fill in the input"). DescriptionFunc(descriptionFunc, &count), )).Run() } ================================================ FILE: examples/dynamic/dynamic-markdown/main.go ================================================ package main import ( "log" "charm.land/glamour/v2" "charm.land/huh/v2" ) func main() { var md string err := huh.NewForm( huh.NewGroup( huh.NewText().Title("Markdown").Value(&md), huh.NewNote().Height(20).Title("Preview"). DescriptionFunc(func() string { fmd, err := glamour.Render(md, "dark") if err != nil { return md } return fmd }, &md), ), ).Run() if err != nil { log.Fatal(err) } } ================================================ FILE: examples/dynamic/dynamic-name/main.go ================================================ package main import ( "fmt" "log" "charm.land/huh/v2" ) func main() { var name string err := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("What's your name?"). Placeholder("Frank"). Value(&name), huh.NewNote(). TitleFunc(func() string { if name == "" { return "Hello!" } return fmt.Sprintf("Hello, %s!", name) }, &name). DescriptionFunc(func() string { if name == "" { return "How are you?" } return fmt.Sprintf("Your name is %d characters long", len(name)) }, &name), huh.NewText(). Title("Biography."). PlaceholderFunc(func() string { placeholder := "Tell me about yourself" if name != "" { placeholder += ", " + name } placeholder += "." return placeholder }, &name), huh.NewConfirm(). TitleFunc(func() string { if name == "" { return "Continue?" } return fmt.Sprintf("Continue, %s?", name) }, &name). DescriptionFunc(func() string { if name == "" { return "Are you sure?" } return fmt.Sprintf("Last chance, %s.", name) }, &name), ), ).Run() if err != nil { log.Fatal(err) } fmt.Println("Until next time, " + name + "!") } ================================================ FILE: examples/dynamic/dynamic-suggestions/main.go ================================================ package main import ( "fmt" "log" "charm.land/huh/v2" "charm.land/huh/v2/spinner" ) func main() { var org string var repo string err := huh.NewForm( huh.NewGroup( huh.NewInput(). Value(&org). Title("Organization"). Placeholder("charmbracelet"), huh.NewInput(). Value(&repo). Title("Repository"). PlaceholderFunc(func() string { switch org { case "hashicorp": return "terraform" case "golang": return "go" default: // charmbracelet return "bubbletea" } }, &org). SuggestionsFunc(func() []string { switch org { case "charmbracelet": return []string{"bubbletea", "huh", "mods", "melt", "freeze", "gum", "vhs", "pop", "lipgloss", "harmonica"} case "hashicorp": return []string{"terraform", "vault", "waypoint"} case "golang": return []string{"go", "net", "sys", "text", "tools"} default: return nil } }, &org), ), ).Run() if err != nil { log.Fatal(err) } spinner.New().Title(fmt.Sprintf("Cloning %s/%s...", org, repo)).Run() } ================================================ FILE: examples/filepicker/artichoke.hs ================================================ ================================================ FILE: examples/filepicker/demo.tape ================================================ Set Shell bash Set Width 800 Set Height 725 Hide Type "clear && go build -o file" Enter Show Sleep .5s Type "./file" Sleep .5s Enter Sleep 1s Type "Frank" Sleep 500ms Enter Sleep 1s Type "_frank" Sleep 500ms Enter Sleep 1s Enter Sleep 1s Type@200ms "jjjj" Sleep 1s Enter Sleep 1.5s Type "hunter2" Sleep 4s ================================================ FILE: examples/filepicker/main.go ================================================ package main import ( "charm.land/huh/v2" ) func main() { var file string huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Name"). Description("What's your name?"), huh.NewInput(). Title("Username"). Description("Select your username."), huh.NewFilePicker(). Title("Profile"). Description("Select your profile picture."). AllowedTypes([]string{".png", ".jpeg", ".webp", ".gif"}). Value(&file), huh.NewInput(). Title("Password"). EchoMode(huh.EchoModePassword). Description("Set your Password."), ), ).WithShowHelp(true).Run() } ================================================ FILE: examples/filepicker-picking/main.go ================================================ package main import ( "fmt" "charm.land/huh/v2" ) func main() { var file string huh.NewForm( huh.NewGroup( huh.NewFilePicker(). Picking(true). Title("Code"). Description("Select a .go file"). AllowedTypes([]string{".go"}). Value(&file), ), ).WithShowHelp(true).Run() fmt.Println(file) } ================================================ FILE: examples/gh/create.go ================================================ package main import ( "fmt" "log" "os" "charm.land/huh/v2" "charm.land/huh/v2/spinner" "charm.land/lipgloss/v2" ) type Action int const ( Cancel Action = iota Push Fork Skip ) var highlight = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D7D7")) func customTheme(isDark bool) *huh.Styles { theme := huh.ThemeBase16(isDark) theme.FieldSeparator = lipgloss.NewStyle().SetString("\n") theme.Help.FullKey.MarginTop(1) return theme } func main() { var action Action repo := "charmbracelet/huh" theme := spinner.ThemeFunc(func(isDark bool) *spinner.Styles { d := spinner.ThemeDefault(isDark) d.Spinner = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) return d }) f := huh.NewForm( huh.NewGroup( huh.NewSelect[Action](). Value(&action). Options( huh.NewOption(repo, Push), huh.NewOption("Create a fork of "+repo, Fork), huh.NewOption("Skip pushing the branch", Skip), huh.NewOption("Cancel", Cancel), ). Title("Where should we push the 'feature' branch?"), ), ).WithTheme(huh.ThemeFunc(customTheme)) err := f.Run() if err != nil { log.Fatal(err) } switch action { case Push: _ = spinner.New().Title("Pushing to charmbracelet/huh").WithTheme(theme).Run() fmt.Println("Pushed to charmbracelet/huh") case Fork: fmt.Println("Creating a fork of charmbracelet/huh...") case Skip: fmt.Println("Skipping pushing the branch...") case Cancel: fmt.Println("Cancelling...") os.Exit(1) } fmt.Printf("Creating pull request for %s into %s in %s\n", highlight.Render("test"), highlight.Render("main"), repo) var nextAction string f = huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Title "). Prompt(""). Inline(true), huh.NewText(). Title("Body"), ), huh.NewGroup( huh.NewSelect[string](). Options(huh.NewOptions("Submit", "Submit as draft", "Continue in browser", "Add metadata", "Cancel")...). Title("What's next?").Value(&nextAction), ), ).WithTheme(huh.ThemeFunc(customTheme)) err = f.Run() if err != nil { log.Fatal(err) } if nextAction == "Submit" { _ = spinner.New().Title("Submitting...").WithTheme(theme).Run() fmt.Println("Pull request submitted!") } } ================================================ FILE: examples/git/main.go ================================================ package main import ( "charm.land/huh/v2" ) // types is the possible commit types specified by the conventional commit spec. var types = []string{"fix", "feat", "docs", "style", "refactor", "test", "chore", "revert"} // This form is used to write a conventional commit message. It prompts the user // to choose the type of commit as specified in the conventional commit spec. // And then prompts for the summary and detailed description of the message and // uses the values provided as the summary and details of the message. func main() { var commit, scope string var summary, description string var confirm bool huh.NewForm( huh.NewGroup( huh.NewInput().Title("Type").Value(&commit).Placeholder("feat").Suggestions(types), huh.NewInput().Title("Scope").Value(&scope).Placeholder("scope"), ), huh.NewGroup( huh.NewInput().Title("Summary").Value(&summary).Placeholder("Summary of changes"), huh.NewText().Title("Description").Value(&description).Placeholder("Detailed description of changes"), ), huh.NewGroup(huh.NewConfirm().Title("Commit changes?").Value(&confirm)), ).Run() } ================================================ FILE: examples/go.mod ================================================ module examples go 1.25.8 replace charm.land/huh/v2 => ../ require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 charm.land/glamour/v2 v2.0.0 charm.land/huh/v2 v2.0.0-00010101000000-000000000000 charm.land/lipgloss/v2 v2.0.2 charm.land/log/v2 v2.0.0 charm.land/wish/v2 v2.0.0 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/x/exp/strings v0.1.0 ) require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/keygen v0.5.4 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/conpty v0.2.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect ) ================================================ FILE: examples/go.sum ================================================ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= charm.land/wish/v2 v2.0.0 h1:0vryoDz6G1SdJNIWSkExy88dLAs7H/w0x9y/cay1vno= charm.land/wish/v2 v2.0.0/go.mod h1:B42DmuVdvQxz215H9aCsbrXVSuAInAqkHAnmwg0nKs8= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY= github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: examples/gum/main.go ================================================ package main import ( "fmt" "os" "charm.land/huh/v2" ) func main() { if len(os.Args) < 2 { fmt.Println("gum ") os.Exit(1) } switch os.Args[1] { case "input": huh.NewInput().Run() case "text": huh.NewText().Run() case "confirm": huh.NewConfirm().Run() case "select": huh.NewSelect[string]().Options(huh.NewOptions(os.Args[2:]...)...).Run() case "multiselect": huh.NewMultiSelect[string]().Options(huh.NewOptions(os.Args[2:]...)...).Run() } } ================================================ FILE: examples/help/main.go ================================================ package main import "charm.land/huh/v2" func main() { f := huh.NewForm( huh.NewGroup( huh.NewInput().Title("Dynamic Help"), huh.NewInput().Title("Dynamic Help"), huh.NewInput().Title("Dynamic Help"), ), ) f.Run() } ================================================ FILE: examples/hide/hide.tape ================================================ Output hide.gif Set Width 700 Set Padding 40 Set Height 350 Set FontSize 28 Hide Type "go build ." Sleep 500ms Enter Ctrl+L Sleep 500ms Type "clear && ./hide" Sleep 500ms Enter Sleep 500ms Show Type@500ms "llllllll" Hide Type "rm hide" Enter ================================================ FILE: examples/hide/main.go ================================================ package main import ( "fmt" "charm.land/huh/v2" ) func main() { var isAllergic bool var allergies string huh.NewForm( huh.NewGroup(huh.NewNote().Title("Just for fun!")).WithHideFunc(func() bool { return true }), huh.NewGroup(huh.NewNote().Title("Just for fun!")).WithHide(true), huh.NewGroup(huh.NewConfirm(). Title("Do you have any allergies?"). Description("If so, please list them."). Value(&isAllergic)), huh.NewGroup( huh.NewText(). Title("Allergies"). Description("Please list all your allergies..."). Value(&allergies), ).WithHideFunc(func() bool { return !isAllergic }), huh.NewGroup(huh.NewNote().Title("Invisible")).WithHide(true), ).Run() if isAllergic { fmt.Println(allergies) } } ================================================ FILE: examples/layout/columns/main.go ================================================ package main import "charm.land/huh/v2" func main() { form := huh.NewForm( huh.NewGroup( huh.NewInput().Title("First"), huh.NewInput().Title("Second"), huh.NewInput().Title("Third"), ), huh.NewGroup( huh.NewInput().Title("Fourth"), huh.NewInput().Title("Fifth"), huh.NewInput().Title("Sixth"), ), huh.NewGroup( huh.NewInput().Title("Seventh"), huh.NewInput().Title("Eighth"), huh.NewInput().Title("Nineth"), huh.NewInput().Title("Tenth"), ), ).WithLayout(huh.LayoutColumns(2)) form.Run() } ================================================ FILE: examples/layout/default/main.go ================================================ package main import "charm.land/huh/v2" func main() { form := huh.NewForm( huh.NewGroup( huh.NewInput().Title("First"), huh.NewInput().Title("Second"), huh.NewInput().Title("Third"), ), huh.NewGroup( huh.NewInput().Title("Fourth"), huh.NewInput().Title("Fifth"), huh.NewInput().Title("Sixth"), ), huh.NewGroup( huh.NewInput().Title("Seventh"), huh.NewInput().Title("Eighth"), huh.NewInput().Title("Nineth"), huh.NewInput().Title("Tenth"), ), ) form.Run() } ================================================ FILE: examples/layout/grid/main.go ================================================ package main import "charm.land/huh/v2" func main() { form := huh.NewForm( huh.NewGroup( huh.NewInput().Title("First"), huh.NewInput().Title("Second"), huh.NewInput().Title("Third"), ), huh.NewGroup( huh.NewInput().Title("Fourth"), huh.NewInput().Title("Fifth"), huh.NewInput().Title("Sixth"), ), huh.NewGroup( huh.NewInput().Title("Seventh"), huh.NewInput().Title("Eighth"), huh.NewInput().Title("Nineth"), huh.NewInput().Title("Tenth"), ), huh.NewGroup( huh.NewInput().Title("Eleventh"), huh.NewInput().Title("Twelveth"), huh.NewInput().Title("Thirteenth"), ), ).WithLayout(huh.LayoutGrid(2, 2)) form.Run() } ================================================ FILE: examples/layout/stack/main.go ================================================ package main import "charm.land/huh/v2" func main() { form := huh.NewForm( huh.NewGroup( huh.NewInput().Title("First"), huh.NewInput().Title("Second"), huh.NewInput().Title("Third"), ), huh.NewGroup( huh.NewInput().Title("Fourth"), huh.NewInput().Title("Fifth"), huh.NewInput().Title("Sixth"), ), huh.NewGroup( huh.NewInput().Title("Seventh"), huh.NewInput().Title("Eighth"), huh.NewInput().Title("Nineth"), huh.NewInput().Title("Tenth"), ), ).WithLayout(huh.LayoutStack) form.Run() } ================================================ FILE: examples/multiple-groups/main.go ================================================ package main import ( "fmt" "os" "charm.land/huh/v2" ) func main() { f := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Options( huh.NewOption("A", "a"), huh.NewOption("B", "b"), huh.NewOption("C", "c"), huh.NewOption("D", "d"), huh.NewOption("E", "e"), huh.NewOption("F", "f"), huh.NewOption("G", "g"), huh.NewOption("H", "h"), huh.NewOption("I", "i"), huh.NewOption("J", "j"), huh.NewOption("K", "k").Selected(true), huh.NewOption("L", "l"), huh.NewOption("M", "m"), huh.NewOption("N", "n"), huh.NewOption("O", "o"), huh.NewOption("P", "p"), ), ).WithHeight(8), huh.NewGroup( huh.NewMultiSelect[string](). Options( huh.NewOption("A", "a"), huh.NewOption("B", "b"), huh.NewOption("C", "c"), huh.NewOption("D", "d"), huh.NewOption("E", "e"), huh.NewOption("F", "f"), huh.NewOption("G", "g"), huh.NewOption("H", "h"), huh.NewOption("I", "i"), huh.NewOption("K", "k").Selected(true), huh.NewOption("L", "l"), huh.NewOption("M", "m"), huh.NewOption("N", "n"), huh.NewOption("O", "o").Selected(true), huh.NewOption("P", "p"), ), ).WithHeight(10), huh.NewGroup( huh.NewSelect[string](). Options( huh.NewOption("A", "a"), huh.NewOption("B", "b"), huh.NewOption("C", "c"), huh.NewOption("D", "d"), huh.NewOption("E", "e"), huh.NewOption("F", "f"), huh.NewOption("G", "g"), huh.NewOption("H", "h"), huh.NewOption("I", "i"), huh.NewOption("J", "j"), huh.NewOption("K", "k").Selected(true), huh.NewOption("L", "l"), huh.NewOption("M", "m"), huh.NewOption("N", "n"), huh.NewOption("O", "o"), huh.NewOption("P", "p"), ), ).WithHeight(5), ) if err := f.Run(); err != nil { fmt.Fprintf(os.Stderr, "Oof: %v\n", err) } } ================================================ FILE: examples/readme/confirm/confirm.tape ================================================ Output confirm.gif Set Width 1100 Set Padding 40 Set Height 375 Set FontSize 36 Hide Type "go build ." Sleep 500ms Enter Ctrl+L Sleep 500ms Type "clear && ./confirm" Sleep 500ms Enter Sleep 500ms Show Sleep 1s Left@500ms 6 Sleep 2s Hide Type "rm confirm" Enter Sleep 500ms ================================================ FILE: examples/readme/confirm/main.go ================================================ package main import ( "charm.land/huh/v2" ) func main() { var happy bool confirm := huh.NewConfirm(). Title("Are you sure? "). Description("Please confirm. "). Affirmative("Yes!"). Negative("No."). Value(&happy) huh.NewForm(huh.NewGroup(confirm)).Run() } ================================================ FILE: examples/readme/input/input.tape ================================================ Output input.gif Set Width 1000 Set Padding 30 Set Height 275 Set FontSize 38 Hide Type "go build ." Sleep 500ms Enter Ctrl+L Sleep 500ms Type "./input" Sleep 500ms Enter Sleep 500ms Show Sleep 1s Type "Spaghetti" Sleep 2s Hide Type "rm input" Enter ================================================ FILE: examples/readme/input/main.go ================================================ package main import ( "fmt" "charm.land/huh/v2" ) func isFood(_ string) error { return nil } func main() { var lunch string input := huh.NewInput(). Title("What's for lunch?"). Prompt("? "). Suggestions([]string{ "Artichoke", "Baking Flour", "Bananas", "Barley", "Bean Sprouts", "Bitter Melon", "Black Cod", "Blood Orange", "Brown Sugar", "Cashew Apple", "Cashews", "Cat Food", "Coconut Milk", "Cucumber", "Curry Paste", "Currywurst", "Dill", "Dragonfruit", "Dried Shrimp", "Eggs", "Fish Cake", "Furikake", "Garlic", }). Validate(isFood). Value(&lunch) huh.NewForm(huh.NewGroup(input)).Run() fmt.Printf("Yummy, %s!\n", lunch) } ================================================ FILE: examples/readme/input/suggestions.tape ================================================ Output suggestions.gif Set Width 1000 Set Padding 30 Set Height 275 Set FontSize 38 Hide Type "go build ." Sleep 500ms Enter Ctrl+L Sleep 500ms Type "./input" Sleep 500ms Enter Sleep 500ms Show Sleep 1s Type@300ms "Curryw" Sleep 1.5s Ctrl+E Sleep 3s Hide Type "rm input" Enter Sleep 0.5s ================================================ FILE: examples/readme/main/main.go ================================================ package main import ( "log" "charm.land/huh/v2" ) // TODO: ensure input is not plagiarized. func checkForPlagiarism(s string) error { return nil } // TODO: ensure input is food. func isFood(s string) error { return nil } // TODO: ensure input is a valid name. func validateName(s string) error { return nil } func main() { var ( lunch string story string country string toppings []string discount bool ) // `Input`s are single line text fields. huh.NewInput(). Title("What's for lunch?"). Prompt("?"). Validate(isFood). Value(&lunch) // `Text`s are multi-line text fields. huh.NewText(). Title("Tell me a story."). Validate(checkForPlagiarism). Value(&story) // `Select`s are multiple choice questions. huh.NewSelect[string](). Title("Pick a country."). Options( huh.NewOption("United States", "US"), huh.NewOption("Germany", "DE"), huh.NewOption("Brazil", "BR"), huh.NewOption("Canada", "CA"), ). Value(&country) // `MultiSelect`s allow multiple selections from a list of options. huh.NewMultiSelect[string](). Options( huh.NewOption("Cheese", "cheese").Selected(true), huh.NewOption("Lettuce", "lettuce").Selected(true), huh.NewOption("Corn", "corn"), huh.NewOption("Salsa", "salsa"), huh.NewOption("Sour Cream", "sour cream"), huh.NewOption("Tomatoes", "tomatoes"), ). Title("Toppings"). Limit(4). Value(&toppings) // `Confirm`s are a confirmation prompt. huh.NewConfirm(). Title("Want a discount?"). Affirmative("Yes!"). Negative("No."). Value(&discount) // Form var ( burger string name string instructions string ) form := huh.NewForm( // Prompt the user to choose a burger. huh.NewGroup( huh.NewSelect[string](). Options( huh.NewOption("Charmburger Classic", "classic"), huh.NewOption("Chickwich", "chickwich"), huh.NewOption("Fishburger", "Fishburger"), huh.NewOption("Charmpossible™ Burger", "charmpossible"), ). Title("Choose your burger"). Value(&burger), ), // Prompt for toppings and special instructions. // The customer can ask for up to 4 toppings. huh.NewGroup( huh.NewMultiSelect[string](). Options( huh.NewOption("Lettuce", "Lettuce").Selected(true), huh.NewOption("Tomatoes", "Tomatoes").Selected(true), huh.NewOption("Charm Sauce", "Charm Sauce"), huh.NewOption("Jalapeños", "Jalapeños"), huh.NewOption("Cheese", "Cheese"), huh.NewOption("Vegan Cheese", "Vegan Cheese"), huh.NewOption("Nutella", "Nutella"), ). Title("Toppings"). Limit(4). Value(&toppings), ), // Gather final details for the order. huh.NewGroup( huh.NewInput(). Title("What's your name?"). Value(&name). Validate(validateName), huh.NewText(). Title("Special Instructions"). Value(&instructions). CharLimit(400), huh.NewConfirm(). Title("Would you like 15% off"). Value(&discount), ), ) err := form.Run() if err != nil { log.Fatal(err) } } ================================================ FILE: examples/readme/multiselect/main.go ================================================ package main import "charm.land/huh/v2" func main() { var toppings []string s := huh.NewMultiSelect[string](). Options( huh.NewOption("Lettuce", "Lettuce").Selected(true), huh.NewOption("Tomatoes", "Tomatoes").Selected(true), huh.NewOption("Charm Sauce", "Charm Sauce"), huh.NewOption("Jalapeños", "Jalapeños"), huh.NewOption("Cheese", "Cheese"), huh.NewOption("Vegan Cheese", "Vegan Cheese"), huh.NewOption("Nutella", "Nutella"), ). Title("Toppings"). Limit(4). Value(&toppings) huh.NewForm(huh.NewGroup(s)).Run() } ================================================ FILE: examples/readme/multiselect/multiselect.tape ================================================ Output multiselect.gif Set Width 1150 Set Padding 40 Set Height 480 Set FontSize 28 Hide Type "go build ." Sleep 500ms Enter Ctrl+L Sleep 500ms Type "clear && ./multiselect" Sleep 500ms Enter Sleep 500ms Show Sleep 1s Down@500ms 3 Sleep 1s Type "x" Sleep 1s Up@500ms 2 Sleep 1s Type "x" Sleep 2s Hide Type "rm multiselect" Enter Sleep 1s ================================================ FILE: examples/readme/note/main.go ================================================ package main import "charm.land/huh/v2" func main() { note := huh.NewNote().Description( "# Heading\n" + "This is _italic_, *bold*" + "\n\n# Heading\n" + "`This is _italic_, *bold*`", ) huh.NewForm( huh.NewGroup(note), ).Run() } ================================================ FILE: examples/readme/select/main.go ================================================ package main import "charm.land/huh/v2" func main() { var country string s := huh.NewSelect[string](). Title("Pick a country."). Options( huh.NewOption("United States", "US"), huh.NewOption("Germany", "DE"), huh.NewOption("Brazil", "BR"), huh.NewOption("Canada", "CA"), ). Value(&country) huh.NewForm(huh.NewGroup(s)).Run() } ================================================ FILE: examples/readme/select/scroll/scroll.go ================================================ package main import "charm.land/huh/v2" type Pokemon struct { id int name string } var pokemons = []Pokemon{ {1, "Bulbasaur"}, {2, "Ivysaur"}, {3, "Venusaur"}, {4, "Charmander"}, {5, "Charmeleon"}, {6, "Charizard"}, {7, "Squirtle"}, {8, "Wartortle"}, {9, "Blastoise"}, {10, "Caterpie"}, {11, "Metapod"}, {12, "Butterfree"}, {13, "Weedle"}, {14, "Kakuna"}, {15, "Beedrill"}, {16, "Pidgey"}, {17, "Pidgeotto"}, {18, "Pidgeot"}, {19, "Rattata"}, {20, "Raticate"}, {21, "Spearow"}, {22, "Fearow"}, {23, "Ekans"}, {24, "Arbok"}, {25, "Pikachu"}, {26, "Raichu"}, {27, "Sandshrew"}, {28, "Sandslash"}, } func (p Pokemon) String() string { return p.name } func main() { var pokemon Pokemon s := huh.NewSelect[Pokemon](). Title("Choose your starter"). Options(huh.NewOptions(pokemons...)...). Value(&pokemon). WithHeight(7) huh.NewForm(huh.NewGroup(s)).Run() } ================================================ FILE: examples/readme/select/scroll/scroll.tape ================================================ Output scroll.gif Set Width 800 Set Padding 40 Set Height 375 Set FontSize 28 Hide Type "go build scroll.go" Sleep 500ms Enter Ctrl+L Sleep 500ms Type "clear && ./scroll" Sleep 500ms Enter Sleep 500ms Show Down@250ms 20 Hide Type "rm scroll" Enter ================================================ FILE: examples/readme/select/select.tape ================================================ Output select.gif Set Width 1100 Set Padding 40 Set Height 375 Set FontSize 28 Hide Type "go build ." Sleep 500ms Enter Ctrl+L Sleep 500ms Type "clear && ./select" Sleep 500ms Enter Sleep 500ms Show Sleep 1s Down@500ms 3 Sleep 1s Up@500ms 2 Sleep 1s Type "/" Sleep 1s Type "cana" Sleep 2s Hide Type "rm select" Enter ================================================ FILE: examples/readme/text/main.go ================================================ package main import "charm.land/huh/v2" // TODO: ensure input is not plagiarized. func checkForPlagiarism(s string) error { return nil } func main() { var story string text := huh.NewText(). Title("Tell me a story."). Validate(checkForPlagiarism). Placeholder("What's on your mind?"). Value(&story) // Create a form to show help. form := huh.NewForm(huh.NewGroup(text)) form.Run() } ================================================ FILE: examples/readme/text/text.tape ================================================ Output text.gif Set Width 1000 Set Padding 40 Set Height 450 Set FontSize 28 Hide Type "go build ." Enter Ctrl+L Type "./text" Enter Sleep 500ms Show Sleep 1s 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." Sleep 4s Hide Type "rm text" Enter ================================================ FILE: examples/scroll/main.go ================================================ package main import "charm.land/huh/v2" func main() { form := huh.NewForm( huh.NewGroup( huh.NewInput().Title("First"), huh.NewInput().Title("Second"), huh.NewInput().Title("Third"), huh.NewInput().Title("Fourth"), huh.NewInput().Title("Fifth"), huh.NewInput().Title("Sixth"), huh.NewInput().Title("Seventh"), huh.NewInput().Title("Eighth"), huh.NewInput().Title("Nineth"), huh.NewInput().Title("Tenth"), ), ).WithHeight(5) form.Run() } ================================================ FILE: examples/skip/main.go ================================================ package main import ( "charm.land/huh/v2" ) func main() { f := huh.NewForm( huh.NewGroup( huh.NewNote(). Title("Charmburger"). Description("Welcome to _Charmburger™_."), huh.NewSelect[string](). Options(huh.NewOptions("Charmburger Classic", "Chickwich", "Fishburger", "Charmpossible™ Burger")...). Title("Choose your burger"). Description("At Charm we truly have a burger for everyone."), huh.NewNote(). Title("🍔"), ), huh.NewGroup( huh.NewNote(). Title("Buy 1 get 1 free"). Description("Welcome back to _Charmburger™_."), huh.NewSelect[string](). Options(huh.NewOptions("Charmburger Classic", "Chickwich", "Fishburger", "Charmpossible™ Burger")...). Title("Choose your burger"). Description("At Charm we truly have a burger for everyone."), huh.NewNote(). Title("🍔"), ), ) f.Run() } ================================================ FILE: examples/spinner/accessible/main.go ================================================ package main import ( "context" "log" "time" "charm.land/huh/v2/spinner" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), time.Second/2) defer cancel() err := spinner.New(). Context(ctx). WithAccessible(true). Run() if err != nil { log.Fatalln(err) } } ================================================ FILE: examples/spinner/context/main.go ================================================ package main import ( "context" "fmt" "time" "charm.land/huh/v2/spinner" ) func main() { action := func() { time.Sleep(5 * time.Second) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() go action() spinner.New().Context(ctx).Run() fmt.Println("Done!") } ================================================ FILE: examples/spinner/context-and-action/main.go ================================================ package main import ( "context" "fmt" "log" "math/rand" "time" "charm.land/huh/v2/spinner" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() err := spinner.New(). Context(ctx). Action(func() { time.Sleep(time.Minute) }). WithAccessible(rand.Int()%2 == 0). Run() if err != nil { log.Fatalln(err) } fmt.Println("Done!") } ================================================ FILE: examples/spinner/context-and-action-and-error/main.go ================================================ package main import ( "context" "fmt" "log" "time" "charm.land/huh/v2/spinner" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() err := spinner.New(). Context(ctx). ActionWithErr(func(context.Context) error { time.Sleep(5 * time.Second) return nil }). WithAccessible(false). Run() if err != nil { log.Fatalln(err) } fmt.Println("Done!") } ================================================ FILE: examples/spinner/loading/demo.tape ================================================ Output spinner.gif Set FontSize 32 Set Height 225 Set Width 800 Hide Type "go build -o spinner ." Enter Ctrl+L Sleep 1s Show Type "./spinner" Sleep 500ms Enter Sleep 4s ================================================ FILE: examples/spinner/loading/main.go ================================================ package main import ( "fmt" "time" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/spinner" ) func main() { action := func() { time.Sleep(1 * time.Second) } if err := spinner.New(). Title("Preparing your burger..."). Action(action). WithViewHook(func(v tea.View) tea.View { v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 1) return v }). Run(); err != nil { fmt.Println("Failed:", err) return } fmt.Println("Order up!") } ================================================ FILE: examples/spinner/static/main.go ================================================ package main import ( "fmt" "charm.land/huh/v2/spinner" ) func main() { _ = spinner.New().Title("Loading").WithAccessible(true).Run() fmt.Println("Done!") } ================================================ FILE: examples/ssh-form/main.go ================================================ package main import ( "context" "errors" "fmt" "net" "os" "os/signal" "syscall" "time" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" "charm.land/lipgloss/v2" "charm.land/log/v2" "charm.land/wish/v2" "charm.land/wish/v2/activeterm" "charm.land/wish/v2/bubbletea" "github.com/charmbracelet/ssh" ) const ( host = "localhost" port = "2222" ) func main() { s, err := wish.NewServer( wish.WithAddress(net.JoinHostPort(host, port)), wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithMiddleware( bubbletea.Middleware(teaHandler), activeterm.Middleware(), ), ) if err != nil { log.Error("Could not start server", "error", err) } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) log.SetReportTimestamp(false) log.Infof("Running form over ssh, connect with:") fmt.Printf("\n ssh %s -p %s\n\n", host, port) go func() { if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { log.Error("Could not start server", "error", err) done <- nil } }() <-done log.Info("Stopping SSH server") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { log.Error("Could not stop server", "error", err) } } func customTheme(hasDarkBg bool) *huh.Styles { custom := huh.ThemeBase(hasDarkBg) custom.Blurred.Title = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) custom.Blurred.TextInput.Prompt = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) custom.Blurred.TextInput.Text = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) custom.Focused.TextInput.Cursor = lipgloss.NewStyle(). Foreground(lipgloss.Color("#7571F9")) custom.Focused.Base = lipgloss.NewStyle(). Padding(0, 1). Border(lipgloss.ThickBorder(), false). BorderLeft(true). BorderForeground(lipgloss.Color("#7571F9")) return custom } func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { form := huh.NewForm( huh.NewGroup( huh.NewInput().Title("Username").Key("username"), huh.NewInput().Title("Password").EchoMode(huh.EchoModePassword), ), ).WithTheme(huh.ThemeFunc(customTheme)) style := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(1, 2). BorderForeground(lipgloss.Color("#444444")). Foreground(lipgloss.Color("#7571F9")) m := model{form: form, style: style} return m, nil } type model struct { form *huh.Form style lipgloss.Style loggedIn bool } func (m model) Init() tea.Cmd { return m.form.Init() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd if m.form != nil { f, cmd := m.form.Update(msg) m.form = f.(*huh.Form) cmds = append(cmds, cmd) } m.loggedIn = m.form.State == huh.StateCompleted if m.form.State == huh.StateAborted { return m, tea.Quit } switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit } } return m, tea.Batch(cmds...) } func (m model) View() tea.View { var view tea.View view.AltScreen = true switch { case m.form == nil: view.SetContent("Starting...") case m.loggedIn: view.SetContent(m.style.Render("Welcome, " + m.form.GetString("username") + "!")) default: view.SetContent(m.form.View()) } return view } ================================================ FILE: examples/stickers/main.go ================================================ package main import ( "charm.land/huh/v2" ) func main() { var ( name string address string country string email string ) huh.NewForm( huh.NewGroup( huh.NewNote(). Title("\nStickers pls."). Description("Make sure to fill out the address exactly\nas it would appear on a parcel."), huh.NewInput(). Title("Full name"). Validate(huh.ValidateMinLength(1)). Value(&name), huh.NewSelect[string](). Title("Country "). Height(1). Value(&country). Inline(true). Options(countries...), huh.NewText(). Title("Address"). Lines(2). Description("Use your country's postal format."). Value(&address), huh.NewInput(). Title("Email"). Description("Optional: so we can send you updates."). Value(&email), ), ).Run() } var countries = huh.NewOptions( // common "United States", "Canada", "Germany", "Brazil", "Mexico", "China", "India", "Afghanistan", "Albania", "Algeria", "American Samoa", "Andorra", "Angola", "Anguilla", "Antarctica", "Antigua and Barbuda", "Argentina", "Armenia", "Aruba", "Australia", "Austria", "Azerbaijan", "Ã…land Islands", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bermuda", "Bhutan", "Bolivia", "Bosnia and Herzegovina", "Botswana", "Bouvet Island", "British Indian Ocean Territory", "British Virgin Islands", "Brunei", "Bulgaria", "Burkina Faso", "Burundi", "Cambodia", "Cameroon", "Cape Verde", "Caribbean Netherlands", "Cayman Islands", "Central African Republic", "Chad", "Chile", "Christmas Island", "Cocos (Keeling) Islands", "Colombia", "Comoros", "Cook Islands", "Costa Rica", "Croatia", "Cuba", "Curaçao", "Cyprus", "Czechia", "DR Congo", "Denmark", "Djibouti", "Dominica", "Dominican Republic", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Eswatini", "Ethiopia", "Falkland Islands", "Faroe Islands", "Fiji", "Finland", "France", "French Guiana", "French Polynesia", "French Southern and Antarctic Lands", "Gabon", "Gambia", "Georgia", "Ghana", "Gibraltar", "Greece", "Greenland", "Grenada", "Guadeloupe", "Guam", "Guatemala", "Guernsey", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Heard Island and McDonald Islands", "Honduras", "Hong Kong", "Hungary", "Iceland", "Indonesia", "Iran", "Iraq", "Ireland", "Isle of Man", "Israel", "Italy", "Ivory Coast", "Jamaica", "Japan", "Jersey", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kosovo", "Kuwait", "Kyrgyzstan", "Laos", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg", "Macau", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Martinique", "Mauritania", "Mauritius", "Mayotte", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Montserrat", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "New Caledonia", "New Zealand", "Nicaragua", "Niger", "Nigeria", "Niue", "Norfolk Island", "North Korea", "North Macedonia", "Northern Mariana Islands", "Norway", "Oman", "Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Pitcairn Islands", "Poland", "Portugal", "Puerto Rico", "Qatar", "Republic of the Congo", "Romania", "Russia", "Rwanda", "São Tomé and Príncipe", "Saint Barthélemy", "Saint Helena, Ascension and Tristan da Cunha", "Saint Kitts and Nevis", "Saint Lucia", "Saint Martin", "Saint Pierre and Miquelon", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Sint Maarten", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Georgia", "South Korea", "South Sudan", "Spain", "Sri Lanka", "Sudan", "Suriname", "Svalbard and Jan Mayen", "Sweden", "Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "Timor-Leste", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Turks and Caicos Islands", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States Minor Outlying Islands", "United States Virgin Islands", "Uruguay", "Uzbekistan", "Vanuatu", "Vatican City", "Venezuela", "Vietnam", "Wallis and Futuna", "Western Sahara", "Yemen", "Zambia", "Zimbabwe", ) ================================================ FILE: examples/theme/main.go ================================================ package main import ( "fmt" "os" "charm.land/huh/v2" ) var themes = map[string]huh.Theme{ "default": huh.ThemeFunc(huh.ThemeBase), "dracula": huh.ThemeFunc(huh.ThemeDracula), "base16": huh.ThemeFunc(huh.ThemeBase16), "charm": huh.ThemeFunc(huh.ThemeCharm), "catppuccin": huh.ThemeFunc(huh.ThemeCatppuccin), } func main() { theme := "base16" repeat := true for { err := huh.NewSelect[string](). Title("Theme"). Value(&theme). Options( huh.NewOption("Default", "default"), huh.NewOption("Dracula", "dracula"), huh.NewOption("Base 16", "base16"), huh.NewOption("Charm", "charm"), huh.NewOption("Catppuccin", "catppuccin"), huh.NewOption("Exit", ""), ).Run() if err != nil { if err == huh.ErrUserAborted { os.Exit(130) } fmt.Println(err) os.Exit(1) } if theme == "" { break } // Display form with selected theme. err = huh.NewForm( huh.NewGroup( huh.NewInput().Title("Thoughts").Placeholder("What's on your mind?"), huh.NewSelect[string]().Options(huh.NewOptions("A", "B", "C")...).Title("Colors"), huh.NewFilePicker().Title("File"), huh.NewMultiSelect[string]().Options(huh.NewOptions("Red", "Green", "Yellow")...).Title("Letters"), huh.NewConfirm().Title("Again?").Description("Try another theme").Value(&repeat), ), ).WithTheme(themes[theme]).Run() if err != nil { if err == huh.ErrUserAborted { os.Exit(130) } fmt.Println(err) os.Exit(1) } if !repeat { break } } } ================================================ FILE: examples/theme/theme.tape ================================================ Output theme.gif Set Width 800 Set Height 740 Set Padding 80 Hide Type "go build -o theme ." Enter Ctrl+L Sleep 500ms Type "clear && ./theme" Enter Show Sleep 2s Enter Screenshot default-theme.png Tab 4 Down 1 Enter Screenshot dracula-theme.png Tab 4 Down 2 Enter Screenshot basesixteen-theme.png Tab 4 Down 3 Enter Screenshot charm-theme.png Tab 4 Down 4 Enter Screenshot catppuccin-theme.png Sleep 1s Hide Type "rm theme" Enter ================================================ FILE: examples/timer/main.go ================================================ package main import ( "log" "strings" "time" "charm.land/bubbles/v2/progress" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" "charm.land/lipgloss/v2" ) var ( focusColor = lipgloss.Color("#2EF8BB") breakColor = lipgloss.Color("#FF5F87") ) var ( focusTitleStyle = lipgloss.NewStyle().Foreground(focusColor).MarginRight(1).SetString("Focus Mode") breakTitleStyle = lipgloss.NewStyle().Foreground(breakColor).MarginRight(1).SetString("Break Mode") pausedStyle = lipgloss.NewStyle().Foreground(breakColor).MarginRight(1).SetString("Continue?") helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginTop(2) sidebarStyle = lipgloss.NewStyle().MarginLeft(3).Padding(1, 3).Border(lipgloss.RoundedBorder()).BorderForeground(helpStyle.GetForeground()) ) var baseTimerStyle = lipgloss.NewStyle().Padding(1, 2) type mode int const ( Initial mode = iota Focusing Paused Breaking ) type Model struct { form *huh.Form quitting bool lastTick time.Time startTime time.Time mode mode focusTime time.Duration breakTime time.Duration progress progress.Model } func (m Model) Init() tea.Cmd { return m.form.Init() } const tickInterval = time.Second / 2 type tickMsg time.Time func tickCmd(t time.Time) tea.Msg { return tickMsg(t) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tickMsg: cmds = append(cmds, tea.Tick(tickInterval, tickCmd)) case tea.KeyPressMsg: switch msg.String() { case "q": switch m.mode { case Focusing: m.mode = Paused m.startTime = time.Now() m.progress.FullColor = breakColor case Paused: m.mode = Breaking m.startTime = time.Now() case Breaking: m.quitting = true return m, tea.Quit } case "ctrl+c": m.quitting = true return m, tea.Interrupt default: if m.mode == Paused { m.mode = Breaking m.startTime = time.Now() } } } // Update form f, cmd := m.form.Update(msg) m.form = f.(*huh.Form) cmds = append(cmds, cmd) if m.form.State != huh.StateCompleted { return m, tea.Batch(cmds...) } // Update timer if m.startTime.IsZero() { m.startTime = time.Now() m.focusTime = m.form.Get("focus").(time.Duration) m.breakTime = m.form.Get("break").(time.Duration) m.mode = Focusing cmds = append(cmds, tea.Tick(tickInterval, tickCmd)) } switch m.mode { case Focusing: if time.Now().After(m.startTime.Add(m.focusTime)) { m.mode = Paused m.startTime = time.Now() m.progress.FullColor = breakColor } case Breaking: if time.Now().After(m.startTime.Add(m.breakTime)) { m.quitting = true return m, tea.Quit } } return m, tea.Batch(cmds...) } func (m Model) View() tea.View { if m.quitting { return tea.NewView("") } if m.form.State != huh.StateCompleted { return tea.NewView(m.form.View()) } var s strings.Builder elapsed := time.Since(m.startTime) var percent float64 switch m.mode { case Focusing: percent = float64(elapsed) / float64(m.focusTime) s.WriteString(focusTitleStyle.String()) s.WriteString(elapsed.Round(time.Second).String()) s.WriteString("\n\n") s.WriteString(m.progress.ViewAs(percent)) s.WriteString(helpStyle.Render("Press 'q' to skip")) case Paused: s.WriteString(pausedStyle.String()) s.WriteString("\n\nFocus time is done, time to take a break.") s.WriteString(helpStyle.Render("press any key to continue.\n")) case Breaking: percent = float64(elapsed) / float64(m.breakTime) s.WriteString(breakTitleStyle.String()) s.WriteString(elapsed.Round(time.Second).String()) s.WriteString("\n\n") s.WriteString(m.progress.ViewAs(percent)) s.WriteString(helpStyle.Render("press 'q' to quit")) } return tea.NewView(baseTimerStyle.Render(s.String())) } func customTheme(isDark bool) *huh.Styles { theme := huh.ThemeCharm(isDark) theme.Focused.Base.Border(lipgloss.HiddenBorder()) theme.Focused.Title.Foreground(focusColor) theme.Focused.SelectSelector.Foreground(focusColor) theme.Focused.SelectedOption.Foreground(lipgloss.Color("15")) theme.Focused.Option.Foreground(lipgloss.Color("7")) return theme } func NewModel() Model { form := huh.NewForm( huh.NewGroup( huh.NewSelect[time.Duration](). Title("Focus Time"). Key("focus"). Options( huh.NewOption("25 minutes", 25*time.Minute), huh.NewOption("30 minutes", 30*time.Minute), huh.NewOption("45 minutes", 45*time.Minute), huh.NewOption("1 hour", time.Hour), ), ), huh.NewGroup( huh.NewSelect[time.Duration](). Title("Break Time"). Key("break"). Options( huh.NewOption("5 minutes", 5*time.Minute), huh.NewOption("10 minutes", 10*time.Minute), huh.NewOption("15 minutes", 15*time.Minute), huh.NewOption("20 minutes", 20*time.Minute), ), ), ).WithShowHelp(false).WithTheme(huh.ThemeFunc(customTheme)) progress := progress.New() progress.FullColor = focusColor progress.SetSpringOptions(1, 1) return Model{ form: form, progress: progress, } } func main() { m := NewModel() mm, err := tea.NewProgram(&m).Run() m = mm.(Model) if err != nil { log.Fatal(err) } } ================================================ FILE: field_confirm.go ================================================ package huh import ( "cmp" "io" "strings" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/accessibility" "charm.land/lipgloss/v2" ) // Confirm is a form confirm field. type Confirm struct { accessor Accessor[bool] key string id int // customization title Eval[string] description Eval[string] affirmative string negative string // error handling validate func(bool) error err error // state focused bool // options width int height int inline bool theme Theme hasDarkBg bool keymap ConfirmKeyMap buttonAlignment lipgloss.Position } // NewConfirm returns a new confirm field. func NewConfirm() *Confirm { return &Confirm{ accessor: &EmbeddedAccessor[bool]{}, id: nextID(), title: Eval[string]{cache: make(map[uint64]string)}, description: Eval[string]{cache: make(map[uint64]string)}, affirmative: "Yes", negative: "No", validate: func(bool) error { return nil }, buttonAlignment: lipgloss.Center, } } // Validate sets the validation function of the confirm field. func (c *Confirm) Validate(validate func(bool) error) *Confirm { c.validate = validate return c } // Error returns the error of the confirm field. func (c *Confirm) Error() error { return c.err } // Skip returns whether the confirm should be skipped or should be blocking. func (*Confirm) Skip() bool { return false } // Zoom returns whether the input should be zoomed. func (*Confirm) Zoom() bool { return false } // Affirmative sets the affirmative value of the confirm field. func (c *Confirm) Affirmative(affirmative string) *Confirm { c.affirmative = affirmative return c } // Negative sets the negative value of the confirm field. func (c *Confirm) Negative(negative string) *Confirm { c.negative = negative return c } // Value sets the value of the confirm field. func (c *Confirm) Value(value *bool) *Confirm { return c.Accessor(NewPointerAccessor(value)) } // Accessor sets the accessor of the confirm field. func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm { c.accessor = accessor return c } // Key sets the key of the confirm field. func (c *Confirm) Key(key string) *Confirm { c.key = key return c } // Title sets the title of the confirm field. func (c *Confirm) Title(title string) *Confirm { c.title.val = title c.title.fn = nil return c } // TitleFunc sets the title func of the confirm field. func (c *Confirm) TitleFunc(f func() string, bindings any) *Confirm { c.title.fn = f c.title.bindings = bindings return c } // Description sets the description of the confirm field. func (c *Confirm) Description(description string) *Confirm { c.description.val = description c.description.fn = nil return c } // DescriptionFunc sets the description function of the confirm field. func (c *Confirm) DescriptionFunc(f func() string, bindings any) *Confirm { c.description.fn = f c.description.bindings = bindings return c } // Inline sets whether the field should be inline. func (c *Confirm) Inline(inline bool) *Confirm { c.inline = inline return c } // Focus focuses the confirm field. func (c *Confirm) Focus() tea.Cmd { c.focused = true return nil } // Blur blurs the confirm field. func (c *Confirm) Blur() tea.Cmd { c.focused = false c.err = c.validate(c.accessor.Get()) return nil } // KeyBinds returns the help message for the confirm field. func (c *Confirm) KeyBinds() []key.Binding { return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next, c.keymap.Accept, c.keymap.Reject} } // Init initializes the confirm field. func (c *Confirm) Init() tea.Cmd { return nil } // Update updates the confirm field. func (c *Confirm) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.BackgroundColorMsg: c.hasDarkBg = msg.IsDark() case updateFieldMsg: if ok, hash := c.title.shouldUpdate(); ok { c.title.bindingsHash = hash if !c.title.loadFromCache() { c.title.loading = true cmds = append(cmds, func() tea.Msg { return updateTitleMsg{id: c.id, title: c.title.fn(), hash: hash} }) } } if ok, hash := c.description.shouldUpdate(); ok { c.description.bindingsHash = hash if !c.description.loadFromCache() { c.description.loading = true cmds = append(cmds, func() tea.Msg { return updateDescriptionMsg{id: c.id, description: c.description.fn(), hash: hash} }) } } case updateTitleMsg: if msg.id == c.id && msg.hash == c.title.bindingsHash { c.title.val = msg.title c.title.loading = false } case updateDescriptionMsg: if msg.id == c.id && msg.hash == c.description.bindingsHash { c.description.val = msg.description c.description.loading = false } case tea.KeyPressMsg: c.err = nil switch { case key.Matches(msg, c.keymap.Toggle): if c.negative == "" { break } c.accessor.Set(!c.accessor.Get()) case key.Matches(msg, c.keymap.Prev): cmds = append(cmds, PrevField) case key.Matches(msg, c.keymap.Next, c.keymap.Submit): cmds = append(cmds, NextField) case key.Matches(msg, c.keymap.Accept): c.accessor.Set(true) cmds = append(cmds, NextField) case key.Matches(msg, c.keymap.Reject): c.accessor.Set(false) cmds = append(cmds, NextField) } } return c, tea.Batch(cmds...) } func (c *Confirm) activeStyles() *FieldStyles { theme := c.theme if theme == nil { theme = ThemeFunc(ThemeCharm) } if c.focused { return &theme.Theme(c.hasDarkBg).Focused } return &theme.Theme(c.hasDarkBg).Blurred } // View renders the confirm field. func (c *Confirm) View() string { styles := c.activeStyles() maxWidth := c.width - styles.Base.GetHorizontalFrameSize() var wroteHeader bool var sb strings.Builder if c.title.val != "" { sb.WriteString(styles.Title.Render(wrap(c.title.val, maxWidth))) wroteHeader = true } if c.err != nil { sb.WriteString(styles.ErrorIndicator.String()) wroteHeader = true } if c.description.val != "" { description := styles.Description.Render(wrap(c.description.val, maxWidth)) if !c.inline && (c.description.val != "" || c.description.fn != nil) { sb.WriteString("\n") } sb.WriteString(description) wroteHeader = true } if !c.inline && wroteHeader { sb.WriteString("\n") sb.WriteString("\n") } var negative string var affirmative string if c.negative != "" { if c.accessor.Get() { affirmative = styles.FocusedButton.Render(c.affirmative) negative = styles.BlurredButton.Render(c.negative) } else { affirmative = styles.BlurredButton.Render(c.affirmative) negative = styles.FocusedButton.Render(c.negative) } c.keymap.Reject.SetHelp("n", c.negative) } else { affirmative = styles.FocusedButton.Render(c.affirmative) c.keymap.Reject.SetEnabled(false) } c.keymap.Accept.SetHelp("y", c.affirmative) buttonsRow := lipgloss.JoinHorizontal(c.buttonAlignment, affirmative, negative) promptWidth := lipgloss.Width(sb.String()) buttonsWidth := lipgloss.Width(buttonsRow) renderWidth := max(buttonsWidth, promptWidth) style := lipgloss.NewStyle().Width(renderWidth).Align(c.buttonAlignment) sb.WriteString(style.Render(buttonsRow)) return styles.Base.Width(c.width).Height(c.height). Render(sb.String()) } // Run runs the confirm field in accessible mode. func (c *Confirm) Run() error { return Run(c) } // RunAccessible runs the confirm field in accessible mode. func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { styles := c.activeStyles() defaultValue := c.GetValue().(bool) opts := "[y/N]" if defaultValue { opts = "[Y/n]" } prompt := styles.Title. PaddingRight(1). Render(cmp.Or(c.title.val, "Choose"), opts) c.accessor.Set(accessibility.PromptBool(w, r, prompt, defaultValue)) return nil } func (c *Confirm) String() string { if c.accessor.Get() { return c.affirmative } return c.negative } // WithTheme sets the theme of the confirm field. func (c *Confirm) WithTheme(theme Theme) Field { if c.theme != nil { return c } c.theme = theme return c } // WithKeyMap sets the keymap of the confirm field. func (c *Confirm) WithKeyMap(k *KeyMap) Field { c.keymap = k.Confirm return c } // WithWidth sets the width of the confirm field. func (c *Confirm) WithWidth(width int) Field { c.width = width return c } // WithHeight sets the height of the confirm field. func (c *Confirm) WithHeight(height int) Field { c.height = height return c } // WithPosition sets the position of the confirm field. func (c *Confirm) WithPosition(p FieldPosition) Field { c.keymap.Prev.SetEnabled(!p.IsFirst()) c.keymap.Next.SetEnabled(!p.IsLast()) c.keymap.Submit.SetEnabled(p.IsLast()) return c } // WithButtonAlignment sets the button position of the confirm field. func (c *Confirm) WithButtonAlignment(p lipgloss.Position) *Confirm { c.buttonAlignment = p return c } // GetKey returns the key of the field. func (c *Confirm) GetKey() string { return c.key } // GetValue returns the value of the field. func (c *Confirm) GetValue() any { return c.accessor.Get() } ================================================ FILE: field_filepicker.go ================================================ package huh import ( "cmp" "errors" "io" "os" "strings" xstrings "github.com/charmbracelet/x/exp/strings" "charm.land/bubbles/v2/filepicker" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/accessibility" "charm.land/lipgloss/v2" ) // FilePicker is a form file file field. type FilePicker struct { accessor Accessor[string] key string picker filepicker.Model // state focused bool picking bool // customization title string description string // error handling validate func(string) error err error // options width int height int theme Theme hasDarkBg bool keymap FilePickerKeyMap } // NewFilePicker returns a new file field. func NewFilePicker() *FilePicker { fp := filepicker.New() fp.ShowSize = false if cmd := fp.Init(); cmd != nil { fp, _ = fp.Update(cmd()) } return &FilePicker{ accessor: &EmbeddedAccessor[string]{}, validate: func(string) error { return nil }, picker: fp, } } // CurrentDirectory sets the directory of the file field. func (f *FilePicker) CurrentDirectory(directory string) *FilePicker { f.picker.CurrentDirectory = directory if cmd := f.picker.Init(); cmd != nil { f.picker, _ = f.picker.Update(cmd()) } return f } // Cursor sets the cursor of the file field. func (f *FilePicker) Cursor(cursor string) *FilePicker { f.picker.Cursor = cursor return f } // Picking sets whether the file picker should be in the picking files state. func (f *FilePicker) Picking(v bool) *FilePicker { f.setPicking(v) return f } // ShowHidden sets whether to show hidden files. func (f *FilePicker) ShowHidden(v bool) *FilePicker { f.picker.ShowHidden = v return f } // ShowSize sets whether to show file sizes. func (f *FilePicker) ShowSize(v bool) *FilePicker { f.picker.ShowSize = v return f } // ShowPermissions sets whether to show file permissions. func (f *FilePicker) ShowPermissions(v bool) *FilePicker { f.picker.ShowPermissions = v return f } // FileAllowed sets whether to allow files to be selected. func (f *FilePicker) FileAllowed(v bool) *FilePicker { f.picker.FileAllowed = v return f } // DirAllowed sets whether to allow directories to be selected. func (f *FilePicker) DirAllowed(v bool) *FilePicker { f.picker.DirAllowed = v return f } // Value sets the value of the file field. func (f *FilePicker) Value(value *string) *FilePicker { return f.Accessor(NewPointerAccessor(value)) } // Accessor sets the accessor of the file field. func (f *FilePicker) Accessor(accessor Accessor[string]) *FilePicker { f.accessor = accessor return f } // Key sets the key of the file field which can be used to retrieve the value // after submission. func (f *FilePicker) Key(key string) *FilePicker { f.key = key return f } // Title sets the title of the file field. func (f *FilePicker) Title(title string) *FilePicker { f.title = title return f } // Description sets the description of the file field. func (f *FilePicker) Description(description string) *FilePicker { f.description = description return f } // AllowedTypes sets the allowed types of the file field. These will be the only // valid file types accepted, other files will show as disabled. func (f *FilePicker) AllowedTypes(types []string) *FilePicker { f.picker.AllowedTypes = types return f } // Height sets the height of the file field. If the number of options // exceeds the height, the file field will become scrollable. func (f *FilePicker) Height(height int) *FilePicker { f.WithHeight(height) return f } // Validate sets the validation function of the file field. func (f *FilePicker) Validate(validate func(string) error) *FilePicker { f.validate = validate return f } // Error returns the error of the file field. func (f *FilePicker) Error() error { return f.err } // Skip returns whether the file should be skipped or should be blocking. func (*FilePicker) Skip() bool { return false } // Zoom returns whether the input should be zoomed. func (f *FilePicker) Zoom() bool { return f.picking } // Focus focuses the file field. func (f *FilePicker) Focus() tea.Cmd { f.focused = true return f.picker.Init() } // Blur blurs the file field. func (f *FilePicker) Blur() tea.Cmd { f.focused = false f.setPicking(false) f.err = f.validate(f.accessor.Get()) return nil } // KeyBinds returns the help keybindings for the file field. func (f *FilePicker) KeyBinds() []key.Binding { return []key.Binding{f.keymap.Up, f.keymap.Down, f.keymap.Close, f.keymap.Open, f.keymap.Prev, f.keymap.Next, f.keymap.Submit} } // Init initializes the file field. func (f *FilePicker) Init() tea.Cmd { return f.picker.Init() } // Update updates the file field. func (f *FilePicker) Update(msg tea.Msg) (Model, tea.Cmd) { f.err = nil switch msg := msg.(type) { case tea.BackgroundColorMsg: f.hasDarkBg = msg.IsDark() case tea.KeyPressMsg: switch { case key.Matches(msg, f.keymap.Open): if f.picking { break } f.setPicking(true) return f, f.picker.Init() case key.Matches(msg, f.keymap.Close): f.setPicking(false) return f, NextField case key.Matches(msg, f.keymap.Next): f.setPicking(false) return f, NextField case key.Matches(msg, f.keymap.Prev): f.setPicking(false) return f, PrevField } } var cmd tea.Cmd f.picker, cmd = f.picker.Update(msg) didSelect, file := f.picker.DidSelectFile(msg) if didSelect { f.accessor.Set(file) f.setPicking(false) return f, NextField } didSelect, _ = f.picker.DidSelectDisabledFile(msg) if didSelect { f.err = errors.New(xstrings.EnglishJoin(f.picker.AllowedTypes, true) + " files only") return f, nil } return f, cmd } func (f *FilePicker) activeStyles() *FieldStyles { theme := f.theme if theme == nil { theme = ThemeFunc(ThemeCharm) } if f.focused { return &theme.Theme(f.hasDarkBg).Focused } return &theme.Theme(f.hasDarkBg).Blurred } func (f *FilePicker) renderTitle() string { styles := f.activeStyles() maxWidth := f.width - styles.Base.GetHorizontalFrameSize() return styles.Title.Render(wrap(f.title, maxWidth)) } func (f FilePicker) renderDescription() string { styles := f.activeStyles() maxWidth := f.width - styles.Base.GetHorizontalFrameSize() return styles.Description.Render(wrap(f.description, maxWidth)) } // View renders the file field. func (f *FilePicker) View() string { styles := f.activeStyles() var parts []string if f.title != "" { parts = append(parts, f.renderTitle()) } if f.description != "" { parts = append(parts, f.renderDescription()) } parts = append(parts, f.pickerView()) return styles.Base.Width(f.width).Height(f.height). Render(strings.Join(parts, "\n")) } func (f *FilePicker) pickerView() string { if f.picking { return f.picker.View() } styles := f.activeStyles() if f.accessor.Get() != "" { return styles.SelectedOption.Render(f.accessor.Get()) } return styles.TextInput.Placeholder.Render("No file selected.") } func (f *FilePicker) setPicking(v bool) { f.picking = v f.keymap.Close.SetEnabled(v) f.keymap.Up.SetEnabled(v) f.keymap.Down.SetEnabled(v) f.keymap.Select.SetEnabled(v) f.keymap.Back.SetEnabled(v) f.picker.KeyMap.Up.SetEnabled(v) f.picker.KeyMap.Down.SetEnabled(v) f.picker.KeyMap.GoToTop.SetEnabled(v) f.picker.KeyMap.GoToLast.SetEnabled(v) f.picker.KeyMap.Select.SetEnabled(v) f.picker.KeyMap.Open.SetEnabled(v) f.picker.KeyMap.Back.SetEnabled(v) } // Run runs the file field. func (f *FilePicker) Run() error { return Run(f) } // RunAccessible runs an accessible file field. func (f *FilePicker) RunAccessible(w io.Writer, r io.Reader) error { styles := f.activeStyles() prompt := styles.Title. PaddingRight(1). Render(cmp.Or(f.title, "Choose a file:")) validateFile := func(s string) error { // is the string a file? if _, err := os.Open(s); err != nil { return errors.New("not a file") } // is it one of the allowed types? valid := len(f.picker.AllowedTypes) == 0 for _, ext := range f.picker.AllowedTypes { if strings.HasSuffix(s, ext) { valid = true break } } if !valid { return errors.New("cannot select: " + s) } // does it pass user validation? return f.validate(s) } f.accessor.Set(accessibility.PromptString( w, r, prompt, f.GetValue().(string), validateFile, )) return nil } // copied from bubbles' filepicker. const ( fileSizeWidth = 7 paddingLeft = 2 ) // WithTheme sets the theme of the file field. func (f *FilePicker) WithTheme(theme Theme) Field { if f.theme != nil || theme == nil { return f } f.theme = theme styles := f.theme.Theme(f.hasDarkBg) // XXX: add specific themes f.picker.Styles = filepicker.Styles{ DisabledCursor: lipgloss.Style{}, Cursor: styles.Focused.TextInput.Prompt, Symlink: lipgloss.NewStyle(), Directory: styles.Focused.Directory, File: styles.Focused.File, DisabledFile: styles.Focused.TextInput.Placeholder, Permission: styles.Focused.TextInput.Placeholder, Selected: styles.Focused.SelectedOption, DisabledSelected: styles.Focused.TextInput.Placeholder, FileSize: styles.Focused.TextInput.Placeholder.Width(fileSizeWidth).Align(lipgloss.Right), EmptyDirectory: styles.Focused.TextInput.Placeholder.PaddingLeft(paddingLeft).SetString("No files found."), } return f } // WithKeyMap sets the keymap on a file field. func (f *FilePicker) WithKeyMap(k *KeyMap) Field { f.keymap = k.FilePicker f.picker.KeyMap = filepicker.KeyMap{ GoToTop: k.FilePicker.GotoTop, GoToLast: k.FilePicker.GotoBottom, Down: k.FilePicker.Down, Up: k.FilePicker.Up, PageUp: k.FilePicker.PageUp, PageDown: k.FilePicker.PageDown, Back: k.FilePicker.Back, Open: k.FilePicker.Open, Select: k.FilePicker.Select, } f.setPicking(f.picking) return f } // WithWidth sets the width of the file field. func (f *FilePicker) WithWidth(width int) Field { f.width = width return f } // WithHeight sets the height of the file field. func (f *FilePicker) WithHeight(height int) Field { if height == 0 { return f } adjust := 0 if f.title != "" { adjust += lipgloss.Height(f.renderTitle()) } if f.description != "" { adjust += lipgloss.Height(f.renderDescription()) } adjust++ // picker's own help height f.picker.SetHeight(height - adjust) return f } // WithPosition sets the position of the file field. func (f *FilePicker) WithPosition(p FieldPosition) Field { f.keymap.Prev.SetEnabled(!p.IsFirst()) f.keymap.Next.SetEnabled(!p.IsLast()) f.keymap.Submit.SetEnabled(p.IsLast()) return f } // GetKey returns the key of the field. func (f *FilePicker) GetKey() string { return f.key } // GetValue returns the value of the field. func (f *FilePicker) GetValue() any { return f.accessor.Get() } ================================================ FILE: field_input.go ================================================ package huh import ( "cmp" "errors" "fmt" "io" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/accessibility" "charm.land/lipgloss/v2" ) // Input is a input field. // // The input field is a field that allows the user to enter text. Use it to user // input. It can be used for collecting text, passwords, or other short input. // // The input field supports Suggestions, Placeholder, and Validation. type Input struct { accessor Accessor[string] key string id int title Eval[string] description Eval[string] placeholder Eval[string] suggestions Eval[[]string] textinput textinput.Model inline bool validate func(string) error err error focused bool width int height int theme Theme hasDarkBg bool keymap InputKeyMap } // NewInput creates a new input field. // // The input field is a field that allows the user to enter text. Use it to user // input. It can be used for collecting text, passwords, or other short input. // // The input field supports Suggestions, Placeholder, and Validation. func NewInput() *Input { input := textinput.New() i := &Input{ accessor: &EmbeddedAccessor[string]{}, textinput: input, validate: func(string) error { return nil }, id: nextID(), title: Eval[string]{cache: make(map[uint64]string)}, description: Eval[string]{cache: make(map[uint64]string)}, placeholder: Eval[string]{cache: make(map[uint64]string)}, suggestions: Eval[[]string]{cache: make(map[uint64][]string)}, } return i } // Value sets the value of the input field. func (i *Input) Value(value *string) *Input { return i.Accessor(NewPointerAccessor(value)) } // Accessor sets the accessor of the input field. func (i *Input) Accessor(accessor Accessor[string]) *Input { i.accessor = accessor i.textinput.SetValue(i.accessor.Get()) return i } // Key sets the key of the input field. func (i *Input) Key(key string) *Input { i.key = key return i } // Title sets the title of the input field. // // The Title is static for dynamic Title use `TitleFunc`. func (i *Input) Title(title string) *Input { i.title.val = title i.title.fn = nil return i } // Description sets the description of the input field. // // The Description is static for dynamic Description use `DescriptionFunc`. func (i *Input) Description(description string) *Input { i.description.val = description i.description.fn = nil return i } // TitleFunc sets the title func of the input field. // // The TitleFunc will be re-evaluated when the binding of the TitleFunc changes. // This is useful when you want to display dynamic content and update the title // when another part of your form changes. // // See README#Dynamic for more usage information. func (i *Input) TitleFunc(f func() string, bindings any) *Input { i.title.fn = f i.title.bindings = bindings return i } // DescriptionFunc sets the description func of the input field. // // The DescriptionFunc will be re-evaluated when the binding of the // DescriptionFunc changes. This is useful when you want to display dynamic // content and update the description when another part of your form changes. // // See README#Dynamic for more usage information. func (i *Input) DescriptionFunc(f func() string, bindings any) *Input { i.description.fn = f i.description.bindings = bindings return i } // Prompt sets the prompt of the input field. func (i *Input) Prompt(prompt string) *Input { i.textinput.Prompt = prompt return i } // CharLimit sets the character limit of the input field. func (i *Input) CharLimit(charlimit int) *Input { i.textinput.CharLimit = charlimit return i } // Suggestions sets the suggestions to display for autocomplete in the input // field. // // The suggestions are static for dynamic suggestions use `SuggestionsFunc`. func (i *Input) Suggestions(suggestions []string) *Input { i.suggestions.fn = nil i.textinput.ShowSuggestions = len(suggestions) > 0 i.textinput.KeyMap.AcceptSuggestion.SetEnabled(len(suggestions) > 0) i.textinput.SetSuggestions(suggestions) return i } // SuggestionsFunc sets the suggestions func to display for autocomplete in the // input field. // // The SuggestionsFunc will be re-evaluated when the binding of the // SuggestionsFunc changes. This is useful when you want to display dynamic // suggestions when another part of your form changes. // // See README#Dynamic for more usage information. func (i *Input) SuggestionsFunc(f func() []string, bindings any) *Input { i.suggestions.fn = f i.suggestions.bindings = bindings i.suggestions.loading = true i.textinput.KeyMap.AcceptSuggestion.SetEnabled(f != nil) i.textinput.ShowSuggestions = f != nil return i } // EchoMode sets the input behavior of the text Input field. type EchoMode textinput.EchoMode const ( // EchoModeNormal displays text as is. // This is the default behavior. EchoModeNormal EchoMode = EchoMode(textinput.EchoNormal) // EchoModePassword displays the EchoCharacter mask instead of actual characters. // This is commonly used for password fields. EchoModePassword EchoMode = EchoMode(textinput.EchoPassword) // EchoModeNone displays nothing as characters are entered. // This is commonly seen for password fields on the command line. EchoModeNone EchoMode = EchoMode(textinput.EchoNone) ) // EchoMode sets the echo mode of the input. func (i *Input) EchoMode(mode EchoMode) *Input { i.textinput.EchoMode = textinput.EchoMode(mode) return i } // Password sets whether or not to hide the input while the user is typing. // // Deprecated: use EchoMode(EchoPassword) instead. func (i *Input) Password(password bool) *Input { if password { i.textinput.EchoMode = textinput.EchoPassword } else { i.textinput.EchoMode = textinput.EchoNormal } return i } // Placeholder sets the placeholder of the text input. func (i *Input) Placeholder(str string) *Input { i.textinput.Placeholder = str return i } // PlaceholderFunc sets the placeholder func of the text input. func (i *Input) PlaceholderFunc(f func() string, bindings any) *Input { i.placeholder.fn = f i.placeholder.bindings = bindings return i } // Inline sets whether the title and input should be on the same line. func (i *Input) Inline(inline bool) *Input { i.inline = inline return i } // Validate sets the validation function of the input field. func (i *Input) Validate(validate func(string) error) *Input { i.validate = validate return i } // Error returns the error of the input field. func (i *Input) Error() error { return i.err } // Skip returns whether the input should be skipped or should be blocking. func (*Input) Skip() bool { return false } // Zoom returns whether the input should be zoomed. func (*Input) Zoom() bool { return false } // Focus focuses the input field. func (i *Input) Focus() tea.Cmd { i.focused = true return i.textinput.Focus() } // Blur blurs the input field. func (i *Input) Blur() tea.Cmd { i.focused = false i.accessor.Set(i.textinput.Value()) i.textinput.Blur() i.err = i.validate(i.accessor.Get()) return nil } // KeyBinds returns the help message for the input field. func (i *Input) KeyBinds() []key.Binding { if i.textinput.ShowSuggestions { return []key.Binding{i.keymap.AcceptSuggestion, i.keymap.Prev, i.keymap.Submit, i.keymap.Next} } return []key.Binding{i.keymap.Prev, i.keymap.Submit, i.keymap.Next} } // Init initializes the input field. func (i *Input) Init() tea.Cmd { i.textinput.Blur() return nil } // Update updates the input field. func (i *Input) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd //nolint:prealloc switch msg := msg.(type) { case tea.BackgroundColorMsg: i.hasDarkBg = msg.IsDark() case updateFieldMsg: var cmds []tea.Cmd if ok, hash := i.title.shouldUpdate(); ok { i.title.bindingsHash = hash if !i.title.loadFromCache() { i.title.loading = true cmds = append(cmds, func() tea.Msg { return updateTitleMsg{id: i.id, title: i.title.fn(), hash: hash} }) } } if ok, hash := i.description.shouldUpdate(); ok { i.description.bindingsHash = hash if !i.description.loadFromCache() { i.description.loading = true cmds = append(cmds, func() tea.Msg { return updateDescriptionMsg{id: i.id, description: i.description.fn(), hash: hash} }) } } if ok, hash := i.placeholder.shouldUpdate(); ok { i.placeholder.bindingsHash = hash if i.placeholder.loadFromCache() { i.textinput.Placeholder = i.placeholder.val } else { i.placeholder.loading = true cmds = append(cmds, func() tea.Msg { return updatePlaceholderMsg{id: i.id, placeholder: i.placeholder.fn(), hash: hash} }) } } if ok, hash := i.suggestions.shouldUpdate(); ok { i.suggestions.bindingsHash = hash if i.suggestions.loadFromCache() { i.textinput.ShowSuggestions = len(i.suggestions.val) > 0 i.textinput.SetSuggestions(i.suggestions.val) } else { i.suggestions.loading = true cmds = append(cmds, func() tea.Msg { return updateSuggestionsMsg{id: i.id, suggestions: i.suggestions.fn(), hash: hash} }) } } return i, tea.Batch(cmds...) case updateTitleMsg: if i.id == msg.id && i.title.bindingsHash == msg.hash { i.title.update(msg.title) } case updateDescriptionMsg: if i.id == msg.id && i.description.bindingsHash == msg.hash { i.description.update(msg.description) } case updatePlaceholderMsg: if i.id == msg.id && i.placeholder.bindingsHash == msg.hash { i.placeholder.update(msg.placeholder) i.textinput.Placeholder = msg.placeholder } case updateSuggestionsMsg: if i.id == msg.id && i.suggestions.bindingsHash == msg.hash { i.suggestions.update(msg.suggestions) i.textinput.ShowSuggestions = len(msg.suggestions) > 0 i.textinput.SetSuggestions(msg.suggestions) } case tea.KeyPressMsg: i.err = nil switch { case key.Matches(msg, i.keymap.Prev): cmds = append(cmds, PrevField) case key.Matches(msg, i.keymap.Next, i.keymap.Submit): value := i.textinput.Value() i.err = i.validate(value) if i.err != nil { return i, nil } cmds = append(cmds, NextField) } } var cmd tea.Cmd i.textinput, cmd = i.textinput.Update(msg) cmds = append(cmds, cmd) i.accessor.Set(i.textinput.Value()) return i, tea.Batch(cmds...) } func (i *Input) activeStyles() *FieldStyles { theme := i.theme if theme == nil { theme = ThemeFunc(ThemeCharm) } if i.focused { return &theme.Theme(i.hasDarkBg).Focused } return &theme.Theme(i.hasDarkBg).Blurred } // View renders the input field. func (i *Input) View() string { styles := i.activeStyles() maxWidth := i.width - styles.Base.GetHorizontalFrameSize() // NB: since the method is on a pointer receiver these are being mutated. // Because this runs on every render this shouldn't matter in practice, // however. st := i.textinput.Styles() st.Cursor.Color = styles.TextInput.Cursor.GetForeground() st.Focused.Prompt = styles.TextInput.Prompt st.Focused.Text = styles.TextInput.Text st.Focused.Placeholder = styles.TextInput.Placeholder i.textinput.SetStyles(st) // Adjust text input size to its char limit if it fit in its width if i.textinput.CharLimit > 0 { i.textinput.SetWidth(max(min(i.textinput.CharLimit, i.textinput.Width(), maxWidth), 0)) } var sb strings.Builder if i.title.val != "" || i.title.fn != nil { sb.WriteString(styles.Title.Render(wrap(i.title.val, maxWidth))) if !i.inline { sb.WriteString("\n") } } if i.description.val != "" || i.description.fn != nil { sb.WriteString(styles.Description.Render(wrap(i.description.val, maxWidth))) if !i.inline { sb.WriteString("\n") } } sb.WriteString(i.textinput.View()) return styles.Base. Width(i.width). Height(i.height). Render(sb.String()) } // Run runs the input field in accessible mode. func (i *Input) Run() error { return i.run() } // run runs the input field. func (i *Input) run() error { return Run(i) } // RunAccessible runs the input field in accessible mode. func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { styles := i.activeStyles() validator := func(input string) error { if i.textinput.CharLimit > 0 && len(input) > i.textinput.CharLimit { return fmt.Errorf("Input cannot exceed %d characters", i.textinput.CharLimit) } return i.validate(input) } switch i.textinput.EchoMode { case textinput.EchoNormal: prompt := styles.Title. PaddingRight(1). Render(cmp.Or(i.title.val, "Input:")) value := accessibility.PromptString(w, r, prompt, i.GetValue().(string), validator) i.accessor.Set(value) return nil default: prompt := styles.Title. PaddingRight(1). Render(cmp.Or(i.title.val, "Password:")) if fd, ok := r.(interface{ Fd() uintptr }); ok { value, err := accessibility.PromptPassword(w, fd.Fd(), prompt, validator) if err != nil { return err //nolint:wrapcheck } i.accessor.Set(value) return nil } return errors.New("password asking needs a tty") } } // WithKeyMap sets the keymap on an input field. func (i *Input) WithKeyMap(k *KeyMap) Field { i.keymap = k.Input i.textinput.KeyMap.AcceptSuggestion = i.keymap.AcceptSuggestion return i } // WithTheme sets the theme of the input field. func (i *Input) WithTheme(theme Theme) Field { if i.theme != nil { return i } i.theme = theme return i } // WithWidth sets the width of the input field. func (i *Input) WithWidth(width int) Field { styles := i.activeStyles() i.width = width frameSize := styles.Base.GetHorizontalFrameSize() promptWidth := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) titleWidth := lipgloss.Width(styles.Title.Render(i.title.val)) descriptionWidth := lipgloss.Width(styles.Description.Render(i.description.val)) i.textinput.SetWidth(width - frameSize - promptWidth - 1) if i.inline { i.textinput.SetWidth(i.textinput.Width() - titleWidth - descriptionWidth) } return i } // WithHeight sets the height of the input field. func (i *Input) WithHeight(height int) Field { i.height = height return i } // WithPosition sets the position of the input field. func (i *Input) WithPosition(p FieldPosition) Field { i.keymap.Prev.SetEnabled(!p.IsFirst()) i.keymap.Next.SetEnabled(!p.IsLast()) i.keymap.Submit.SetEnabled(p.IsLast()) return i } // GetKey returns the key of the field. func (i *Input) GetKey() string { return i.key } // GetValue returns the value of the field. func (i *Input) GetValue() any { return i.accessor.Get() } ================================================ FILE: field_multiselect.go ================================================ package huh import ( "cmp" "fmt" "io" "slices" "strings" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/accessibility" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/exp/ordered" ) // MultiSelect is a form multi-select field. type MultiSelect[T comparable] struct { accessor Accessor[[]T] key string id int // customization title Eval[string] description Eval[string] options Eval[[]Option[T]] filterable bool filteredOptions []Option[T] limit int // error handling validate func([]T) error err error // state cursor int focused bool filtering bool filter textinput.Model viewport viewport.Model spinner spinner.Model // options width int height int theme Theme hasDarkBg bool keymap MultiSelectKeyMap } // NewMultiSelect returns a new multi-select field. func NewMultiSelect[T comparable]() *MultiSelect[T] { filter := textinput.New() filter.Prompt = "/" s := spinner.New(spinner.WithSpinner(spinner.Line)) return &MultiSelect[T]{ accessor: &EmbeddedAccessor[[]T]{}, validate: func([]T) error { return nil }, filtering: false, filter: filter, id: nextID(), options: Eval[[]Option[T]]{cache: make(map[uint64][]Option[T])}, title: Eval[string]{cache: make(map[uint64]string)}, description: Eval[string]{cache: make(map[uint64]string)}, spinner: s, filterable: true, } } // Value sets the value of the multi-select field. func (m *MultiSelect[T]) Value(value *[]T) *MultiSelect[T] { return m.Accessor(NewPointerAccessor(value)) } // Accessor sets the accessor of the input field. func (m *MultiSelect[T]) Accessor(accessor Accessor[[]T]) *MultiSelect[T] { m.accessor = accessor for i, o := range m.options.val { if slices.Contains(m.accessor.Get(), o.Value) { m.options.val[i].selected = true } } return m } // Key sets the key of the select field which can be used to retrieve the value // after submission. func (m *MultiSelect[T]) Key(key string) *MultiSelect[T] { m.key = key return m } // Title sets the title of the multi-select field. func (m *MultiSelect[T]) Title(title string) *MultiSelect[T] { m.title.val = title m.title.fn = nil return m } // TitleFunc sets the title func of the multi-select field. func (m *MultiSelect[T]) TitleFunc(f func() string, bindings any) *MultiSelect[T] { m.title.fn = f m.title.bindings = bindings return m } // Description sets the description of the multi-select field. func (m *MultiSelect[T]) Description(description string) *MultiSelect[T] { m.description.val = description return m } // DescriptionFunc sets the description func of the multi-select field. func (m *MultiSelect[T]) DescriptionFunc(f func() string, bindings any) *MultiSelect[T] { m.description.fn = f m.description.bindings = bindings return m } // Options sets the options of the multi-select field. func (m *MultiSelect[T]) Options(options ...Option[T]) *MultiSelect[T] { if len(options) <= 0 { return m } m.options.val = options m.filteredOptions = options m.selectOptions() m.updateViewportSize() return m } func (m *MultiSelect[T]) selectOptions() { // Set the cursor to the existing value or the last selected option. for i, o := range m.options.val { for _, v := range m.accessor.Get() { if o.Value == v { m.options.val[i].selected = true } } } for i, o := range m.options.val { if !o.selected { continue } m.cursor = i m.ensureCursorVisible() break } } // OptionsFunc sets the options func of the multi-select field. func (m *MultiSelect[T]) OptionsFunc(f func() []Option[T], bindings any) *MultiSelect[T] { m.options.fn = f m.options.bindings = bindings m.filteredOptions = make([]Option[T], 0) // If there is no height set, we should attach a static height since these // options are possibly dynamic. if m.height <= 0 { m.height = defaultHeight m.updateViewportSize() } if m.width <= 0 { m.Width(20) } return m } // Filterable sets the multi-select field as filterable. func (m *MultiSelect[T]) Filterable(filterable bool) *MultiSelect[T] { m.filterable = filterable return m } // Filtering sets the filtering state of the multi-select field. func (m *MultiSelect[T]) Filtering(filtering bool) *MultiSelect[T] { m.filtering = filtering m.filter.Focus() return m } // Limit sets the limit of the multi-select field. func (m *MultiSelect[T]) Limit(limit int) *MultiSelect[T] { m.limit = limit m.setSelectAllHelp() return m } // Width sets the width of the multi-select field. func (m *MultiSelect[T]) Width(width int) *MultiSelect[T] { // What we really want to do is set the width of the viewport, but we // need a theme applied before we can calcualate its width. m.width = width m.updateViewportSize() return m } // Height sets the height of the multi-select field. func (m *MultiSelect[T]) Height(height int) *MultiSelect[T] { // What we really want to do is set the height of the viewport, but we // need a theme applied before we can calcualate its height. m.height = height m.updateViewportSize() return m } // Validate sets the validation function of the multi-select field. func (m *MultiSelect[T]) Validate(validate func([]T) error) *MultiSelect[T] { m.validate = validate return m } // Error returns the error of the multi-select field. func (m *MultiSelect[T]) Error() error { return m.err } // Skip returns whether the multiselect should be skipped or should be blocking. func (*MultiSelect[T]) Skip() bool { return false } // Zoom returns whether the multiselect should be zoomed. func (*MultiSelect[T]) Zoom() bool { return false } // Focus focuses the multi-select field. func (m *MultiSelect[T]) Focus() tea.Cmd { m.updateValue() m.focused = true return nil } // Blur blurs the multi-select field. func (m *MultiSelect[T]) Blur() tea.Cmd { m.updateValue() m.focused = false return nil } // Hovered returns the value of the option under the cursor, and a bool // indicating whether one was found. If there are no visible options, returns // a zero-valued T and false. func (m *MultiSelect[T]) Hovered() (T, bool) { if len(m.filteredOptions) == 0 || m.cursor >= len(m.filteredOptions) { var zero T return zero, false } return m.filteredOptions[m.cursor].Value, true } // KeyBinds returns the help message for the multi-select field. func (m *MultiSelect[T]) KeyBinds() []key.Binding { m.setSelectAllHelp() binds := []key.Binding{ m.keymap.Toggle, m.keymap.Up, m.keymap.Down, } if m.filterable { binds = append( binds, m.keymap.Filter, m.keymap.SetFilter, m.keymap.ClearFilter, ) } binds = append( binds, m.keymap.Prev, m.keymap.Submit, m.keymap.Next, m.keymap.SelectAll, m.keymap.SelectNone, ) return binds } // Init initializes the multi-select field. func (m *MultiSelect[T]) Init() tea.Cmd { return nil } // Update updates the multi-select field. func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd // Enforce height on the viewport during update as we need themes to // be applied before we can calculate the height. m.updateViewportSize() var cmd tea.Cmd if m.filtering { m.filter, cmd = m.filter.Update(msg) m.setSelectAllHelp() cmds = append(cmds, cmd) } switch msg := msg.(type) { case tea.BackgroundColorMsg: m.hasDarkBg = msg.IsDark() case updateFieldMsg: var fieldCmds []tea.Cmd if ok, hash := m.title.shouldUpdate(); ok { m.title.bindingsHash = hash if !m.title.loadFromCache() { m.title.loading = true fieldCmds = append(fieldCmds, func() tea.Msg { return updateTitleMsg{id: m.id, title: m.title.fn(), hash: hash} }) } } if ok, hash := m.description.shouldUpdate(); ok { m.description.bindingsHash = hash if !m.description.loadFromCache() { m.description.loading = true fieldCmds = append(fieldCmds, func() tea.Msg { return updateDescriptionMsg{id: m.id, description: m.description.fn(), hash: hash} }) } } if ok, hash := m.options.shouldUpdate(); ok { m.options.bindingsHash = hash if m.options.loadFromCache() { m.filteredOptions = m.options.val m.updateValue() m.cursor = ordered.Clamp(m.cursor, 0, len(m.filteredOptions)-1) } else { m.options.loading = true m.options.loadingStart = time.Now() fieldCmds = append(fieldCmds, func() tea.Msg { return updateOptionsMsg[T]{id: m.id, options: m.options.fn(), hash: hash} }, m.spinner.Tick) } } return m, tea.Batch(fieldCmds...) case spinner.TickMsg: if !m.options.loading { break } m.spinner, cmd = m.spinner.Update(msg) return m, cmd case updateTitleMsg: if msg.id == m.id && msg.hash == m.title.bindingsHash { m.title.update(msg.title) } case updateDescriptionMsg: if msg.id == m.id && msg.hash == m.description.bindingsHash { m.description.update(msg.description) } case updateOptionsMsg[T]: if msg.id == m.id && msg.hash == m.options.bindingsHash { m.options.update(msg.options) m.selectOptions() // since we're updating the options, we need to reset the cursor. m.filteredOptions = m.options.val m.updateValue() m.cursor = ordered.Clamp(m.cursor, 0, len(m.filteredOptions)-1) } case tea.KeyPressMsg: m.err = nil switch { case key.Matches(msg, m.keymap.Filter): m.setFilter(true) return m, m.filter.Focus() case key.Matches(msg, m.keymap.SetFilter): if len(m.filteredOptions) <= 0 { m.filter.SetValue("") m.filteredOptions = m.options.val } m.setFilter(false) case key.Matches(msg, m.keymap.ClearFilter): m.filter.SetValue("") m.filteredOptions = m.options.val m.setFilter(false) case key.Matches(msg, m.keymap.Up): //nolint:godox // FIXME: should use keys in keymap if m.filtering && msg.String() == "k" { break } m.cursor = max(m.cursor-1, 0) m.ensureCursorVisible() case key.Matches(msg, m.keymap.Down): //nolint:godox // FIXME: should use keys in keymap if m.filtering && msg.String() == "j" { break } m.cursor = min(m.cursor+1, len(m.filteredOptions)-1) m.ensureCursorVisible() case key.Matches(msg, m.keymap.GotoTop): if m.filtering { break } m.cursor = 0 m.viewport.GotoTop() case key.Matches(msg, m.keymap.GotoBottom): if m.filtering { break } m.cursor = len(m.filteredOptions) - 1 m.viewport.GotoBottom() case key.Matches(msg, m.keymap.HalfPageUp): m.cursor = max(m.cursor-m.viewport.Height()/2, 0) m.ensureCursorVisible() case key.Matches(msg, m.keymap.HalfPageDown): m.cursor = min(m.cursor+m.viewport.Height()/2, len(m.filteredOptions)-1) m.ensureCursorVisible() case key.Matches(msg, m.keymap.Toggle) && !m.filtering: for i, option := range m.options.val { if option.Key == m.filteredOptions[m.cursor].Key { if !m.options.val[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit { break } selected := m.options.val[i].selected m.options.val[i].selected = !selected m.filteredOptions[m.cursor].selected = !selected } } m.setSelectAllHelp() m.updateValue() case key.Matches(msg, m.keymap.SelectAll, m.keymap.SelectNone) && m.limit <= 0: selected := false for _, option := range m.filteredOptions { if !option.selected { selected = true break } } for i, option := range m.options.val { for j := range m.filteredOptions { if option.Key == m.filteredOptions[j].Key { m.options.val[i].selected = selected m.filteredOptions[j].selected = selected break } } } m.setSelectAllHelp() m.updateValue() case key.Matches(msg, m.keymap.Prev): m.updateValue() m.err = m.validate(m.accessor.Get()) if m.err != nil { return m, nil } return m, PrevField case key.Matches(msg, m.keymap.Next, m.keymap.Submit): m.updateValue() m.err = m.validate(m.accessor.Get()) if m.err != nil { return m, nil } return m, NextField } if m.filtering { m.filteredOptions = m.options.val if m.filter.Value() != "" { m.filteredOptions = nil for _, option := range m.options.val { if m.filterFunc(option.Key) { m.filteredOptions = append(m.filteredOptions, option) } } } if len(m.filteredOptions) > 0 { m.cursor = min(m.cursor, len(m.filteredOptions)-1) } } m.ensureCursorVisible() } return m, tea.Batch(cmds...) } // updateViewportSize updates the viewport size according to the Height setting // on this multi-select field. func (m *MultiSelect[T]) updateViewportSize() { yoffset := 0 if ss := m.titleView(); ss != "" { yoffset += lipgloss.Height(ss) } if ss := m.descriptionView(); ss != "" { yoffset += lipgloss.Height(ss) } v, _, _ := m.optionsView() height := m.height if height <= 0 { height = lipgloss.Height(v) } width := m.width if m.width <= 0 { width = lipgloss.Width(v) } m.viewport.SetWidth(width) m.viewport.SetHeight(max(minHeight, height) - yoffset) } // numSelected returns the total number of selected options. func (m *MultiSelect[T]) numSelected() int { var count int for _, o := range m.options.val { if o.selected { count++ } } return count } // numFilteredOptionsSelected returns the number of selected options with the // current filter applied. func (m *MultiSelect[T]) numFilteredSelected() int { var count int for _, o := range m.filteredOptions { if o.selected { count++ } } return count } func (m *MultiSelect[T]) updateValue() { value := make([]T, 0) for _, option := range m.options.val { if option.selected { value = append(value, option.Value) } } m.accessor.Set(value) m.err = m.validate(m.accessor.Get()) } func (m *MultiSelect[T]) activeStyles() *FieldStyles { theme := m.theme if theme == nil { theme = ThemeFunc(ThemeCharm) } if m.focused { return &theme.Theme(m.hasDarkBg).Focused } return &theme.Theme(m.hasDarkBg).Blurred } func (m *MultiSelect[T]) titleView() string { if m.title.val == "" { return "" } var ( styles = m.activeStyles() sb = strings.Builder{} maxWidth = m.width - styles.Base.GetHorizontalFrameSize() ) if m.filtering { sb.WriteString(m.filter.View()) } else if m.filter.Value() != "" { sb.WriteString(styles.Title.Render(wrap(m.title.val, maxWidth))) sb.WriteString(styles.Description.Render("/" + m.filter.Value())) } else { sb.WriteString(styles.Title.Render(wrap(m.title.val, maxWidth))) } if m.err != nil { sb.WriteString(styles.ErrorIndicator.String()) } return sb.String() } func (m *MultiSelect[T]) descriptionView() string { if m.description.val == "" { return "" } maxWidth := m.width - m.activeStyles().Base.GetHorizontalFrameSize() return m.activeStyles().Description.Render(wrap(m.description.val, maxWidth)) } func (m *MultiSelect[T]) renderOption(option Option[T], cursor, selected bool) string { styles := m.activeStyles() var parts []string if cursor { parts = append(parts, styles.MultiSelectSelector.String()) } else { parts = append(parts, strings.Repeat(" ", lipgloss.Width(styles.MultiSelectSelector.String()))) } if selected { parts = append(parts, styles.SelectedPrefix.String()) parts = append(parts, styles.SelectedOption.Render(option.Key)) } else { parts = append(parts, styles.UnselectedPrefix.String()) parts = append(parts, styles.UnselectedOption.Render(option.Key)) } return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } // cursorLineOffset computes the line offset and height (in lines) for the // current cursor position without rendering the full options string. func (m *MultiSelect[T]) cursorLineOffset() (offset int, height int) { for i, option := range m.filteredOptions { line := m.renderOption(option, m.cursor == i, m.filteredOptions[i].selected) h := lipgloss.Height(line) if i < m.cursor { offset += h } if i == m.cursor { height = h return offset, height } } return offset, height } func (m *MultiSelect[T]) ensureCursorVisible() { offset, height := m.cursorLineOffset() ensureVisible(&m.viewport, offset, height) } func (m *MultiSelect[T]) optionsView() (string, int, int) { var sb strings.Builder if m.options.loading && time.Since(m.options.loadingStart) > spinnerShowThreshold { m.spinner.Style = m.activeStyles().MultiSelectSelector.UnsetString() sb.WriteString(m.spinner.View() + " Loading...") return sb.String(), -1, 1 } var cursorOffset int var cursorHeight int for i, option := range m.filteredOptions { cursor := m.cursor == i line := m.renderOption(option, cursor, m.filteredOptions[i].selected) if i < m.cursor { cursorOffset += lipgloss.Height(line) } if cursor { cursorHeight = lipgloss.Height(line) } sb.WriteString(line) if i < len(m.options.val)-1 { sb.WriteString("\n") } } for i := len(m.filteredOptions); i < len(m.options.val)-1; i++ { sb.WriteString("\n") } return sb.String(), cursorOffset, cursorHeight } // View renders the multi-select field. func (m *MultiSelect[T]) View() string { styles := m.activeStyles() vpc, _, _ := m.optionsView() m.viewport.SetContent(vpc) var sb strings.Builder if m.title.val != "" || m.title.fn != nil { sb.WriteString(m.titleView()) sb.WriteString("\n") } if m.description.val != "" || m.description.fn != nil { sb.WriteString(m.descriptionView() + "\n") } sb.WriteString(m.viewport.View()) return styles.Base.Width(m.width).Height(m.height). Render(sb.String()) } func (m *MultiSelect[T]) printOptions(w io.Writer) { styles := m.activeStyles() var sb strings.Builder for i, option := range m.options.val { if option.selected { sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key))) } else { _, _ = fmt.Fprintf(&sb, "%d. %s", i+1, option.Key) } sb.WriteString("\n") } sb.WriteString("0. Confirm selection\n") _, _ = fmt.Fprint(w, sb.String()) } // setFilter sets the filter of the select field. func (m *MultiSelect[T]) setFilter(filter bool) { m.filtering = filter m.keymap.SetFilter.SetEnabled(filter) m.keymap.Filter.SetEnabled(!filter) m.keymap.Next.SetEnabled(!filter) m.keymap.Submit.SetEnabled(!filter) m.keymap.Prev.SetEnabled(!filter) m.keymap.ClearFilter.SetEnabled(!filter && m.filter.Value() != "") } // filterFunc returns true if the option matches the filter. func (m *MultiSelect[T]) filterFunc(option string) bool { // XXX: remove diacritics or allow customization of filter function. return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value())) } // setSelectAllHelp enables the appropriate select all or select none keybinding. func (m *MultiSelect[T]) setSelectAllHelp() { if m.limit > 0 { m.keymap.SelectAll.SetEnabled(false) m.keymap.SelectNone.SetEnabled(false) return } noneSelected := m.numFilteredSelected() <= 0 allSelected := m.numFilteredSelected() > 0 && m.numFilteredSelected() < len(m.filteredOptions) selectAll := noneSelected || allSelected m.keymap.SelectAll.SetEnabled(selectAll) m.keymap.SelectNone.SetEnabled(!selectAll) } // Run runs the multi-select field. func (m *MultiSelect[T]) Run() error { return Run(m) } // RunAccessible runs the multi-select field in accessible mode. func (m *MultiSelect[T]) RunAccessible(w io.Writer, r io.Reader) error { styles := m.activeStyles() title := styles.Title. PaddingRight(1). Render(cmp.Or(m.title.val, "Select:")) _, _ = fmt.Fprintln(w, title) limit := m.limit if limit == 0 { limit = len(m.options.val) } _, _ = fmt.Fprintf(w, "Select up to %d options.\n", limit) var choice int for { m.printOptions(w) prompt := fmt.Sprintf("Enter a number between %d and %d: ", 0, len(m.options.val)) choice = accessibility.PromptInt(w, r, prompt, 0, len(m.options.val), nil) if choice <= 0 { m.updateValue() err := m.validate(m.accessor.Get()) if err != nil { _, _ = fmt.Fprintln(w, err) continue } break } if !m.options.val[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit { _, _ = fmt.Fprintf(w, "You can't select more than %d options.\n", m.limit) _, _ = fmt.Fprintln(w) continue } m.options.val[choice-1].selected = !m.options.val[choice-1].selected _, _ = fmt.Fprintln(w) } return nil } // WithTheme sets the theme of the multi-select field. func (m *MultiSelect[T]) WithTheme(theme Theme) Field { if m.theme != nil { return m } m.theme = theme styles := m.theme.Theme(m.hasDarkBg) st := m.filter.Styles() st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground() st.Focused.Prompt = styles.Focused.TextInput.Prompt st.Focused.Text = styles.Focused.TextInput.Text st.Focused.Placeholder = styles.Focused.TextInput.Placeholder m.filter.SetStyles(st) m.updateViewportSize() return m } // WithKeyMap sets the keymap of the multi-select field. func (m *MultiSelect[T]) WithKeyMap(k *KeyMap) Field { m.keymap = k.MultiSelect if !m.filterable { m.keymap.Filter.SetEnabled(false) m.keymap.ClearFilter.SetEnabled(false) m.keymap.SetFilter.SetEnabled(false) } return m } // WithWidth sets the width of the multi-select field. func (m *MultiSelect[T]) WithWidth(width int) Field { m.width = width m.updateViewportSize() return m } // WithHeight sets the total height of the multi-select field. Including padding // and help menu heights. func (m *MultiSelect[T]) WithHeight(height int) Field { m.Height(height) return m } // WithPosition sets the position of the multi-select field. func (m *MultiSelect[T]) WithPosition(p FieldPosition) Field { if m.filtering { return m } m.keymap.Prev.SetEnabled(!p.IsFirst()) m.keymap.Next.SetEnabled(!p.IsLast()) m.keymap.Submit.SetEnabled(p.IsLast()) return m } // GetKey returns the multi-select's key. func (m *MultiSelect[T]) GetKey() string { return m.key } // GetValue returns the multi-select's value. func (m *MultiSelect[T]) GetValue() any { return m.accessor.Get() } // GetFiltering returns whether the multi-select is filtering. func (m *MultiSelect[T]) GetFiltering() bool { return m.filtering } ================================================ FILE: field_note.go ================================================ package huh import ( "fmt" "io" "strings" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" ) // Note is a note field. // // A note is responsible for displaying information to the user. Use it to // provide context around a different field. Generally, the notes are not // interacted with unless the note has a next button `Next(true)`. type Note struct { id int title Eval[string] description Eval[string] nextLabel string focused bool showNextButton bool skip bool height int width int theme Theme hasDarkBg bool keymap NoteKeyMap } // NewNote creates a new note field. // // A note is responsible for displaying information to the user. Use it to // provide context around a different field. Generally, the notes are not // interacted with unless the note has a next button `Next(true)`. func NewNote() *Note { return &Note{ id: nextID(), showNextButton: false, skip: true, nextLabel: "Next", title: Eval[string]{cache: make(map[uint64]string)}, description: Eval[string]{cache: make(map[uint64]string)}, } } // Title sets the note field's title. // // This title will be static, for dynamic titles use `TitleFunc`. func (n *Note) Title(title string) *Note { n.title.val = title n.title.fn = nil return n } // TitleFunc sets the title func of the note field. // // The TitleFunc will be re-evaluated when the binding of the TitleFunc changes. // This is useful when you want to display dynamic content and update the title // of a note when another part of your form changes. // // See README.md#Dynamic for more usage information. func (n *Note) TitleFunc(f func() string, bindings any) *Note { n.title.fn = f n.title.bindings = bindings return n } // Description sets the note field's description. // // This description will be static, for dynamic descriptions use `DescriptionFunc`. func (n *Note) Description(description string) *Note { n.description.val = description n.description.fn = nil return n } // DescriptionFunc sets the description func of the note field. // // The DescriptionFunc will be re-evaluated when the binding of the // DescriptionFunc changes. This is useful when you want to display dynamic // content and update the description of a note when another part of your form // changes. // // For example, you can make a dynamic markdown preview with the following Form & Group. // // huh.NewText().Title("Markdown").Value(&md), // huh.NewNote().Height(20).Title("Preview"). // DescriptionFunc(func() string { // return md // }, &md), // // Notice the `binding` of the Note is the same as the `Value` of the Text field. // This binds the two values together, so that when the `Value` of the Text // field changes so does the Note description. func (n *Note) DescriptionFunc(f func() string, bindings any) *Note { n.description.fn = f n.description.bindings = bindings return n } // Height sets the note field's height. func (n *Note) Height(height int) *Note { n.height = height return n } // Next sets whether or not to show the next button. // // Title // Description // // [ Next ] func (n *Note) Next(show bool) *Note { n.showNextButton = show return n } // NextLabel sets the next button label. func (n *Note) NextLabel(label string) *Note { n.nextLabel = label return n } // Focus focuses the note field. func (n *Note) Focus() tea.Cmd { n.focused = true return nil } // Blur blurs the note field. func (n *Note) Blur() tea.Cmd { n.focused = false return nil } // Error returns the error of the note field. func (n *Note) Error() error { return nil } // Skip returns whether the note should be skipped or should be blocking. func (n *Note) Skip() bool { return n.skip } // Zoom returns whether the note should be zoomed. func (n *Note) Zoom() bool { return false } // KeyBinds returns the help message for the note field. func (n *Note) KeyBinds() []key.Binding { return []key.Binding{ n.keymap.Prev, n.keymap.Submit, n.keymap.Next, } } // Init initializes the note field. func (n *Note) Init() tea.Cmd { return nil } // Update updates the note field. func (n *Note) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case tea.BackgroundColorMsg: n.hasDarkBg = msg.IsDark() case updateFieldMsg: var cmds []tea.Cmd if ok, hash := n.title.shouldUpdate(); ok { n.title.bindingsHash = hash if !n.title.loadFromCache() { n.title.loading = true cmds = append(cmds, func() tea.Msg { return updateTitleMsg{id: n.id, title: n.title.fn(), hash: hash} }) } } if ok, hash := n.description.shouldUpdate(); ok { n.description.bindingsHash = hash if !n.description.loadFromCache() { n.description.loading = true cmds = append(cmds, func() tea.Msg { return updateDescriptionMsg{id: n.id, description: n.description.fn(), hash: hash} }) } } return n, tea.Batch(cmds...) case updateTitleMsg: if msg.id == n.id && msg.hash == n.title.bindingsHash { n.title.update(msg.title) } case updateDescriptionMsg: if msg.id == n.id && msg.hash == n.description.bindingsHash { n.description.update(msg.description) } case tea.KeyPressMsg: switch { case key.Matches(msg, n.keymap.Prev): return n, PrevField case key.Matches(msg, n.keymap.Next, n.keymap.Submit): return n, NextField } return n, NextField } return n, nil } func (n *Note) activeStyles() *FieldStyles { theme := n.theme if theme == nil { theme = ThemeFunc(ThemeCharm) } if n.focused { return &theme.Theme(n.hasDarkBg).Focused } return &theme.Theme(n.hasDarkBg).Blurred } // View renders the note field. func (n *Note) View() string { styles := n.activeStyles() maxWidth := n.width - styles.Card.GetHorizontalFrameSize() sb := strings.Builder{} if n.title.val != "" || n.title.fn != nil { sb.WriteString(styles.NoteTitle.Render(wrap(n.title.val, maxWidth))) } if n.description.val != "" || n.description.fn != nil { sb.WriteRune('\n') sb.WriteString(wrap(render(n.description.val), maxWidth)) sb.WriteRune('\n') } if n.showNextButton { sb.WriteRune('\n') sb.WriteString(styles.Next.Render(n.nextLabel)) } return styles.Card. Height(n.height). Width(n.width). Render(sb.String()) } // Run runs the note field. func (n *Note) Run() error { return Run(n) } // RunAccessible runs an accessible note field. func (n *Note) RunAccessible(w io.Writer, _ io.Reader) error { styles := n.activeStyles() if n.title.val != "" { _, _ = fmt.Fprintln(w, styles.Title.Render(n.title.val)) } if n.description.val != "" { _, _ = fmt.Fprintln(w, n.description.val) } return nil } // WithTheme sets the theme on a note field. func (n *Note) WithTheme(theme Theme) Field { if n.theme != nil { return n } n.theme = theme return n } // WithKeyMap sets the keymap on a note field. func (n *Note) WithKeyMap(k *KeyMap) Field { n.keymap = k.Note return n } // WithWidth sets the width of the note field. func (n *Note) WithWidth(width int) Field { n.width = width return n } // WithHeight sets the height of the note field. func (n *Note) WithHeight(height int) Field { n.Height(height) return n } // WithPosition sets the position information of the note field. func (n *Note) WithPosition(p FieldPosition) Field { // if the note is the only field on the screen, // we shouldn't skip the entire group. if p.Field == p.FirstField && p.Field == p.LastField { n.skip = false } n.keymap.Prev.SetEnabled(!p.IsFirst()) n.keymap.Next.SetEnabled(!p.IsLast()) n.keymap.Submit.SetEnabled(p.IsLast()) return n } // GetValue satisfies the Field interface, notes do not have values. func (n *Note) GetValue() any { return nil } // GetKey satisfies the Field interface, notes do not have keys. func (n *Note) GetKey() string { return "" } func render(input string) string { var result strings.Builder var italic, bold, codeblock bool var escape bool for _, char := range input { if escape || codeblock { result.WriteRune(char) escape = false continue } switch char { case '\\': escape = true case '_': if !italic { result.WriteString("\033[3m") italic = true } else { result.WriteString("\033[23m") italic = false } case '*': if !bold { result.WriteString("\033[1m") bold = true } else { result.WriteString("\033[22m") bold = false } case '`': if !codeblock { result.WriteString("\033[0;37;40m") result.WriteString(" ") codeblock = true } else { result.WriteString(" ") result.WriteString("\033[0m") codeblock = false if bold { result.WriteString("\033[1m") } if italic { result.WriteString("\033[3m") } } default: result.WriteRune(char) } } // Reset any open formatting result.WriteString("\033[0m") return result.String() } ================================================ FILE: field_select.go ================================================ package huh import ( "cmp" "fmt" "io" "strings" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/accessibility" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/exp/ordered" ) const ( minHeight = 1 defaultHeight = 10 ) // Select is a select field. // // A select field is a field that allows the user to select from a list of // options. The options can be provided statically or dynamically using Options // or OptionsFunc. The options can be filtered using "/" and navigation is done // using j/k, up/down, or ctrl+n/ctrl+p keys. type Select[T comparable] struct { id int accessor Accessor[T] key string viewport viewport.Model title Eval[string] description Eval[string] options Eval[[]Option[T]] filteredOptions []Option[T] validate func(T) error err error selected int focused bool filtering bool filter textinput.Model spinner spinner.Model inline bool width int height int theme Theme hasDarkBg bool keymap SelectKeyMap } // NewSelect creates a new select field. // // A select field is a field that allows the user to select from a list of // options. The options can be provided statically or dynamically using Options // or OptionsFunc. The options can be filtered using "/" and navigation is done // using j/k, up/down, or ctrl+n/ctrl+p keys. func NewSelect[T comparable]() *Select[T] { filter := textinput.New() filter.Prompt = "/" s := spinner.New(spinner.WithSpinner(spinner.Line)) return &Select[T]{ accessor: &EmbeddedAccessor[T]{}, validate: func(T) error { return nil }, filtering: false, filter: filter, id: nextID(), options: Eval[[]Option[T]]{cache: make(map[uint64][]Option[T])}, title: Eval[string]{cache: make(map[uint64]string)}, description: Eval[string]{cache: make(map[uint64]string)}, spinner: s, } } // Value sets the value of the select field. func (s *Select[T]) Value(value *T) *Select[T] { return s.Accessor(NewPointerAccessor(value)) } // Accessor sets the accessor of the select field. func (s *Select[T]) Accessor(accessor Accessor[T]) *Select[T] { s.accessor = accessor s.selectValue(s.accessor.Get()) s.updateValue() return s } func (s *Select[T]) selectValue(value T) { for i, o := range s.options.val { if o.Value == value { s.selected = i break } } } // Key sets the key of the select field which can be used to retrieve the value // after submission. func (s *Select[T]) Key(key string) *Select[T] { s.key = key return s } // Title sets the title of the select field. // // This title will be static, for dynamic titles use `TitleFunc`. func (s *Select[T]) Title(title string) *Select[T] { s.title.val = title s.title.fn = nil return s } // TitleFunc sets the title func of the select field. // // This TitleFunc will be re-evaluated when the binding of the TitleFunc // changes. This when you want to display dynamic content and update the title // when another part of your form changes. // // See README#Dynamic for more usage information. func (s *Select[T]) TitleFunc(f func() string, bindings any) *Select[T] { s.title.fn = f s.title.bindings = bindings return s } // Filtering sets the filtering state of the select field. func (s *Select[T]) Filtering(filtering bool) *Select[T] { s.filtering = filtering s.filter.Focus() return s } // Description sets the description of the select field. // // This description will be static, for dynamic descriptions use `DescriptionFunc`. func (s *Select[T]) Description(description string) *Select[T] { s.description.val = description return s } // DescriptionFunc sets the description func of the select field. // // This DescriptionFunc will be re-evaluated when the binding of the // DescriptionFunc changes. This is useful when you want to display dynamic // content and update the description when another part of your form changes. // // See README#Dynamic for more usage information. func (s *Select[T]) DescriptionFunc(f func() string, bindings any) *Select[T] { s.description.fn = f s.description.bindings = bindings return s } // Options sets the options of the select field. // // This is what your user will select from. // // Title // Description // // -> Option 1 // Option 2 // Option 3 // // These options will be static, for dynamic options use `OptionsFunc`. func (s *Select[T]) Options(options ...Option[T]) *Select[T] { if len(options) <= 0 { return s } s.options.val = options s.filteredOptions = options s.selectOption() s.updateViewportSize() s.updateValue() return s } func (s *Select[T]) selectOption() { // Set the cursor to the existing value or the last selected option. for i, option := range s.options.val { if option.Value == s.accessor.Get() { s.selected = i break } if option.selected { s.selected = i break } } s.ensureCursorVisible() } // OptionsFunc sets the options func of the select field. // // This OptionsFunc will be re-evaluated when the binding of the OptionsFunc // changes. This is useful when you want to display dynamic content and update // the options when another part of your form changes. // // For example, changing the state / provinces, based on the selected country. // // huh.NewSelect[string](). // Options(huh.NewOptions("United States", "Canada", "Mexico")...). // Value(&country). // Title("Country"). // Height(5), // // huh.NewSelect[string](). // Title("State / Province"). // This can also be made dynamic with `TitleFunc`. // OptionsFunc(func() []huh.Option[string] { // s := states[country] // time.Sleep(1000 * time.Millisecond) // return huh.NewOptions(s...) // }, &country), // // See examples/dynamic/dynamic-country/main.go for the full example. func (s *Select[T]) OptionsFunc(f func() []Option[T], bindings any) *Select[T] { s.options.fn = f s.options.bindings = bindings // If there is no height set, we should attach a static height since these // options are possibly dynamic. if s.height <= 0 { s.height = defaultHeight s.updateViewportSize() } return s } // Inline sets whether the select input should be inline. func (s *Select[T]) Inline(v bool) *Select[T] { s.inline = v if v { s.Height(1) } s.keymap.Left.SetEnabled(v) s.keymap.Right.SetEnabled(v) s.keymap.Up.SetEnabled(!v) s.keymap.Down.SetEnabled(!v) return s } // Height sets the height of the select field. If the number of options exceeds // the height, the select field will become scrollable. func (s *Select[T]) Height(height int) *Select[T] { s.height = height s.updateViewportSize() return s } // Validate sets the validation function of the select field. func (s *Select[T]) Validate(validate func(T) error) *Select[T] { s.validate = validate return s } // Error returns the error of the select field. func (s *Select[T]) Error() error { return s.err } // Skip returns whether the select should be skipped or should be blocking. func (*Select[T]) Skip() bool { return false } // Zoom returns whether the input should be zoomed. func (*Select[T]) Zoom() bool { return false } // Focus focuses the select field. func (s *Select[T]) Focus() tea.Cmd { s.focused = true return nil } // Blur blurs the select field. func (s *Select[T]) Blur() tea.Cmd { value := s.accessor.Get() if s.inline { s.clearFilter() s.selectValue(value) } s.focused = false s.err = s.validate(value) return nil } // Hovered returns the value of the option under the cursor, and a bool // indicating whether one was found. If there are no visible options, returns // a zero-valued T and false. func (s *Select[T]) Hovered() (T, bool) { if len(s.filteredOptions) == 0 || s.selected >= len(s.filteredOptions) { var zero T return zero, false } return s.filteredOptions[s.selected].Value, true } // KeyBinds returns the help keybindings for the select field. func (s *Select[T]) KeyBinds() []key.Binding { return []key.Binding{ s.keymap.Up, s.keymap.Down, s.keymap.Left, s.keymap.Right, s.keymap.Filter, s.keymap.SetFilter, s.keymap.ClearFilter, s.keymap.Prev, s.keymap.Next, s.keymap.Submit, } } // Init initializes the select field. func (s *Select[T]) Init() tea.Cmd { return nil } // Update updates the select field. func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) { s.updateViewportSize() var cmd tea.Cmd if s.filtering { s.filter, cmd = s.filter.Update(msg) } switch msg := msg.(type) { case tea.BackgroundColorMsg: s.hasDarkBg = msg.IsDark() case updateFieldMsg: var cmds []tea.Cmd if ok, hash := s.title.shouldUpdate(); ok { s.title.bindingsHash = hash if !s.title.loadFromCache() { s.title.loading = true cmds = append(cmds, func() tea.Msg { return updateTitleMsg{id: s.id, title: s.title.fn(), hash: hash} }) } } if ok, hash := s.description.shouldUpdate(); ok { s.description.bindingsHash = hash if !s.description.loadFromCache() { s.description.loading = true cmds = append(cmds, func() tea.Msg { return updateDescriptionMsg{id: s.id, description: s.description.fn(), hash: hash} }) } } if ok, hash := s.options.shouldUpdate(); ok { s.clearFilter() s.options.bindingsHash = hash if s.options.loadFromCache() { s.filteredOptions = s.options.val s.selected = ordered.Clamp(s.selected, 0, len(s.options.val)-1) } else { s.options.loading = true s.options.loadingStart = time.Now() cmds = append(cmds, func() tea.Msg { return updateOptionsMsg[T]{id: s.id, hash: hash, options: s.options.fn()} }, s.spinner.Tick) } } return s, tea.Batch(cmds...) case spinner.TickMsg: if !s.options.loading { break } s.spinner, cmd = s.spinner.Update(msg) return s, cmd case updateTitleMsg: if msg.id == s.id && msg.hash == s.title.bindingsHash { s.title.update(msg.title) } case updateDescriptionMsg: if msg.id == s.id && msg.hash == s.description.bindingsHash { s.description.update(msg.description) } case updateOptionsMsg[T]: if msg.id == s.id && msg.hash == s.options.bindingsHash { s.options.update(msg.options) s.selectOption() // since we're updating the options, we need to update the selected // cursor position and filteredOptions. s.selected = ordered.Clamp(s.selected, 0, len(msg.options)-1) s.filteredOptions = msg.options s.updateValue() } case tea.KeyPressMsg: s.err = nil switch { case key.Matches(msg, s.keymap.Filter): s.setFiltering(true) return s, s.filter.Focus() case key.Matches(msg, s.keymap.SetFilter): if len(s.filteredOptions) <= 0 { s.filter.SetValue("") s.filteredOptions = s.options.val } s.setFiltering(false) case key.Matches(msg, s.keymap.ClearFilter): s.clearFilter() case key.Matches(msg, s.keymap.Up, s.keymap.Left): // When filtering we should ignore j/k keybindings // // XXX: Currently, the below check doesn't account for keymap // changes. When making this fix it's worth considering ignoring // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 // may not be enough when international keyboards are considered. if s.filtering && (msg.String() == "k" || msg.String() == "h") { break } s.selected = s.selected - 1 if s.selected < 0 { s.selected = len(s.filteredOptions) - 1 s.viewport.GotoBottom() } else { s.ensureCursorVisible() } s.updateValue() case key.Matches(msg, s.keymap.GotoTop): if s.filtering { break } s.selected = 0 s.viewport.GotoTop() s.updateValue() case key.Matches(msg, s.keymap.GotoBottom): if s.filtering { break } s.selected = len(s.filteredOptions) - 1 s.viewport.GotoBottom() case key.Matches(msg, s.keymap.HalfPageUp): s.selected = max(s.selected-s.viewport.Height()/2, 0) s.ensureCursorVisible() s.updateValue() case key.Matches(msg, s.keymap.HalfPageDown): s.selected = min(s.selected+s.viewport.Height()/2, len(s.filteredOptions)-1) s.ensureCursorVisible() s.updateValue() case key.Matches(msg, s.keymap.Down, s.keymap.Right): // When filtering we should ignore j/k keybindings // // XXX: See note in the previous case match. if s.filtering && (msg.String() == "j" || msg.String() == "l") { break } s.selected = s.selected + 1 if s.selected > len(s.filteredOptions)-1 { s.selected = 0 s.viewport.GotoTop() } else { s.ensureCursorVisible() } s.updateValue() case key.Matches(msg, s.keymap.Prev): if s.selected >= len(s.filteredOptions) { break } s.updateValue() s.err = s.validate(s.accessor.Get()) if s.err != nil { return s, nil } s.updateValue() return s, PrevField case key.Matches(msg, s.keymap.Next, s.keymap.Submit): if s.selected >= len(s.filteredOptions) { break } s.setFiltering(false) s.updateValue() s.err = s.validate(s.accessor.Get()) if s.err != nil { return s, nil } s.updateValue() return s, NextField } if s.filtering { s.filteredOptions = s.options.val if s.filter.Value() != "" { s.filteredOptions = nil for _, option := range s.options.val { if s.filterFunc(option.Key) { s.filteredOptions = append(s.filteredOptions, option) } } } if len(s.filteredOptions) > 0 { s.selected = min(s.selected, len(s.filteredOptions)-1) } } s.ensureCursorVisible() } return s, cmd } func (s *Select[T]) updateValue() { if s.selected < len(s.filteredOptions) && s.selected >= 0 { s.accessor.Set(s.filteredOptions[s.selected].Value) } } // updateViewportSize updates the viewport size according to the Height setting // on this select field. func (s *Select[T]) updateViewportSize() { if s.height > 0 { yoffset := 0 if ss := s.titleView(); ss != "" { yoffset += lipgloss.Height(ss) } if ss := s.descriptionView(); ss != "" { yoffset += lipgloss.Height(ss) } s.viewport.SetHeight(max(minHeight, s.height-yoffset)) s.ensureCursorVisible() } else { // If no height is set size the viewport to the number of options. v, _, _ := s.optionsView() s.viewport.SetHeight(lipgloss.Height(v)) } if s.width > 0 { s.viewport.SetWidth(s.width) } else { v, _, _ := s.optionsView() s.viewport.SetWidth(lipgloss.Width(v)) } } func (s *Select[T]) activeStyles() *FieldStyles { theme := s.theme if theme == nil { theme = ThemeFunc(ThemeCharm) } if s.focused { return &theme.Theme(s.hasDarkBg).Focused } return &theme.Theme(s.hasDarkBg).Blurred } func (s *Select[T]) titleView() string { var ( styles = s.activeStyles() sb = strings.Builder{} maxWidth = s.width - styles.Base.GetHorizontalFrameSize() ) if s.filtering { sb.WriteString(s.filter.View()) } else if s.filter.Value() != "" && !s.inline { sb.WriteString(styles.Description.Render("/" + s.filter.Value())) } else { sb.WriteString(styles.Title.Render(wrap(s.title.val, maxWidth))) } if s.err != nil { sb.WriteString(styles.ErrorIndicator.String()) } return sb.String() } func (s *Select[T]) descriptionView() string { if s.description.val == "" { return "" } maxWidth := s.width - s.activeStyles().Base.GetHorizontalFrameSize() return s.activeStyles().Description.Render(wrap(s.description.val, maxWidth)) } func (s *Select[T]) optionsView() (string, int, int) { var ( styles = s.activeStyles() sb strings.Builder ) if s.options.loading && time.Since(s.options.loadingStart) > spinnerShowThreshold { s.spinner.Style = s.activeStyles().MultiSelectSelector.UnsetString() sb.WriteString(s.spinner.View() + " Loading...") return sb.String(), -1, 1 } if s.inline { option := styles.TextInput.Placeholder.Render("No matches") if len(s.filteredOptions) > 0 { option = styles.SelectedOption.Render(s.filteredOptions[s.selected].Key) } return lipgloss.NewStyle(). Width(s.width). Render(lipgloss.JoinHorizontal( lipgloss.Left, styles.PrevIndicator.Faint(s.selected <= 0).String(), option, styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String(), )), -1, 1 } var cursorOffset int var cursorHeight int for i, option := range s.filteredOptions { selected := s.selected == i line := s.renderOption(option, selected) if i < s.selected { cursorOffset += lipgloss.Height(line) } if selected { cursorHeight = lipgloss.Height(line) } sb.WriteString(line) if i < len(s.options.val)-1 { sb.WriteString("\n") } } for i := len(s.filteredOptions); i < len(s.options.val)-1; i++ { sb.WriteString("\n") } return sb.String(), cursorOffset, cursorHeight } // cursorLineOffset computes the line offset and height (in lines) for the // currently selected option without rendering the full options string. func (s *Select[T]) cursorLineOffset() (offset int, height int) { for i, option := range s.filteredOptions { line := s.renderOption(option, s.selected == i) h := lipgloss.Height(line) if i < s.selected { offset += h } if i == s.selected { height = h return offset, height } } return offset, height } // ensureVisible scrolls a viewport the minimum amount so that the region // [offset, offset+height) is within the visible area. func ensureVisible(vp *viewport.Model, offset, height int) { if height <= 0 { return } yOff := vp.YOffset() vHeight := vp.Height() if offset < yOff { vp.ScrollUp(yOff - offset) } else if offset+height > yOff+vHeight { vp.ScrollDown(offset + height - yOff - vHeight) } } func (s *Select[T]) ensureCursorVisible() { offset, height := s.cursorLineOffset() ensureVisible(&s.viewport, offset, height) } func (s *Select[T]) renderOption(option Option[T], selected bool) string { var ( styles = s.activeStyles() cursor = styles.SelectSelector.String() cursorW = lipgloss.Width(cursor) maxWidth = s.width - s.activeStyles().Base.GetHorizontalFrameSize() - cursorW ) key := wrap(option.Key, maxWidth) if selected { return lipgloss.JoinHorizontal( lipgloss.Left, cursor, styles.SelectedOption.Render(key), ) } return lipgloss.JoinHorizontal( lipgloss.Left, strings.Repeat(" ", cursorW), styles.UnselectedOption.Render(key), ) } // View renders the select field. func (s *Select[T]) View() string { styles := s.activeStyles() vpc, _, _ := s.optionsView() s.viewport.SetContent(vpc) var parts []string if s.title.val != "" || s.title.fn != nil { parts = append(parts, s.titleView()) } if s.description.val != "" || s.description.fn != nil { parts = append(parts, s.descriptionView()) } parts = append(parts, s.viewport.View()) return styles.Base.Width(s.width).Height(s.height). Render(strings.Join(parts, "\n")) } // clearFilter clears the value of the filter. func (s *Select[T]) clearFilter() { s.filter.SetValue("") s.filteredOptions = s.options.val s.setFiltering(false) } // setFiltering sets the filter of the select field. func (s *Select[T]) setFiltering(filtering bool) { if s.inline && filtering { s.filter.SetWidth(lipgloss.Width(s.titleView()) - 1 - 1) } s.filtering = filtering s.keymap.SetFilter.SetEnabled(filtering) s.keymap.Filter.SetEnabled(!filtering) s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") } // filterFunc returns true if the option matches the filter. func (s *Select[T]) filterFunc(option string) bool { // XXX: remove diacritics or allow customization of filter function. return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) } // Run runs the select field. func (s *Select[T]) Run() error { return Run(s) } // RunAccessible runs an accessible select field. func (s *Select[T]) RunAccessible(w io.Writer, r io.Reader) error { styles := s.activeStyles() _, _ = fmt.Fprintln(w, styles.Title. PaddingRight(1). Render(cmp.Or(s.title.val, "Select:"))) for i, option := range s.options.val { _, _ = fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) } var defaultValue *int switch s.accessor.(type) { case *PointerAccessor[T]: // if its of this type, it means it has a default value s.selectOption() // make sure s.selected is set idx := s.selected + 1 defaultValue = &idx } prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options.val)) if len(s.options.val) == 1 { prompt = "There is only one option available; enter the number 1:" } for { choice := accessibility.PromptInt(w, r, prompt, 1, len(s.options.val), defaultValue) option := s.options.val[choice-1] if err := s.validate(option.Value); err != nil { _, _ = fmt.Fprintln(w, err.Error()) _, _ = fmt.Fprintln(w) continue } s.accessor.Set(option.Value) return nil } } // WithTheme sets the theme of the select field. func (s *Select[T]) WithTheme(theme Theme) Field { if s.theme != nil { return s } s.theme = theme styles := s.theme.Theme(s.hasDarkBg) st := s.filter.Styles() st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground() st.Focused.Prompt = styles.Focused.TextInput.Prompt st.Focused.Text = styles.Focused.TextInput.Text st.Focused.Placeholder = styles.Focused.TextInput.Placeholder s.filter.SetStyles(st) s.updateViewportSize() return s } // WithKeyMap sets the keymap on a select field. func (s *Select[T]) WithKeyMap(k *KeyMap) Field { s.keymap = k.Select s.keymap.Left.SetEnabled(s.inline) s.keymap.Right.SetEnabled(s.inline) s.keymap.Up.SetEnabled(!s.inline) s.keymap.Down.SetEnabled(!s.inline) return s } // WithWidth sets the width of the select field. func (s *Select[T]) WithWidth(width int) Field { s.width = width s.updateViewportSize() return s } // WithHeight sets the height of the select field. func (s *Select[T]) WithHeight(height int) Field { return s.Height(height) } // WithPosition sets the position of the select field. func (s *Select[T]) WithPosition(p FieldPosition) Field { if s.filtering { return s } s.keymap.Prev.SetEnabled(!p.IsFirst()) s.keymap.Next.SetEnabled(!p.IsLast()) s.keymap.Submit.SetEnabled(p.IsLast()) return s } // GetKey returns the key of the field. func (s *Select[T]) GetKey() string { return s.key } // GetValue returns the value of the field. func (s *Select[T]) GetValue() any { return s.accessor.Get() } // GetFiltering returns the filtering state of the field. func (s *Select[T]) GetFiltering() bool { return s.filtering } ================================================ FILE: field_text.go ================================================ package huh import ( "cmp" "context" "fmt" "io" "os" "os/exec" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/accessibility" "charm.land/lipgloss/v2" ) // Text is a text field. // // A text box is responsible for getting multi-line input from the user. Use // it to gather longer-form user input. The Text field can be filled with an // EDITOR. type Text struct { accessor Accessor[string] key string id int title Eval[string] description Eval[string] placeholder Eval[string] externalEditor bool editorCmd string editorArgs []string editorExtension string textarea textarea.Model focused bool validate func(string) error err error width int theme Theme hasDarkBg bool keymap TextKeyMap } // NewText creates a new text field. // // A text box is responsible for getting multi-line input from the user. Use // it to gather longer-form user input. The Text field can be filled with an // EDITOR. func NewText() *Text { text := textarea.New() text.ShowLineNumbers = false text.Prompt = "" st := text.Styles() st.Focused.CursorLine = lipgloss.NewStyle() text.SetStyles(st) editorCmd, editorArgs := getEditor() t := &Text{ accessor: &EmbeddedAccessor[string]{}, id: nextID(), textarea: text, validate: func(string) error { return nil }, externalEditor: true, editorCmd: editorCmd, editorArgs: editorArgs, editorExtension: "md", title: Eval[string]{cache: make(map[uint64]string)}, description: Eval[string]{cache: make(map[uint64]string)}, placeholder: Eval[string]{cache: make(map[uint64]string)}, } return t } // Value sets the value of the text field. func (t *Text) Value(value *string) *Text { return t.Accessor(NewPointerAccessor(value)) } // Accessor sets the accessor of the text field. func (t *Text) Accessor(accessor Accessor[string]) *Text { t.accessor = accessor t.textarea.SetValue(t.accessor.Get()) return t } // Key sets the key of the text field. func (t *Text) Key(key string) *Text { t.key = key return t } // Title sets the text field's title. // // This title will be static, for dynamic titles use `TitleFunc`. func (t *Text) Title(title string) *Text { t.title.val = title t.title.fn = nil return t } // TitleFunc sets the text field's title func. // // The TitleFunc will be re-evaluated when the binding of the TitleFunc changes. // This is useful when you want to display dynamic content and update the title // when another part of your form changes. // // See README#Dynamic for more usage information. func (t *Text) TitleFunc(f func() string, bindings any) *Text { t.title.fn = f t.title.bindings = bindings return t } // Description sets the description of the text field. // // This description will be static, for dynamic description use `DescriptionFunc`. func (t *Text) Description(description string) *Text { t.description.val = description t.description.fn = nil return t } // DescriptionFunc sets the description func of the text field. // // The DescriptionFunc will be re-evaluated when the binding of the // DescriptionFunc changes. This is useful when you want to display dynamic // content and update the description when another part of your form changes. // // See README#Dynamic for more usage information. func (t *Text) DescriptionFunc(f func() string, bindings any) *Text { t.description.fn = f t.description.bindings = bindings return t } // Lines sets the number of lines to show of the text field. func (t *Text) Lines(lines int) *Text { t.textarea.SetHeight(lines) return t } // CharLimit sets the character limit of the text field. func (t *Text) CharLimit(charlimit int) *Text { t.textarea.CharLimit = charlimit return t } // ShowLineNumbers sets whether or not to show line numbers. func (t *Text) ShowLineNumbers(show bool) *Text { t.textarea.ShowLineNumbers = show return t } // Placeholder sets the placeholder of the text field. // // This placeholder will be static, for dynamic placeholders use `PlaceholderFunc`. func (t *Text) Placeholder(str string) *Text { t.textarea.Placeholder = str return t } // PlaceholderFunc sets the placeholder func of the text field. // // The PlaceholderFunc will be re-evaluated when the binding of the // PlaceholderFunc changes. This is useful when you want to display dynamic // content and update the placeholder when another part of your form changes. // // See README#Dynamic for more usage information. func (t *Text) PlaceholderFunc(f func() string, bindings any) *Text { t.placeholder.fn = f t.placeholder.bindings = bindings return t } // Validate sets the validation function of the text field. func (t *Text) Validate(validate func(string) error) *Text { t.validate = validate return t } // ExternalEditor sets whether option to launch an editor is available. func (t *Text) ExternalEditor(enabled bool) *Text { t.externalEditor = enabled return t } const defaultEditor = "nano" // getEditor returns the editor command and arguments. func getEditor() (string, []string) { editor := strings.Fields(os.Getenv("EDITOR")) if len(editor) > 0 { return editor[0], editor[1:] } return defaultEditor, nil } // Editor specifies which editor to use. // // The first argument provided is used as the editor command (vim, nvim, nano, etc...) // The following (optional) arguments provided are passed as arguments to the editor command. func (t *Text) Editor(editor ...string) *Text { if len(editor) > 0 { t.editorCmd = editor[0] } if len(editor) > 1 { t.editorArgs = editor[1:] } return t } // EditorExtension specifies arguments to pass into the editor. func (t *Text) EditorExtension(extension string) *Text { t.editorExtension = extension return t } // Error returns the error of the text field. func (t *Text) Error() error { return t.err } // Skip returns whether the textarea should be skipped or should be blocking. func (*Text) Skip() bool { return false } // Zoom returns whether the note should be zoomed. func (*Text) Zoom() bool { return false } // Focus focuses the text field. func (t *Text) Focus() tea.Cmd { t.focused = true return t.textarea.Focus() } // Blur blurs the text field. func (t *Text) Blur() tea.Cmd { t.focused = false t.accessor.Set(t.textarea.Value()) t.textarea.Blur() t.err = t.validate(t.accessor.Get()) return nil } // KeyBinds returns the help message for the text field. func (t *Text) KeyBinds() []key.Binding { t.keymap.Editor.SetEnabled(t.externalEditor) return []key.Binding{t.keymap.NewLine, t.keymap.Editor, t.keymap.Prev, t.keymap.Submit, t.keymap.Next} } type updateValueMsg []byte // Init initializes the text field. func (t *Text) Init() tea.Cmd { t.textarea.Blur() return nil } // Update updates the text field. func (t *Text) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd switch msg := msg.(type) { case tea.BackgroundColorMsg: t.hasDarkBg = msg.IsDark() case updateValueMsg: t.textarea.SetValue(string(msg)) t.textarea, cmd = t.textarea.Update(msg) cmds = append(cmds, cmd) t.accessor.Set(t.textarea.Value()) case updateFieldMsg: var cmds []tea.Cmd if ok, hash := t.placeholder.shouldUpdate(); ok { t.placeholder.bindingsHash = hash if t.placeholder.loadFromCache() { t.textarea.Placeholder = t.placeholder.val } else { t.placeholder.loading = true cmds = append(cmds, func() tea.Msg { return updatePlaceholderMsg{id: t.id, placeholder: t.placeholder.fn(), hash: hash} }) } } if ok, hash := t.title.shouldUpdate(); ok { t.title.bindingsHash = hash if !t.title.loadFromCache() { cmds = append(cmds, func() tea.Msg { return updateTitleMsg{id: t.id, title: t.title.fn(), hash: hash} }) } } if ok, hash := t.description.shouldUpdate(); ok { t.description.bindingsHash = hash if !t.description.loadFromCache() { t.description.loading = true cmds = append(cmds, func() tea.Msg { return updateDescriptionMsg{id: t.id, description: t.description.fn(), hash: hash} }) } } return t, tea.Batch(cmds...) case updatePlaceholderMsg: if t.id == msg.id && t.placeholder.bindingsHash == msg.hash { t.placeholder.update(msg.placeholder) t.textarea.Placeholder = msg.placeholder } case updateTitleMsg: if t.id == msg.id && t.title.bindingsHash == msg.hash { t.title.update(msg.title) } case updateDescriptionMsg: if t.id == msg.id && t.description.bindingsHash == msg.hash { t.description.update(msg.description) } case tea.KeyPressMsg: t.err = nil switch { case key.Matches(msg, t.keymap.Editor): ext := strings.TrimPrefix(t.editorExtension, ".") tmpFile, _ := os.CreateTemp(os.TempDir(), "*."+ext) //nolint:gosec cmd := exec.CommandContext( context.TODO(), t.editorCmd, append(t.editorArgs, tmpFile.Name())..., ) _ = os.WriteFile(tmpFile.Name(), []byte(t.textarea.Value()), 0o644) //nolint:mnd,gosec cmds = append(cmds, tea.ExecProcess(cmd, func(error) tea.Msg { content, _ := os.ReadFile(tmpFile.Name()) _ = os.Remove(tmpFile.Name()) return updateValueMsg(content) })) case key.Matches(msg, t.keymap.Next, t.keymap.Submit): value := t.textarea.Value() t.err = t.validate(value) if t.err != nil { return t, nil } cmds = append(cmds, NextField) case key.Matches(msg, t.keymap.Prev): value := t.textarea.Value() t.err = t.validate(value) if t.err != nil { return t, nil } cmds = append(cmds, PrevField) } } t.textarea, cmd = t.textarea.Update(msg) cmds = append(cmds, cmd) t.accessor.Set(t.textarea.Value()) return t, tea.Batch(cmds...) } func (t *Text) activeStyles() *FieldStyles { theme := t.theme if theme == nil { theme = ThemeFunc(ThemeCharm) } if t.focused { return &theme.Theme(t.hasDarkBg).Focused } return &theme.Theme(t.hasDarkBg).Blurred } // View renders the text field. func (t *Text) View() string { styles := t.activeStyles() st := t.textarea.Styles() if t.focused { st.Focused.Placeholder = styles.TextInput.Placeholder st.Focused.Text = styles.TextInput.Text st.Focused.Prompt = styles.TextInput.Prompt st.Focused.CursorLine = styles.TextInput.Text } else { st.Blurred.Placeholder = styles.TextInput.Placeholder st.Blurred.Text = styles.TextInput.Text st.Blurred.Prompt = styles.TextInput.Prompt st.Blurred.CursorLine = styles.TextInput.Text } st.Cursor.Color = styles.TextInput.Cursor.GetBackground() t.textarea.SetStyles(st) maxWidth := t.width - styles.Base.GetHorizontalFrameSize() var parts []string if t.title.val != "" || t.title.fn != nil { header := styles.Title.Render(wrap(t.title.val, maxWidth)) if t.err != nil { header += styles.ErrorIndicator.String() } parts = append(parts, header) } if t.description.val != "" || t.description.fn != nil { parts = append(parts, styles.Description.Render(wrap(t.description.val, maxWidth))) } parts = append(parts, t.textarea.View()) return styles.Base. Render(strings.Join(parts, "\n")) } // Run runs the text field. func (t *Text) Run() error { return Run(t) } // RunAccessible runs an accessible text field. func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { styles := t.activeStyles() prompt := styles.Title. PaddingRight(1). Render(cmp.Or(t.title.val, "Input:")) t.accessor.Set(accessibility.PromptString( w, r, prompt, t.GetValue().(string), func(input string) error { if err := t.validate(input); err != nil { // Handle the error from t.validate, return it return err } if t.textarea.CharLimit > 0 && len(input) > t.textarea.CharLimit { return fmt.Errorf("Input cannot exceed %d characters", t.textarea.CharLimit) } return nil }, )) return nil } // WithTheme sets the theme on a text field. func (t *Text) WithTheme(theme Theme) Field { if t.theme != nil { return t } t.theme = theme return t } // WithKeyMap sets the keymap on a text field. func (t *Text) WithKeyMap(k *KeyMap) Field { t.keymap = k.Text t.textarea.KeyMap.InsertNewline.SetKeys(t.keymap.NewLine.Keys()...) return t } // WithWidth sets the width of the text field. func (t *Text) WithWidth(width int) Field { t.width = width t.textarea.SetWidth(width - t.activeStyles().Base.GetHorizontalFrameSize()) return t } // WithHeight sets the height of the text field. func (t *Text) WithHeight(height int) Field { adjust := 0 if t.title.val != "" { adjust++ } if t.description.val != "" { adjust++ } t.textarea.SetHeight(height - t.activeStyles().Base.GetVerticalFrameSize() - adjust) return t } // WithPosition sets the position information of the text field. func (t *Text) WithPosition(p FieldPosition) Field { t.keymap.Prev.SetEnabled(!p.IsFirst()) t.keymap.Next.SetEnabled(!p.IsLast()) t.keymap.Submit.SetEnabled(p.IsLast()) return t } // GetKey returns the key of the field. func (t *Text) GetKey() string { return t.key } // GetValue returns the value of the field. func (t *Text) GetValue() any { return t.accessor.Get() } ================================================ FILE: form.go ================================================ package huh import ( "cmp" "context" "errors" "fmt" "io" "os" "sync" "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/compat" "charm.land/huh/v2/internal/selector" ) const defaultWidth = 80 // Internal ID management. Used during animating to ensure that frame messages // are received only by spinner components that sent them. var ( lastID int idMtx sync.Mutex ) // Return the next ID we should use on the Model. func nextID() int { idMtx.Lock() defer idMtx.Unlock() lastID++ return lastID } // Model is an alias to [compat.Model]. type Model = compat.Model // FormState represents the current state of the form. type FormState int const ( // StateNormal is when the user is completing the form. StateNormal FormState = iota // StateCompleted is when the user has completed the form. StateCompleted // StateAborted is when the user has aborted the form. StateAborted ) // ErrUserAborted is the error returned when a user exits the form before submitting. var ErrUserAborted = errors.New("user aborted") // ErrTimeout is the error returned when the timeout is reached. var ErrTimeout = errors.New("timeout") // ErrTimeoutUnsupported is the error returned when timeout is used while in accessible mode. var ErrTimeoutUnsupported = errors.New("timeout is not supported in accessible mode") // Form is a collection of groups that are displayed one at a time on a "page". // // The form can navigate between groups and is complete once all the groups are // complete. type Form struct { // collection of groups selector *selector.Selector[*Group] results map[string]any // callbacks SubmitCmd tea.Cmd CancelCmd tea.Cmd State FormState // whether or not to use bubble tea rendering for accessibility // purposes, if true, the form will render with basic prompting primitives // to be more accessible to screen readers. accessible bool quitting bool aborted bool // options width int height int theme Theme hasDarkBg bool keymap *KeyMap timeout time.Duration teaOptions []tea.ProgramOption viewHook compat.ViewHook layout Layout // accessible mode IO output io.Writer input io.Reader } // NewForm returns a form with the given groups and default themes and // keybindings. // // Use With* methods to customize the form with options, such as setting // different themes and keybindings. func NewForm(groups ...*Group) *Form { selector := selector.NewSelector(groups) f := &Form{ selector: selector, keymap: NewDefaultKeyMap(), results: make(map[string]any), layout: LayoutDefault, teaOptions: []tea.ProgramOption{ tea.WithOutput(os.Stderr), }, } // NB: If dynamic forms come into play this will need to be applied when // groups and fields are added. f.WithKeyMap(f.keymap) f.WithWidth(f.width) f.WithHeight(f.height) f.UpdateFieldPositions() if os.Getenv("TERM") == "dumb" { f.WithWidth(defaultWidth) f.WithAccessible(true) } return f } // Field is a primitive of a form. // // A field represents a single input control on a form such as a text input, // confirm button, select option, etc... // // Each field implements the Bubble Tea Model interface. type Field interface { // Bubble Tea Model Model // Bubble Tea Events Blur() tea.Cmd Focus() tea.Cmd // Errors and Validation Error() error // Run runs the field individually. Run() error // RunAccessible runs the field in accessible mode with the given IO. RunAccessible(w io.Writer, r io.Reader) error // Skip returns whether this input should be skipped or not. Skip() bool // Zoom returns whether this input should be zoomed or not. // Zoom allows the field to take focus of the group / form height. Zoom() bool // KeyBinds returns help keybindings. KeyBinds() []key.Binding // WithTheme sets the theme on a field. WithTheme(Theme) Field // WithKeyMap sets the keymap on a field. WithKeyMap(*KeyMap) Field // WithWidth sets the width of a field. WithWidth(int) Field // WithHeight sets the height of a field. WithHeight(int) Field // WithPosition tells the field the index of the group and position it is in. WithPosition(FieldPosition) Field // GetKey returns the field's key. GetKey() string // GetValue returns the field's value. GetValue() any } // FieldPosition is positional information about the given field and form. type FieldPosition struct { Group int Field int FirstField int LastField int GroupCount int FirstGroup int LastGroup int } // IsFirst returns whether a field is the form's first field. func (p FieldPosition) IsFirst() bool { return p.Field == p.FirstField && p.Group == p.FirstGroup } // IsLast returns whether a field is the form's last field. func (p FieldPosition) IsLast() bool { return p.Field == p.LastField && p.Group == p.LastGroup } // nextGroupMsg is a message to move to the next group. type nextGroupMsg struct{} // prevGroupMsg is a message to move to the previous group. type prevGroupMsg struct{} // nextGroup is the command to move to the next group. func nextGroup() tea.Msg { return nextGroupMsg{} } // prevGroup is the command to move to the previous group. func prevGroup() tea.Msg { return prevGroupMsg{} } // WithAccessible sets the form to run in accessible mode to avoid redrawing the // views which makes it easier for screen readers to read and describe the form. // // This avoids using the Bubble Tea renderer and instead simply uses basic // terminal prompting to gather input which degrades the user experience but // provides accessibility. func (f *Form) WithAccessible(accessible bool) *Form { f.accessible = accessible return f } // WithShowHelp sets whether or not the form should show help. // // This allows the form groups and field to show what keybindings are available // to the user. func (f *Form) WithShowHelp(v bool) *Form { f.selector.Range(func(_ int, group *Group) bool { group.WithShowHelp(v) return true }) return f } // WithShowErrors sets whether or not the form should show errors. // // This allows the form groups and fields to show errors when the Validate // function returns an error. func (f *Form) WithShowErrors(v bool) *Form { f.selector.Range(func(_ int, group *Group) bool { group.WithShowErrors(v) return true }) return f } // WithTheme sets the theme on a form. // // This allows all groups and fields to be themed consistently, however themes // can be applied to each group and field individually for more granular // control. func (f *Form) WithTheme(theme Theme) *Form { if theme == nil { return f } f.theme = theme f.selector.Range(func(_ int, group *Group) bool { group.WithTheme(theme) return true }) return f } // WithKeyMap sets the keymap on a form. // // This allows customization of the form key bindings. func (f *Form) WithKeyMap(keymap *KeyMap) *Form { if keymap == nil { return f } f.keymap = keymap f.selector.Range(func(_ int, group *Group) bool { group.WithKeyMap(keymap) return true }) f.UpdateFieldPositions() return f } // WithWidth sets the width of a form. // // This allows all groups and fields to be sized consistently, however width // can be applied to each group and field individually for more granular // control. func (f *Form) WithWidth(width int) *Form { if width <= 0 { return f } f.width = width f.selector.Range(func(_ int, group *Group) bool { width := f.layout.GroupWidth(f, group, width) group.WithWidth(width) return true }) return f } // WithHeight sets the height of a form. func (f *Form) WithHeight(height int) *Form { if height <= 0 { return f } f.height = height f.selector.Range(func(_ int, group *Group) bool { group.WithHeight(height) return true }) return f } // WithOutput sets the io.Writer to output the form. // Default is STDOUT when [Form] is accessible (set with [Form.WithAccessible], STDERR otherwise. func (f *Form) WithOutput(w io.Writer) *Form { f.output = w f.teaOptions = append(f.teaOptions, tea.WithOutput(w)) return f } // WithInput sets the io.Reader to the input form. // Default is STDIN. func (f *Form) WithInput(r io.Reader) *Form { f.input = r f.teaOptions = append(f.teaOptions, tea.WithInput(r)) return f } // WithTimeout sets the duration for the form to be killed. func (f *Form) WithTimeout(t time.Duration) *Form { f.timeout = t return f } // WithProgramOptions sets the tea options of the form. func (f *Form) WithProgramOptions(opts ...tea.ProgramOption) *Form { f.teaOptions = opts return f } // WithViewHook allows to set a [compat.ViewHook]. func (f *Form) WithViewHook(hook compat.ViewHook) *Form { f.viewHook = hook return f } // WithLayout sets the layout on a form. // // This allows customization of the form group layout. func (f *Form) WithLayout(layout Layout) *Form { f.layout = layout return f } // UpdateFieldPositions sets the position on all the fields. func (f *Form) UpdateFieldPositions() *Form { firstGroup := 0 lastGroup := f.selector.Total() - 1 // determine the first non-hidden group. f.selector.Range(func(_ int, g *Group) bool { if !f.isGroupHidden(g) { return false } firstGroup++ return true }) // determine the last non-hidden group. f.selector.ReverseRange(func(_ int, g *Group) bool { if !f.isGroupHidden(g) { return false } lastGroup-- return true }) f.selector.Range(func(g int, group *Group) bool { // determine the first non-skippable field. var firstField int group.selector.Range(func(_ int, field Field) bool { if !field.Skip() || group.selector.Total() == 1 { return false } firstField++ return true }) // determine the last non-skippable field. var lastField int group.selector.ReverseRange(func(i int, field Field) bool { lastField = i if !field.Skip() || group.selector.Total() == 1 { return false } return true }) group.selector.Range(func(i int, field Field) bool { field.WithPosition(FieldPosition{ Group: g, Field: i, FirstField: firstField, LastField: lastField, FirstGroup: firstGroup, LastGroup: lastGroup, }) return true }) return true }) return f } // Errors returns the current groups' errors. func (f *Form) Errors() []error { return f.selector.Selected().Errors() } // Help returns the current groups' help. func (f *Form) Help() help.Model { return f.selector.Selected().help } // KeyBinds returns the current fields' keybinds. func (f *Form) KeyBinds() []key.Binding { group := f.selector.Selected() return group.selector.Selected().KeyBinds() } // Get returns a result from the form. func (f *Form) Get(key string) any { return f.results[key] } // GetString returns a result as a string from the form. func (f *Form) GetString(key string) string { v, ok := f.results[key].(string) if !ok { return "" } return v } // GetInt returns a result as a int from the form. func (f *Form) GetInt(key string) int { v, ok := f.results[key].(int) if !ok { return 0 } return v } // GetBool returns a result as a string from the form. func (f *Form) GetBool(key string) bool { v, ok := f.results[key].(bool) if !ok { return false } return v } // NextGroup moves the form to the next group. func (f *Form) NextGroup() tea.Cmd { _, cmd := f.Update(nextGroup()) return cmd } // PrevGroup moves the form to the next group. func (f *Form) PrevGroup() tea.Cmd { _, cmd := f.Update(prevGroup()) return cmd } // NextField moves the form to the next field. func (f *Form) NextField() tea.Cmd { _, cmd := f.Update(NextField()) return cmd } // PrevField moves the form to the next field. func (f *Form) PrevField() tea.Cmd { _, cmd := f.Update(PrevField()) return cmd } // GetFocusedField returns the focused form field. func (f *Form) GetFocusedField() Field { return f.selector.Selected().selector.Selected() } // Init initializes the form. func (f *Form) Init() tea.Cmd { var cmds []tea.Cmd f.selector.Range(func(i int, group *Group) bool { if i == 0 { group.active = true } cmds = append(cmds, group.Init()) return true }) if f.isGroupHidden(f.selector.Selected()) { cmds = append(cmds, nextGroup) } cmds = append(cmds, tea.RequestWindowSize) return tea.Sequence(cmds...) } // Update updates the form. func (f *Form) Update(msg tea.Msg) (Model, tea.Cmd) { // If the form is aborted or completed there's no need to update it. if f.State != StateNormal { return f, nil } group := f.selector.Selected() switch msg := msg.(type) { case tea.BackgroundColorMsg: f.hasDarkBg = msg.IsDark() case tea.WindowSizeMsg: if f.width == 0 { f.selector.Range(func(_ int, group *Group) bool { width := f.layout.GroupWidth(f, group, msg.Width) group.WithWidth(width) return true }) } if f.height == 0 { // calculate the needed height, which is the height of the // heightest group, accounting for the width, wraps, etc. neededHeight := 0 f.selector.Range(func(_ int, group *Group) bool { neededHeight = max(neededHeight, group.rawHeight()) return true }) f.selector.Range(func(_ int, group *Group) bool { group.WithHeight(min(neededHeight, msg.Height)) return true }) } case tea.KeyPressMsg: switch { case key.Matches(msg, f.keymap.Quit): f.aborted = true f.quitting = true f.State = StateAborted return f, f.CancelCmd } case nextFieldMsg: // Form is progressing to the next field, let's save the value of the current field. field := group.selector.Selected() f.results[field.GetKey()] = field.GetValue() case nextGroupMsg: if len(group.Errors()) > 0 { return f, nil } submit := func() (Model, tea.Cmd) { f.quitting = true f.State = StateCompleted return f, f.SubmitCmd } if f.selector.OnLast() { return submit() } for i := f.selector.Index() + 1; i < f.selector.Total(); i++ { if !f.isGroupHidden(f.selector.Get(i)) { f.selector.SetIndex(i) break } // all subsequent groups are hidden, so we must act as // if we were in the last one. if i == f.selector.Total()-1 { return submit() } } f.selector.Selected().active = true return f, f.selector.Selected().Init() case prevGroupMsg: if len(group.Errors()) > 0 { return f, nil } for i := f.selector.Index() - 1; i >= 0; i-- { if !f.isGroupHidden(f.selector.Get(i)) { f.selector.SetIndex(i) break } } f.selector.Selected().active = true return f, f.selector.Selected().Init() } m, cmd := group.Update(msg) f.selector.Set(f.selector.Index(), m.(*Group)) // A user input a key, this could hide or show other groups, // let's update all of their positions. switch msg.(type) { case tea.KeyPressMsg: f.UpdateFieldPositions() } return f, cmd } func (f *Form) isGroupHidden(group *Group) bool { hide := group.hide if hide == nil { return false } return hide() } func (f *Form) getTheme() *Styles { if f.theme != nil { return f.theme.Theme(f.hasDarkBg) } return ThemeCharm(f.hasDarkBg) } func (f *Form) styles() FormStyles { return f.getTheme().Form } // View renders the form. func (f *Form) View() string { if f.quitting { return "" } return f.styles().Base.Render(f.layout.View(f)) } // Run runs the form. func (f *Form) Run() error { return f.RunWithContext(context.Background()) } // RunWithContext runs the form with the given context. func (f *Form) RunWithContext(ctx context.Context) error { f.SubmitCmd = tea.Quit f.CancelCmd = tea.Interrupt if f.selector.Total() == 0 { return nil } if f.accessible { return f.runAccessible( cmp.Or[io.Writer](f.output, os.Stdout), cmp.Or[io.Reader](f.input, os.Stdin), ) } return f.run(ctx) } // run runs the form in normal mode. func (f *Form) run(ctx context.Context) error { var cancel context.CancelFunc if f.timeout > 0 { ctx, cancel = context.WithTimeout(ctx, f.timeout) defer cancel() } f.teaOptions = append(f.teaOptions, tea.WithContext(ctx)) m, err := tea.NewProgram( compat.ViewModel{ Model: f, ViewHook: func(v tea.View) tea.View { v.ReportFocus = true if f.viewHook == nil { return v } return f.viewHook(v) }, }, f.teaOptions..., ).Run() if m.(compat.ViewModel).Model.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { return ErrUserAborted } if errors.Is(err, tea.ErrProgramKilled) { return ErrTimeout } if err != nil { return fmt.Errorf("huh: %w", err) } return nil } // runAccessible runs the form in accessible mode. func (f *Form) runAccessible(w io.Writer, r io.Reader) error { // Timeouts are not supported in this mode. if f.timeout > 0 { return ErrTimeoutUnsupported } f.selector.Range(func(_ int, group *Group) bool { group.selector.Range(func(_ int, field Field) bool { field.Init() field.Focus() _ = field.RunAccessible(w, r) _, _ = fmt.Fprintln(w) return true }) return true }) return nil } ================================================ FILE: go.mod ================================================ module charm.land/huh/v2 go 1.25.8 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2 github.com/catppuccin/go v0.3.0 github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/strings v0.1.0 github.com/charmbracelet/x/term v0.2.2 github.com/charmbracelet/x/xpty v0.1.3 github.com/mitchellh/hashstructure/v2 v2.0.2 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/conpty v0.1.1 // indirect github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect ) ================================================ FILE: go.sum ================================================ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= ================================================ FILE: group.go ================================================ package huh import ( "strings" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/selector" "charm.land/lipgloss/v2" ) // Group is a collection of fields that are displayed together with a page of // the form. While a group is displayed the form completer can switch between // fields in the group. // // If any of the fields in a group have errors, the form will not be able to // progress to the next group. type Group struct { // collection of fields selector *selector.Selector[Field] // information title string description string // navigation viewport viewport.Model // help showHelp bool help help.Model // errors showErrors bool // group options width int height int theme Theme hasDarkBg bool keymap *KeyMap hide func() bool active bool } // NewGroup returns a new group with the given fields. func NewGroup(fields ...Field) *Group { selector := selector.NewSelector(fields) group := &Group{ selector: selector, help: help.New(), showHelp: true, showErrors: true, active: false, } group.width = 80 height := group.rawHeight() v := viewport.New( viewport.WithWidth(group.width), viewport.WithHeight(height), ) //nolint:mnd group.viewport = v group.height = height return group } // Title sets the group's title. func (g *Group) Title(title string) *Group { g.title = title return g } // Description sets the group's description. func (g *Group) Description(description string) *Group { g.description = description return g } // WithShowHelp sets whether or not the group's help should be shown. func (g *Group) WithShowHelp(show bool) *Group { g.showHelp = show return g } // WithShowErrors sets whether or not the group's errors should be shown. func (g *Group) WithShowErrors(show bool) *Group { g.showErrors = show return g } // WithTheme sets the theme on a group. func (g *Group) WithTheme(t Theme) *Group { g.theme = t g.help.Styles = t.Theme(g.hasDarkBg).Help g.selector.Range(func(_ int, field Field) bool { field.WithTheme(t) return true }) if g.height <= 0 { g.WithHeight(g.rawHeight()) } return g } // WithKeyMap sets the keymap on a group. func (g *Group) WithKeyMap(k *KeyMap) *Group { g.keymap = k g.selector.Range(func(_ int, field Field) bool { field.WithKeyMap(k) return true }) return g } // WithWidth sets the width on a group. func (g *Group) WithWidth(width int) *Group { g.width = width g.viewport.SetWidth(width) g.help.SetWidth(width) g.selector.Range(func(_ int, field Field) bool { field.WithWidth(width) return true }) return g } // WithHeight sets the height on a group. func (g *Group) WithHeight(height int) *Group { g.height = height h := height - g.titleFooterHeight() g.viewport.SetHeight(h) g.selector.Range(func(_ int, field Field) bool { // A field height must not exceed the form height. if h < lipgloss.Height(field.View()) { field.WithHeight(h) } return true }) return g } // WithHide sets whether this group should be skipped. func (g *Group) WithHide(hide bool) *Group { g.WithHideFunc(func() bool { return hide }) return g } // WithHideFunc sets the function that checks if this group should be skipped. func (g *Group) WithHideFunc(hideFunc func() bool) *Group { g.hide = hideFunc return g } // Errors returns the groups' fields' errors. func (g *Group) Errors() []error { var errs []error g.selector.Range(func(_ int, field Field) bool { if err := field.Error(); err != nil { errs = append(errs, err) } return true }) return errs } // updateFieldMsg is a message to update the fields of a group that is currently // displayed. // // This is used to update all TitleFunc, DescriptionFunc, and ...Func update // methods to make all fields dynamically update based on user input. type updateFieldMsg struct{} // nextFieldMsg is a message to move to the next field, // // each field controls when to send this message such that it is able to use // different key bindings or events to trigger group progression. type nextFieldMsg struct{} // prevFieldMsg is a message to move to the previous field. // // each field controls when to send this message such that it is able to use // different key bindings or events to trigger group progression. type prevFieldMsg struct{} // NextField is the command to move to the next field. func NextField() tea.Msg { return nextFieldMsg{} } // PrevField is the command to move to the previous field. func PrevField() tea.Msg { return prevFieldMsg{} } // Init initializes the group. func (g *Group) Init() tea.Cmd { var cmds []tea.Cmd cmds = append(cmds, func() tea.Msg { return updateFieldMsg{} }) if g.selector.Selected().Skip() { if g.selector.OnLast() { cmds = append(cmds, g.prevField()...) } else if g.selector.OnFirst() { cmds = append(cmds, g.nextField()...) } return tea.Batch(cmds...) } if g.active { cmd := g.selector.Selected().Focus() cmds = append(cmds, cmd) } g.buildView() return tea.Batch(cmds...) } // nextField moves to the next field. func (g *Group) nextField() []tea.Cmd { blurCmd := g.selector.Selected().Blur() if g.selector.OnLast() { return []tea.Cmd{blurCmd, nextGroup} } g.selector.Next() for g.selector.Selected().Skip() { if g.selector.OnLast() { return []tea.Cmd{blurCmd, nextGroup} } g.selector.Next() } focusCmd := g.selector.Selected().Focus() return []tea.Cmd{blurCmd, focusCmd} } // prevField moves to the previous field. func (g *Group) prevField() []tea.Cmd { blurCmd := g.selector.Selected().Blur() if g.selector.OnFirst() { return []tea.Cmd{blurCmd, prevGroup} } g.selector.Prev() for g.selector.Selected().Skip() { if g.selector.OnFirst() { return []tea.Cmd{blurCmd, prevGroup} } g.selector.Prev() } focusCmd := g.selector.Selected().Focus() return []tea.Cmd{blurCmd, focusCmd} } // Update updates the group. func (g *Group) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd // Update all the fields in the group. g.selector.Range(func(i int, field Field) bool { switch msg := msg.(type) { case tea.KeyPressMsg, tea.PasteMsg: break default: m, cmd := field.Update(msg) g.selector.Set(i, m.(Field)) cmds = append(cmds, cmd) } if g.selector.Index() == i { m, cmd := field.Update(msg) g.selector.Set(i, m.(Field)) cmds = append(cmds, cmd) } m, cmd := field.Update(updateFieldMsg{}) g.selector.Set(i, m.(Field)) cmds = append(cmds, cmd) return true }) switch msg := msg.(type) { case tea.BackgroundColorMsg: g.hasDarkBg = msg.IsDark() case nextFieldMsg: cmds = append(cmds, g.nextField()...) case prevFieldMsg: cmds = append(cmds, g.prevField()...) } g.buildView() return g, tea.Batch(cmds...) } func (g *Group) getTheme() *Styles { if theme := g.theme; theme != nil { return theme.Theme(g.hasDarkBg) } return ThemeFunc(ThemeCharm).Theme(g.hasDarkBg) } func (g *Group) styles() GroupStyles { return g.getTheme().Group } func (g *Group) getContent() (int, string) { var fields strings.Builder offset := 0 gap := g.getTheme().FieldSeparator.Render() // if the focused field is requesting it be zoomed, only show that field. if g.selector.Selected().Zoom() { g.selector.Selected().WithHeight(g.height) fields.WriteString(g.selector.Selected().View()) } else { g.selector.Range(func(i int, field Field) bool { fields.WriteString(field.View()) if i == g.selector.Index() { offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) } if i < g.selector.Total()-1 { fields.WriteString(gap) } return true }) } return offset, fields.String() } func (g *Group) buildView() { offset, content := g.getContent() g.viewport.SetContent(content) g.viewport.SetYOffset(offset) } // Header renders the group's header only (no content). func (g *Group) Header() string { styles := g.styles() var parts []string if g.title != "" { parts = append(parts, styles.Title.Render(wrap(g.title, g.width))) } if g.description != "" { parts = append(parts, styles.Description.Render(wrap(g.description, g.width))) } return strings.Join(parts, "\n") } // titleFooterHeight returns the height of the footer + header. func (g *Group) titleFooterHeight() int { h := 0 if s := g.Header(); s != "" { h += lipgloss.Height(s) } if s := g.Footer(); s != "" { h += lipgloss.Height(s) } return h } // rawHeight returns the full height of the group, without using a viewport. func (g *Group) rawHeight() int { return lipgloss.Height(g.Content()) + g.titleFooterHeight() } // View renders the group. func (g *Group) View() string { var parts []string if s := g.Header(); s != "" { parts = append(parts, s) } parts = append(parts, g.viewport.View()) if s := g.Footer(); s != "" { // append an empty line, and the footer (usually the help). parts = append(parts, "", s) } if len(parts) > 0 { // Trim suffix spaces from the last part as it can accidentally // scroll the view up on some terminals (like Apple's Terminal.app) // when we right to the bottom rightmost corner cell. lastIdx := len(parts) - 1 parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], " ") } return strings.Join(parts, "\n") } // Content renders the group's content only (no footer). func (g *Group) Content() string { _, content := g.getContent() return content } // Footer renders the group's footer only (no content). func (g *Group) Footer() string { var parts []string errors := g.Errors() if g.showHelp && len(errors) <= 0 { parts = append(parts, g.help.ShortHelpView(g.selector.Selected().KeyBinds())) } if g.showErrors { for _, err := range errors { parts = append(parts, wrap( g.getTheme().Focused.ErrorMessage.Render(err.Error()), g.width, )) } } return g.styles().Base. Render(strings.Join(parts, "\n")) } ================================================ FILE: huh.go ================================================ // Package huh provides components to build terminal-based forms and prompts. package huh ================================================ FILE: huh_test.go ================================================ package huh import ( "bytes" "context" "errors" "fmt" "io" "regexp" "strings" "testing" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/xpty" ) const text = "Huh" var pretty = lipgloss.NewStyle(). Width(60). Border(lipgloss.NormalBorder()). MarginTop(1). Padding(1, 3, 1, 2) func TestForm(t *testing.T) { type Taco struct { Shell string Base string Toppings []string } type Order struct { Taco Taco Name string Instructions string Discount bool } var taco Taco order := Order{Taco: taco} f := NewForm( NewGroup( NewSelect[string](). Options(NewOptions("Soft", "Hard")...). Title("Shell?"). Description("Our tortillas are made fresh in-house every day."). Validate(func(t string) error { if t == "Hard" { return fmt.Errorf("we're out of hard shells, sorry") } return nil }). Value(&order.Taco.Shell), NewSelect[string](). Options(NewOptions("Chicken", "Beef", "Fish", "Beans")...). Value(&order.Taco.Base). Title("Base"), ), // Prompt for toppings and special instructions. // The customer can ask for up to 4 toppings. NewGroup( NewMultiSelect[string](). Title("Toppings"). Description("Choose up to 4."). Options( NewOption("Lettuce", "lettuce").Selected(true), NewOption("Tomatoes", "tomatoes").Selected(true), NewOption("Corn", "corn"), NewOption("Salsa", "salsa"), NewOption("Sour Cream", "sour cream"), NewOption("Cheese", "cheese"), ). Validate(func(t []string) error { if len(t) <= 0 { return fmt.Errorf("at least one topping is required") } return nil }). Value(&order.Taco.Toppings). Filterable(true). Limit(4), ), // Gather final details for the order. NewGroup( NewInput(). Value(&order.Name). Title("What's your name?"). Placeholder("Margaret Thatcher"). Description("For when your order is ready."), NewText(). Value(&order.Instructions). Placeholder("Just put it in the mailbox please"). Title("Special Instructions"). Description("Anything we should know?"). CharLimit(400), NewConfirm(). Title("Would you like 15% off?"). Value(&order.Discount). Affirmative("Yes!"). Negative("No."), ), ) f.Update(f.Init()) view := viewModel(f) // // ┃ Shell? // ┃ Our tortillas are made fresh in-house every day. // ┃ > Soft // ┃ Hard // // Base // > Chicken // Beef // Fish // Beans // // ↑ up • ↓ down • / filter • enter select // if !strings.Contains(view, "┃ Shell?") { t.Log(pretty.Render(view)) t.Error("Expected form to contain Shell? title") } if !strings.Contains(view, "Our tortillas are made fresh in-house every day.") { t.Log(pretty.Render(view)) t.Error("Expected form to contain tortilla description") } if !strings.Contains(view, "Base") { t.Log(pretty.Render(view)) t.Error("Expected form to contain Base title") } // Attempt to select hard shell and retrieve error. m := batchUpdate(f.Update(keypress('j'))) m = batchUpdate(m.Update(codeKeypress(tea.KeyTab))) view = viewModel(m) if !strings.Contains(view, "* we're out of hard shells, sorry") { t.Log(pretty.Render(view)) t.Error("Expected form to show out of hard shells error") } // select back the soft shell m = batchUpdate(m.Update(keypress('k'))) m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) view = viewModel(m) if !strings.Contains(view, "┃ > Chicken") { t.Log(pretty.Render(view)) t.Fatal("Expected form to continue to base group") } // batchMsg + nextGroup m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) view = viewModel(m) // // ┃ Toppings // ┃ Choose up to 4. // ┃ > ✓ Lettuce // ┃ ✓ Tomatoes // ┃ • Corn // ┃ • Salsa // ┃ • Sour Cream // ┃ • Cheese // // x toggle • ↑ up • ↓ down • enter confirm • shift+tab back // if !strings.Contains(view, "Toppings") { t.Log(pretty.Render(view)) t.Fatal("Expected form to show toppings group") } if !strings.Contains(view, "Choose up to 4.") { t.Log(pretty.Render(view)) t.Error("Expected form to show toppings description") } if !strings.Contains(view, "> ✓ Lettuce ") { t.Log(pretty.Render(view)) t.Error("Expected form to preselect lettuce") } if !strings.Contains(view, " ✓ Tomatoes") { t.Log(pretty.Render(view)) t.Error("Expected form to preselect tomatoes") } m = batchUpdate(m.Update(keypress('j'))) m = batchUpdate(m.Update(keypress('j'))) view = viewModel(m) if !strings.Contains(view, "> • Corn") { t.Log(pretty.Render(view)) t.Error("Expected form to change selection to corn") } m = batchUpdate(m.Update(keypress('x'))) view = viewModel(m) if !strings.Contains(view, "> ✓ Corn") { t.Log(pretty.Render(view)) t.Error("Expected form to change selection to corn") } m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) view = viewModel(m) if !strings.Contains(view, "What's your name?") { t.Log(pretty.Render(view)) t.Error("Expected form to prompt for name") } if !strings.Contains(view, "Special Instructions") { t.Log(pretty.Render(view)) t.Error("Expected form to prompt for special instructions") } if !strings.Contains(view, "Would you like 15% off?") { t.Log(pretty.Render(view)) t.Error("Expected form to prompt for discount") } // // ┃ What's your name? // ┃ For when your order is ready. // ┃ > Margaret Thatcher // // Special Instructions // Anything we should know? // Just put it in the mailbox please // // Would you like 15% off? // // Yes! No. // // enter next • shift+tab back // typeText(m, "Glen") view = viewModel(m) if !strings.Contains(view, "Glen") { t.Log(pretty.Render(view)) t.Error("Expected form to accept user input") } if order.Taco.Shell != "Soft" { t.Error("Expected order shell to be Soft") } if order.Taco.Base != "Chicken" { t.Error("Expected order shell to be Chicken") } if len(order.Taco.Toppings) != 3 { t.Error("Expected order to have 3 toppings") } if order.Name != "Glen" { t.Error("Expected order name to be Glen") } // TODO: Finish and submit form. } func TestInput(t *testing.T) { field := NewInput() f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, ">") { t.Log(pretty.Render(view)) t.Error("Expected field to contain prompt.") } // Type Huh in the form. f = typeText(f, text) view = viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if !strings.Contains(view, "enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestPasteNotDuplicated(t *testing.T) { field := NewInput().Title("Name") f := NewForm(NewGroup(field)) f.Update(f.Init()) f = batchUpdate(f.Update(tea.PasteMsg{Content: "hello"})).(*Form) if field.GetValue() != "hello" { t.Errorf("Expected field value to be %q, got %q (paste was duplicated)", "hello", field.GetValue()) } } func TestInlineInput(t *testing.T) { field := NewInput(). Title("Input "). Prompt(": "). Description("Description"). Inline(true) f := NewForm(NewGroup(field)).WithWidth(40) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "┃ Input Description:") { t.Log(pretty.Render(view)) t.Error("Expected field to contain inline input.") } // Type Huh in the form. f = typeText(f, text) view = viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if !strings.Contains(view, "enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if !strings.Contains(view, "┃ Input Description: "+text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestText(t *testing.T) { field := NewText() f := NewForm(NewGroup(field)) f.Update(f.Init()) // Type Huh in the form. f = typeText(f, text) view := viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if !strings.Contains(view, "alt+enter / ctrl+j new line • ctrl+e open editor • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestTextExternalEditorHidden(t *testing.T) { field := NewText().ExternalEditor(false) f := NewForm(NewGroup(field)) f.Update(f.Init()) // Type Huh in the form. f = typeText(f, text) view := viewModel(f) if !strings.Contains(view, text) { t.Log(pretty.Render(view)) t.Error("Expected field to contain " + text) } if strings.Contains(view, "ctrl+e open editor") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help without ctrl+e.") } if field.GetValue() != text { t.Error("Expected field value to be " + text) } } func TestConfirm(t *testing.T) { field := NewConfirm().Title("Are you sure?") f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Yes") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Yes.") } if !strings.Contains(view, "No") { t.Log(pretty.Render(view)) t.Error("Expected field to contain No.") } if !strings.Contains(view, "Are you sure?") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Are you sure?.") } if !strings.Contains(view, "←/→ toggle • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } if field.GetValue() != false { t.Log(pretty.Render(view)) t.Error("Expected field value to be false") } // Toggle left f.Update(codeKeypress(tea.KeyLeft)) if field.GetValue() != true { t.Error("Expected field value to be true") } // Toggle right f.Update(codeKeypress(tea.KeyRight)) if field.GetValue() != false { t.Error("Expected field value to be false") } } func TestSelect(t *testing.T) { field := NewSelect[string](). Options(NewOptions( "Foo\nLine 2", "Bar\nLine 2", "Baz\nLine 2", "Ban\nLine 2", )...). Title("Which one?") f := NewForm(NewGroup(field)).WithHeight(5) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Foo.") } if !strings.Contains(view, "Which one?") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Which one?.") } if !strings.Contains(view, "> Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Foo.") } // Move selection cursor down f = batchUpdate(f.Update(codeKeypress(tea.KeyDown))).(*Form) view = viewModel(f) if got, ok := field.Hovered(); !ok || got != "Bar\nLine 2" { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if strings.Contains(view, "> Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "> Bar") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "↑ up • ↓ down • / filter • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } // Submit f.Update(codeKeypress(tea.KeyEnter)) if field.GetValue() != "Bar\nLine 2" { t.Error("Expected field value to be Bar") } } // doAllUpdates updates the form with the given command, then continues updating it with any resultant commands from the update until no more are returned. func doAllUpdates(f *Form, cmd tea.Cmd) { if cmd == nil { return } var cmds []tea.Cmd switch msg := cmd().(type) { case tea.BatchMsg: for _, subcommand := range msg { doAllUpdates(f, subcommand) } return default: _, result := f.Update(msg) cmds = append(cmds, result) } doAllUpdates(f, tea.Batch(cmds...)) } func TestSelectDynamic(t *testing.T) { trigger := "initial" field1 := NewSelect[string](). TitleFunc(func() string { return "field1 title " + trigger }, &trigger). DescriptionFunc(func() string { return "field1 desc " + trigger }, &trigger). OptionsFunc(func() []Option[string] { return []Option[string]{NewOption("field1 opt "+trigger, "field1 opt "+trigger)} }, &trigger) field2 := NewSelect[string](). TitleFunc(func() string { return "field2 title " + trigger }, &trigger). DescriptionFunc(func() string { return "field2 desc " + trigger }, &trigger). OptionsFunc(func() []Option[string] { return []Option[string]{NewOption("field2 opt "+trigger, "field2 opt "+trigger)} }, &trigger) field1.WithHeight(5) field2.WithHeight(5) f := NewForm(NewGroup(field1, field2)).WithHeight(10) doAllUpdates(f, f.Init()) view := viewModel(f) expectedStrings := []string{ "field1 title initial", "field1 desc initial", "field1 opt initial", "field2 title initial", "field2 desc initial", "field2 opt initial", } for _, expected := range expectedStrings { if !strings.Contains(view, expected) { t.Log(pretty.Render(view)) t.Error("Expected view to contain " + expected) } } if field1.GetValue() != "field1 opt initial" { t.Errorf("Expected field1 value to be field1 opt initial but was %s", field1.GetValue()) } if field2.GetValue() != "field2 opt initial" { t.Errorf("Expected field2 value to be field2 opt initial but was %s", field2.GetValue()) } trigger = "updated" _, cmd := f.Update(nil) doAllUpdates(f, cmd) view = viewModel(f) expectedStrings = []string{ "field1 title updated", "field1 desc updated", "field1 opt updated", "field2 title updated", "field2 desc updated", "field2 opt updated", } for _, expected := range expectedStrings { if !strings.Contains(view, expected) { t.Log(pretty.Render(view)) t.Error("Expected view to contain " + expected) } } if field1.GetValue() != "field1 opt updated" { t.Errorf("Expected field1 value to be field1 opt updated but was %s", field1.GetValue()) } if field2.GetValue() != "field2 opt updated" { t.Errorf("Expected field2 value to be field2 opt updated but was %s", field1.GetValue()) } } func TestMultiSelect(t *testing.T) { field := NewMultiSelect[string](). Options(NewOptions( "Foo\nLine2", "Bar\nLine2", "Baz\nLine2", "Ban\nLine2", )...). Title("Which one?") f := NewForm(NewGroup(field)). WithHeight(5) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Foo.") } if !strings.Contains(view, "Which one?") { t.Log(pretty.Render(view)) t.Error("Expected field to contain Which one?.") } if !strings.Contains(view, "> • Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Foo.") } // Move selection cursor down m := batchUpdate(f.Update(keypress('j'))) view = viewModel(m) if got, ok := field.Hovered(); !ok || got != "Bar\nLine2" { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if strings.Contains(view, "> • Foo") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "> • Bar") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } // Toggle m = batchUpdate(f.Update(keypress('x'))) view = viewModel(m) if !strings.Contains(view, "> ✓ Bar") { t.Log(pretty.Render(view)) t.Error("Expected cursor to be on Bar.") } if !strings.Contains(view, "x toggle • ↑ up • ↓ down • / filter • enter submit") { t.Log(pretty.Render(view)) t.Error("Expected field to contain help.") } // Submit f.Update(codeKeypress(tea.KeyEnter)) value := field.GetValue() v, ok := value.([]string) if !ok { t.Error("Expected field value to a slice of string") return } if len(v) != 1 { t.Error("Expected field value length to be 1") } else { if v[0] != "Bar\nLine2" { t.Error("Expected first field value to be Bar") } } } func TestMultiSelectFiltering(t *testing.T) { tests := []struct { name string filtering bool }{ {"Filtering off", false}, {"Filtering on", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(tc.filtering) f := NewForm(NewGroup(field)) f.Update(f.Init()) // Filter for values starting with a 'B' only. f.Update(keypress('/')) f.Update(keypress('B')) view := viewModel(f) // When we're filtering, the list should change. if tc.filtering && strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Foo should not in filtered list.") } // When we're not filtering, the list shouldn't change. if !tc.filtering && !strings.Contains(view, "Foo") { t.Log(pretty.Render(view)) t.Error("Expected list to contain Foo.") } }) } t.Run("Remove filter option from help menu.", func(t *testing.T) { field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(false) f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if strings.Contains(view, "filter") { t.Log(pretty.Render(view)) t.Error("Expected list to hide filtering in help menu.") } }) } func TestSelectPageNavigation(t *testing.T) { opts := NewOptions( "Qux", "Quux", "Foo", "Bar", "Baz", "Corge", "Grault", "Garply", "Waldo", "Fred", "Plugh", "Xyzzy", "Thud", "Norf", "Blip", "Flob", "Zorp", "Smurf", "Bloop", "Ping", ) reFirst := regexp.MustCompile(`>( •)? Qux`) reLast := regexp.MustCompile(`>( •)? Ping`) reHalfDown := regexp.MustCompile(`>( •)? Baz`) for name, field := range map[string]Field{ "multiselect": NewMultiSelect[string]().Options(opts...).Title("Choose"), "select": NewSelect[string]().Options(opts...).Title("Choose"), } { t.Run(name, func(t *testing.T) { f := NewForm(NewGroup(field)).WithHeight(10) f.Update(f.Init()) view := viewModel(f) if !reFirst.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) } m := batchUpdate(f.Update(keypress('G'))) // if name == "multiselect" { // mm := field.(*MultiSelect[string]) // t.Logf("AQUI: height=%d offset=%d", mm.viewport.Height(), mm.viewport.YOffset()) // t.Log("LOOK AT THIS SHIT", ansi.Strip(mm.viewport.View())) // } view = viewModel(m) if !reLast.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String()) } m = batchUpdate(f.Update(keypress('g'))) view = viewModel(m) if !reFirst.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) } m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'}))) view = viewModel(m) if !reHalfDown.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (half down item)", reHalfDown.String()) } // sends multiple to verify it stays within boundaries for range 10 { m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'u'}))) } view = viewModel(m) if !reFirst.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) } // verify it stays within boundaries for range 10 { m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'}))) } view = viewModel(m) if !reLast.MatchString(view) { t.Log(pretty.Render(view)) t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String()) } }) } } func TestFile(t *testing.T) { field := NewFilePicker().Title("Which file?") cmd := field.Init() field.Update(cmd()) view := viewModel(field) if !strings.Contains(view, "No file selected") { t.Log(pretty.Render(view)) t.Error("Expected file picker to show no file selected.") } if !strings.Contains(view, "Which file?") { t.Log(pretty.Render(view)) t.Error("Expected file picker to show title.") } } func TestHideGroup(t *testing.T) { f := NewForm( NewGroup(NewNote().Description("Foo")). WithHide(true), NewGroup(NewNote().Description("Bar")), NewGroup(NewNote().Description("Baz")), NewGroup(NewNote().Description("Qux")). WithHideFunc(func() bool { return false }). WithHide(true), ) f = batchUpdate(f, f.NextGroup()).(*Form) if v := f.View(); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to be visible") } // should have no effect as previous group is hidden f.Update(prevGroup()) if v := f.View(); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to be visible") } f.Update(nextGroup()) if v := f.View(); !strings.Contains(v, "Baz") { t.Log(pretty.Render(v)) t.Error("expected Baz to be visible") } f.Update(nextGroup()) if v := f.View(); strings.Contains(v, "Qux") { t.Log(pretty.Render(v)) t.Error("expected Qux to be hidden") } if v := f.State; v != StateCompleted { t.Error("should have been completed") } } func TestHideGroupLastAndFirstGroupsNotHidden(t *testing.T) { f := NewForm( NewGroup(NewNote().Description("Bar")), NewGroup(NewNote().Description("Foo")). WithHide(true), NewGroup(NewNote().Description("Baz")), ) f = batchUpdate(f, f.Init()).(*Form) if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to not be hidden") } // should have no effect as there isn't any f.Update(prevGroup()) if v := f.View(); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to not be hidden") } f.Update(nextGroup()) if v := ansi.Strip(f.View()); !strings.Contains(v, "Baz") { t.Log(pretty.Render(v)) t.Error("expected Baz to not be hidden") } // should submit the form f.Update(nextGroup()) if v := f.State; v != StateCompleted { t.Error("should have been completed") } } func TestPrevGroup(t *testing.T) { f := NewForm( NewGroup(NewNote().Description("Bar")), NewGroup(NewNote().Description("Foo")), NewGroup(NewNote().Description("Baz")), ) f = batchUpdate(f, f.Init()).(*Form) f.Update(nextGroup()) f.Update(nextGroup()) f.Update(prevGroup()) f.Update(prevGroup()) if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { t.Log(pretty.Render(v)) t.Error("expected Bar to not be hidden") } } func TestNote(t *testing.T) { field := NewNote(). Title("Taco"). Description("How may we take your order?"). Next(true) f := NewForm(NewGroup(field)) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Taco") { t.Log(view) t.Error("Expected field to contain Taco title.") } if !strings.Contains(view, "order?") { t.Log(view) t.Error("Expected field to contain Taco description.") } if !strings.Contains(view, "Next") { t.Log(view) t.Error("Expected field to contain next button") } const expect = 7 if h := lipgloss.Height(ansi.Strip(view)); h != expect { t.Log(view) t.Errorf("Expected field to have height %d, got %d", expect, h) } if !strings.Contains(view, "enter submit") { t.Log(view) t.Error("Expected field to contain help.") } } func TestDynamicHelp(t *testing.T) { f := NewForm( NewGroup( NewInput().Title("Dynamic Help"), NewInput().Title("Dynamic Help"), NewInput().Title("Dynamic Help"), ), ) f.Update(f.Init()) view := viewModel(f) if !strings.Contains(view, "Dynamic Help") { t.Log(pretty.Render(view)) t.Fatal("Expected help to contain title.") } if strings.Contains(view, "shift+tab") || strings.Contains(view, "submit") { t.Log(pretty.Render(view)) t.Error("Expected help not to contain shift+tab or submit.") } } func TestSkip(t *testing.T) { f := NewForm( NewGroup( NewInput().Title("First"), NewNote().Title("Skipped"), NewNote().Title("Skipped"), NewInput().Title("Second"), ), ).WithWidth(25) f = batchUpdate(f, f.Init()).(*Form) view := viewModel(f) if !strings.Contains(view, "┃ First") { t.Log(pretty.Render(view)) t.Error("Expected first field to be focused") } // next field should skip both of the notes and proceed to the last input. f.Update(NextField()) view = viewModel(f) if strings.Contains(view, "┃ First") { t.Log(pretty.Render(view)) t.Error("Expected first field to be blurred") } if !strings.Contains(view, "┃ Second") { t.Log(pretty.Render(view)) t.Error("Expected second field to be focused") } // previous field should skip both of the notes and focus the first input. f.Update(PrevField()) view = viewModel(f) if strings.Contains(view, "┃ Second") { t.Log(pretty.Render(view)) t.Error("Expected second field to be blurred") } if !strings.Contains(view, "┃ First") { t.Log(pretty.Render(view)) t.Error("Expected first field to be focused") } } func TestTimeout(t *testing.T) { // This test requires a real program, so make sure it doesn't interfere with our test runner. f := formProgram() // Test that the form times out after 1ms and returns a timeout error. err := f.WithTimeout(100 * time.Millisecond).Run() if err == nil || !errors.Is(err, ErrTimeout) { t.Errorf("expected timeout error, got %v", err) } } func TestAbort(t *testing.T) { // This test requires a real program, so make sure it doesn't interfere with our test runner. f := formProgram() // Test that the form aborts without throwing a timeout error when explicitly told to abort. ctx, cancel := context.WithCancel(context.Background()) // Since the context is cancelled, the program should exit immediately. cancel() // Tell the form to abort. f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'c'})) // Run the program. err := f.RunWithContext(ctx) if err == nil || !errors.Is(err, ErrUserAborted) { t.Errorf("expected user aborted error, got %v", err) } } const ( title = "A Title" description = "A Description" ) var titleAndDescTests = map[string]struct { Empty interface{ View() string } EmptyHeight int Title interface{ View() string } Description interface{ View() string } }{ "Group": { NewGroup(NewInput()), 1, // > NewGroup(NewInput()).Title(title), NewGroup(NewInput()).Description(description), }, "Confirm": { NewConfirm(), 1, // yes | no NewConfirm().Title(title), NewConfirm().Description(description), }, "FilePicker": { NewFilePicker(), 1, // "no file selected" NewFilePicker().Title(title), NewFilePicker().Description(description), }, "Input": { NewInput(), 1, // > NewInput().Title(title), NewInput().Description(description), }, "Note": { NewNote(), 1, // | NewNote().Title(title), NewNote().Description(description), }, "Text": { NewText(), 6, // textarea NewText().Title(title), NewText().Description(description), }, "Select": { NewSelect[string](), 1, // > NewSelect[string]().Title(title), NewSelect[string]().Description(description), }, "MultiSelect": { NewMultiSelect[string](), 1, // > NewMultiSelect[string]().Title(title), NewMultiSelect[string]().Description(description), }, } func TestNoTitleOrDescription(t *testing.T) { for name, tt := range titleAndDescTests { t.Run(name, func(t *testing.T) { view := tt.Empty.View() got := lipgloss.Height(ansi.Strip(view)) want := tt.EmptyHeight if got != want { t.Log(pretty.Render(view)) t.Fatalf("got != want; height should be %d, got %d", want, got) } }) } } func TestTitleRowRender(t *testing.T) { for name, tt := range titleAndDescTests { t.Run(name, func(t *testing.T) { view := tt.Title.View() if !strings.Contains(view, title) { t.Log(pretty.Render(view)) t.Error("Expected title to be visible") } }) } } func TestDescriptionRowRender(t *testing.T) { for name, tt := range titleAndDescTests { t.Run(name, func(t *testing.T) { view := tt.Description.View() if !strings.Contains(view, description) { t.Log(pretty.Render(view)) t.Error("Expected description to be visible") } }) } } func TestGetFocusedField(t *testing.T) { f := NewForm( NewGroup( NewInput().Title("First").Key("First"), NewInput().Title("Second").Key("Second"), NewInput().Title("Third").Key("Third"), ), ).WithWidth(25) f = batchUpdate(f, f.Init()).(*Form) f.NextField() field := f.GetFocusedField() if field.GetKey() != "Second" { t.Error("Expected Second field to be focused but was '" + field.GetKey() + "'") } } // formProgram returns a new Form with a nil input and output, so it can be used as a test program. func formProgram() *Form { return NewForm(NewGroup(NewInput().Title("Foo"))). WithInput(nil). WithOutput(io.Discard). WithAccessible(false) } func batchUpdate(m Model, cmd tea.Cmd) Model { if cmd == nil { return m } msg := cmd() m, cmd = m.Update(msg) if cmd == nil { return m } msg = cmd() m, _ = m.Update(msg) return m } func codeKeypress(r rune) tea.KeyPressMsg { return tea.KeyPressMsg(tea.Key{ Code: r, }) } func keypress(r rune) tea.KeyPressMsg { return tea.KeyPressMsg(tea.Key{ Text: string(r), Code: r, ShiftedCode: r, }) } func typeText[T Model](m T, s string) T { var tm Model = m for _, r := range s { tm, _ = tm.Update(keypress(r)) } return tm.(T) } func TestAccessibleForm(t *testing.T) { var out bytes.Buffer f := NewForm( NewGroup( NewInput().Title("Hello:"), ), ). WithAccessible(true). WithOutput(&out). WithInput(strings.NewReader("carlos\n")) if err := f.Run(); err != nil { t.Error(err) } if !strings.Contains(out.String(), "Hello:") { t.Error("invalid output:\n", out.String()) } } func TestAccessibleFields(t *testing.T) { for name, test := range map[string]struct { Field Field FieldFn func() Field Input string CheckOutput func(tb testing.TB, output string) CheckValue func(tb testing.TB, value any) }{ "input": { Field: NewInput(), Input: "Hello", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "Hello", value.(string)) }, }, "input with charlimit": { Field: NewInput().CharLimit(2), Input: "Hello", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input cannot exceed 2 characters") }, }, "input with default": { FieldFn: func() Field { v := "hi" return NewInput().Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "hi", value.(string)) }, }, "confirm": { Field: NewConfirm(), Input: "Y", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose [y/N]") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, true, value.(bool)) }, }, "confirm with default": { FieldFn: func() Field { v := true return NewConfirm().Value(&v) }, CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose [Y/n]") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, true, value.(bool)) }, }, "confirm with default choose": { FieldFn: func() Field { v := true return NewConfirm().Value(&v) }, Input: "n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Y/n") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, false, value.(bool)) }, }, "filepicker": { Field: NewFilePicker(), Input: "huh_test.go", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose a file:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "huh_test.go", value.(string)) }, }, "filepicker with default": { FieldFn: func() Field { v := "huh_test.go" return NewFilePicker().Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Choose a file:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "huh_test.go", value.(string)) }, }, "multiselect": { Field: NewMultiSelect[string]().Options(NewOptions("a", "b")...), Input: "2", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "2. ✓ b") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() got := value.([]string) requireEqual(tb, 1, len(got)) requireEqual(tb, "b", got[0]) }, }, "multiselect default value": { FieldFn: func() Field { v := []string{"b", "c"} return NewMultiSelect[string]().Options(NewOptions("a", "b", "c", "d")...).Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "2. ✓ b") requireContains(tb, output, "3. ✓ c") }, }, "select": { Field: NewSelect[string]().Options(NewOptions("a", "b")...), Input: "2", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "b", value.(string)) }, }, "select default value": { FieldFn: func() Field { v := "c" return NewSelect[string](). Options(NewOptions("a", "b", "c", "d")...). Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") requireContains(tb, output, "Enter a number between 1 and 4") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "c", value.(string)) }, }, "select no input": { Field: NewSelect[string]().Options(NewOptions("a", "b")...), Input: "\n2\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") requireContains(tb, output, "Enter a number between 1 and 2") requireContains(tb, output, "Invalid: must be a number between 1 and 2") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "b", value.(string)) }, }, "select single option": { Field: NewSelect[string]().Options(NewOptions("a")...), Input: "\n1\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Select:") requireContains(tb, output, "There is only one option available; enter the number 1:") requireContains(tb, output, "Invalid: must be 1") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "a", value.(string)) }, }, "note": { Field: NewNote().Title("Hi").Description("there"), CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Hi") requireContains(tb, output, "there") }, }, "text": { Field: NewText().Title("Text:"), Input: "hello world", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Text:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "hello world", value.(string)) }, }, "text with limit": { Field: NewText().CharLimit(2).Title("Text"), Input: "hello world", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Input cannot exceed 2 characters") }, }, "text default value": { FieldFn: func() Field { v := "test" return NewText().Title("Text:").Value(&v) }, Input: "\n", CheckOutput: func(tb testing.TB, output string) { tb.Helper() requireContains(tb, output, "Text:") }, CheckValue: func(tb testing.TB, value any) { tb.Helper() requireEqual(tb, "test", value.(string)) }, }, } { t.Run(name, func(t *testing.T) { field := test.Field if test.FieldFn != nil { field = test.FieldFn() } var out bytes.Buffer if err := field.RunAccessible( &out, strings.NewReader(test.Input), ); err != nil { t.Error(err) } if test.CheckOutput != nil { test.CheckOutput(t, out.String()) } if test.CheckValue != nil { test.CheckValue(t, field.GetValue()) } }) } } func TestInputPasswordAccessible(t *testing.T) { t.Run("not a tty", func(t *testing.T) { var out bytes.Buffer if err := NewInput(). EchoMode(EchoModeNone). RunAccessible(&out, bytes.NewReader(nil)); err == nil { t.Error("expected it to error") } if err := NewInput(). EchoMode(EchoModePassword). RunAccessible(&out, bytes.NewReader(nil)); err == nil { t.Error("expected it to error") } }) t.Run("is a tty", func(t *testing.T) { var out bytes.Buffer pty, err := xpty.NewPty(50, 30) if err != nil { t.Skipf("could not open pty: %v", err) } upty, ok := pty.(*xpty.UnixPty) if !ok { t.Skipf("test only works on unix") } input := NewInput().EchoMode(EchoModePassword) errs := make(chan error, 1) go func() { errs <- input.RunAccessible(&out, upty.Slave()) }() _, _ = upty.Master().Write([]byte("a password\n")) if err := <-errs; err != nil { t.Errorf("expected no error, got %v", err) } t.Logf("%q", out.String()) requireContains(t, out.String(), "Password:") requireEqual(t, "a password", input.GetValue().(string)) }) } func requireEqual[T comparable](tb testing.TB, a, b T) { tb.Helper() if a != b { tb.Fatalf("expected %v to be equal to %v", a, b) } } func requireContains(tb testing.TB, s, subtr string) { tb.Helper() if !strings.Contains(s, subtr) { tb.Fatalf("%q does not contain %q", s, subtr) } } func viewModel(m Model) string { return ansi.Strip(m.View()) } ================================================ FILE: internal/accessibility/accessibility.go ================================================ // Package accessibility provides accessible functions to capture user input. package accessibility import ( "bufio" "cmp" "errors" "fmt" "io" "slices" "strconv" "strings" "github.com/charmbracelet/x/term" ) func atoi(s string) (int, error) { if strings.TrimSpace(s) == "" { return -1, nil } return strconv.Atoi(s) //nolint:wrapcheck } // PromptInt prompts a user for an integer between a certain range. // // Given invalid input (non-integers, integers outside of the range), the user // will continue to be reprompted until a valid input is given, ensuring that // the return value is always valid. func PromptInt( out io.Writer, in io.Reader, prompt string, low, high int, defaultValue *int, ) int { var choice int validInt := func(s string) error { if strings.TrimSpace(s) == "" && defaultValue != nil { return nil } i, err := atoi(s) if err != nil || i < low || i > high { if low == high { return fmt.Errorf("Invalid: must be %d", low) //nolint:staticcheck } return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) //nolint:staticcheck } return nil } input := PromptString( out, in, prompt, ptrToStr(defaultValue, strconv.Itoa), validInt, ) choice, _ = strconv.Atoi(input) return choice } func parseBool(s string) (bool, error) { s = strings.ToLower(s) if slices.Contains([]string{"y", "yes"}, s) { return true, nil } // As a special case, we default to "" to no since the usage of this // function suggests N is the default. if slices.Contains([]string{"n", "no"}, s) { return false, nil } return false, errors.New("invalid input. please try again") } // PromptBool prompts a user for a boolean value. // // Given invalid input (non-boolean), the user will continue to be reprompted // until a valid input is given, ensuring that the return value is always valid. func PromptBool( out io.Writer, in io.Reader, prompt string, defaultValue bool, ) bool { validBool := func(s string) error { if strings.TrimSpace(s) == "" { return nil } _, err := parseBool(s) return err } input := PromptString( out, in, prompt, boolToStr(defaultValue), validBool, ) b, _ := parseBool(input) return b } // PromptPassword allows to prompt for a password. // In must be the fd of a tty. func PromptPassword( out io.Writer, in uintptr, prompt string, validator func(input string) error, ) (string, error) { for { _, _ = fmt.Fprint(out, prompt) pwd, err := term.ReadPassword(in) if err != nil { return "", err //nolint:wrapcheck } _, _ = fmt.Fprintln(out) if err := validator(string(pwd)); err != nil { _, _ = fmt.Fprintln(out, err) continue } return string(pwd), nil } } // PromptString prompts a user for a string value and validates it against a // validator function. It re-prompts the user until a valid input is given. func PromptString( out io.Writer, in io.Reader, prompt string, defaultValue string, validator func(input string) error, ) string { scanner := bufio.NewScanner(in) var ( valid bool input string ) for !valid { _, _ = fmt.Fprint(out, prompt) if !scanner.Scan() { // no way to bubble up errors or signal cancellation // but the program is probably not continuing if // stdin sent EOF _, _ = fmt.Fprintln(out) break } input = scanner.Text() if err := validator(input); err != nil { _, _ = fmt.Fprintln(out, err) continue } break } return cmp.Or(strings.TrimSpace(input), defaultValue) } func ptrToStr[T any](t *T, fn func(t T) string) string { if t == nil { return "" } return fn(*t) } func boolToStr(b bool) string { if b { return "y" } return "N" } ================================================ FILE: internal/compat/model.go ================================================ // Package compat provides common types used across the application. package compat import tea "charm.land/bubbletea/v2" // Model is a bubbletea v1 [tea.Model]. type Model interface { Init() tea.Cmd Update(msg tea.Msg) (Model, tea.Cmd) View() string } // ViewHook is a function that modifies a [tea.View]. type ViewHook = func(tea.View) tea.View // ViewModel wraps a [Model] and [ViewHook]. type ViewModel struct { Model ViewHook ViewHook } // Update implements [tea.Model]. func (w ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m, cmd := w.Model.Update(msg) return ViewModel{ Model: m, ViewHook: w.ViewHook, }, cmd } // View implements [tea.Model]. func (w ViewModel) View() tea.View { var view tea.View if w.ViewHook != nil { view = w.ViewHook(view) } view.SetContent(w.Model.View()) return view } ================================================ FILE: internal/selector/selector.go ================================================ // Package selector provides a helper type for selecting items. package selector // Selector is a helper type for selecting items. type Selector[T any] struct { items []T index int } // NewSelector creates a new item selector. func NewSelector[T any](items []T) *Selector[T] { return &Selector[T]{ items: items, } } // Append adds an item to the selector. func (s *Selector[T]) Append(item T) { s.items = append(s.items, item) } // Next moves the selector to the next item. func (s *Selector[T]) Next() { if s.index < len(s.items)-1 { s.index++ } } // Prev moves the selector to the previous item. func (s *Selector[T]) Prev() { if s.index > 0 { s.index-- } } // OnFirst returns true if the selector is on the first item. func (s *Selector[T]) OnFirst() bool { return s.index == 0 } // OnLast returns true if the selector is on the last item. func (s *Selector[T]) OnLast() bool { return s.index == len(s.items)-1 } // Selected returns the index of the current selected item. func (s *Selector[T]) Selected() T { return s.items[s.index] } // Index returns the index of the current selected item. func (s *Selector[T]) Index() int { return s.index } // Total returns the total number of items. func (s *Selector[T]) Total() int { return len(s.items) } // SetIndex sets the selected item. func (s *Selector[T]) SetIndex(i int) { if i < 0 || i >= len(s.items) { return } s.index = i } // Get returns the item at the given index. func (s *Selector[T]) Get(i int) T { return s.items[i] } // Set sets the item at the given index. func (s *Selector[T]) Set(i int, item T) { s.items[i] = item } // Range iterates over the items. // The callback function should return true to continue the iteration. func (s *Selector[T]) Range(f func(i int, item T) bool) { for i, item := range s.items { if !f(i, item) { break } } } // ReverseRange iterates over the items in reverse. // The callback function should return true to continue the iteration. func (s *Selector[T]) ReverseRange(f func(i int, item T) bool) { for i := len(s.items) - 1; i >= 0; i-- { if !f(i, s.items[i]) { break } } } ================================================ FILE: keymap.go ================================================ package huh import "charm.land/bubbles/v2/key" // KeyMap is the keybindings to navigate the form. type KeyMap struct { Quit key.Binding Confirm ConfirmKeyMap FilePicker FilePickerKeyMap Input InputKeyMap MultiSelect MultiSelectKeyMap Note NoteKeyMap Select SelectKeyMap Text TextKeyMap } // InputKeyMap is the keybindings for input fields. type InputKeyMap struct { AcceptSuggestion key.Binding Next key.Binding Prev key.Binding Submit key.Binding } // TextKeyMap is the keybindings for text fields. type TextKeyMap struct { Next key.Binding Prev key.Binding NewLine key.Binding Editor key.Binding Submit key.Binding } // SelectKeyMap is the keybindings for select fields. type SelectKeyMap struct { Next key.Binding Prev key.Binding Up key.Binding Down key.Binding HalfPageUp key.Binding HalfPageDown key.Binding GotoTop key.Binding GotoBottom key.Binding Left key.Binding Right key.Binding Filter key.Binding SetFilter key.Binding ClearFilter key.Binding Submit key.Binding } // MultiSelectKeyMap is the keybindings for multi-select fields. type MultiSelectKeyMap struct { Next key.Binding Prev key.Binding Up key.Binding Down key.Binding HalfPageUp key.Binding HalfPageDown key.Binding GotoTop key.Binding GotoBottom key.Binding Toggle key.Binding Filter key.Binding SetFilter key.Binding ClearFilter key.Binding Submit key.Binding SelectAll key.Binding SelectNone key.Binding } // FilePickerKeyMap is the keybindings for filepicker fields. type FilePickerKeyMap struct { Open key.Binding Close key.Binding GotoTop key.Binding GotoBottom key.Binding PageUp key.Binding PageDown key.Binding Back key.Binding Select key.Binding Up key.Binding Down key.Binding Prev key.Binding Next key.Binding Submit key.Binding } // NoteKeyMap is the keybindings for note fields. type NoteKeyMap struct { Next key.Binding Prev key.Binding Submit key.Binding } // ConfirmKeyMap is the keybindings for confirm fields. type ConfirmKeyMap struct { Next key.Binding Prev key.Binding Toggle key.Binding Submit key.Binding Accept key.Binding Reject key.Binding } // NewDefaultKeyMap returns a new default keymap. func NewDefaultKeyMap() *KeyMap { return &KeyMap{ Quit: key.NewBinding(key.WithKeys("ctrl+c")), Input: InputKeyMap{ AcceptSuggestion: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "complete")), Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), }, FilePicker: FilePickerKeyMap{ GotoTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first"), key.WithDisabled()), GotoBottom: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last"), key.WithDisabled()), PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up"), key.WithDisabled()), PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down"), key.WithDisabled()), Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back"), key.WithDisabled()), Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select"), key.WithDisabled()), Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up"), key.WithDisabled()), Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down"), key.WithDisabled()), Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("enter", "open")), Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"), key.WithDisabled()), Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), Next: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next")), Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), }, Text: TextKeyMap{ Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), Next: key.NewBinding(key.WithKeys("tab", "enter"), key.WithHelp("enter", "next")), Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), NewLine: key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"), key.WithHelp("alt+enter / ctrl+j", "new line")), Editor: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "open editor")), }, Select: SelectKeyMap{ Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "select")), Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up")), Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down")), Left: key.NewBinding(key.WithKeys("h", "left"), key.WithHelp("←", "left"), key.WithDisabled()), Right: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("→", "right"), key.WithDisabled()), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), SetFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), }, MultiSelect: MultiSelectKeyMap{ Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "confirm")), Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), Toggle: key.NewBinding(key.WithKeys("space", "x"), key.WithHelp("x", "toggle")), Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+p"), key.WithHelp("↑", "up")), Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+n"), key.WithHelp("↓", "down")), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), SetFilter: key.NewBinding(key.WithKeys("enter", "esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), SelectAll: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "select all")), SelectNone: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "select none"), key.WithDisabled()), }, Note: NoteKeyMap{ Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), }, Confirm: ConfirmKeyMap{ Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("←/→", "toggle")), Accept: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "Yes")), Reject: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "No")), }, } } ================================================ FILE: layout.go ================================================ package huh import ( "strings" "charm.land/lipgloss/v2" ) // A Layout is responsible for laying out groups in a form. type Layout interface { View(f *Form) string GroupWidth(f *Form, g *Group, w int) int } // LayoutDefault is the default layout shows a single group at a time. var LayoutDefault Layout = &layoutDefault{} // LayoutStack is a layout stacks all groups on top of each other. var LayoutStack Layout = &layoutStack{} // LayoutColumns layout distributes groups in even columns. func LayoutColumns(columns int) Layout { return &layoutColumns{columns: columns} } // LayoutGrid layout distributes groups in a grid. func LayoutGrid(rows int, columns int) Layout { return &layoutGrid{rows: rows, columns: columns} } type layoutDefault struct{} func (l *layoutDefault) View(f *Form) string { return f.selector.Selected().View() } func (l *layoutDefault) GroupWidth(_ *Form, _ *Group, w int) int { return w } type layoutColumns struct { columns int } func (l *layoutColumns) visibleGroups(f *Form) []*Group { segmentIndex := f.selector.Index() / l.columns start := segmentIndex * l.columns end := start + l.columns total := f.selector.Total() if end > total { end = total } var groups []*Group f.selector.Range(func(i int, group *Group) bool { if i >= start && i < end { groups = append(groups, group) return true } return true }) return groups } func (l *layoutColumns) View(f *Form) string { groups := l.visibleGroups(f) if len(groups) == 0 { return "" } columns := make([]string, 0, len(groups)) for _, group := range groups { columns = append(columns, group.Content()) } header := f.selector.Selected().Header() footer := f.selector.Selected().Footer() return strings.Join([]string{ header, lipgloss.JoinHorizontal(lipgloss.Left, columns...), footer, }, "\n") } func (l *layoutColumns) GroupWidth(_ *Form, _ *Group, w int) int { return w / l.columns } type layoutStack struct{} func (l *layoutStack) View(f *Form) string { var columns []string f.selector.Range(func(_ int, group *Group) bool { columns = append(columns, group.Content(), "") return true }) if footer := f.selector.Selected().Footer(); footer != "" { columns = append(columns, footer) } return strings.Join(columns, "\n") } func (l *layoutStack) GroupWidth(_ *Form, _ *Group, w int) int { return w } type layoutGrid struct { rows, columns int } func (l *layoutGrid) visibleGroups(f *Form) [][]*Group { total := l.rows * l.columns segmentIndex := f.selector.Index() / total start := segmentIndex * total end := start + total if glen := f.selector.Total(); end > glen { end = glen } var visible []*Group f.selector.Range(func(i int, group *Group) bool { if i >= start && i < end { visible = append(visible, group) return true } return true }) grid := make([][]*Group, l.rows) for i := 0; i < l.rows; i++ { startRow := i * l.columns endRow := startRow + l.columns if startRow >= len(visible) { break } if endRow > len(visible) { endRow = len(visible) } grid[i] = visible[startRow:endRow] } return grid } func (l *layoutGrid) View(f *Form) string { grid := l.visibleGroups(f) if len(grid) == 0 { return "" } rows := make([]string, 0, len(grid)) for _, row := range grid { var columns []string for _, group := range row { columns = append(columns, group.Content()) } rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, columns...), "") } footer := f.selector.Selected().Footer() return strings.Join(append(rows, footer), "\n") } func (l *layoutGrid) GroupWidth(_ *Form, _ *Group, w int) int { return w / l.columns } ================================================ FILE: option.go ================================================ package huh import "fmt" // Option is an option for select fields. type Option[T comparable] struct { Key string Value T selected bool } // NewOptions returns new options from a list of values. func NewOptions[T comparable](values ...T) []Option[T] { options := make([]Option[T], len(values)) for i, o := range values { options[i] = Option[T]{ Key: fmt.Sprint(o), Value: o, } } return options } // NewOption returns a new select option. func NewOption[T comparable](key string, value T) Option[T] { return Option[T]{Key: key, Value: value} } // Selected sets whether the option is currently selected. func (o Option[T]) Selected(selected bool) Option[T] { o.selected = selected return o } // String returns the key of the option. func (o Option[T]) String() string { return o.Key } ================================================ FILE: run.go ================================================ package huh // Run runs a single field by wrapping it within a group and a form. func Run(field Field) error { group := NewGroup(field) form := NewForm(group).WithShowHelp(false) return form.Run() } ================================================ FILE: spinner/spinner.go ================================================ // Package spinner provides a loading spinner. package spinner import ( "cmp" "context" "io" "os" "strings" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/huh/v2/internal/compat" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" ) // Model is an alias to [compat.Model]. type Model = compat.Model // Spinner represents a loading spinner. // To get started simply create a new spinner and call `Run`. // // s := spinner.New() // s.Run() // // ⣾ Loading... type Spinner struct { spinner spinner.Model action func(ctx context.Context) error ctx context.Context accessible bool title string err error teaOptions []tea.ProgramOption viewHook compat.ViewHook theme Theme output io.Writer // acessible mode output input io.Reader // acessible mode output hasDarkBg bool } // Styles are the spinner styles. type Styles struct { Spinner, Title lipgloss.Style } // Theme represents a theme for a huh. type Theme interface { Theme(isDark bool) *Styles } // ThemeFunc is a function that returns a new theme. type ThemeFunc func(isDark bool) *Styles // Theme implements the Theme interface. func (f ThemeFunc) Theme(isDark bool) *Styles { return f(isDark) } // ThemeDefault is the default theme. func ThemeDefault(isDark bool) *Styles { lightDark := lipgloss.LightDark(isDark) title := lightDark( lipgloss.Color("#00020A"), lipgloss.Color("#FFFDF5"), ) return &Styles{ Spinner: lipgloss.NewStyle().Foreground(lipgloss.Color("#F780E2")), Title: lipgloss.NewStyle().Foreground(title), } } // Type is a set of frames used in animating the spinner. type Type spinner.Spinner // Spinner [Type]s. var ( Line = Type(spinner.Line) Dots = Type(spinner.Dot) MiniDot = Type(spinner.MiniDot) Jump = Type(spinner.Jump) Points = Type(spinner.Points) Pulse = Type(spinner.Pulse) Globe = Type(spinner.Globe) Moon = Type(spinner.Moon) Monkey = Type(spinner.Monkey) Meter = Type(spinner.Meter) Hamburger = Type(spinner.Hamburger) Ellipsis = Type(spinner.Ellipsis) ) // Type sets the type of the spinner. func (s *Spinner) Type(t Type) *Spinner { s.spinner.Spinner = spinner.Spinner(t) return s } // Title sets the title of the spinner. func (s *Spinner) Title(title string) *Spinner { s.title = title return s } // WithOutput set the output for the spinner. // Default is STDOUT when [Spinner.WithAccessible], STDERR otherwise. func (s *Spinner) WithOutput(w io.Writer) *Spinner { s.teaOptions = append(s.teaOptions, tea.WithOutput(w)) s.output = w return s } // WithInput set the input for the spinner. // Default is STDIN. func (s *Spinner) WithInput(r io.Reader) *Spinner { s.teaOptions = append(s.teaOptions, tea.WithInput(r)) s.input = r return s } // WithViewHook allows to set a [compat.ViewHook]. func (s *Spinner) WithViewHook(hook compat.ViewHook) *Spinner { s.viewHook = hook return s } // Action sets the action of the spinner. func (s *Spinner) Action(action func()) *Spinner { s.action = func(context.Context) error { action() return nil } return s } // ActionWithErr sets the action of the spinner. // // This is just like [Spinner.Action], but allows the action to use a `context.Context` // and to return an error. func (s *Spinner) ActionWithErr(action func(context.Context) error) *Spinner { s.action = action return s } // Context sets the context of the spinner. func (s *Spinner) Context(ctx context.Context) *Spinner { s.ctx = ctx return s } // WithAccessible sets the spinner to be static. func (s *Spinner) WithAccessible(accessible bool) *Spinner { s.accessible = accessible return s } // New creates a new spinner. func New() *Spinner { s := spinner.New() s.Spinner = spinner.Dot return &Spinner{ spinner: s, title: "Loading...", theme: ThemeFunc(ThemeDefault), } } // WithTheme sets the theme for the spinner. func (s *Spinner) WithTheme(theme Theme) *Spinner { if theme == nil { return s } s.theme = theme return s } // Init initializes the spinner. func (s *Spinner) Init() tea.Cmd { return tea.Batch( tea.RequestBackgroundColor, s.spinner.Tick, func() tea.Msg { if s.action != nil { err := s.action(s.ctx) return doneMsg{err} } return nil }, ) } // Update updates the spinner. func (s *Spinner) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case tea.BackgroundColorMsg: s.hasDarkBg = msg.IsDark() case doneMsg: s.err = msg.err return s, tea.Quit case tea.KeyPressMsg: switch msg.String() { case "ctrl+c": return s, tea.Interrupt } } var cmd tea.Cmd s.spinner, cmd = s.spinner.Update(msg) return s, cmd } // View returns the spinner view. func (s *Spinner) View() string { styles := s.theme.Theme(s.hasDarkBg) s.spinner.Style = styles.Spinner var title string if s.title != "" { title = styles.Title.Render(s.title) } return s.spinner.View() + title } // Run runs the spinner. func (s *Spinner) Run() error { if s.ctx == nil && s.action == nil { return nil } if s.ctx == nil { s.ctx = context.Background() } if err := s.ctx.Err(); err != nil { return err //nolint:wrapcheck } if s.accessible { out := cmp.Or[io.Writer](s.output, os.Stdout) in := cmp.Or[io.Reader](s.input, os.Stdin) return s.runAccessible(in, out) } opts := append(s.teaOptions, tea.WithContext(s.ctx)) if s.output != nil { opts = append(opts, tea.WithOutput(s.output)) } if s.input != nil { opts = append(opts, tea.WithInput(s.input)) } m, err := tea.NewProgram(compat.ViewModel{ Model: s, ViewHook: s.viewHook, }, opts...).Run() mm := m.(compat.ViewModel).Model.(*Spinner) if mm.err != nil { return mm.err } return err //nolint:wrapcheck } // runAccessible runs the spinner in an accessible mode (statically). func (s *Spinner) runAccessible(in io.Reader, out io.Writer) error { tin, iok := in.(term.File) tout, ook := out.(term.File) s.hasDarkBg = true if iok && ook { s.hasDarkBg = lipgloss.HasDarkBackground(tin, tout) } styles := s.theme.Theme(s.hasDarkBg) _, _ = io.WriteString(out, ansi.HideCursor) frame := s.spinner.Style.Render("...") title := styles.Title.Render(strings.TrimSuffix(s.title, "...")) _, _ = io.WriteString(out, title+frame) defer func() { _, _ = io.WriteString(out, ansi.ShowCursor) }() actionDone := make(chan error) if s.action != nil { go func() { actionDone <- s.action(s.ctx) }() } for { select { case <-s.ctx.Done(): return s.ctx.Err() //nolint:wrapcheck case err := <-actionDone: return err } } } type doneMsg struct { err error } ================================================ FILE: spinner/spinner_test.go ================================================ package spinner import ( "context" "errors" "io" "reflect" "strings" "testing" "time" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) func TestNewSpinner(t *testing.T) { s := New() if s.title != "Loading..." { t.Errorf("Expected default title 'Loading...', got '%s'", s.title) } if !reflect.DeepEqual(s.spinner.Spinner, spinner.Dot) { t.Errorf("Expected default spinner type to be Dot, got %v", s.spinner.Spinner) } } func TestSpinnerType(t *testing.T) { s := New().Type(Dots) if !reflect.DeepEqual(s.spinner.Spinner, spinner.Dot) { t.Errorf("Expected spinner type to be Dot, got %v", s.spinner.Spinner) } } func TestSpinnerDifferentTypes(t *testing.T) { s := New().Type(Line) if !reflect.DeepEqual(s.spinner.Spinner, spinner.Line) { t.Errorf("Expected spinner type to be Line, got %v", s.spinner.Spinner) } } func TestSpinnerView(t *testing.T) { s := New().Title("Test") view := s.View() if !strings.Contains(view, "Test") { t.Errorf("Expected view to contain title 'Test', got '%s'", view) } } func TestSpinnerContextCancellation(t *testing.T) { exercise(t, func() *Spinner { ctx, cancel := context.WithCancel(context.Background()) s := New().Context(ctx) cancel() // Cancel before running return s }, requireContextCanceled) } func TestSpinnerContextCancellationWhileRunning(t *testing.T) { exercise(t, func() *Spinner { ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(250 * time.Millisecond) cancel() }() return New().Context(ctx) }, requireContextCanceled) } func TestSpinnerStyleMethods(t *testing.T) { s := New() theme := ThemeFunc(func(bool) *Styles { return &Styles{ Spinner: lipgloss.NewStyle().Foreground(lipgloss.Color("red")), Title: lipgloss.NewStyle().Foreground(lipgloss.Color("blue")), } }) s.WithTheme(theme).View() styles := s.theme.Theme(true) if !reflect.DeepEqual(s.spinner.Style, styles.Spinner) { t.Errorf("Style was not set correctly") } } func TestSpinnerInit(t *testing.T) { s := New() cmd := s.Init() if cmd == nil { t.Errorf("Init did not return a valid command") } } func TestSpinnerUpdate(t *testing.T) { s := New() cmd := s.Init() if cmd == nil { t.Errorf("Init did not return a valid command") } model, cmd := s.Update(spinner.TickMsg{}) if reflect.TypeOf(model) != reflect.TypeOf(&Spinner{}) { t.Errorf("Update did not return correct model type") } if cmd == nil { t.Errorf("Update should return a non-nil command in this scenario") } // Simulate key press _, cmd = s.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'c'})) if cmd == nil { t.Errorf("Update did not handle key press correctly") } } func TestSpinnerSimple(t *testing.T) { exercise(t, func() *Spinner { return New().Action(func() {}) }, requireNoError) } func TestSpinnerWithContextAndAction(t *testing.T) { exercise(t, func() *Spinner { ctx := context.Background() return New().Context(ctx).Action(func() {}) }, requireNoError) } func TestSpinnerWithActionError(t *testing.T) { fake := errors.New("fake") exercise(t, func() *Spinner { return New().ActionWithErr(func(context.Context) error { return fake }) }, requireErrorIs(fake)) } func exercise(t *testing.T, factory func() *Spinner, checker func(tb testing.TB, err error)) { t.Helper() t.Run("accessible", func(t *testing.T) { err := factory(). WithAccessible(true). WithOutput(io.Discard). WithInput(nilReader{}). Run() checker(t, err) }) t.Run("regular", func(t *testing.T) { err := factory(). WithAccessible(false). WithOutput(io.Discard). WithInput(nilReader{}). Run() checker(t, err) }) } func requireNoError(tb testing.TB, err error) { tb.Helper() if err != nil { tb.Errorf("expected no error, got %v", err) } } func requireErrorIs(target error) func(tb testing.TB, err error) { return func(tb testing.TB, err error) { tb.Helper() if !errors.Is(err, target) { tb.Errorf("expected error to be %v, got %v", target, err) } } } func requireContextCanceled(tb testing.TB, err error) { tb.Helper() switch { case errors.Is(err, context.Canceled): case errors.Is(err, tea.ErrProgramKilled): default: tb.Errorf("expected to get a context canceled error, got %v", err) } } type nilReader struct{} // Read implements io.Reader. func (nilReader) Read([]byte) (int, error) { return 0, nil } ================================================ FILE: theme.go ================================================ package huh import ( "charm.land/bubbles/v2/help" "charm.land/lipgloss/v2" catppuccin "github.com/catppuccin/go" ) // Theme represents a theme for a huh. type Theme interface { Theme(isDark bool) *Styles } // ThemeFunc is a function that returns a new theme. type ThemeFunc func(isDark bool) *Styles // Theme implements the Theme interface. func (f ThemeFunc) Theme(isDark bool) *Styles { return f(isDark) } // Styles is a collection of styles for components of the form. // Themes can be applied to a form using the WithTheme option. type Styles struct { Form FormStyles Group GroupStyles FieldSeparator lipgloss.Style Blurred FieldStyles Focused FieldStyles Help help.Styles } // FormStyles are the styles for a form. type FormStyles struct { Base lipgloss.Style } // GroupStyles are the styles for a group. type GroupStyles struct { Base lipgloss.Style Title lipgloss.Style Description lipgloss.Style } // FieldStyles are the styles for input fields. type FieldStyles struct { Base lipgloss.Style Title lipgloss.Style Description lipgloss.Style ErrorIndicator lipgloss.Style ErrorMessage lipgloss.Style // Select styles. SelectSelector lipgloss.Style // Selection indicator Option lipgloss.Style // Select options NextIndicator lipgloss.Style PrevIndicator lipgloss.Style // FilePicker styles. Directory lipgloss.Style File lipgloss.Style // Multi-select styles. MultiSelectSelector lipgloss.Style SelectedOption lipgloss.Style SelectedPrefix lipgloss.Style UnselectedOption lipgloss.Style UnselectedPrefix lipgloss.Style // Textinput and teatarea styles. TextInput TextInputStyles // Confirm styles. FocusedButton lipgloss.Style BlurredButton lipgloss.Style // Card styles. Card lipgloss.Style NoteTitle lipgloss.Style Next lipgloss.Style } // TextInputStyles are the styles for text inputs. type TextInputStyles struct { Cursor lipgloss.Style CursorText lipgloss.Style Placeholder lipgloss.Style Prompt lipgloss.Style Text lipgloss.Style } const ( buttonPaddingHorizontal = 2 buttonPaddingVertical = 0 ) // ThemeBase returns a new base theme with general styles to be inherited by // other themes. func ThemeBase(bool) *Styles { var t Styles t.Form.Base = lipgloss.NewStyle() t.Group.Base = lipgloss.NewStyle() t.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") button := lipgloss.NewStyle(). Padding(buttonPaddingVertical, buttonPaddingHorizontal). MarginRight(1) // Focused styles. t.Focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) t.Focused.Card = t.Focused.Base t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") t.Focused.SelectSelector = lipgloss.NewStyle().SetString("> ") t.Focused.NextIndicator = lipgloss.NewStyle().MarginLeft(1).SetString("→") t.Focused.PrevIndicator = lipgloss.NewStyle().MarginRight(1).SetString("←") t.Focused.MultiSelectSelector = lipgloss.NewStyle().SetString("> ") t.Focused.SelectedPrefix = lipgloss.NewStyle().SetString("[•] ") t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("[ ] ") t.Focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) t.Focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) t.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) t.Help = help.New().Styles // Blurred styles. t.Blurred = t.Focused t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) t.Blurred.Card = t.Blurred.Base t.Blurred.MultiSelectSelector = lipgloss.NewStyle().SetString(" ") t.Blurred.NextIndicator = lipgloss.NewStyle() t.Blurred.PrevIndicator = lipgloss.NewStyle() return &t } // ThemeCharm returns a new theme based on the Charm color scheme. func ThemeCharm(isDark bool) *Styles { t := ThemeBase(isDark) lightDark := lipgloss.LightDark(isDark) var ( normalFg = lightDark(lipgloss.Color("252"), lipgloss.Color("235")) indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) cream = lightDark(lipgloss.Color("#FFFDF5"), lipgloss.Color("#FFFDF5")) fuchsia = lipgloss.Color("#F780E2") green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) red = lightDark(lipgloss.Color("#FF4672"), lipgloss.Color("#ED567A")) ) t.Focused.Base = t.Focused.Base.BorderForeground(lipgloss.Color("238")) t.Focused.Card = t.Focused.Base t.Focused.Title = t.Focused.Title.Foreground(indigo).Bold(true) t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(indigo).Bold(true).MarginBottom(1) t.Focused.Directory = t.Focused.Directory.Foreground(indigo) t.Focused.Description = t.Focused.Description.Foreground(lightDark(lipgloss.Color(""), lipgloss.Color("243"))) t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(fuchsia) t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(fuchsia) t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(fuchsia) t.Focused.Option = t.Focused.Option.Foreground(normalFg) t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(fuchsia) t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("#02CF92"), lipgloss.Color("#02A877"))).SetString("✓ ") t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color(""), lipgloss.Color("243"))).SetString("• ") t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(normalFg) t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(cream).Background(fuchsia) t.Focused.Next = t.Focused.FocusedButton t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lightDark(lipgloss.Color("237"), lipgloss.Color("252"))) t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(green) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(lightDark(lipgloss.Color("248"), lipgloss.Color("238"))) t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(fuchsia) t.Blurred = t.Focused t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) t.Blurred.Card = t.Blurred.Base t.Blurred.NextIndicator = lipgloss.NewStyle() t.Blurred.PrevIndicator = lipgloss.NewStyle() t.Group.Title = t.Focused.Title t.Group.Description = t.Focused.Description return t } // ThemeDracula returns a new theme based on the Dracula color scheme. func ThemeDracula(isDark bool) *Styles { t := ThemeBase(isDark) var ( background = lipgloss.Color("#282a36") selection = lipgloss.Color("#44475a") foreground = lipgloss.Color("#f8f8f2") comment = lipgloss.Color("#6272a4") green = lipgloss.Color("#50fa7b") purple = lipgloss.Color("#bd93f9") red = lipgloss.Color("#ff5555") yellow = lipgloss.Color("#f1fa8c") ) t.Focused.Base = t.Focused.Base.BorderForeground(selection) t.Focused.Card = t.Focused.Base t.Focused.Title = t.Focused.Title.Foreground(purple) t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(purple) t.Focused.Description = t.Focused.Description.Foreground(comment) t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) t.Focused.Directory = t.Focused.Directory.Foreground(purple) t.Focused.File = t.Focused.File.Foreground(foreground) t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(yellow) t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(yellow) t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(yellow) t.Focused.Option = t.Focused.Option.Foreground(foreground) t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(yellow) t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(green) t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(foreground) t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(comment) t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(yellow).Background(purple).Bold(true) t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(foreground).Background(background) t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(yellow) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(comment) t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(yellow) t.Blurred = t.Focused t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) t.Blurred.Card = t.Blurred.Base t.Blurred.NextIndicator = lipgloss.NewStyle() t.Blurred.PrevIndicator = lipgloss.NewStyle() t.Group.Title = t.Focused.Title t.Group.Description = t.Focused.Description return t } // ThemeBase16 returns a new theme based on the base16 color scheme. func ThemeBase16(isDark bool) *Styles { t := ThemeBase(isDark) t.Focused.Base = t.Focused.Base.BorderForeground(lipgloss.Color("8")) t.Focused.Card = t.Focused.Base t.Focused.Title = t.Focused.Title.Foreground(lipgloss.Color("6")) t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(lipgloss.Color("6")) t.Focused.Directory = t.Focused.Directory.Foreground(lipgloss.Color("6")) t.Focused.Description = t.Focused.Description.Foreground(lipgloss.Color("8")) t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(lipgloss.Color("9")) t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(lipgloss.Color("9")) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(lipgloss.Color("3")) t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(lipgloss.Color("3")) t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(lipgloss.Color("3")) t.Focused.Option = t.Focused.Option.Foreground(lipgloss.Color("7")) t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(lipgloss.Color("3")) t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(lipgloss.Color("2")) t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(lipgloss.Color("2")) t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(lipgloss.Color("7")) t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("5")) t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) t.Focused.TextInput.Cursor.Foreground(lipgloss.Color("5")) t.Focused.TextInput.Placeholder.Foreground(lipgloss.Color("8")) t.Focused.TextInput.Prompt.Foreground(lipgloss.Color("3")) t.Blurred = t.Focused t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) t.Blurred.Card = t.Blurred.Base t.Blurred.NoteTitle = t.Blurred.NoteTitle.Foreground(lipgloss.Color("8")) t.Blurred.Title = t.Blurred.NoteTitle.Foreground(lipgloss.Color("8")) t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(lipgloss.Color("8")) t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(lipgloss.Color("7")) t.Blurred.NextIndicator = lipgloss.NewStyle() t.Blurred.PrevIndicator = lipgloss.NewStyle() t.Group.Title = t.Focused.Title t.Group.Description = t.Focused.Description return t } // ThemeCatppuccin returns a new theme based on the Catppuccin color scheme. func ThemeCatppuccin(isDark bool) *Styles { t := ThemeBase(isDark) flavour := catppuccin.Latte if isDark { flavour = catppuccin.Mocha } var ( base = flavour.Base() text = flavour.Text() subtext1 = flavour.Subtext1() subtext0 = flavour.Subtext0() overlay1 = flavour.Overlay1() overlay0 = flavour.Overlay0() green = flavour.Green() red = flavour.Red() pink = flavour.Pink() mauve = flavour.Mauve() cursor = flavour.Rosewater() ) t.Focused.Base = t.Focused.Base.BorderForeground(subtext1) t.Focused.Card = t.Focused.Base t.Focused.Title = t.Focused.Title.Foreground(mauve) t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(mauve) t.Focused.Directory = t.Focused.Directory.Foreground(mauve) t.Focused.Description = t.Focused.Description.Foreground(subtext0) t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(pink) t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(pink) t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(pink) t.Focused.Option = t.Focused.Option.Foreground(text) t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(pink) t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(green) t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(text) t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(text) t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(base).Background(pink) t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(base) t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(cursor) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(overlay0) t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(pink) t.Blurred = t.Focused t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) t.Blurred.Card = t.Blurred.Base t.Help.Ellipsis = t.Help.Ellipsis.Foreground(subtext0) t.Help.ShortKey = t.Help.ShortKey.Foreground(subtext0) t.Help.ShortDesc = t.Help.ShortDesc.Foreground(overlay1) t.Help.ShortSeparator = t.Help.ShortSeparator.Foreground(subtext0) t.Help.FullKey = t.Help.FullKey.Foreground(subtext0) t.Help.FullDesc = t.Help.FullDesc.Foreground(overlay1) t.Help.FullSeparator = t.Help.FullSeparator.Foreground(subtext0) t.Group.Title = t.Focused.Title t.Group.Description = t.Focused.Description return t } ================================================ FILE: validate.go ================================================ package huh import ( "fmt" "unicode/utf8" ) // ValidateNotEmpty checks if the input is not empty. func ValidateNotEmpty() func(s string) error { return func(s string) error { if err := ValidateMinLength(1)(s); err != nil { return fmt.Errorf("input cannot be empty") } return nil } } // ValidateMinLength checks if the length of the input is at least min. func ValidateMinLength(v int) func(s string) error { return func(s string) error { if utf8.RuneCountInString(s) < v { return fmt.Errorf("input must be at least %d characters long", v) } return nil } } // ValidateMaxLength checks if the length of the input is at most max. func ValidateMaxLength(v int) func(s string) error { return func(s string) error { if utf8.RuneCountInString(s) > v { return fmt.Errorf("input must be at most %d characters long", v) } return nil } } // ValidateLength checks if the length of the input is within the specified range. func ValidateLength(minl, maxl int) func(s string) error { return func(s string) error { if err := ValidateMinLength(minl)(s); err != nil { return err } return ValidateMaxLength(maxl)(s) } } // ValidateOneOf checks if a string is one of the specified options. func ValidateOneOf(options ...string) func(string) error { validOptions := make(map[string]struct{}) for _, option := range options { validOptions[option] = struct{}{} } return func(value string) error { if _, ok := validOptions[value]; !ok { return fmt.Errorf("invalid option: %s", value) } return nil } } ================================================ FILE: wrap.go ================================================ package huh import "charm.land/lipgloss/v2" func wrap(s string, limit int) string { return lipgloss.Wrap(s, limit, ",.-; ") } ================================================ FILE: zz_resize_width_test.go ================================================ package huh import "testing" func TestSelectWithWidthUpdatesViewportWidth(t *testing.T) { f := NewSelect[string](). Title("Pick one"). Options( NewOption("Option 1", "1"), NewOption("Option 2", "2"), ) f.WithWidth(18) if got, want := f.viewport.Width(), 18; got != want { t.Fatalf("viewport width after first WithWidth = %d, want %d", got, want) } f.WithWidth(42) if got, want := f.viewport.Width(), 42; got != want { t.Fatalf("viewport width after resize WithWidth = %d, want %d", got, want) } } func TestMultiSelectWithWidthUpdatesViewportWidth(t *testing.T) { f := NewMultiSelect[string](). Title("Pick many"). Options( NewOption("Option 1", "1"), NewOption("Option 2", "2"), ) f.WithWidth(20) if got, want := f.viewport.Width(), 20; got != want { t.Fatalf("viewport width after first WithWidth = %d, want %d", got, want) } f.WithWidth(44) if got, want := f.viewport.Width(), 44; got != want { t.Fatalf("viewport width after resize WithWidth = %d, want %d", got, want) } }