Showing preview only (357K chars total). Download the full file or copy to clipboard to get everything.
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?
<p>
<img src="https://stuff.charm.sh/huh/glenn.png" width="400" />
<br><br>
<a href="https://github.com/charmbracelet/huh/releases"><img src="https://img.shields.io/github/release/charmbracelet/huh.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/charm.land/huh/v2#section-documentation"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="Go Docs"></a>
<a href="https://github.com/charmbracelet/huh/actions"><img src="https://github.com/charmbracelet/huh/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status"></a>
</p>
A simple, powerful library for building interactive forms and prompts in the terminal.
<img alt="Running a burger form" width="600" src="https://vhs.charm.sh/vhs-3J4i6HE3yBmz6SUO3HqILr.gif">
`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.
<img alt="Input field" width="600" src="https://vhs.charm.sh/vhs-1ULe9JbTHfwFmm3hweRVtD.gif">
```go
huh.NewInput().
Title("What’s for lunch?").
Prompt("?").
Validate(isFood).
Value(&lunch)
```
### Text
Prompt the user for multiple lines of text.
<img alt="Text field" width="600" src="https://vhs.charm.sh/vhs-2rrIuVSEf38bT0cwc8hfEG.gif">
```go
huh.NewText().
Title("Tell me a story.").
Validate(checkForPlagiarism).
Value(&story)
```
### Select
Prompt the user to select a single option from a list.
<img alt="Select field" width="600" src="https://vhs.charm.sh/vhs-7wFqZlxMWgbWmOIpBqXJTi.gif">
```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.
<img alt="Multiselect field" width="600" src="https://vhs.charm.sh/vhs-3TLImcoexOehRNLELysMpK.gif">
```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).
<img alt="Confirm field" width="600" src="https://vhs.charm.sh/vhs-2HeX5MdOxLsrWwsa0TNMIL.gif">
```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.
<img alt="Accessible cuisine form" width="600" src="https://vhs.charm.sh/vhs-19xEBn4LgzPZDtgzXRRJYS.gif">
## 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`
<br />
<p>
<img alt="Charm-themed form" width="400" src="https://stuff.charm.sh/huh/themes/charm-theme.png">
<img alt="Dracula-themed form" width="400" src="https://stuff.charm.sh/huh/themes/dracula-theme.png">
<img alt="Catppuccin-themed form" width="400" src="https://stuff.charm.sh/huh/themes/catppuccin-theme.png">
<img alt="Base 16-themed form" width="400" src="https://stuff.charm.sh/huh/themes/basesixteen-theme.png">
<img alt="Default-themed form" width="400" src="https://stuff.charm.sh/huh/themes/default-theme.png">
</p>
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)
}
```
<img width="600" src="https://vhs.charm.sh/vhs-6FRmBjNi2aiRb4INPXwIjo.gif" alt="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.
<img alt="Spinner while making a burger" width="600" src="https://vhs.charm.sh/vhs-6HvYomAFP6H8mngOYWXvwJ.gif">
Create a new spinner, set a title, set the action (or provide a `Context`), and run the spinner:
<table>
<tr>
<td> <strong>Action Style</strong> </td><td> <strong>Context Style</strong> </td></tr>
<tr>
<td>
```go
err := spinner.New().
Title("Making your burger...").
Action(makeBurger).
Run()
fmt.Println("Order up!")
```
</td>
<td>
```go
go makeBurger()
err := spinner.New().
Type(spinner.Line).
Title("Making your burger...").
Context(ctx).
Run()
fmt.Println("Order up!")
```
</td>
</tr>
</table>
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?
<img alt="Bubbletea + Huh?" width="174" src="https://stuff.charm.sh/huh/bubbletea-huh.png">
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.
<img alt="Bubble Tea embedded form example" width="800" src="https://vhs.charm.sh/vhs-3wGaB7EUKWmojeaHpARMUv.gif">
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).
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
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).
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
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 <input | text>")
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(
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
SYMBOL INDEX (687 symbols across 71 files)
FILE: accessor.go
type Accessor (line 4) | type Accessor interface
type EmbeddedAccessor (line 10) | type EmbeddedAccessor struct
method Get (line 15) | func (a *EmbeddedAccessor[T]) Get() T {
method Set (line 20) | func (a *EmbeddedAccessor[T]) Set(value T) {
type PointerAccessor (line 25) | type PointerAccessor struct
function NewPointerAccessor (line 30) | func NewPointerAccessor[T any](value *T) *PointerAccessor[T] {
method Get (line 37) | func (a *PointerAccessor[T]) Get() T {
method Set (line 42) | func (a *PointerAccessor[T]) Set(value T) {
FILE: eval.go
type Eval (line 14) | type Eval struct
constant spinnerShowThreshold (line 26) | spinnerShowThreshold = 25 * time.Millisecond
function hash (line 28) | func hash(val any) uint64 {
method shouldUpdate (line 33) | func (e *Eval[T]) shouldUpdate() (bool, uint64) {
method loadFromCache (line 41) | func (e *Eval[T]) loadFromCache() bool {
method update (line 50) | func (e *Eval[T]) update(val T) {
type updateTitleMsg (line 56) | type updateTitleMsg struct
type updateDescriptionMsg (line 62) | type updateDescriptionMsg struct
type updatePlaceholderMsg (line 68) | type updatePlaceholderMsg struct
type updateSuggestionsMsg (line 74) | type updateSuggestionsMsg struct
type updateOptionsMsg (line 80) | type updateOptionsMsg struct
FILE: examples/accessibility-secure-input/main.go
function validate (line 10) | func validate(s string) error {
function main (line 17) | func main() {
FILE: examples/accessibility/main.go
function main (line 9) | func main() {
FILE: examples/bubbletea-options/main.go
function main (line 10) | func main() {
FILE: examples/bubbletea/main.go
constant maxWidth (line 14) | maxWidth = 80
type Styles (line 16) | type Styles struct
function NewStyles (line 28) | func NewStyles(hasDarkBg bool) *Styles {
type state (line 60) | type state
constant statusNormal (line 63) | statusNormal state = iota
constant stateDone (line 64) | stateDone
type Model (line 67) | type Model struct
method Init (line 114) | func (m Model) Init() tea.Cmd {
method Update (line 125) | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 158) | func (m Model) View() tea.View {
method errorView (line 228) | func (m Model) errorView() string {
method appBoundaryView (line 236) | func (m Model) appBoundaryView(text string) string {
method appErrorBoundaryView (line 247) | func (m Model) appErrorBoundaryView(text string) string {
method getRole (line 258) | func (m Model) getRole() (string, string) {
function NewModel (line 75) | func NewModel() Model {
function min (line 118) | func min(x, y int) int {
function main (line 293) | func main() {
FILE: examples/burger/main.go
type Spice (line 17) | type Spice
method String (line 25) | func (s Spice) String() string {
constant Mild (line 20) | Mild Spice = iota + 1
constant Medium (line 21) | Medium
constant Hot (line 22) | Hot
type Order (line 38) | type Order struct
type Burger (line 46) | type Burger struct
function main (line 52) | func main() {
FILE: examples/conditional/main.go
type consumable (line 10) | type consumable
method String (line 18) | func (c consumable) String() string {
constant fruits (line 13) | fruits consumable = iota
constant vegetables (line 14) | vegetables
constant drinks (line 15) | drinks
function main (line 22) | func main() {
FILE: examples/dynamic/dynamic-all/main.go
function main (line 10) | func main() {
FILE: examples/dynamic/dynamic-bubbletea/main.go
constant maxWidth (line 14) | maxWidth = 80
type Styles (line 16) | type Styles struct
function NewStyles (line 28) | func NewStyles(hasDarkBg bool) *Styles {
type state (line 60) | type state
constant statusNormal (line 63) | statusNormal state = iota
constant stateDone (line 64) | stateDone
type Model (line 67) | type Model struct
method Init (line 122) | func (m Model) Init() tea.Cmd {
method Update (line 133) | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 164) | func (m Model) View() tea.View {
method errorView (line 234) | func (m Model) errorView() string {
method appBoundaryView (line 242) | func (m Model) appBoundaryView(text string) string {
method appErrorBoundaryView (line 253) | func (m Model) appErrorBoundaryView(text string) string {
method getRole (line 264) | func (m Model) getRole() (string, string) {
function NewModel (line 75) | func NewModel() Model {
function min (line 126) | func min(x, y int) int {
function main (line 299) | func main() {
FILE: examples/dynamic/dynamic-count/main.go
function main (line 12) | func main() {
FILE: examples/dynamic/dynamic-country/main.go
function main (line 11) | func main() {
FILE: examples/dynamic/dynamic-increment/main.go
function main (line 10) | func main() {
FILE: examples/dynamic/dynamic-markdown/main.go
function main (line 10) | func main() {
FILE: examples/dynamic/dynamic-name/main.go
function main (line 10) | func main() {
FILE: examples/dynamic/dynamic-suggestions/main.go
function main (line 11) | func main() {
FILE: examples/filepicker-picking/main.go
function main (line 9) | func main() {
FILE: examples/filepicker/main.go
function main (line 7) | func main() {
FILE: examples/gh/create.go
type Action (line 13) | type Action
constant Cancel (line 16) | Cancel Action = iota
constant Push (line 17) | Push
constant Fork (line 18) | Fork
constant Skip (line 19) | Skip
function customTheme (line 24) | func customTheme(isDark bool) *huh.Styles {
function main (line 31) | func main() {
FILE: examples/git/main.go
function main (line 14) | func main() {
FILE: examples/gum/main.go
function main (line 10) | func main() {
FILE: examples/help/main.go
function main (line 5) | func main() {
FILE: examples/hide/main.go
function main (line 9) | func main() {
FILE: examples/layout/columns/main.go
function main (line 5) | func main() {
FILE: examples/layout/default/main.go
function main (line 5) | func main() {
FILE: examples/layout/grid/main.go
function main (line 5) | func main() {
FILE: examples/layout/stack/main.go
function main (line 5) | func main() {
FILE: examples/multiple-groups/main.go
function main (line 10) | func main() {
FILE: examples/readme/confirm/main.go
function main (line 7) | func main() {
FILE: examples/readme/input/main.go
function isFood (line 9) | func isFood(_ string) error {
function main (line 13) | func main() {
FILE: examples/readme/main/main.go
function checkForPlagiarism (line 10) | func checkForPlagiarism(s string) error { return nil }
function isFood (line 13) | func isFood(s string) error { return nil }
function validateName (line 16) | func validateName(s string) error { return nil }
function main (line 18) | func main() {
FILE: examples/readme/multiselect/main.go
function main (line 5) | func main() {
FILE: examples/readme/note/main.go
function main (line 5) | func main() {
FILE: examples/readme/select/main.go
function main (line 5) | func main() {
FILE: examples/readme/select/scroll/scroll.go
type Pokemon (line 5) | type Pokemon struct
method String (line 41) | func (p Pokemon) String() string {
function main (line 45) | func main() {
FILE: examples/readme/text/main.go
function checkForPlagiarism (line 6) | func checkForPlagiarism(s string) error { return nil }
function main (line 8) | func main() {
FILE: examples/scroll/main.go
function main (line 5) | func main() {
FILE: examples/skip/main.go
function main (line 7) | func main() {
FILE: examples/spinner/accessible/main.go
function main (line 11) | func main() {
FILE: examples/spinner/context-and-action-and-error/main.go
function main (line 12) | func main() {
FILE: examples/spinner/context-and-action/main.go
function main (line 13) | func main() {
FILE: examples/spinner/context/main.go
function main (line 11) | func main() {
FILE: examples/spinner/loading/main.go
function main (line 11) | func main() {
FILE: examples/spinner/static/main.go
function main (line 9) | func main() {
FILE: examples/ssh-form/main.go
constant host (line 24) | host = "localhost"
constant port (line 25) | port = "2222"
function main (line 28) | func main() {
function customTheme (line 62) | func customTheme(hasDarkBg bool) *huh.Styles {
function teaHandler (line 80) | func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
type model (line 96) | type model struct
method Init (line 102) | func (m model) Init() tea.Cmd { return m.form.Init() }
method Update (line 104) | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 129) | func (m model) View() tea.View {
FILE: examples/stickers/main.go
function main (line 7) | func main() {
FILE: examples/theme/main.go
function main (line 18) | func main() {
FILE: examples/timer/main.go
type mode (line 29) | type mode
constant Initial (line 32) | Initial mode = iota
constant Focusing (line 33) | Focusing
constant Paused (line 34) | Paused
constant Breaking (line 35) | Breaking
type Model (line 38) | type Model struct
method Init (line 53) | func (m Model) Init() tea.Cmd {
method Update (line 65) | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 131) | func (m Model) View() tea.View {
constant tickInterval (line 57) | tickInterval = time.Second / 2
type tickMsg (line 59) | type tickMsg
function tickCmd (line 61) | func tickCmd(t time.Time) tea.Msg {
function customTheme (line 168) | func customTheme(isDark bool) *huh.Styles {
function NewModel (line 178) | func NewModel() Model {
function main (line 214) | func main() {
FILE: field_confirm.go
type Confirm (line 15) | type Confirm struct
method Validate (line 58) | func (c *Confirm) Validate(validate func(bool) error) *Confirm {
method Error (line 64) | func (c *Confirm) Error() error {
method Skip (line 69) | func (*Confirm) Skip() bool {
method Zoom (line 74) | func (*Confirm) Zoom() bool {
method Affirmative (line 79) | func (c *Confirm) Affirmative(affirmative string) *Confirm {
method Negative (line 85) | func (c *Confirm) Negative(negative string) *Confirm {
method Value (line 91) | func (c *Confirm) Value(value *bool) *Confirm {
method Accessor (line 96) | func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm {
method Key (line 102) | func (c *Confirm) Key(key string) *Confirm {
method Title (line 108) | func (c *Confirm) Title(title string) *Confirm {
method TitleFunc (line 115) | func (c *Confirm) TitleFunc(f func() string, bindings any) *Confirm {
method Description (line 122) | func (c *Confirm) Description(description string) *Confirm {
method DescriptionFunc (line 129) | func (c *Confirm) DescriptionFunc(f func() string, bindings any) *Conf...
method Inline (line 136) | func (c *Confirm) Inline(inline bool) *Confirm {
method Focus (line 142) | func (c *Confirm) Focus() tea.Cmd {
method Blur (line 148) | func (c *Confirm) Blur() tea.Cmd {
method KeyBinds (line 155) | func (c *Confirm) KeyBinds() []key.Binding {
method Init (line 160) | func (c *Confirm) Init() tea.Cmd {
method Update (line 165) | func (c *Confirm) Update(msg tea.Msg) (Model, tea.Cmd) {
method activeStyles (line 225) | func (c *Confirm) activeStyles() *FieldStyles {
method View (line 237) | func (c *Confirm) View() string {
method Run (line 299) | func (c *Confirm) Run() error {
method RunAccessible (line 304) | func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error {
method String (line 318) | func (c *Confirm) String() string {
method WithTheme (line 326) | func (c *Confirm) WithTheme(theme Theme) Field {
method WithKeyMap (line 335) | func (c *Confirm) WithKeyMap(k *KeyMap) Field {
method WithWidth (line 341) | func (c *Confirm) WithWidth(width int) Field {
method WithHeight (line 347) | func (c *Confirm) WithHeight(height int) Field {
method WithPosition (line 353) | func (c *Confirm) WithPosition(p FieldPosition) Field {
method WithButtonAlignment (line 361) | func (c *Confirm) WithButtonAlignment(p lipgloss.Position) *Confirm {
method GetKey (line 367) | func (c *Confirm) GetKey() string {
method GetValue (line 372) | func (c *Confirm) GetValue() any {
function NewConfirm (line 44) | func NewConfirm() *Confirm {
FILE: field_filepicker.go
type FilePicker (line 20) | type FilePicker struct
method CurrentDirectory (line 62) | func (f *FilePicker) CurrentDirectory(directory string) *FilePicker {
method Cursor (line 71) | func (f *FilePicker) Cursor(cursor string) *FilePicker {
method Picking (line 77) | func (f *FilePicker) Picking(v bool) *FilePicker {
method ShowHidden (line 83) | func (f *FilePicker) ShowHidden(v bool) *FilePicker {
method ShowSize (line 89) | func (f *FilePicker) ShowSize(v bool) *FilePicker {
method ShowPermissions (line 95) | func (f *FilePicker) ShowPermissions(v bool) *FilePicker {
method FileAllowed (line 101) | func (f *FilePicker) FileAllowed(v bool) *FilePicker {
method DirAllowed (line 107) | func (f *FilePicker) DirAllowed(v bool) *FilePicker {
method Value (line 113) | func (f *FilePicker) Value(value *string) *FilePicker {
method Accessor (line 118) | func (f *FilePicker) Accessor(accessor Accessor[string]) *FilePicker {
method Key (line 125) | func (f *FilePicker) Key(key string) *FilePicker {
method Title (line 131) | func (f *FilePicker) Title(title string) *FilePicker {
method Description (line 137) | func (f *FilePicker) Description(description string) *FilePicker {
method AllowedTypes (line 144) | func (f *FilePicker) AllowedTypes(types []string) *FilePicker {
method Height (line 151) | func (f *FilePicker) Height(height int) *FilePicker {
method Validate (line 157) | func (f *FilePicker) Validate(validate func(string) error) *FilePicker {
method Error (line 163) | func (f *FilePicker) Error() error {
method Skip (line 168) | func (*FilePicker) Skip() bool {
method Zoom (line 173) | func (f *FilePicker) Zoom() bool {
method Focus (line 178) | func (f *FilePicker) Focus() tea.Cmd {
method Blur (line 184) | func (f *FilePicker) Blur() tea.Cmd {
method KeyBinds (line 192) | func (f *FilePicker) KeyBinds() []key.Binding {
method Init (line 197) | func (f *FilePicker) Init() tea.Cmd {
method Update (line 202) | func (f *FilePicker) Update(msg tea.Msg) (Model, tea.Cmd) {
method activeStyles (line 245) | func (f *FilePicker) activeStyles() *FieldStyles {
method renderTitle (line 256) | func (f *FilePicker) renderTitle() string {
method renderDescription (line 262) | func (f FilePicker) renderDescription() string {
method View (line 269) | func (f *FilePicker) View() string {
method pickerView (line 283) | func (f *FilePicker) pickerView() string {
method setPicking (line 294) | func (f *FilePicker) setPicking(v bool) {
method Run (line 313) | func (f *FilePicker) Run() error {
method RunAccessible (line 318) | func (f *FilePicker) RunAccessible(w io.Writer, r io.Reader) error {
method WithTheme (line 363) | func (f *FilePicker) WithTheme(theme Theme) Field {
method WithKeyMap (line 389) | func (f *FilePicker) WithKeyMap(k *KeyMap) Field {
method WithWidth (line 407) | func (f *FilePicker) WithWidth(width int) Field {
method WithHeight (line 413) | func (f *FilePicker) WithHeight(height int) Field {
method WithPosition (line 430) | func (f *FilePicker) WithPosition(p FieldPosition) Field {
method GetKey (line 438) | func (f *FilePicker) GetKey() string {
method GetValue (line 443) | func (f *FilePicker) GetValue() any {
function NewFilePicker (line 46) | func NewFilePicker() *FilePicker {
constant fileSizeWidth (line 358) | fileSizeWidth = 7
constant paddingLeft (line 359) | paddingLeft = 2
FILE: field_input.go
type Input (line 23) | type Input struct
method Value (line 72) | func (i *Input) Value(value *string) *Input {
method Accessor (line 77) | func (i *Input) Accessor(accessor Accessor[string]) *Input {
method Key (line 84) | func (i *Input) Key(key string) *Input {
method Title (line 92) | func (i *Input) Title(title string) *Input {
method Description (line 101) | func (i *Input) Description(description string) *Input {
method TitleFunc (line 114) | func (i *Input) TitleFunc(f func() string, bindings any) *Input {
method DescriptionFunc (line 127) | func (i *Input) DescriptionFunc(f func() string, bindings any) *Input {
method Prompt (line 134) | func (i *Input) Prompt(prompt string) *Input {
method CharLimit (line 140) | func (i *Input) CharLimit(charlimit int) *Input {
method Suggestions (line 149) | func (i *Input) Suggestions(suggestions []string) *Input {
method SuggestionsFunc (line 166) | func (i *Input) SuggestionsFunc(f func() []string, bindings any) *Input {
method EchoMode (line 194) | func (i *Input) EchoMode(mode EchoMode) *Input {
method Password (line 202) | func (i *Input) Password(password bool) *Input {
method Placeholder (line 212) | func (i *Input) Placeholder(str string) *Input {
method PlaceholderFunc (line 218) | func (i *Input) PlaceholderFunc(f func() string, bindings any) *Input {
method Inline (line 225) | func (i *Input) Inline(inline bool) *Input {
method Validate (line 231) | func (i *Input) Validate(validate func(string) error) *Input {
method Error (line 237) | func (i *Input) Error() error { return i.err }
method Skip (line 240) | func (*Input) Skip() bool { return false }
method Zoom (line 243) | func (*Input) Zoom() bool { return false }
method Focus (line 246) | func (i *Input) Focus() tea.Cmd {
method Blur (line 252) | func (i *Input) Blur() tea.Cmd {
method KeyBinds (line 261) | func (i *Input) KeyBinds() []key.Binding {
method Init (line 269) | func (i *Input) Init() tea.Cmd {
method Update (line 275) | func (i *Input) Update(msg tea.Msg) (Model, tea.Cmd) {
method activeStyles (line 368) | func (i *Input) activeStyles() *FieldStyles {
method View (line 380) | func (i *Input) View() string {
method Run (line 421) | func (i *Input) Run() error {
method run (line 426) | func (i *Input) run() error {
method RunAccessible (line 431) | func (i *Input) RunAccessible(w io.Writer, r io.Reader) error {
method WithKeyMap (line 465) | func (i *Input) WithKeyMap(k *KeyMap) Field {
method WithTheme (line 472) | func (i *Input) WithTheme(theme Theme) Field {
method WithWidth (line 481) | func (i *Input) WithWidth(width int) Field {
method WithHeight (line 496) | func (i *Input) WithHeight(height int) Field {
method WithPosition (line 502) | func (i *Input) WithPosition(p FieldPosition) Field {
method GetKey (line 510) | func (i *Input) GetKey() string { return i.key }
method GetValue (line 513) | func (i *Input) GetValue() any {
function NewInput (line 54) | func NewInput() *Input {
type EchoMode (line 177) | type EchoMode
constant EchoModeNormal (line 182) | EchoModeNormal EchoMode = EchoMode(textinput.EchoNormal)
constant EchoModePassword (line 186) | EchoModePassword EchoMode = EchoMode(textinput.EchoPassword)
constant EchoModeNone (line 190) | EchoModeNone EchoMode = EchoMode(textinput.EchoNone)
FILE: field_multiselect.go
type MultiSelect (line 22) | type MultiSelect struct
function NewMultiSelect (line 56) | func NewMultiSelect[T comparable]() *MultiSelect[T] {
method Value (line 77) | func (m *MultiSelect[T]) Value(value *[]T) *MultiSelect[T] {
method Accessor (line 82) | func (m *MultiSelect[T]) Accessor(accessor Accessor[[]T]) *MultiSelect[T] {
method Key (line 94) | func (m *MultiSelect[T]) Key(key string) *MultiSelect[T] {
method Title (line 100) | func (m *MultiSelect[T]) Title(title string) *MultiSelect[T] {
method TitleFunc (line 107) | func (m *MultiSelect[T]) TitleFunc(f func() string, bindings any) *Multi...
method Description (line 114) | func (m *MultiSelect[T]) Description(description string) *MultiSelect[T] {
method DescriptionFunc (line 120) | func (m *MultiSelect[T]) DescriptionFunc(f func() string, bindings any) ...
method Options (line 127) | func (m *MultiSelect[T]) Options(options ...Option[T]) *MultiSelect[T] {
method selectOptions (line 139) | func (m *MultiSelect[T]) selectOptions() {
method OptionsFunc (line 160) | func (m *MultiSelect[T]) OptionsFunc(f func() []Option[T], bindings any)...
method Filterable (line 177) | func (m *MultiSelect[T]) Filterable(filterable bool) *MultiSelect[T] {
method Filtering (line 183) | func (m *MultiSelect[T]) Filtering(filtering bool) *MultiSelect[T] {
method Limit (line 190) | func (m *MultiSelect[T]) Limit(limit int) *MultiSelect[T] {
method Width (line 197) | func (m *MultiSelect[T]) Width(width int) *MultiSelect[T] {
method Height (line 206) | func (m *MultiSelect[T]) Height(height int) *MultiSelect[T] {
method Validate (line 215) | func (m *MultiSelect[T]) Validate(validate func([]T) error) *MultiSelect...
method Error (line 221) | func (m *MultiSelect[T]) Error() error {
method Skip (line 226) | func (*MultiSelect[T]) Skip() bool {
method Zoom (line 231) | func (*MultiSelect[T]) Zoom() bool {
method Focus (line 236) | func (m *MultiSelect[T]) Focus() tea.Cmd {
method Blur (line 243) | func (m *MultiSelect[T]) Blur() tea.Cmd {
method Hovered (line 252) | func (m *MultiSelect[T]) Hovered() (T, bool) {
method KeyBinds (line 261) | func (m *MultiSelect[T]) KeyBinds() []key.Binding {
method Init (line 288) | func (m *MultiSelect[T]) Init() tea.Cmd {
method Update (line 293) | func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
method updateViewportSize (line 495) | func (m *MultiSelect[T]) updateViewportSize() {
method numSelected (line 518) | func (m *MultiSelect[T]) numSelected() int {
method numFilteredSelected (line 530) | func (m *MultiSelect[T]) numFilteredSelected() int {
method updateValue (line 540) | func (m *MultiSelect[T]) updateValue() {
method activeStyles (line 551) | func (m *MultiSelect[T]) activeStyles() *FieldStyles {
method titleView (line 562) | func (m *MultiSelect[T]) titleView() string {
method descriptionView (line 585) | func (m *MultiSelect[T]) descriptionView() string {
method renderOption (line 593) | func (m *MultiSelect[T]) renderOption(option Option[T], cursor, selected...
method cursorLineOffset (line 613) | func (m *MultiSelect[T]) cursorLineOffset() (offset int, height int) {
method ensureCursorVisible (line 628) | func (m *MultiSelect[T]) ensureCursorVisible() {
method optionsView (line 633) | func (m *MultiSelect[T]) optionsView() (string, int, int) {
method View (line 667) | func (m *MultiSelect[T]) View() string {
method printOptions (line 686) | func (m *MultiSelect[T]) printOptions(w io.Writer) {
method setFilter (line 702) | func (m *MultiSelect[T]) setFilter(filter bool) {
method filterFunc (line 713) | func (m *MultiSelect[T]) filterFunc(option string) bool {
method setSelectAllHelp (line 719) | func (m *MultiSelect[T]) setSelectAllHelp() {
method Run (line 734) | func (m *MultiSelect[T]) Run() error {
method RunAccessible (line 739) | func (m *MultiSelect[T]) RunAccessible(w io.Writer, r io.Reader) error {
method WithTheme (line 780) | func (m *MultiSelect[T]) WithTheme(theme Theme) Field {
method WithKeyMap (line 799) | func (m *MultiSelect[T]) WithKeyMap(k *KeyMap) Field {
method WithWidth (line 810) | func (m *MultiSelect[T]) WithWidth(width int) Field {
method WithHeight (line 818) | func (m *MultiSelect[T]) WithHeight(height int) Field {
method WithPosition (line 824) | func (m *MultiSelect[T]) WithPosition(p FieldPosition) Field {
method GetKey (line 835) | func (m *MultiSelect[T]) GetKey() string {
method GetValue (line 840) | func (m *MultiSelect[T]) GetValue() any {
method GetFiltering (line 845) | func (m *MultiSelect[T]) GetFiltering() bool {
FILE: field_note.go
type Note (line 17) | type Note struct
method Title (line 55) | func (n *Note) Title(title string) *Note {
method TitleFunc (line 68) | func (n *Note) TitleFunc(f func() string, bindings any) *Note {
method Description (line 77) | func (n *Note) Description(description string) *Note {
method DescriptionFunc (line 101) | func (n *Note) DescriptionFunc(f func() string, bindings any) *Note {
method Height (line 108) | func (n *Note) Height(height int) *Note {
method Next (line 119) | func (n *Note) Next(show bool) *Note {
method NextLabel (line 125) | func (n *Note) NextLabel(label string) *Note {
method Focus (line 131) | func (n *Note) Focus() tea.Cmd {
method Blur (line 137) | func (n *Note) Blur() tea.Cmd {
method Error (line 143) | func (n *Note) Error() error { return nil }
method Skip (line 146) | func (n *Note) Skip() bool { return n.skip }
method Zoom (line 149) | func (n *Note) Zoom() bool { return false }
method KeyBinds (line 152) | func (n *Note) KeyBinds() []key.Binding {
method Init (line 161) | func (n *Note) Init() tea.Cmd { return nil }
method Update (line 164) | func (n *Note) Update(msg tea.Msg) (Model, tea.Cmd) {
method activeStyles (line 209) | func (n *Note) activeStyles() *FieldStyles {
method View (line 221) | func (n *Note) View() string {
method Run (line 245) | func (n *Note) Run() error {
method RunAccessible (line 250) | func (n *Note) RunAccessible(w io.Writer, _ io.Reader) error {
method WithTheme (line 262) | func (n *Note) WithTheme(theme Theme) Field {
method WithKeyMap (line 271) | func (n *Note) WithKeyMap(k *KeyMap) Field {
method WithWidth (line 277) | func (n *Note) WithWidth(width int) Field {
method WithHeight (line 283) | func (n *Note) WithHeight(height int) Field {
method WithPosition (line 289) | func (n *Note) WithPosition(p FieldPosition) Field {
method GetValue (line 302) | func (n *Note) GetValue() any { return nil }
method GetKey (line 305) | func (n *Note) GetKey() string { return "" }
function NewNote (line 41) | func NewNote() *Note {
function render (line 307) | func render(input string) string {
FILE: field_select.go
constant minHeight (line 21) | minHeight = 1
constant defaultHeight (line 22) | defaultHeight = 10
type Select (line 31) | type Select struct
function NewSelect (line 66) | func NewSelect[T comparable]() *Select[T] {
method Value (line 86) | func (s *Select[T]) Value(value *T) *Select[T] {
method Accessor (line 91) | func (s *Select[T]) Accessor(accessor Accessor[T]) *Select[T] {
method selectValue (line 98) | func (s *Select[T]) selectValue(value T) {
method Key (line 109) | func (s *Select[T]) Key(key string) *Select[T] {
method Title (line 117) | func (s *Select[T]) Title(title string) *Select[T] {
method TitleFunc (line 130) | func (s *Select[T]) TitleFunc(f func() string, bindings any) *Select[T] {
method Filtering (line 137) | func (s *Select[T]) Filtering(filtering bool) *Select[T] {
method Description (line 146) | func (s *Select[T]) Description(description string) *Select[T] {
method DescriptionFunc (line 158) | func (s *Select[T]) DescriptionFunc(f func() string, bindings any) *Sele...
method Options (line 176) | func (s *Select[T]) Options(options ...Option[T]) *Select[T] {
method selectOption (line 191) | func (s *Select[T]) selectOption() {
method OptionsFunc (line 229) | func (s *Select[T]) OptionsFunc(f func() []Option[T], bindings any) *Sel...
method Inline (line 242) | func (s *Select[T]) Inline(v bool) *Select[T] {
method Height (line 256) | func (s *Select[T]) Height(height int) *Select[T] {
method Validate (line 263) | func (s *Select[T]) Validate(validate func(T) error) *Select[T] {
method Error (line 269) | func (s *Select[T]) Error() error { return s.err }
method Skip (line 272) | func (*Select[T]) Skip() bool { return false }
method Zoom (line 275) | func (*Select[T]) Zoom() bool { return false }
method Focus (line 278) | func (s *Select[T]) Focus() tea.Cmd {
method Blur (line 284) | func (s *Select[T]) Blur() tea.Cmd {
method Hovered (line 298) | func (s *Select[T]) Hovered() (T, bool) {
method KeyBinds (line 307) | func (s *Select[T]) KeyBinds() []key.Binding {
method Init (line 323) | func (s *Select[T]) Init() tea.Cmd {
method Update (line 328) | func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
method updateValue (line 515) | func (s *Select[T]) updateValue() {
method updateViewportSize (line 523) | func (s *Select[T]) updateViewportSize() {
method activeStyles (line 547) | func (s *Select[T]) activeStyles() *FieldStyles {
method titleView (line 558) | func (s *Select[T]) titleView() string {
method descriptionView (line 577) | func (s *Select[T]) descriptionView() string {
method optionsView (line 585) | func (s *Select[T]) optionsView() (string, int, int) {
method cursorLineOffset (line 640) | func (s *Select[T]) cursorLineOffset() (offset int, height int) {
function ensureVisible (line 657) | func ensureVisible(vp *viewport.Model, offset, height int) {
method ensureCursorVisible (line 670) | func (s *Select[T]) ensureCursorVisible() {
method renderOption (line 675) | func (s *Select[T]) renderOption(option Option[T], selected bool) string {
method View (line 700) | func (s *Select[T]) View() string {
method clearFilter (line 718) | func (s *Select[T]) clearFilter() {
method setFiltering (line 725) | func (s *Select[T]) setFiltering(filtering bool) {
method filterFunc (line 736) | func (s *Select[T]) filterFunc(option string) bool {
method Run (line 742) | func (s *Select[T]) Run() error {
method RunAccessible (line 747) | func (s *Select[T]) RunAccessible(w io.Writer, r io.Reader) error {
method WithTheme (line 782) | func (s *Select[T]) WithTheme(theme Theme) Field {
method WithKeyMap (line 801) | func (s *Select[T]) WithKeyMap(k *KeyMap) Field {
method WithWidth (line 811) | func (s *Select[T]) WithWidth(width int) Field {
method WithHeight (line 818) | func (s *Select[T]) WithHeight(height int) Field {
method WithPosition (line 823) | func (s *Select[T]) WithPosition(p FieldPosition) Field {
method GetKey (line 834) | func (s *Select[T]) GetKey() string { return s.key }
method GetValue (line 837) | func (s *Select[T]) GetValue() any {
method GetFiltering (line 842) | func (s *Select[T]) GetFiltering() bool {
FILE: field_text.go
type Text (line 24) | type Text struct
method Value (line 84) | func (t *Text) Value(value *string) *Text {
method Accessor (line 89) | func (t *Text) Accessor(accessor Accessor[string]) *Text {
method Key (line 96) | func (t *Text) Key(key string) *Text {
method Title (line 104) | func (t *Text) Title(title string) *Text {
method TitleFunc (line 117) | func (t *Text) TitleFunc(f func() string, bindings any) *Text {
method Description (line 126) | func (t *Text) Description(description string) *Text {
method DescriptionFunc (line 139) | func (t *Text) DescriptionFunc(f func() string, bindings any) *Text {
method Lines (line 146) | func (t *Text) Lines(lines int) *Text {
method CharLimit (line 152) | func (t *Text) CharLimit(charlimit int) *Text {
method ShowLineNumbers (line 158) | func (t *Text) ShowLineNumbers(show bool) *Text {
method Placeholder (line 166) | func (t *Text) Placeholder(str string) *Text {
method PlaceholderFunc (line 178) | func (t *Text) PlaceholderFunc(f func() string, bindings any) *Text {
method Validate (line 185) | func (t *Text) Validate(validate func(string) error) *Text {
method ExternalEditor (line 191) | func (t *Text) ExternalEditor(enabled bool) *Text {
method Editor (line 211) | func (t *Text) Editor(editor ...string) *Text {
method EditorExtension (line 222) | func (t *Text) EditorExtension(extension string) *Text {
method Error (line 228) | func (t *Text) Error() error { return t.err }
method Skip (line 231) | func (*Text) Skip() bool { return false }
method Zoom (line 234) | func (*Text) Zoom() bool { return false }
method Focus (line 237) | func (t *Text) Focus() tea.Cmd {
method Blur (line 243) | func (t *Text) Blur() tea.Cmd {
method KeyBinds (line 252) | func (t *Text) KeyBinds() []key.Binding {
method Init (line 260) | func (t *Text) Init() tea.Cmd {
method Update (line 266) | func (t *Text) Update(msg tea.Msg) (Model, tea.Cmd) {
method activeStyles (line 365) | func (t *Text) activeStyles() *FieldStyles {
method View (line 377) | func (t *Text) View() string {
method Run (line 414) | func (t *Text) Run() error {
method RunAccessible (line 419) | func (t *Text) RunAccessible(w io.Writer, r io.Reader) error {
method WithTheme (line 445) | func (t *Text) WithTheme(theme Theme) Field {
method WithKeyMap (line 454) | func (t *Text) WithKeyMap(k *KeyMap) Field {
method WithWidth (line 461) | func (t *Text) WithWidth(width int) Field {
method WithHeight (line 468) | func (t *Text) WithHeight(height int) Field {
method WithPosition (line 481) | func (t *Text) WithPosition(p FieldPosition) Field {
method GetKey (line 489) | func (t *Text) GetKey() string { return t.key }
method GetValue (line 492) | func (t *Text) GetValue() any {
function NewText (line 56) | func NewText() *Text {
constant defaultEditor (line 196) | defaultEditor = "nano"
function getEditor (line 199) | func getEditor() (string, []string) {
type updateValueMsg (line 257) | type updateValueMsg
FILE: form.go
constant defaultWidth (line 20) | defaultWidth = 80
function nextID (line 30) | func nextID() int {
type FormState (line 41) | type FormState
constant StateNormal (line 45) | StateNormal FormState = iota
constant StateCompleted (line 48) | StateCompleted
constant StateAborted (line 51) | StateAborted
type Form (line 67) | type Form struct
method WithAccessible (line 235) | func (f *Form) WithAccessible(accessible bool) *Form {
method WithShowHelp (line 244) | func (f *Form) WithShowHelp(v bool) *Form {
method WithShowErrors (line 256) | func (f *Form) WithShowErrors(v bool) *Form {
method WithTheme (line 269) | func (f *Form) WithTheme(theme Theme) *Form {
method WithKeyMap (line 284) | func (f *Form) WithKeyMap(keymap *KeyMap) *Form {
method WithWidth (line 302) | func (f *Form) WithWidth(width int) *Form {
method WithHeight (line 316) | func (f *Form) WithHeight(height int) *Form {
method WithOutput (line 330) | func (f *Form) WithOutput(w io.Writer) *Form {
method WithInput (line 338) | func (f *Form) WithInput(r io.Reader) *Form {
method WithTimeout (line 345) | func (f *Form) WithTimeout(t time.Duration) *Form {
method WithProgramOptions (line 351) | func (f *Form) WithProgramOptions(opts ...tea.ProgramOption) *Form {
method WithViewHook (line 357) | func (f *Form) WithViewHook(hook compat.ViewHook) *Form {
method WithLayout (line 365) | func (f *Form) WithLayout(layout Layout) *Form {
method UpdateFieldPositions (line 371) | func (f *Form) UpdateFieldPositions() *Form {
method Errors (line 432) | func (f *Form) Errors() []error {
method Help (line 437) | func (f *Form) Help() help.Model {
method KeyBinds (line 442) | func (f *Form) KeyBinds() []key.Binding {
method Get (line 448) | func (f *Form) Get(key string) any {
method GetString (line 453) | func (f *Form) GetString(key string) string {
method GetInt (line 462) | func (f *Form) GetInt(key string) int {
method GetBool (line 471) | func (f *Form) GetBool(key string) bool {
method NextGroup (line 480) | func (f *Form) NextGroup() tea.Cmd {
method PrevGroup (line 486) | func (f *Form) PrevGroup() tea.Cmd {
method NextField (line 492) | func (f *Form) NextField() tea.Cmd {
method PrevField (line 498) | func (f *Form) PrevField() tea.Cmd {
method GetFocusedField (line 504) | func (f *Form) GetFocusedField() Field {
method Init (line 509) | func (f *Form) Init() tea.Cmd {
method Update (line 528) | func (f *Form) Update(msg tea.Msg) (Model, tea.Cmd) {
method isGroupHidden (line 634) | func (f *Form) isGroupHidden(group *Group) bool {
method getTheme (line 642) | func (f *Form) getTheme() *Styles {
method styles (line 649) | func (f *Form) styles() FormStyles {
method View (line 654) | func (f *Form) View() string {
method Run (line 663) | func (f *Form) Run() error {
method RunWithContext (line 668) | func (f *Form) RunWithContext(ctx context.Context) error {
method run (line 687) | func (f *Form) run(ctx context.Context) error {
method runAccessible (line 721) | func (f *Form) runAccessible(w io.Writer, r io.Reader) error {
function NewForm (line 109) | func NewForm(groups ...*Group) *Form {
type Field (line 143) | type Field interface
type FieldPosition (line 193) | type FieldPosition struct
method IsFirst (line 204) | func (p FieldPosition) IsFirst() bool {
method IsLast (line 209) | func (p FieldPosition) IsLast() bool {
type nextGroupMsg (line 214) | type nextGroupMsg struct
type prevGroupMsg (line 217) | type prevGroupMsg struct
function nextGroup (line 220) | func nextGroup() tea.Msg {
function prevGroup (line 225) | func prevGroup() tea.Msg {
FILE: group.go
type Group (line 19) | type Group struct
method Title (line 71) | func (g *Group) Title(title string) *Group {
method Description (line 77) | func (g *Group) Description(description string) *Group {
method WithShowHelp (line 83) | func (g *Group) WithShowHelp(show bool) *Group {
method WithShowErrors (line 89) | func (g *Group) WithShowErrors(show bool) *Group {
method WithTheme (line 95) | func (g *Group) WithTheme(t Theme) *Group {
method WithKeyMap (line 109) | func (g *Group) WithKeyMap(k *KeyMap) *Group {
method WithWidth (line 119) | func (g *Group) WithWidth(width int) *Group {
method WithHeight (line 131) | func (g *Group) WithHeight(height int) *Group {
method WithHide (line 146) | func (g *Group) WithHide(hide bool) *Group {
method WithHideFunc (line 152) | func (g *Group) WithHideFunc(hideFunc func() bool) *Group {
method Errors (line 158) | func (g *Group) Errors() []error {
method Init (line 199) | func (g *Group) Init() tea.Cmd {
method nextField (line 222) | func (g *Group) nextField() []tea.Cmd {
method prevField (line 239) | func (g *Group) prevField() []tea.Cmd {
method Update (line 256) | func (g *Group) Update(msg tea.Msg) (Model, tea.Cmd) {
method getTheme (line 294) | func (g *Group) getTheme() *Styles {
method styles (line 301) | func (g *Group) styles() GroupStyles { return g.getTheme().Group }
method getContent (line 303) | func (g *Group) getContent() (int, string) {
method buildView (line 329) | func (g *Group) buildView() {
method Header (line 336) | func (g *Group) Header() string {
method titleFooterHeight (line 349) | func (g *Group) titleFooterHeight() int {
method rawHeight (line 361) | func (g *Group) rawHeight() int {
method View (line 366) | func (g *Group) View() string {
method Content (line 387) | func (g *Group) Content() string {
method Footer (line 393) | func (g *Group) Footer() string {
function NewGroup (line 48) | func NewGroup(fields ...Field) *Group {
type updateFieldMsg (line 174) | type updateFieldMsg struct
type nextFieldMsg (line 180) | type nextFieldMsg struct
type prevFieldMsg (line 186) | type prevFieldMsg struct
function NextField (line 189) | func NextField() tea.Msg {
function PrevField (line 194) | func PrevField() tea.Msg {
FILE: huh_test.go
constant text (line 20) | text = "Huh"
function TestForm (line 28) | func TestForm(t *testing.T) {
function TestInput (line 280) | func TestInput(t *testing.T) {
function TestPasteNotDuplicated (line 311) | func TestPasteNotDuplicated(t *testing.T) {
function TestInlineInput (line 323) | func TestInlineInput(t *testing.T) {
function TestText (line 364) | func TestText(t *testing.T) {
function TestTextExternalEditorHidden (line 388) | func TestTextExternalEditorHidden(t *testing.T) {
function TestConfirm (line 412) | func TestConfirm(t *testing.T) {
function TestSelect (line 458) | func TestSelect(t *testing.T) {
function doAllUpdates (line 521) | func doAllUpdates(f *Form, cmd tea.Cmd) {
function TestSelectDynamic (line 539) | func TestSelectDynamic(t *testing.T) {
function TestMultiSelect (line 620) | func TestMultiSelect(t *testing.T) {
function TestMultiSelectFiltering (line 701) | func TestMultiSelectFiltering(t *testing.T) {
function TestSelectPageNavigation (line 744) | func TestSelectPageNavigation(t *testing.T) {
function TestFile (line 835) | func TestFile(t *testing.T) {
function TestHideGroup (line 853) | func TestHideGroup(t *testing.T) {
function TestHideGroupLastAndFirstGroupsNotHidden (line 898) | func TestHideGroupLastAndFirstGroupsNotHidden(t *testing.T) {
function TestPrevGroup (line 935) | func TestPrevGroup(t *testing.T) {
function TestNote (line 954) | func TestNote(t *testing.T) {
function TestDynamicHelp (line 991) | func TestDynamicHelp(t *testing.T) {
function TestSkip (line 1014) | func TestSkip(t *testing.T) {
function TestTimeout (line 1061) | func TestTimeout(t *testing.T) {
function TestAbort (line 1072) | func TestAbort(t *testing.T) {
constant title (line 1090) | title = "A Title"
constant description (line 1091) | description = "A Description"
function TestNoTitleOrDescription (line 1150) | func TestNoTitleOrDescription(t *testing.T) {
function TestTitleRowRender (line 1164) | func TestTitleRowRender(t *testing.T) {
function TestDescriptionRowRender (line 1176) | func TestDescriptionRowRender(t *testing.T) {
function TestGetFocusedField (line 1188) | func TestGetFocusedField(t *testing.T) {
function formProgram (line 1207) | func formProgram() *Form {
function batchUpdate (line 1214) | func batchUpdate(m Model, cmd tea.Cmd) Model {
function codeKeypress (line 1228) | func codeKeypress(r rune) tea.KeyPressMsg {
function keypress (line 1234) | func keypress(r rune) tea.KeyPressMsg {
function typeText (line 1242) | func typeText[T Model](m T, s string) T {
function TestAccessibleForm (line 1250) | func TestAccessibleForm(t *testing.T) {
function TestAccessibleFields (line 1271) | func TestAccessibleFields(t *testing.T) {
function TestInputPasswordAccessible (line 1533) | func TestInputPasswordAccessible(t *testing.T) {
function requireEqual (line 1578) | func requireEqual[T comparable](tb testing.TB, a, b T) {
function requireContains (line 1585) | func requireContains(tb testing.TB, s, subtr string) {
function viewModel (line 1592) | func viewModel(m Model) string { return ansi.Strip(m.View()) }
FILE: internal/accessibility/accessibility.go
function atoi (line 17) | func atoi(s string) (int, error) {
function PromptInt (line 29) | func PromptInt(
function parseBool (line 63) | func parseBool(s string) (bool, error) {
function PromptBool (line 83) | func PromptBool(
function PromptPassword (line 108) | func PromptPassword(
function PromptString (line 131) | func PromptString(
function ptrToStr (line 167) | func ptrToStr[T any](t *T, fn func(t T) string) string {
function boolToStr (line 174) | func boolToStr(b bool) string {
FILE: internal/compat/model.go
type Model (line 7) | type Model interface
type ViewModel (line 17) | type ViewModel struct
method Update (line 23) | func (w ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 32) | func (w ViewModel) View() tea.View {
FILE: internal/selector/selector.go
type Selector (line 5) | type Selector struct
function NewSelector (line 11) | func NewSelector[T any](items []T) *Selector[T] {
method Append (line 18) | func (s *Selector[T]) Append(item T) {
method Next (line 23) | func (s *Selector[T]) Next() {
method Prev (line 30) | func (s *Selector[T]) Prev() {
method OnFirst (line 37) | func (s *Selector[T]) OnFirst() bool {
method OnLast (line 42) | func (s *Selector[T]) OnLast() bool {
method Selected (line 47) | func (s *Selector[T]) Selected() T {
method Index (line 52) | func (s *Selector[T]) Index() int {
method Total (line 57) | func (s *Selector[T]) Total() int {
method SetIndex (line 62) | func (s *Selector[T]) SetIndex(i int) {
method Get (line 70) | func (s *Selector[T]) Get(i int) T {
method Set (line 75) | func (s *Selector[T]) Set(i int, item T) {
method Range (line 81) | func (s *Selector[T]) Range(f func(i int, item T) bool) {
method ReverseRange (line 91) | func (s *Selector[T]) ReverseRange(f func(i int, item T) bool) {
FILE: keymap.go
type KeyMap (line 6) | type KeyMap struct
type InputKeyMap (line 19) | type InputKeyMap struct
type TextKeyMap (line 27) | type TextKeyMap struct
type SelectKeyMap (line 36) | type SelectKeyMap struct
type MultiSelectKeyMap (line 54) | type MultiSelectKeyMap struct
type FilePickerKeyMap (line 73) | type FilePickerKeyMap struct
type NoteKeyMap (line 90) | type NoteKeyMap struct
type ConfirmKeyMap (line 97) | type ConfirmKeyMap struct
function NewDefaultKeyMap (line 107) | func NewDefaultKeyMap() *KeyMap {
FILE: layout.go
type Layout (line 10) | type Layout interface
function LayoutColumns (line 22) | func LayoutColumns(columns int) Layout {
function LayoutGrid (line 27) | func LayoutGrid(rows int, columns int) Layout {
type layoutDefault (line 31) | type layoutDefault struct
method View (line 33) | func (l *layoutDefault) View(f *Form) string {
method GroupWidth (line 37) | func (l *layoutDefault) GroupWidth(_ *Form, _ *Group, w int) int {
type layoutColumns (line 41) | type layoutColumns struct
method visibleGroups (line 45) | func (l *layoutColumns) visibleGroups(f *Form) []*Group {
method View (line 67) | func (l *layoutColumns) View(f *Form) string {
method GroupWidth (line 88) | func (l *layoutColumns) GroupWidth(_ *Form, _ *Group, w int) int {
type layoutStack (line 92) | type layoutStack struct
method View (line 94) | func (l *layoutStack) View(f *Form) string {
method GroupWidth (line 107) | func (l *layoutStack) GroupWidth(_ *Form, _ *Group, w int) int {
type layoutGrid (line 111) | type layoutGrid struct
method visibleGroups (line 115) | func (l *layoutGrid) visibleGroups(f *Form) [][]*Group {
method View (line 148) | func (l *layoutGrid) View(f *Form) string {
method GroupWidth (line 167) | func (l *layoutGrid) GroupWidth(_ *Form, _ *Group, w int) int {
FILE: option.go
type Option (line 6) | type Option struct
function NewOptions (line 13) | func NewOptions[T comparable](values ...T) []Option[T] {
function NewOption (line 25) | func NewOption[T comparable](key string, value T) Option[T] {
method Selected (line 30) | func (o Option[T]) Selected(selected bool) Option[T] {
method String (line 36) | func (o Option[T]) String() string {
FILE: run.go
function Run (line 4) | func Run(field Field) error {
FILE: spinner/spinner.go
type Spinner (line 29) | type Spinner struct
method Type (line 95) | func (s *Spinner) Type(t Type) *Spinner {
method Title (line 101) | func (s *Spinner) Title(title string) *Spinner {
method WithOutput (line 108) | func (s *Spinner) WithOutput(w io.Writer) *Spinner {
method WithInput (line 116) | func (s *Spinner) WithInput(r io.Reader) *Spinner {
method WithViewHook (line 123) | func (s *Spinner) WithViewHook(hook compat.ViewHook) *Spinner {
method Action (line 129) | func (s *Spinner) Action(action func()) *Spinner {
method ActionWithErr (line 141) | func (s *Spinner) ActionWithErr(action func(context.Context) error) *S...
method Context (line 147) | func (s *Spinner) Context(ctx context.Context) *Spinner {
method WithAccessible (line 153) | func (s *Spinner) WithAccessible(accessible bool) *Spinner {
method WithTheme (line 170) | func (s *Spinner) WithTheme(theme Theme) *Spinner {
method Init (line 180) | func (s *Spinner) Init() tea.Cmd {
method Update (line 195) | func (s *Spinner) Update(msg tea.Msg) (Model, tea.Cmd) {
method View (line 215) | func (s *Spinner) View() string {
method Run (line 226) | func (s *Spinner) Run() error {
method runAccessible (line 262) | func (s *Spinner) runAccessible(in io.Reader, out io.Writer) error {
type Styles (line 45) | type Styles struct
type Theme (line 50) | type Theme interface
type ThemeFunc (line 55) | type ThemeFunc
method Theme (line 58) | func (f ThemeFunc) Theme(isDark bool) *Styles {
function ThemeDefault (line 63) | func ThemeDefault(isDark bool) *Styles {
type Type (line 76) | type Type
function New (line 159) | func New() *Spinner {
type doneMsg (line 299) | type doneMsg struct
FILE: spinner/spinner_test.go
function TestNewSpinner (line 17) | func TestNewSpinner(t *testing.T) {
function TestSpinnerType (line 27) | func TestSpinnerType(t *testing.T) {
function TestSpinnerDifferentTypes (line 34) | func TestSpinnerDifferentTypes(t *testing.T) {
function TestSpinnerView (line 41) | func TestSpinnerView(t *testing.T) {
function TestSpinnerContextCancellation (line 50) | func TestSpinnerContextCancellation(t *testing.T) {
function TestSpinnerContextCancellationWhileRunning (line 59) | func TestSpinnerContextCancellationWhileRunning(t *testing.T) {
function TestSpinnerStyleMethods (line 70) | func TestSpinnerStyleMethods(t *testing.T) {
function TestSpinnerInit (line 87) | func TestSpinnerInit(t *testing.T) {
function TestSpinnerUpdate (line 96) | func TestSpinnerUpdate(t *testing.T) {
function TestSpinnerSimple (line 119) | func TestSpinnerSimple(t *testing.T) {
function TestSpinnerWithContextAndAction (line 125) | func TestSpinnerWithContextAndAction(t *testing.T) {
function TestSpinnerWithActionError (line 132) | func TestSpinnerWithActionError(t *testing.T) {
function exercise (line 139) | func exercise(t *testing.T, factory func() *Spinner, checker func(tb tes...
function requireNoError (line 159) | func requireNoError(tb testing.TB, err error) {
function requireErrorIs (line 166) | func requireErrorIs(target error) func(tb testing.TB, err error) {
function requireContextCanceled (line 175) | func requireContextCanceled(tb testing.TB, err error) {
type nilReader (line 185) | type nilReader struct
method Read (line 188) | func (nilReader) Read([]byte) (int, error) { return 0, nil }
FILE: theme.go
type Theme (line 10) | type Theme interface
type ThemeFunc (line 15) | type ThemeFunc
method Theme (line 18) | func (f ThemeFunc) Theme(isDark bool) *Styles {
type Styles (line 24) | type Styles struct
type FormStyles (line 34) | type FormStyles struct
type GroupStyles (line 39) | type GroupStyles struct
type FieldStyles (line 46) | type FieldStyles struct
type TextInputStyles (line 84) | type TextInputStyles struct
constant buttonPaddingHorizontal (line 93) | buttonPaddingHorizontal = 2
constant buttonPaddingVertical (line 94) | buttonPaddingVertical = 0
function ThemeBase (line 99) | func ThemeBase(bool) *Styles {
function ThemeCharm (line 139) | func ThemeCharm(isDark bool) *Styles {
function ThemeDracula (line 189) | func ThemeDracula(isDark bool) *Styles {
function ThemeBase16 (line 240) | func ThemeBase16(isDark bool) *Styles {
function ThemeCatppuccin (line 285) | func ThemeCatppuccin(isDark bool) *Styles {
FILE: validate.go
function ValidateNotEmpty (line 9) | func ValidateNotEmpty() func(s string) error {
function ValidateMinLength (line 19) | func ValidateMinLength(v int) func(s string) error {
function ValidateMaxLength (line 29) | func ValidateMaxLength(v int) func(s string) error {
function ValidateLength (line 39) | func ValidateLength(minl, maxl int) func(s string) error {
function ValidateOneOf (line 49) | func ValidateOneOf(options ...string) func(string) error {
FILE: wrap.go
function wrap (line 5) | func wrap(s string, limit int) string {
FILE: zz_resize_width_test.go
function TestSelectWithWidthUpdatesViewportWidth (line 5) | func TestSelectWithWidthUpdatesViewportWidth(t *testing.T) {
function TestMultiSelectWithWidthUpdatesViewportWidth (line 24) | func TestMultiSelectWithWidthUpdatesViewportWidth(t *testing.T) {
Condensed preview — 108 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (380K chars).
[
{
"path": ".gitattributes",
"chars": 84,
"preview": "*.gif filter=lfs diff=lfs merge=lfs -text\n*.png filter=lfs diff=lfs merge=lfs -text\n"
},
{
"path": ".github/CODEOWNERS",
"chars": 26,
"preview": "* @charmbracelet/everyone\n"
},
{
"path": ".github/dependabot.yml",
"chars": 2009,
"preview": "version: 2\n\nupdates:\n - package-ecosystem: \"gomod\"\n directory: \"/\"\n schedule:\n interval: \"weekly\"\n day:"
},
{
"path": ".github/workflows/build.yml",
"chars": 445,
"preview": "name: build\non: [push, pull_request]\njobs:\n build:\n uses: charmbracelet/meta/.github/workflows/build.yml@main\n\n bui"
},
{
"path": ".github/workflows/dependabot-sync.yml",
"chars": 419,
"preview": "name: dependabot-sync\non:\n schedule:\n - cron: \"0 0 * * 0\" # every Sunday at midnight\n workflow_dispatch: # allows m"
},
{
"path": ".github/workflows/lint-sync.yml",
"chars": 271,
"preview": "name: lint-sync\non:\n schedule:\n # every Sunday at midnight\n - cron: \"0 0 * * 0\"\n workflow_dispatch: # allows man"
},
{
"path": ".github/workflows/lint.yml",
"chars": 179,
"preview": "name: lint\non:\n push:\n pull_request:\n\njobs:\n lint:\n uses: charmbracelet/meta/.github/workflows/lint.yml@main\n w"
},
{
"path": ".github/workflows/release.yml",
"chars": 1106,
"preview": "name: goreleaser\n\non:\n push:\n tags:\n - v*.*.*\n\nconcurrency:\n group: goreleaser\n cancel-in-progress: true\n\njob"
},
{
"path": ".gitignore",
"chars": 501,
"preview": "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gi"
},
{
"path": ".golangci.yml",
"chars": 764,
"preview": "version: \"2\"\nrun:\n tests: false\nlinters:\n enable:\n - bodyclose\n - exhaustive\n - goconst\n - godot\n - gom"
},
{
"path": ".goreleaser.yml",
"chars": 159,
"preview": "includes:\n - from_url:\n url: charmbracelet/meta/main/goreleaser-lib.yaml\n\n# yaml-language-server: $schema=https://"
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2023–2026 Charm\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "Makefile",
"chars": 215,
"preview": ".PHONY: spinner\n\n$(V).SILENT:\ntest:\n\tgo test ./...\n\nspinner:\n\tcd spinner/examples/loading && go run .\n\nburger:\n\tcd examp"
},
{
"path": "README.md",
"chars": 15167,
"preview": "# Huh?\n\n<p>\n <img src=\"https://stuff.charm.sh/huh/glenn.png\" width=\"400\" />\n <br><br>\n <a href=\"https://github.com/ch"
},
{
"path": "UPGRADE_GUIDE_V2.md",
"chars": 8135,
"preview": "# Huh v2 Upgrade Guide\n\nThis guide will help you migrate from Huh v1 to v2. Most changes are straightforward, and many a"
},
{
"path": "accessor.go",
"chars": 885,
"preview": "package huh\n\n// Accessor give read/write access to field values.\ntype Accessor[T any] interface {\n\tGet() T\n\tSet(value T)"
},
{
"path": "eval.go",
"chars": 1459,
"preview": "package huh\n\nimport (\n\t\"time\"\n\n\t\"github.com/mitchellh/hashstructure/v2\"\n)\n\n// Eval is an evaluatable value, it stores a "
},
{
"path": "examples/.gitignore",
"chars": 5,
"preview": ".ssh\n"
},
{
"path": "examples/accessibility/accessible.tape",
"chars": 328,
"preview": "Output accessible.gif\n\nSet Height 600\nSet Width 1000\n\nHide\n Type \"go build -o accessible .\" Enter\n Type \"export ACCESS"
},
{
"path": "examples/accessibility/main.go",
"chars": 441,
"preview": "package main\n\nimport (\n\t\"log\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tform := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewSel"
},
{
"path": "examples/accessibility-secure-input/main.go",
"chars": 984,
"preview": "package main\n\nimport (\n\t\"errors\"\n\t\"log\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc validate(s string) error {\n\tif s == \"\" {\n\t\treturn "
},
{
"path": "examples/bubbletea/demo.tape",
"chars": 232,
"preview": "Set Height 775\nSet Padding 60\nSet Width 1200\nSet FontSize 20\n\nHide\n Type \"clear && go run .\"\n Enter\n Sleep 1s\nShow\nSl"
},
{
"path": "examples/bubbletea/main.go",
"chars": 7126,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"image/color\"\n\t\"os\"\n\t\"strings\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/huh/v2\"\n\t\"cha"
},
{
"path": "examples/bubbletea-options/main.go",
"chars": 415,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar name string\n\tfo"
},
{
"path": "examples/burger/demo.tape",
"chars": 580,
"preview": "Output burger.gif\n\nSet Height 700\nSet Width 1000\n\nHide\nType \"go build -o burger .\" Enter\nCtrl+L\nSleep 1s\n\nType \"clear &&"
},
{
"path": "examples/burger/main.go",
"chars": 4597,
"preview": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"charm.land/huh/v2/sp"
},
{
"path": "examples/conditional/main.go",
"chars": 1883,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"charm.land/huh/v2\"\n)\n\ntype consumable int\n\nconst (\n\tfruits consumable = iota\n\tveg"
},
{
"path": "examples/dynamic/demo.tape",
"chars": 357,
"preview": "Output dynamic.gif\n\nSet Shell \"bash\"\nSet FontSize 28\nSet Width 1000\nSet Height 700\n\nHide\n Type \"clear && go build -o dy"
},
{
"path": "examples/dynamic/dynamic-all/main.go",
"chars": 1579,
"preview": "package main\n\nimport (\n\t\"log\"\n\t\"strconv\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar value string = \"Dynamic\"\n\n\tf := huh"
},
{
"path": "examples/dynamic/dynamic-bubbletea/main.go",
"chars": 7332,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"image/color\"\n\t\"os\"\n\t\"strings\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/huh/v2\"\n\t\"cha"
},
{
"path": "examples/dynamic/dynamic-count/main.go",
"chars": 1229,
"preview": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"strconv\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar value string\n\tdefa"
},
{
"path": "examples/dynamic/dynamic-country/main.go",
"chars": 2540,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n\t\"charm.land/log/v2\"\n)\n\nfunc main() {\n\tlog.SetReportTimestam"
},
{
"path": "examples/dynamic/dynamic-increment/main.go",
"chars": 477,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tcount := 0\n\tgo func() {\n\t\tfor {\n\t\t\tcount++"
},
{
"path": "examples/dynamic/dynamic-markdown/main.go",
"chars": 445,
"preview": "package main\n\nimport (\n\t\"log\"\n\n\t\"charm.land/glamour/v2\"\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar md string\n\terr := huh"
},
{
"path": "examples/dynamic/dynamic-name/main.go",
"chars": 1233,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar name string\n\n\terr := huh.NewForm(\n\t\thuh"
},
{
"path": "examples/dynamic/dynamic-suggestions/main.go",
"chars": 1084,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\n\t\"charm.land/huh/v2\"\n\t\"charm.land/huh/v2/spinner\"\n)\n\nfunc main() {\n\tvar org string"
},
{
"path": "examples/filepicker/artichoke.hs",
"chars": 0,
"preview": ""
},
{
"path": "examples/filepicker/demo.tape",
"chars": 317,
"preview": "Set Shell bash\n\nSet Width 800\nSet Height 725\n\nHide\n Type \"clear && go build -o file\"\n Enter\nShow\n\nSleep .5s\nType \"./fi"
},
{
"path": "examples/filepicker/main.go",
"chars": 599,
"preview": "package main\n\nimport (\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar file string\n\n\thuh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewI"
},
{
"path": "examples/filepicker-picking/main.go",
"chars": 323,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar file string\n\thuh.NewForm(\n\t\thuh.NewGroup(\n\t\t\th"
},
{
"path": "examples/gh/create.go",
"chars": 2216,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\n\t\"charm.land/huh/v2\"\n\t\"charm.land/huh/v2/spinner\"\n\t\"charm.land/lipgloss/v2\"\n"
},
{
"path": "examples/git/main.go",
"chars": 1113,
"preview": "package main\n\nimport (\n\t\"charm.land/huh/v2\"\n)\n\n// types is the possible commit types specified by the conventional commi"
},
{
"path": "examples/go.mod",
"chars": 2446,
"preview": "module examples\n\ngo 1.25.8\n\nreplace charm.land/huh/v2 => ../\n\nrequire (\n\tcharm.land/bubbles/v2 v2.0.0\n\tcharm.land/bubble"
},
{
"path": "examples/go.sum",
"chars": 10746,
"preview": "charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=\ncharm.land/bubbles/v2 v2.0.0/go.mod h1:rCHo"
},
{
"path": "examples/gum/main.go",
"chars": 484,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tif len(os.Args) < 2 {\n\t\tfmt.Println(\"gum <in"
},
{
"path": "examples/help/main.go",
"chars": 233,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tf := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().Title(\"Dyna"
},
{
"path": "examples/hide/hide.tape",
"chars": 265,
"preview": "Output hide.gif\n\nSet Width 700\nSet Padding 40\nSet Height 350\nSet FontSize 28\n\nHide\n Type \"go build .\"\n Sleep 500ms\n E"
},
{
"path": "examples/hide/main.go",
"chars": 750,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar isAllergic bool\n\tvar allergies string\n\n\thuh.Ne"
},
{
"path": "examples/layout/columns/main.go",
"chars": 537,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tform := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().Title(\"F"
},
{
"path": "examples/layout/default/main.go",
"chars": 504,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tform := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().Title(\"F"
},
{
"path": "examples/layout/grid/main.go",
"chars": 671,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tform := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().Title(\"F"
},
{
"path": "examples/layout/stack/main.go",
"chars": 532,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tform := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().Title(\"F"
},
{
"path": "examples/multiple-groups/main.go",
"chars": 1904,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tf := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.New"
},
{
"path": "examples/readme/confirm/confirm.tape",
"chars": 295,
"preview": "Output confirm.gif\n\nSet Width 1100\nSet Padding 40\nSet Height 375\nSet FontSize 36\n\nHide\n Type \"go build .\"\n Sleep 500ms"
},
{
"path": "examples/readme/confirm/main.go",
"chars": 273,
"preview": "package main\n\nimport (\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar happy bool\n\n\tconfirm := huh.NewConfirm().\n\t\tTitle(\"Are"
},
{
"path": "examples/readme/input/input.tape",
"chars": 272,
"preview": "Output input.gif\n\nSet Width 1000\nSet Padding 30\nSet Height 275\nSet FontSize 38\n\nHide\n Type \"go build .\"\n Sleep 500ms\n "
},
{
"path": "examples/readme/input/main.go",
"chars": 723,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc isFood(_ string) error {\n\treturn nil\n}\n\nfunc main() {\n\tvar l"
},
{
"path": "examples/readme/input/suggestions.tape",
"chars": 311,
"preview": "Output suggestions.gif\n\nSet Width 1000\nSet Padding 30\nSet Height 275\nSet FontSize 38\n\nHide\n Type \"go build .\"\n Sleep 5"
},
{
"path": "examples/readme/main/main.go",
"chars": 3035,
"preview": "package main\n\nimport (\n\t\"log\"\n\n\t\"charm.land/huh/v2\"\n)\n\n// TODO: ensure input is not plagiarized.\nfunc checkForPlagiarism"
},
{
"path": "examples/readme/multiselect/main.go",
"chars": 554,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tvar toppings []string\n\ts := huh.NewMultiSelect[string]().\n\t\tOpt"
},
{
"path": "examples/readme/multiselect/multiselect.tape",
"chars": 361,
"preview": "Output multiselect.gif\n\nSet Width 1150\nSet Padding 40\nSet Height 480\nSet FontSize 28\n\nHide\n Type \"go build .\"\n Sleep 5"
},
{
"path": "examples/readme/note/main.go",
"chars": 242,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tnote := huh.NewNote().Description(\n\t\t\"# Heading\\n\" + \"This is _"
},
{
"path": "examples/readme/select/main.go",
"chars": 352,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tvar country string\n\ts := huh.NewSelect[string]().\n\t\tTitle(\"Pick"
},
{
"path": "examples/readme/select/scroll/scroll.go",
"chars": 903,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\ntype Pokemon struct {\n\tid int\n\tname string\n}\n\nvar pokemons = []Pokemon{\n\t{1,"
},
{
"path": "examples/readme/select/scroll/scroll.tape",
"chars": 270,
"preview": "Output scroll.gif\n\nSet Width 800\nSet Padding 40\nSet Height 375\nSet FontSize 28\n\nHide\n Type \"go build scroll.go\"\n Sleep"
},
{
"path": "examples/readme/select/select.tape",
"chars": 340,
"preview": "Output select.gif\n\nSet Width 1100\nSet Padding 40\nSet Height 375\nSet FontSize 28\n\nHide\n Type \"go build .\"\n Sleep 500ms\n"
},
{
"path": "examples/readme/text/main.go",
"chars": 401,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\n// TODO: ensure input is not plagiarized.\nfunc checkForPlagiarism(s string) er"
},
{
"path": "examples/readme/text/text.tape",
"chars": 491,
"preview": "Output text.gif\n\nSet Width 1000\nSet Padding 40\nSet Height 450\nSet FontSize 28\n\nHide\n Type \"go build .\" Enter\n Ctrl+L\n "
},
{
"path": "examples/scroll/main.go",
"chars": 476,
"preview": "package main\n\nimport \"charm.land/huh/v2\"\n\nfunc main() {\n\tform := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewInput().Title(\"F"
},
{
"path": "examples/skip/main.go",
"chars": 870,
"preview": "package main\n\nimport (\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tf := huh.NewForm(\n\t\thuh.NewGroup(\n\t\t\thuh.NewNote().\n\t\t\t\tTi"
},
{
"path": "examples/spinner/accessible/main.go",
"chars": 298,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"time\"\n\n\t\"charm.land/huh/v2/spinner\"\n)\n\nfunc main() {\n\tctx, cancel := context."
},
{
"path": "examples/spinner/context/main.go",
"chars": 304,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"charm.land/huh/v2/spinner\"\n)\n\nfunc main() {\n\taction := func() { time"
},
{
"path": "examples/spinner/context-and-action/main.go",
"chars": 402,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"math/rand\"\n\t\"time\"\n\n\t\"charm.land/huh/v2/spinner\"\n)\n\nfunc main() {\n\tctx"
},
{
"path": "examples/spinner/context-and-action-and-error/main.go",
"chars": 423,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\t\"charm.land/huh/v2/spinner\"\n)\n\nfunc main() {\n\tctx, cancel := c"
},
{
"path": "examples/spinner/loading/demo.tape",
"chars": 173,
"preview": "Output spinner.gif\n\nSet FontSize 32\nSet Height 225\nSet Width 800\n\nHide\nType \"go build -o spinner .\" Enter\nCtrl+L\nSleep 1"
},
{
"path": "examples/spinner/loading/main.go",
"chars": 474,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/huh/v2/spinner\"\n)\n\nfunc main() {\n\tact"
},
{
"path": "examples/spinner/static/main.go",
"chars": 164,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"charm.land/huh/v2/spinner\"\n)\n\nfunc main() {\n\t_ = spinner.New().Title(\"Loading\").WithAcc"
},
{
"path": "examples/ssh-form/main.go",
"chars": 3371,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\ttea \"charm.land/bubble"
},
{
"path": "examples/stickers/main.go",
"chars": 4554,
"preview": "package main\n\nimport (\n\t\"charm.land/huh/v2\"\n)\n\nfunc main() {\n\tvar (\n\t\tname string\n\t\taddress string\n\t\tcountry string\n\t"
},
{
"path": "examples/theme/main.go",
"chars": 1523,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"charm.land/huh/v2\"\n)\n\nvar themes = map[string]huh.Theme{\n\t\"default\": huh.Theme"
},
{
"path": "examples/theme/theme.tape",
"chars": 445,
"preview": "Output theme.gif\n\nSet Width 800\nSet Height 740\nSet Padding 80\n\nHide\nType \"go build -o theme .\"\nEnter\nCtrl+L\nSleep 500ms\n"
},
{
"path": "examples/timer/main.go",
"chars": 5157,
"preview": "package main\n\nimport (\n\t\"log\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/progress\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"cha"
},
{
"path": "field_confirm.go",
"chars": 9177,
"preview": "package huh\n\nimport (\n\t\"cmp\"\n\t\"io\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/key\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/"
},
{
"path": "field_filepicker.go",
"chars": 10830,
"preview": "package huh\n\nimport (\n\t\"cmp\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\txstrings \"github.com/charmbracelet/x/exp/strings\"\n\n\t\"cha"
},
{
"path": "field_input.go",
"chars": 14565,
"preview": "package huh\n\nimport (\n\t\"cmp\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/tex"
},
{
"path": "field_multiselect.go",
"chars": 22284,
"preview": "package huh\n\nimport (\n\t\"cmp\"\n\t\"fmt\"\n\t\"io\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubble"
},
{
"path": "field_note.go",
"chars": 8882,
"preview": "package huh\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/key\"\n\ttea \"charm.land/bubbletea/v2\"\n)\n\n// Note is"
},
{
"path": "field_select.go",
"chars": 22549,
"preview": "package huh\n\nimport (\n\t\"cmp\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/spinn"
},
{
"path": "field_text.go",
"chars": 13173,
"preview": "package huh\n\nimport (\n\t\"cmp\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.l"
},
{
"path": "form.go",
"chars": 17001,
"preview": "package huh\n\nimport (\n\t\"cmp\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/help\"\n\t\"ch"
},
{
"path": "go.mod",
"chars": 1438,
"preview": "module charm.land/huh/v2\n\ngo 1.25.8\n\nrequire (\n\tcharm.land/bubbles/v2 v2.0.0\n\tcharm.land/bubbletea/v2 v2.0.2\n\tcharm.land"
},
{
"path": "go.sum",
"chars": 5966,
"preview": "charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=\ncharm.land/bubbles/v2 v2.0.0/go.mod h1:rCHo"
},
{
"path": "group.go",
"chars": 9929,
"preview": "package huh\n\nimport (\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/help\"\n\t\"charm.land/bubbles/v2/viewport\"\n\ttea \"charm.land/bubbl"
},
{
"path": "huh.go",
"chars": 90,
"preview": "// Package huh provides components to build terminal-based forms and prompts.\npackage huh\n"
},
{
"path": "huh_test.go",
"chars": 38693,
"preview": "package huh\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\ttea \"charm.la"
},
{
"path": "internal/accessibility/accessibility.go",
"chars": 3689,
"preview": "// Package accessibility provides accessible functions to capture user input.\npackage accessibility\n\nimport (\n\t\"bufio\"\n\t"
},
{
"path": "internal/compat/model.go",
"chars": 836,
"preview": "// Package compat provides common types used across the application.\npackage compat\n\nimport tea \"charm.land/bubbletea/v2"
},
{
"path": "internal/selector/selector.go",
"chars": 2134,
"preview": "// Package selector provides a helper type for selecting items.\npackage selector\n\n// Selector is a helper type for selec"
},
{
"path": "keymap.go",
"chars": 8599,
"preview": "package huh\n\nimport \"charm.land/bubbles/v2/key\"\n\n// KeyMap is the keybindings to navigate the form.\ntype KeyMap struct {"
},
{
"path": "layout.go",
"chars": 3659,
"preview": "package huh\n\nimport (\n\t\"strings\"\n\n\t\"charm.land/lipgloss/v2\"\n)\n\n// A Layout is responsible for laying out groups in a for"
},
{
"path": "option.go",
"chars": 819,
"preview": "package huh\n\nimport \"fmt\"\n\n// Option is an option for select fields.\ntype Option[T comparable] struct {\n\tKey string"
},
{
"path": "run.go",
"chars": 203,
"preview": "package huh\n\n// Run runs a single field by wrapping it within a group and a form.\nfunc Run(field Field) error {\n\tgroup :"
},
{
"path": "spinner/spinner.go",
"chars": 6687,
"preview": "// Package spinner provides a loading spinner.\npackage spinner\n\nimport (\n\t\"cmp\"\n\t\"context\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"cha"
},
{
"path": "spinner/spinner_test.go",
"chars": 4427,
"preview": "package spinner\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/s"
},
{
"path": "theme.go",
"chars": 14290,
"preview": "package huh\n\nimport (\n\t\"charm.land/bubbles/v2/help\"\n\t\"charm.land/lipgloss/v2\"\n\tcatppuccin \"github.com/catppuccin/go\"\n)\n\n"
},
{
"path": "validate.go",
"chars": 1546,
"preview": "package huh\n\nimport (\n\t\"fmt\"\n\t\"unicode/utf8\"\n)\n\n// ValidateNotEmpty checks if the input is not empty.\nfunc ValidateNotEm"
},
{
"path": "wrap.go",
"chars": 129,
"preview": "package huh\n\nimport \"charm.land/lipgloss/v2\"\n\nfunc wrap(s string, limit int) string {\n\treturn lipgloss.Wrap(s, limit, \","
},
{
"path": "zz_resize_width_test.go",
"chars": 1029,
"preview": "package huh\n\nimport \"testing\"\n\nfunc TestSelectWithWidthUpdatesViewportWidth(t *testing.T) {\n\tf := NewSelect[string]().\n\t"
}
]
About this extraction
This page contains the full source code of the charmbracelet/huh GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 108 files (331.5 KB), approximately 101.3k tokens, and a symbol index with 687 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.