diff --git a/.gitignore b/.gitignore index 3b735ec4..ba65d3b0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # Go workspace file go.work + +# Debugging +debug.log diff --git a/eval.go b/eval.go new file mode 100644 index 00000000..58e09238 --- /dev/null +++ b/eval.go @@ -0,0 +1,79 @@ +package huh + +import ( + "time" + + "github.com/mitchellh/hashstructure/v2" +) + +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] +} diff --git a/examples/conditional/main.go b/examples/conditional/main.go index 36db9176..98de37e1 100644 --- a/examples/conditional/main.go +++ b/examples/conditional/main.go @@ -38,44 +38,40 @@ func main() { huh.NewOption("A vegetable", vegetables), huh.NewOption("A drink", drinks), ), - ), - - huh.NewGroup( - huh.NewSelect[string](). - Title("Okay, what kind of fruit are you in the mood for?"). - Options( - huh.NewOption("Tangerine", "tangerine"), - huh.NewOption("Canteloupe", "canteloupe"), - huh.NewOption("Pomelo", "pomelo"), - huh.NewOption("Grapefruit", "grapefruit"), - ). - Value(&choice), - ).WithHideFunc(func() bool { return category != fruits }), - - huh.NewGroup( - huh.NewSelect[string](). - Title("Okay, what kind of vegetable are you in the mood for?"). - Options( - huh.NewOption("Carrot", "carrot"), - huh.NewOption("Jicama", "jicama"), - huh.NewOption("Kohlrabi", "kohlrabi"), - huh.NewOption("Fennel", "fennel"), - huh.NewOption("Ginger", "ginger"), - ). - Value(&choice), - ).WithHideFunc(func() bool { return category != vegetables }), - huh.NewGroup( huh.NewSelect[string](). - Title(fmt.Sprintf("Okay, what kind of %s are you in the mood for?", category)). - Options( - huh.NewOption("Coffee", "coffee"), - huh.NewOption("Tea", "tea"), - huh.NewOption("Bubble Tea", "bubble tea"), - huh.NewOption("Agua Fresca", "agua-fresca"), - ). - Value(&choice), - ).WithHideFunc(func() bool { return category != drinks }), + 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 { diff --git a/examples/dynamic/dynamic-all/main.go b/examples/dynamic/dynamic-all/main.go new file mode 100644 index 00000000..a5c75c0d --- /dev/null +++ b/examples/dynamic/dynamic-all/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "log" + "strconv" + + "github.com/charmbracelet/huh" +) + +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) + } +} diff --git a/examples/dynamic/dynamic-country/main.go b/examples/dynamic/dynamic-country/main.go new file mode 100644 index 00000000..cad19cfd --- /dev/null +++ b/examples/dynamic/dynamic-country/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/log" + + "github.com/charmbracelet/huh" +) + +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.NewMultiSelect[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 */), + huh.NewSelect[string](). + 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 */), + huh.NewNote(). + TitleFunc(func() string { + return fmt.Sprintf("You selected: %s", country) + }, &country). + DescriptionFunc(func() string { + return fmt.Sprintf("You selected: %s", strings.Join(state, ", ")) + }, []any{&country, &state}), + ), + ) + + err := form.Run() + if err != nil { + log.Fatal(err) + } + + fmt.Println(state) +} + +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", + }, +} diff --git a/examples/dynamic/dynamic-markdown/main.go b/examples/dynamic/dynamic-markdown/main.go new file mode 100644 index 00000000..277db8e6 --- /dev/null +++ b/examples/dynamic/dynamic-markdown/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + + "github.com/charmbracelet/huh" +) + +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 { + return md + }, &md), + ), + ).Run() + if err != nil { + log.Fatal(err) + } +} diff --git a/examples/dynamic/dynamic-name/main.go b/examples/dynamic/dynamic-name/main.go new file mode 100644 index 00000000..99bdca53 --- /dev/null +++ b/examples/dynamic/dynamic-name/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "log" + + "github.com/charmbracelet/huh" +) + +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 + "!") +} diff --git a/examples/dynamic/dynamic-suggestions/main.go b/examples/dynamic/dynamic-suggestions/main.go new file mode 100644 index 00000000..245d50f9 --- /dev/null +++ b/examples/dynamic/dynamic-suggestions/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "log" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/huh/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"} + 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() +} diff --git a/examples/go.mod b/examples/go.mod index e108a6df..329bb7dd 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -31,6 +31,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index c1f88a41..b73c917a 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -46,6 +46,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/field_confirm.go b/field_confirm.go index debed0f4..dfcc6c82 100644 --- a/field_confirm.go +++ b/field_confirm.go @@ -12,12 +12,14 @@ import ( // Confirm is a form confirm field. type Confirm struct { + id int + value *bool key string // customization - title string - description string + title Eval[string] + description Eval[string] affirmative string negative string @@ -40,7 +42,10 @@ type Confirm struct { // NewConfirm returns a new confirm field. func NewConfirm() *Confirm { return &Confirm{ + id: nextID(), value: new(bool), + 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 }, @@ -94,13 +99,29 @@ func (c *Confirm) Key(key string) *Confirm { // Title sets the title of the confirm field. func (c *Confirm) Title(title string) *Confirm { - c.title = title + 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 = description + 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 } @@ -138,6 +159,36 @@ func (c *Confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + 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.KeyMsg: c.err = nil switch { @@ -173,14 +224,14 @@ func (c *Confirm) View() string { styles := c.activeStyles() var sb strings.Builder - sb.WriteString(styles.Title.Render(c.title)) + sb.WriteString(styles.Title.Render(c.title.val)) if c.err != nil { sb.WriteString(styles.ErrorIndicator.String()) } - description := styles.Description.Render(c.description) + description := styles.Description.Render(c.description.val) - if !c.inline && c.description != "" { + if !c.inline && (c.description.val != "" || c.description.fn != nil) { sb.WriteString("\n") } sb.WriteString(description) @@ -219,7 +270,7 @@ func (c *Confirm) Run() error { // runAccessible runs the confirm field in accessible mode. func (c *Confirm) runAccessible() error { styles := c.activeStyles() - fmt.Println(styles.Title.Render(c.title)) + fmt.Println(styles.Title.Render(c.title.val)) fmt.Println() *c.value = accessibility.PromptBool() fmt.Println(styles.SelectedOption.Render("Chose: "+c.String()) + "\n") diff --git a/field_input.go b/field_input.go index ad0b06b1..13304592 100644 --- a/field_input.go +++ b/field_input.go @@ -13,13 +13,17 @@ import ( // Input is a form input field. type Input struct { + id int value *string key string // customization - title string - description string - inline bool + title Eval[string] + description Eval[string] + placeholder Eval[string] + suggestions Eval[[]string] + + inline bool // error handling validate func(string) error @@ -44,9 +48,14 @@ func NewInput() *Input { input := textinput.New() i := &Input{ - value: new(string), - textinput: input, - validate: func(string) error { return nil }, + id: nextID(), + value: new(string), + textinput: input, + validate: func(string) error { return nil }, + 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 @@ -67,13 +76,29 @@ func (i *Input) Key(key string) *Input { // Title sets the title of the input field. func (i *Input) Title(title string) *Input { - i.title = title + i.title.val = title + i.title.fn = nil return i } // Description sets the description of the input field. func (i *Input) Description(description string) *Input { - i.description = description + i.description.val = description + i.description.fn = nil + return i +} + +// TitleFunc sets the title func of the text field. +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 text field. +func (i *Input) DescriptionFunc(f func() string, bindings any) *Input { + i.description.fn = f + i.description.bindings = bindings return i } @@ -92,12 +117,26 @@ func (i *Input) CharLimit(charlimit int) *Input { // Suggestions sets the suggestions to display for autocomplete in the input // field. 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. +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 @@ -139,6 +178,13 @@ func (i *Input) Placeholder(str string) *Input { 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 @@ -205,6 +251,69 @@ func (i *Input) Update(msg tea.Msg) (tea.Model, tea.Cmd) { *i.value = i.textinput.Value() switch msg := msg.(type) { + 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.KeyMsg: i.err = nil @@ -253,14 +362,14 @@ func (i *Input) View() string { i.textinput.TextStyle = styles.TextInput.Text var sb strings.Builder - if i.title != "" { - sb.WriteString(styles.Title.Render(i.title)) + if i.title.val != "" || i.title.fn != nil { + sb.WriteString(styles.Title.Render(i.title.val)) if !i.inline { sb.WriteString("\n") } } - if i.description != "" { - sb.WriteString(styles.Description.Render(i.description)) + if i.description.val != "" || i.description.fn != nil { + sb.WriteString(styles.Description.Render(i.description.val)) if !i.inline { sb.WriteString("\n") } @@ -286,7 +395,7 @@ func (i *Input) run() error { // runAccessible runs the input field in accessible mode. func (i *Input) runAccessible() error { styles := i.activeStyles() - fmt.Println(styles.Title.Render(i.title)) + fmt.Println(styles.Title.Render(i.title.val)) fmt.Println() *i.value = accessibility.PromptString("Input: ", i.validate) fmt.Println(styles.SelectedOption.Render("Input: " + *i.value + "\n")) @@ -321,8 +430,8 @@ func (i *Input) WithWidth(width int) Field { i.width = width frameSize := styles.Base.GetHorizontalFrameSize() promptWidth := lipgloss.Width(i.textinput.PromptStyle.Render(i.textinput.Prompt)) - titleWidth := lipgloss.Width(styles.Title.Render(i.title)) - descriptionWidth := lipgloss.Width(styles.Description.Render(i.description)) + titleWidth := lipgloss.Width(styles.Title.Render(i.title.val)) + descriptionWidth := lipgloss.Width(styles.Description.Render(i.description.val)) i.textinput.Width = width - frameSize - promptWidth - 1 if i.inline { i.textinput.Width -= titleWidth diff --git a/field_multiselect.go b/field_multiselect.go index a9fb70a5..6ca9c0a4 100644 --- a/field_multiselect.go +++ b/field_multiselect.go @@ -3,8 +3,10 @@ package huh import ( "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -14,13 +16,14 @@ import ( // MultiSelect is a form multi-select field. type MultiSelect[T comparable] struct { + id int value *[]T key string // customization - title string - description string - options []Option[T] + title Eval[string] + description Eval[string] + options Eval[[]Option[T]] filterable bool filteredOptions []Option[T] limit int @@ -36,6 +39,7 @@ type MultiSelect[T comparable] struct { filtering bool filter textinput.Model viewport viewport.Model + spinner spinner.Model // options width int @@ -49,22 +53,28 @@ func NewMultiSelect[T comparable]() *MultiSelect[T] { filter := textinput.New() filter.Prompt = "/" + s := spinner.New(spinner.WithSpinner(spinner.Line)) + return &MultiSelect[T]{ - options: []Option[T]{}, - value: new([]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)}, + value: new([]T), + validate: func([]T) error { return nil }, + filtering: false, + filter: filter, + spinner: s, } } // Value sets the value of the multi-select field. func (m *MultiSelect[T]) Value(value *[]T) *MultiSelect[T] { m.value = value - for i, o := range m.options { + for i, o := range m.options.val { for _, v := range *value { if o.Value == v { - m.options[i].selected = true + m.options.val[i].selected = true break } } @@ -81,13 +91,28 @@ func (m *MultiSelect[T]) Key(key string) *MultiSelect[T] { // Title sets the title of the multi-select field. func (m *MultiSelect[T]) Title(title string) *MultiSelect[T] { - m.title = title + 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 = description + 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 } @@ -105,12 +130,25 @@ func (m *MultiSelect[T]) Options(options ...Option[T]) *MultiSelect[T] { } } } - m.options = options + m.options.val = options m.filteredOptions = options m.updateViewportHeight() return m } +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.updateViewportHeight() + } + return m +} + // Filterable sets the multi-select field as filterable. func (m *MultiSelect[T]) Filterable(filterable bool) *MultiSelect[T] { m.filterable = filterable @@ -155,12 +193,14 @@ func (*MultiSelect[T]) Zoom() bool { // 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 } @@ -187,6 +227,8 @@ func (m *MultiSelect[T]) Init() tea.Cmd { // Update updates the multi-select field. func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.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.updateViewportHeight() @@ -194,13 +236,72 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if m.filtering { m.filter, cmd = m.filter.Update(msg) + cmds = append(cmds, cmd) } switch msg := msg.(type) { - case tea.KeyMsg: + case updateFieldMsg: + var cmds []tea.Cmd + if ok, hash := m.title.shouldUpdate(); ok { + m.title.bindingsHash = hash + if !m.title.loadFromCache() { + m.title.loading = true + cmds = append(cmds, 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 + cmds = append(cmds, 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 = clamp(m.cursor, 0, len(m.filteredOptions)-1) + } else { + m.options.loading = true + m.options.loadingStart = time.Now() + cmds = append(cmds, func() tea.Msg { + return updateOptionsMsg[T]{id: m.id, options: m.options.fn(), hash: hash} + }, m.spinner.Tick) + } + } - m.err = nil + return m, tea.Batch(cmds...) + 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) + // since we're updating the options, we need to reset the cursor. + m.filteredOptions = m.options.val + m.updateValue() + m.cursor = clamp(m.cursor, 0, len(m.filteredOptions)-1) + } + case tea.KeyMsg: + m.err = nil switch { case key.Matches(msg, m.keymap.Filter): m.setFilter(true) @@ -208,12 +309,12 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keymap.SetFilter): if len(m.filteredOptions) <= 0 { m.filter.SetValue("") - m.filteredOptions = m.options + m.filteredOptions = m.options.val } m.setFilter(false) case key.Matches(msg, m.keymap.ClearFilter): m.filter.SetValue("") - m.filteredOptions = m.options + m.filteredOptions = m.options.val m.setFilter(false) case key.Matches(msg, m.keymap.Up): if m.filtering && msg.String() == "k" { @@ -252,24 +353,27 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1) m.viewport.HalfViewDown() case key.Matches(msg, m.keymap.Toggle): - for i, option := range m.options { + for i, option := range m.options.val { if option.Key == m.filteredOptions[m.cursor].Key { - if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit { + if !m.options.val[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit { break } - selected := m.options[i].selected - m.options[i].selected = !selected + selected := m.options.val[i].selected + m.options.val[i].selected = !selected m.filteredOptions[m.cursor].selected = !selected } } + m.updateValue() case key.Matches(msg, m.keymap.Prev): - m.finalize() + m.updateValue() + m.err = m.validate(*m.value) if m.err != nil { return m, nil } return m, PrevField case key.Matches(msg, m.keymap.Next, m.keymap.Submit): - m.finalize() + m.updateValue() + m.err = m.validate(*m.value) if m.err != nil { return m, nil } @@ -277,10 +381,10 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.filtering { - m.filteredOptions = m.options + m.filteredOptions = m.options.val if m.filter.Value() != "" { m.filteredOptions = nil - for _, option := range m.options { + for _, option := range m.options.val { if m.filterFunc(option.Key) { m.filteredOptions = append(m.filteredOptions, option) } @@ -293,7 +397,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return m, cmd + return m, tea.Batch(cmds...) } // updateViewportHeight updates the viewport size according to the Height setting @@ -301,7 +405,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *MultiSelect[T]) updateViewportHeight() { // If no height is set size the viewport to the number of options. if m.height <= 0 { - m.viewport.Height = len(m.options) + m.viewport.Height = len(m.options.val) return } @@ -313,7 +417,7 @@ func (m *MultiSelect[T]) updateViewportHeight() { func (m *MultiSelect[T]) numSelected() int { var count int - for _, o := range m.options { + for _, o := range m.options.val { if o.selected { count++ } @@ -321,14 +425,13 @@ func (m *MultiSelect[T]) numSelected() int { return count } -func (m *MultiSelect[T]) finalize() { +func (m MultiSelect[T]) updateValue() { *m.value = make([]T, 0) - for _, option := range m.options { + for _, option := range m.options.val { if option.selected { *m.value = append(*m.value, option.Value) } } - m.err = m.validate(*m.value) } func (m *MultiSelect[T]) activeStyles() *FieldStyles { @@ -343,7 +446,7 @@ func (m *MultiSelect[T]) activeStyles() *FieldStyles { } func (m *MultiSelect[T]) titleView() string { - if m.title == "" { + if m.title.val == "" { return "" } var ( @@ -353,9 +456,9 @@ func (m *MultiSelect[T]) titleView() string { if m.filtering { sb.WriteString(m.filter.View()) } else if m.filter.Value() != "" { - sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value())) + sb.WriteString(styles.Title.Render(m.title.val) + styles.Description.Render("/"+m.filter.Value())) } else { - sb.WriteString(styles.Title.Render(m.title)) + sb.WriteString(styles.Title.Render(m.title.val)) } if m.err != nil { sb.WriteString(styles.ErrorIndicator.String()) @@ -364,15 +467,22 @@ func (m *MultiSelect[T]) titleView() string { } func (m *MultiSelect[T]) descriptionView() string { - return m.activeStyles().Description.Render(m.description) + return m.activeStyles().Description.Render(m.description.val) } -func (m *MultiSelect[T]) choicesView() string { +func (m *MultiSelect[T]) optionsView() string { var ( styles = m.activeStyles() c = styles.MultiSelectSelector.String() 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() + } + for i, option := range m.filteredOptions { if m.cursor == i { sb.WriteString(c) @@ -387,12 +497,12 @@ func (m *MultiSelect[T]) choicesView() string { sb.WriteString(styles.UnselectedPrefix.String()) sb.WriteString(styles.UnselectedOption.Render(option.Key)) } - if i < len(m.options)-1 { + if i < len(m.options.val)-1 { sb.WriteString("\n") } } - for i := len(m.filteredOptions); i < len(m.options)-1; i++ { + for i := len(m.filteredOptions); i < len(m.options.val)-1; i++ { sb.WriteString("\n") } @@ -402,14 +512,15 @@ func (m *MultiSelect[T]) choicesView() string { // View renders the multi-select field. func (m *MultiSelect[T]) View() string { styles := m.activeStyles() - m.viewport.SetContent(m.choicesView()) + + m.viewport.SetContent(m.optionsView()) var sb strings.Builder - if m.title != "" { + if m.title.val != "" || m.title.fn != nil { sb.WriteString(m.titleView()) sb.WriteString("\n") } - if m.description != "" { + if m.description.val != "" || m.description.fn != nil { sb.WriteString(m.descriptionView() + "\n") } sb.WriteString(m.viewport.View()) @@ -419,11 +530,10 @@ func (m *MultiSelect[T]) View() string { func (m *MultiSelect[T]) printOptions() { styles := m.activeStyles() var sb strings.Builder - - sb.WriteString(styles.Title.Render(m.title)) + sb.WriteString(styles.Title.Render(m.title.val)) sb.WriteString("\n") - for i, option := range m.options { + 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 { @@ -469,9 +579,9 @@ func (m *MultiSelect[T]) runAccessible() error { for { fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit) - choice = accessibility.PromptInt("Select: ", 0, len(m.options)) + choice = accessibility.PromptInt("Select: ", 0, len(m.options.val)) if choice == 0 { - m.finalize() + m.updateValue() err := m.validate(*m.value) if err != nil { fmt.Println(err) @@ -480,15 +590,15 @@ func (m *MultiSelect[T]) runAccessible() error { break } - if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit { + if !m.options.val[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit { fmt.Printf("You can't select more than %d options.\n", m.limit) continue } - m.options[choice-1].selected = !m.options[choice-1].selected - if m.options[choice-1].selected { - fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key) + m.options.val[choice-1].selected = !m.options.val[choice-1].selected + if m.options.val[choice-1].selected { + fmt.Printf("Selected: %s\n\n", m.options.val[choice-1].Key) } else { - fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key) + fmt.Printf("Deselected: %s\n\n", m.options.val[choice-1].Key) } m.printOptions() @@ -496,7 +606,7 @@ func (m *MultiSelect[T]) runAccessible() error { var values []string - for _, option := range m.options { + for _, option := range m.options.val { if option.selected { *m.value = append(*m.value, option.Value) values = append(values, option.Key) diff --git a/field_note.go b/field_note.go index 43bdd36c..1a67d685 100644 --- a/field_note.go +++ b/field_note.go @@ -10,9 +10,11 @@ import ( // Note is a form note field. type Note struct { + id int + // customization - title string - description string + title Eval[string] + description Eval[string] // state showNextButton bool @@ -30,20 +32,45 @@ type Note struct { // NewNote creates a new note field. func NewNote() *Note { return &Note{ + id: nextID(), showNextButton: false, skip: true, + title: Eval[string]{cache: make(map[uint64]string)}, + description: Eval[string]{cache: make(map[uint64]string)}, } } // Title sets the title of the note field. func (n *Note) Title(title string) *Note { - n.title = title + n.title.val = title + n.title.fn = nil + return n +} + +// TitleFunc sets the title func of the note field. +func (n *Note) TitleFunc(f func() string, bindings any) *Note { + n.title.fn = f + n.title.bindings = bindings return n } // Description sets the description of the note field. func (n *Note) Description(description string) *Note { - n.description = description + n.description.val = description + n.description.fn = nil + return n +} + +// DescriptionFunc sets the description func of the note field. +func (n *Note) DescriptionFunc(f func() string, bindings any) *Note { + n.description.fn = f + n.description.bindings = bindings + return n +} + +// Height sets the height of the note field. +func (n *Note) Height(height int) *Note { + n.height = height return n } @@ -93,6 +120,35 @@ func (n *Note) Init() tea.Cmd { // Update updates the note field. func (n *Note) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + 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.KeyMsg: switch { case key.Matches(msg, n.keymap.Prev): @@ -123,17 +179,17 @@ func (n *Note) View() string { sb strings.Builder ) - if n.title != "" { - sb.WriteString(styles.NoteTitle.Render(n.title)) + if n.title.val != "" || n.title.fn != nil { + sb.WriteString(styles.NoteTitle.Render(n.title.val)) } - if n.description != "" { + if n.description.val != "" || n.description.fn != nil { sb.WriteString("\n") - sb.WriteString(render(n.description)) + sb.WriteString(render(n.description.val)) } if n.showNextButton { sb.WriteString(styles.Next.Render("Next")) } - return styles.Card.Render(sb.String()) + return styles.Card.Height(n.height).Render(sb.String()) } // Run runs the note field. @@ -148,11 +204,11 @@ func (n *Note) Run() error { func (n *Note) runAccessible() error { var body string - if n.title != "" { - body = n.title + "\n\n" + if n.title.val != "" { + body = n.title.val + "\n\n" } - body += n.description + body += n.description.val fmt.Println(body) fmt.Println() @@ -188,7 +244,7 @@ func (n *Note) WithWidth(width int) Field { // WithHeight sets the height of the note field. func (n *Note) WithHeight(height int) Field { - n.height = height + n.Height(height) return n } diff --git a/field_select.go b/field_select.go index 8e503b05..96f8990e 100644 --- a/field_select.go +++ b/field_select.go @@ -3,8 +3,11 @@ package huh import ( "fmt" "strings" + "sync" + "time" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -12,16 +15,21 @@ import ( "github.com/charmbracelet/lipgloss" ) +const minHeight = 1 +const defaultHeight = 10 + // Select is a form select field. type Select[T comparable] struct { + // customization + id int value *T key string viewport viewport.Model - // customization - title string - description string - options []Option[T] + title Eval[string] + description Eval[string] + options Eval[[]Option[T]] + filteredOptions []Option[T] height int @@ -34,6 +42,7 @@ type Select[T comparable] struct { focused bool filtering bool filter textinput.Model + spinner spinner.Model // options inline bool @@ -48,12 +57,18 @@ func NewSelect[T comparable]() *Select[T] { filter := textinput.New() filter.Prompt = "/" + s := spinner.New(spinner.WithSpinner(spinner.Line)) + return &Select[T]{ - options: []Option[T]{}, - value: new(T), - validate: func(T) error { return nil }, - filtering: false, - filter: filter, + value: new(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, } } @@ -61,11 +76,12 @@ func NewSelect[T comparable]() *Select[T] { func (s *Select[T]) Value(value *T) *Select[T] { s.value = value s.selectValue(*value) + s.updateValue() return s } func (s *Select[T]) selectValue(value T) { - for i, o := range s.options { + for i, o := range s.options.val { if o.Value == value { s.selected = i break @@ -73,6 +89,21 @@ func (s *Select[T]) selectValue(value T) { } } +// Internal ID management. Used during animating to ensure that frame messages +// are received only by spinner components that sent them. +var ( + lastID int + idMtx sync.Mutex +) + +// Return the next ID we should use on the Model. +func nextID() int { + idMtx.Lock() + defer idMtx.Unlock() + lastID++ + return lastID +} + // 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] { @@ -82,13 +113,40 @@ func (s *Select[T]) Key(key string) *Select[T] { // Title sets the title of the select field. func (s *Select[T]) Title(title string) *Select[T] { - s.title = title + s.title.val = title + s.title.fn = nil + return s +} + +// TitleFunc sets the title func of the select field. +func (s *Select[T]) TitleFunc(f func() string, bindings any) *Select[T] { + s.title.fn = f + s.title.bindings = bindings return s } // Description sets the description of the select field. func (s *Select[T]) Description(description string) *Select[T] { - s.description = description + s.description.val = description + return s +} + +// DescriptionFunc sets the title func of the select field. +func (s *Select[T]) DescriptionFunc(f func() string, bindings any) *Select[T] { + s.description.fn = f + s.description.bindings = bindings + return s +} + +func (s *Select[T]) OptionsFunc(f func() []Option[T], bindings any) *Select[T] { + s.options.fn = f + s.options.bindings = bindings + // If there is no height set, we should attach a static height since these + // options are possibly dynamic. + if s.height <= 0 { + s.height = defaultHeight + s.updateViewportHeight() + } return s } @@ -97,7 +155,7 @@ func (s *Select[T]) Options(options ...Option[T]) *Select[T] { if len(options) <= 0 { return s } - s.options = options + s.options.val = options s.filteredOptions = options // Set the cursor to the existing value or the last selected option. @@ -111,6 +169,7 @@ func (s *Select[T]) Options(options ...Option[T]) *Select[T] { } s.updateViewportHeight() + s.updateValue() return s } @@ -211,6 +270,66 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case updateFieldMsg: + var cmds []tea.Cmd + if ok, hash := s.title.shouldUpdate(); ok { + s.title.bindingsHash = hash + if !s.title.loadFromCache() { + s.title.loading = true + cmds = append(cmds, func() tea.Msg { + return updateTitleMsg{id: s.id, title: s.title.fn(), hash: hash} + }) + } + } + if ok, hash := s.description.shouldUpdate(); ok { + s.description.bindingsHash = hash + if !s.description.loadFromCache() { + s.description.loading = true + cmds = append(cmds, func() tea.Msg { + return updateDescriptionMsg{id: s.id, description: s.description.fn(), hash: hash} + }) + } + } + if ok, hash := s.options.shouldUpdate(); ok { + s.clearFilter() + s.options.bindingsHash = hash + if s.options.loadFromCache() { + s.filteredOptions = s.options.val + s.selected = clamp(s.selected, 0, len(s.options.val)-1) + } else { + s.options.loading = true + s.options.loadingStart = time.Now() + cmds = append(cmds, func() tea.Msg { + return updateOptionsMsg[T]{id: s.id, hash: hash, options: s.options.fn()} + }, s.spinner.Tick) + } + } + return s, tea.Batch(cmds...) + + case spinner.TickMsg: + if !s.options.loading { + break + } + s.spinner, cmd = s.spinner.Update(msg) + return s, cmd + + case updateTitleMsg: + if msg.id == s.id && msg.hash == s.title.bindingsHash { + s.title.update(msg.title) + } + case updateDescriptionMsg: + if msg.id == s.id && msg.hash == s.description.bindingsHash { + s.description.update(msg.description) + } + case updateOptionsMsg[T]: + if msg.id == s.id && msg.hash == s.options.bindingsHash { + s.options.update(msg.options) + + // since we're updating the options, we need to update the selected cursor + // position and filteredOptions. + s.selected = clamp(s.selected, 0, len(msg.options)-1) + s.filteredOptions = msg.options + } case tea.KeyMsg: s.err = nil switch { @@ -220,7 +339,7 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, s.keymap.SetFilter): if len(s.filteredOptions) <= 0 { s.filter.SetValue("") - s.filteredOptions = s.options + s.filteredOptions = s.options.val } s.setFiltering(false) case key.Matches(msg, s.keymap.ClearFilter): @@ -239,12 +358,14 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if s.selected < s.viewport.YOffset { s.viewport.SetYOffset(s.selected) } + s.updateValue() case key.Matches(msg, s.keymap.GotoTop): if s.filtering { break } s.selected = 0 s.viewport.GotoTop() + s.updateValue() case key.Matches(msg, s.keymap.GotoBottom): if s.filtering { break @@ -254,9 +375,11 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, s.keymap.HalfPageUp): s.selected = max(s.selected-s.viewport.Height/2, 0) s.viewport.HalfViewUp() + s.updateValue() case key.Matches(msg, s.keymap.HalfPageDown): s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1) s.viewport.HalfViewDown() + s.updateValue() case key.Matches(msg, s.keymap.Down, s.keymap.Right): // When filtering we should ignore j/k keybindings // @@ -268,36 +391,35 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if s.selected >= s.viewport.YOffset+s.viewport.Height { s.viewport.LineDown(1) } + s.updateValue() case key.Matches(msg, s.keymap.Prev): if s.selected >= len(s.filteredOptions) { break } - value := s.filteredOptions[s.selected].Value - s.err = s.validate(value) + s.updateValue() + s.err = s.validate(*s.value) if s.err != nil { return s, nil } - *s.value = value return s, PrevField case key.Matches(msg, s.keymap.Next, s.keymap.Submit): if s.selected >= len(s.filteredOptions) { break } - value := s.filteredOptions[s.selected].Value s.setFiltering(false) - s.err = s.validate(value) + s.updateValue() + s.err = s.validate(*s.value) if s.err != nil { return s, nil } - *s.value = value return s, NextField } if s.filtering { - s.filteredOptions = s.options + s.filteredOptions = s.options.val if s.filter.Value() != "" { s.filteredOptions = nil - for _, option := range s.options { + for _, option := range s.options.val { if s.filterFunc(option.Key) { s.filteredOptions = append(s.filteredOptions, option) } @@ -313,12 +435,18 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, cmd } +func (s *Select[T]) updateValue() { + if s.selected < len(s.filteredOptions) && s.selected >= 0 { + *s.value = s.filteredOptions[s.selected].Value + } +} + // updateViewportHeight updates the viewport size according to the Height setting // on this select field. func (s *Select[T]) updateViewportHeight() { // If no height is set size the viewport to the number of options. if s.height <= 0 { - s.viewport.Height = len(s.options) + s.viewport.Height = len(s.options.val) return } @@ -340,9 +468,6 @@ func (s *Select[T]) activeStyles() *FieldStyles { } func (s *Select[T]) titleView() string { - if s.title == "" { - return "" - } var ( styles = s.activeStyles() sb = strings.Builder{} @@ -350,9 +475,9 @@ func (s *Select[T]) titleView() string { if s.filtering { sb.WriteString(styles.Title.Render(s.filter.View())) } else if s.filter.Value() != "" && !s.inline { - sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value())) + sb.WriteString(styles.Title.Render(s.title.val) + styles.Description.Render("/"+s.filter.Value())) } else { - sb.WriteString(styles.Title.Render(s.title)) + sb.WriteString(styles.Title.Render(s.title.val)) } if s.err != nil { sb.WriteString(styles.ErrorIndicator.String()) @@ -361,16 +486,22 @@ func (s *Select[T]) titleView() string { } func (s *Select[T]) descriptionView() string { - return s.activeStyles().Description.Render(s.description) + return s.activeStyles().Description.Render(s.description.val) } -func (s *Select[T]) choicesView() string { +func (s *Select[T]) optionsView() string { var ( styles = s.activeStyles() c = styles.SelectSelector.String() sb strings.Builder ) + if s.options.loading && time.Since(s.options.loadingStart) > spinnerShowThreshold { + s.spinner.Style = s.activeStyles().MultiSelectSelector.UnsetString() + sb.WriteString(s.spinner.View() + " Loading...") + return sb.String() + } + if s.inline { sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String()) if len(s.filteredOptions) > 0 { @@ -388,12 +519,12 @@ func (s *Select[T]) choicesView() string { } else { sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key)) } - if i < len(s.options)-1 { + if i < len(s.options.val)-1 { sb.WriteString("\n") } } - for i := len(s.filteredOptions); i < len(s.options)-1; i++ { + for i := len(s.filteredOptions); i < len(s.options.val)-1; i++ { sb.WriteString("\n") } @@ -403,16 +534,16 @@ func (s *Select[T]) choicesView() string { // View renders the select field. func (s *Select[T]) View() string { styles := s.activeStyles() - s.viewport.SetContent(s.choicesView()) + s.viewport.SetContent(s.optionsView()) var sb strings.Builder - if s.title != "" { + if s.title.val != "" || s.title.fn != nil { sb.WriteString(s.titleView()) if !s.inline { sb.WriteString("\n") } } - if s.description != "" { + if s.description.val != "" || s.description.fn != nil { sb.WriteString(s.descriptionView()) if !s.inline { sb.WriteString("\n") @@ -425,7 +556,7 @@ func (s *Select[T]) View() string { // clearFilter clears the value of the filter. func (s *Select[T]) clearFilter() { s.filter.SetValue("") - s.filteredOptions = s.options + s.filteredOptions = s.options.val s.setFiltering(false) } @@ -458,10 +589,9 @@ func (s *Select[T]) Run() error { func (s *Select[T]) runAccessible() error { var sb strings.Builder styles := s.activeStyles() + sb.WriteString(styles.Title.Render(s.title.val) + "\n") - sb.WriteString(styles.Title.Render(s.title) + "\n") - - for i, option := range s.options { + for i, option := range s.options.val { sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key)) sb.WriteString("\n") } @@ -469,8 +599,8 @@ func (s *Select[T]) runAccessible() error { fmt.Println(sb.String()) for { - choice := accessibility.PromptInt("Choose: ", 1, len(s.options)) - option := s.options[choice-1] + choice := accessibility.PromptInt("Choose: ", 1, len(s.options.val)) + option := s.options.val[choice-1] if err := s.validate(option.Value); err != nil { fmt.Println(err.Error()) continue diff --git a/field_text.go b/field_text.go index a9da3ffa..1bd300d8 100644 --- a/field_text.go +++ b/field_text.go @@ -15,6 +15,8 @@ import ( // Text is a form text field. It allows for a multi-line string input. type Text struct { + id int + value *string key string @@ -26,8 +28,10 @@ type Text struct { textarea textarea.Model // customization - title string - description string + title Eval[string] + description Eval[string] + placeholder Eval[string] + editorCmd string editorArgs []string editorExtension string @@ -52,12 +56,16 @@ func NewText() *Text { editorCmd, editorArgs := getEditor() t := &Text{ + id: nextID(), value: new(string), textarea: text, validate: func(string) error { return nil }, editorCmd: editorCmd, editorArgs: editorArgs, editorExtension: "md", + title: Eval[string]{cache: make(map[uint64]string)}, + description: Eval[string]{cache: make(map[uint64]string)}, + placeholder: Eval[string]{cache: make(map[uint64]string)}, } return t @@ -78,19 +86,35 @@ func (t *Text) Key(key string) *Text { // Title sets the title of the text field. func (t *Text) Title(title string) *Text { - t.title = title + t.title.val = title + t.title.fn = nil return t } -// Lines sets the number of lines to show of the text field. -func (t *Text) Lines(lines int) *Text { - t.textarea.SetHeight(lines) +// Description sets the description of the text field. +func (t *Text) Description(description string) *Text { + t.description.val = description + t.description.fn = nil + return t +} + +// TitleFunc sets the title of the text field. +func (t *Text) TitleFunc(f func() string, bindings any) *Text { + t.title.fn = f + t.title.bindings = bindings return t } // Description sets the description of the text field. -func (t *Text) Description(description string) *Text { - t.description = description +func (t *Text) DescriptionFunc(f func() string, bindings any) *Text { + t.description.fn = f + t.description.bindings = bindings + return t +} + +// Lines sets the number of lines to show of the text field. +func (t *Text) Lines(lines int) *Text { + t.textarea.SetHeight(lines) return t } @@ -112,6 +136,13 @@ func (t *Text) Placeholder(str string) *Text { return t } +// PlaceholderFunc sets the placeholder func of the text field. +func (t *Text) PlaceholderFunc(f func() string, bindings any) *Text { + t.placeholder.fn = f + t.placeholder.bindings = bindings + return t +} + // Validate sets the validation function of the text field. func (t *Text) Validate(validate func(string) error) *Text { t.validate = validate @@ -207,7 +238,50 @@ func (t *Text) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.textarea, cmd = t.textarea.Update(msg) cmds = append(cmds, cmd) *t.value = t.textarea.Value() - + case updateFieldMsg: + var cmds []tea.Cmd + if ok, hash := t.placeholder.shouldUpdate(); ok { + t.placeholder.bindingsHash = hash + if t.placeholder.loadFromCache() { + t.textarea.Placeholder = t.placeholder.val + } else { + t.placeholder.loading = true + cmds = append(cmds, func() tea.Msg { + return updatePlaceholderMsg{id: t.id, placeholder: t.placeholder.fn(), hash: hash} + }) + } + } + if ok, hash := t.title.shouldUpdate(); ok { + t.title.bindingsHash = hash + if !t.title.loadFromCache() { + cmds = append(cmds, func() tea.Msg { + return updateTitleMsg{id: t.id, title: t.title.fn(), hash: hash} + }) + } + } + if ok, hash := t.description.shouldUpdate(); ok { + t.description.bindingsHash = hash + if !t.description.loadFromCache() { + t.description.loading = true + cmds = append(cmds, func() tea.Msg { + return updateDescriptionMsg{id: t.id, description: t.description.fn(), hash: hash} + }) + } + } + return t, tea.Batch(cmds...) + case updatePlaceholderMsg: + if t.id == msg.id && t.placeholder.bindingsHash == msg.hash { + t.placeholder.update(msg.placeholder) + t.textarea.Placeholder = msg.placeholder + } + case updateTitleMsg: + if t.id == msg.id && t.title.bindingsHash == msg.hash { + t.title.update(msg.title) + } + case updateDescriptionMsg: + if t.id == msg.id && t.description.bindingsHash == msg.hash { + t.description.update(msg.description) + } case tea.KeyMsg: t.err = nil @@ -278,15 +352,15 @@ func (t *Text) View() string { t.textarea.Cursor.Style = styles.TextInput.Cursor var sb strings.Builder - if t.title != "" { - sb.WriteString(styles.Title.Render(t.title)) + if t.title.val != "" || t.title.fn != nil { + sb.WriteString(styles.Title.Render(t.title.val)) if t.err != nil { sb.WriteString(styles.ErrorIndicator.String()) } sb.WriteString("\n") } - if t.description != "" { - sb.WriteString(styles.Description.Render(t.description)) + if t.description.val != "" || t.description.fn != nil { + sb.WriteString(styles.Description.Render(t.description.val)) sb.WriteString("\n") } sb.WriteString(t.textarea.View()) @@ -305,7 +379,7 @@ func (t *Text) Run() error { // runAccessible runs an accessible text field. func (t *Text) runAccessible() error { styles := t.activeStyles() - fmt.Println(styles.Title.Render(t.title)) + fmt.Println(styles.Title.Render(t.title.val)) fmt.Println() *t.value = accessibility.PromptString("Input: ", func(input string) error { if err := t.validate(input); err != nil { @@ -354,10 +428,10 @@ func (t *Text) WithWidth(width int) Field { // WithHeight sets the height of the text field. func (t *Text) WithHeight(height int) Field { adjust := 0 - if t.title != "" { + if t.title.val != "" { adjust++ } - if t.description != "" { + if t.description.val != "" { adjust++ } t.textarea.SetHeight(height - t.activeStyles().Base.GetVerticalFrameSize() - adjust) diff --git a/form.go b/form.go index e122710b..1b1ddf3c 100644 --- a/form.go +++ b/form.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/paginator" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/log" ) const defaultWidth = 80 @@ -551,6 +552,12 @@ func (f *Form) View() string { // Run runs the form. func (f *Form) Run() error { + debugFile, err := tea.LogToFile("debug.log", "debug") + if err != nil { + return err + } + log.SetOutput(debugFile) + f.submitCmd = tea.Quit f.cancelCmd = tea.Quit diff --git a/go.mod b/go.mod index ba26b953..ff7d7cc1 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,16 @@ module github.com/charmbracelet/huh -go 1.18 +go 1.20 require ( github.com/catppuccin/go v0.2.0 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.1 github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb + github.com/charmbracelet/log v0.4.0 github.com/charmbracelet/x/exp/strings v0.0.0-20240506152644-8135bef4e495 github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 + github.com/mitchellh/hashstructure/v2 v2.0.2 ) require ( @@ -16,6 +18,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -25,6 +28,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/term v0.20.0 // indirect diff --git a/go.sum b/go.sum index 9c2b79c0..87c2116f 100644 --- a/go.sum +++ b/go.sum @@ -8,16 +8,12 @@ github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/ github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0= github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo= -github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= -github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb h1:Hs3xzxHuruNT2Iuo87iS40c0PhLqpnUKBI6Xw6Ad3wQ= github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs= -github.com/charmbracelet/x/exp/strings v0.0.0-20240403043919-dea9035a27d4 h1:3hiHarKRDh7NF0mtD7iMjW0dHz4XKelswmRpFNR2lKw= -github.com/charmbracelet/x/exp/strings v0.0.0-20240403043919-dea9035a27d4/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/x/exp/strings v0.0.0-20240506152644-8135bef4e495 h1:/aQyDLa4ptexEC+jETEzgXNfMZety/g+niLB4eYsKhM= github.com/charmbracelet/x/exp/strings v0.0.0-20240506152644-8135bef4e495/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/exp/term v0.0.0-20240321133156-7faadd06c281 h1:ZYwrF0GAd859tU6oF63T2pIkZVQ4z9BosDVD7jYu93A= -github.com/charmbracelet/x/exp/term v0.0.0-20240321133156-7faadd06c281/go.mod h1:madZtB2OVDOG+ZnLruGITVZceYy047W+BLQ1MNQzbWg= github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA= github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -25,6 +21,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -34,6 +32,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -48,20 +48,16 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ 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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/group.go b/group.go index ee97fa36..aa080e5c 100644 --- a/group.go +++ b/group.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/paginator" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -155,6 +156,16 @@ func (g *Group) Errors() []error { return errs } +// updateFieldMsg is a message to update the fields of a group that is currently +// displayed. +// +// This is used to update all TitleFunc, DescriptionFunc, and ...Func update +// methods to make all fields dynamically update based on user input. +type updateFieldMsg struct { + id int + msg tea.Msg +} + // nextFieldMsg is a message to move to the next field, // // each field controls when to send this message such that it is able to use @@ -234,9 +245,29 @@ func (g *Group) prevField() []tea.Cmd { func (g *Group) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - m, cmd := g.fields[g.paginator.Page].Update(msg) - g.fields[g.paginator.Page] = m.(Field) - cmds = append(cmds, cmd) + // Update all the fields in the group. + for i := range g.fields { + switch msg := msg.(type) { + case spinner.TickMsg, + updateTitleMsg, + updateDescriptionMsg, + updateSuggestionsMsg, + updateOptionsMsg[string], + updatePlaceholderMsg: + m, cmd := g.fields[i].Update(msg) + g.fields[i] = m.(Field) + cmds = append(cmds, cmd) + } + var _msg tea.Msg + if g.paginator.Page == i { + _msg = msg + } else { + _msg = updateFieldMsg{} + } + m, cmd := g.fields[i].Update(_msg) + g.fields[i] = m.(Field) + cmds = append(cmds, cmd) + } switch msg := msg.(type) { case tea.WindowSizeMsg: