From bb7e9345259399371f061e5c7211c5d6d5777160 Mon Sep 17 00:00:00 2001 From: upstream Date: Wed, 2 Sep 2026 10:59:11 +0200 Subject: [PATCH] Upstream charmbracelet/huh state of the touched files before a4f4a7bca9 --- field_multiselect.go | 847 +++++++++++++++++++++ huh_test.go | 1687 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2534 insertions(+) create mode 100644 field_multiselect.go create mode 100644 huh_test.go diff --git a/field_multiselect.go b/field_multiselect.go new file mode 100644 index 0000000..70ac298 --- /dev/null +++ b/field_multiselect.go @@ -0,0 +1,847 @@ +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 +} diff --git a/huh_test.go b/huh_test.go new file mode 100644 index 0000000..189622d --- /dev/null +++ b/huh_test.go @@ -0,0 +1,1687 @@ +package huh + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "regexp" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/xpty" +) + +const text = "Huh" + +var pretty = lipgloss.NewStyle(). + Width(60). + Border(lipgloss.NormalBorder()). + MarginTop(1). + Padding(1, 3, 1, 2) + +func TestForm(t *testing.T) { + type Taco struct { + Shell string + Base string + Toppings []string + } + + type Order struct { + Taco Taco + Name string + Instructions string + Discount bool + } + + var taco Taco + order := Order{Taco: taco} + + f := NewForm( + NewGroup( + NewSelect[string](). + Options(NewOptions("Soft", "Hard")...). + Title("Shell?"). + Description("Our tortillas are made fresh in-house every day."). + Validate(func(t string) error { + if t == "Hard" { + return fmt.Errorf("we're out of hard shells, sorry") + } + return nil + }). + Value(&order.Taco.Shell), + + NewSelect[string](). + Options(NewOptions("Chicken", "Beef", "Fish", "Beans")...). + Value(&order.Taco.Base). + Title("Base"), + ), + + // Prompt for toppings and special instructions. + // The customer can ask for up to 4 toppings. + NewGroup( + NewMultiSelect[string](). + Title("Toppings"). + Description("Choose up to 4."). + Options( + NewOption("Lettuce", "lettuce").Selected(true), + NewOption("Tomatoes", "tomatoes").Selected(true), + NewOption("Corn", "corn"), + NewOption("Salsa", "salsa"), + NewOption("Sour Cream", "sour cream"), + NewOption("Cheese", "cheese"), + ). + Validate(func(t []string) error { + if len(t) <= 0 { + return fmt.Errorf("at least one topping is required") + } + return nil + }). + Value(&order.Taco.Toppings). + Filterable(true). + Limit(4), + ), + + // Gather final details for the order. + NewGroup( + NewInput(). + Value(&order.Name). + Title("What's your name?"). + Placeholder("Margaret Thatcher"). + Description("For when your order is ready."), + + NewText(). + Value(&order.Instructions). + Placeholder("Just put it in the mailbox please"). + Title("Special Instructions"). + Description("Anything we should know?"). + CharLimit(400), + + NewConfirm(). + Title("Would you like 15% off?"). + Value(&order.Discount). + Affirmative("Yes!"). + Negative("No."), + ), + ) + + f.Update(f.Init()) + + view := viewModel(f) + + // + // ┃ Shell? + // ┃ Our tortillas are made fresh in-house every day. + // ┃ > Soft + // ┃ Hard + // + // Base + // > Chicken + // Beef + // Fish + // Beans + // + // ↑ up • ↓ down • / filter • enter select + // + + if !strings.Contains(view, "┃ Shell?") { + t.Log(pretty.Render(view)) + t.Error("Expected form to contain Shell? title") + } + + if !strings.Contains(view, "Our tortillas are made fresh in-house every day.") { + t.Log(pretty.Render(view)) + t.Error("Expected form to contain tortilla description") + } + + if !strings.Contains(view, "Base") { + t.Log(pretty.Render(view)) + t.Error("Expected form to contain Base title") + } + + // Attempt to select hard shell and retrieve error. + m := batchUpdate(f.Update(keypress('j'))) + m = batchUpdate(m.Update(codeKeypress(tea.KeyTab))) + view = viewModel(m) + + if !strings.Contains(view, "* we're out of hard shells, sorry") { + t.Log(pretty.Render(view)) + t.Error("Expected form to show out of hard shells error") + } + + // select back the soft shell + m = batchUpdate(m.Update(keypress('k'))) + m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + + view = viewModel(m) + + if !strings.Contains(view, "┃ > Chicken") { + t.Log(pretty.Render(view)) + t.Fatal("Expected form to continue to base group") + } + + // batchMsg + nextGroup + m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + view = viewModel(m) + + // + // ┃ Toppings + // ┃ Choose up to 4. + // ┃ > ✓ Lettuce + // ┃ ✓ Tomatoes + // ┃ • Corn + // ┃ • Salsa + // ┃ • Sour Cream + // ┃ • Cheese + // + // x toggle • ↑ up • ↓ down • enter confirm • shift+tab back + // + if !strings.Contains(view, "Toppings") { + t.Log(pretty.Render(view)) + t.Fatal("Expected form to show toppings group") + } + + if !strings.Contains(view, "Choose up to 4.") { + t.Log(pretty.Render(view)) + t.Error("Expected form to show toppings description") + } + + if !strings.Contains(view, "> ✓ Lettuce ") { + t.Log(pretty.Render(view)) + t.Error("Expected form to preselect lettuce") + } + + if !strings.Contains(view, " ✓ Tomatoes") { + t.Log(pretty.Render(view)) + t.Error("Expected form to preselect tomatoes") + } + + m = batchUpdate(m.Update(keypress('j'))) + m = batchUpdate(m.Update(keypress('j'))) + view = viewModel(m) + + if !strings.Contains(view, "> • Corn") { + t.Log(pretty.Render(view)) + t.Error("Expected form to change selection to corn") + } + + m = batchUpdate(m.Update(keypress('x'))) + view = viewModel(m) + + if !strings.Contains(view, "> ✓ Corn") { + t.Log(pretty.Render(view)) + t.Error("Expected form to change selection to corn") + } + + m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter))) + view = viewModel(m) + + if !strings.Contains(view, "What's your name?") { + t.Log(pretty.Render(view)) + t.Error("Expected form to prompt for name") + } + + if !strings.Contains(view, "Special Instructions") { + t.Log(pretty.Render(view)) + t.Error("Expected form to prompt for special instructions") + } + + if !strings.Contains(view, "Would you like 15% off?") { + t.Log(pretty.Render(view)) + t.Error("Expected form to prompt for discount") + } + + // + // ┃ What's your name? + // ┃ For when your order is ready. + // ┃ > Margaret Thatcher + // + // Special Instructions + // Anything we should know? + // Just put it in the mailbox please + // + // Would you like 15% off? + // + // Yes! No. + // + // enter next • shift+tab back + // + typeText(m, "Glen") + view = viewModel(m) + if !strings.Contains(view, "Glen") { + t.Log(pretty.Render(view)) + t.Error("Expected form to accept user input") + } + + if order.Taco.Shell != "Soft" { + t.Error("Expected order shell to be Soft") + } + + if order.Taco.Base != "Chicken" { + t.Error("Expected order shell to be Chicken") + } + + if len(order.Taco.Toppings) != 3 { + t.Error("Expected order to have 3 toppings") + } + + if order.Name != "Glen" { + t.Error("Expected order name to be Glen") + } + + // TODO: Finish and submit form. +} + +func TestInput(t *testing.T) { + field := NewInput() + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + + view := viewModel(f) + + if !strings.Contains(view, ">") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain prompt.") + } + + // Type Huh in the form. + f = typeText(f, text) + view = viewModel(f) + + if !strings.Contains(view, text) { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain " + text) + } + + if !strings.Contains(view, "enter submit") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help.") + } + + if field.GetValue() != text { + t.Error("Expected field value to be " + text) + } +} + +func TestPasteNotDuplicated(t *testing.T) { + field := NewInput().Title("Name") + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + + f = batchUpdate(f.Update(tea.PasteMsg{Content: "hello"})).(*Form) + + if field.GetValue() != "hello" { + t.Errorf("Expected field value to be %q, got %q (paste was duplicated)", "hello", field.GetValue()) + } +} + +func TestInlineInput(t *testing.T) { + field := NewInput(). + Title("Input "). + Prompt(": "). + Description("Description"). + Inline(true) + + f := NewForm(NewGroup(field)).WithWidth(40) + f.Update(f.Init()) + + view := viewModel(f) + + if !strings.Contains(view, "┃ Input Description:") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain inline input.") + } + + // Type Huh in the form. + f = typeText(f, text) + view = viewModel(f) + + if !strings.Contains(view, text) { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain " + text) + } + + if !strings.Contains(view, "enter submit") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help.") + } + + if !strings.Contains(view, "┃ Input Description: "+text) { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help.") + } + + if field.GetValue() != text { + t.Error("Expected field value to be " + text) + } +} + +func TestText(t *testing.T) { + field := NewText() + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + + // Type Huh in the form. + f = typeText(f, text) + view := viewModel(f) + + if !strings.Contains(view, text) { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain " + text) + } + + if !strings.Contains(view, "alt+enter / ctrl+j new line • ctrl+e open editor • enter submit") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help.") + } + + if field.GetValue() != text { + t.Error("Expected field value to be " + text) + } +} + +func TestTextExternalEditorHidden(t *testing.T) { + field := NewText().ExternalEditor(false) + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + + // Type Huh in the form. + f = typeText(f, text) + view := viewModel(f) + + if !strings.Contains(view, text) { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain " + text) + } + + if strings.Contains(view, "ctrl+e open editor") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help without ctrl+e.") + } + + if field.GetValue() != text { + t.Error("Expected field value to be " + text) + } +} + +func TestConfirm(t *testing.T) { + field := NewConfirm().Title("Are you sure?") + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + view := viewModel(f) + + if !strings.Contains(view, "Yes") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain Yes.") + } + + if !strings.Contains(view, "No") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain No.") + } + + if !strings.Contains(view, "Are you sure?") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain Are you sure?.") + } + + if !strings.Contains(view, "←/→ toggle • enter submit") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help.") + } + + if field.GetValue() != false { + t.Log(pretty.Render(view)) + t.Error("Expected field value to be false") + } + + // Toggle left + f.Update(codeKeypress(tea.KeyLeft)) + + if field.GetValue() != true { + t.Error("Expected field value to be true") + } + + // Toggle right + f.Update(codeKeypress(tea.KeyRight)) + + if field.GetValue() != false { + t.Error("Expected field value to be false") + } +} + +func TestSelect(t *testing.T) { + field := NewSelect[string](). + Options(NewOptions( + "Foo\nLine 2", + "Bar\nLine 2", + "Baz\nLine 2", + "Ban\nLine 2", + )...). + Title("Which one?") + f := NewForm(NewGroup(field)).WithHeight(5) + f.Update(f.Init()) + + view := viewModel(f) + + if !strings.Contains(view, "Foo") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain Foo.") + } + + if !strings.Contains(view, "Which one?") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain Which one?.") + } + + if !strings.Contains(view, "> Foo") { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Foo.") + } + + // Move selection cursor down + f = batchUpdate(f.Update(codeKeypress(tea.KeyDown))).(*Form) + + view = viewModel(f) + + if got, ok := field.Hovered(); !ok || got != "Bar\nLine 2" { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Bar.") + } + + if strings.Contains(view, "> Foo") { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Bar.") + } + + if !strings.Contains(view, "> Bar") { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Bar.") + } + + if !strings.Contains(view, "↑ up • ↓ down • / filter • enter submit") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help.") + } + + // Submit + f.Update(codeKeypress(tea.KeyEnter)) + + if field.GetValue() != "Bar\nLine 2" { + t.Error("Expected field value to be Bar") + } +} + +// doAllUpdates updates the form with the given command, then continues updating it with any resultant commands from the update until no more are returned. +func doAllUpdates(f *Form, cmd tea.Cmd) { + if cmd == nil { + return + } + var cmds []tea.Cmd + switch msg := cmd().(type) { + case tea.BatchMsg: + for _, subcommand := range msg { + doAllUpdates(f, subcommand) + } + return + default: + _, result := f.Update(msg) + cmds = append(cmds, result) + } + doAllUpdates(f, tea.Batch(cmds...)) +} + +func TestSelectDynamic(t *testing.T) { + trigger := "initial" + + field1 := NewSelect[string](). + TitleFunc(func() string { + return "field1 title " + trigger + }, &trigger). + DescriptionFunc(func() string { + return "field1 desc " + trigger + }, &trigger). + OptionsFunc(func() []Option[string] { + return []Option[string]{NewOption("field1 opt "+trigger, "field1 opt "+trigger)} + }, &trigger) + field2 := NewSelect[string](). + TitleFunc(func() string { + return "field2 title " + trigger + }, &trigger). + DescriptionFunc(func() string { + return "field2 desc " + trigger + }, &trigger). + OptionsFunc(func() []Option[string] { + return []Option[string]{NewOption("field2 opt "+trigger, "field2 opt "+trigger)} + }, &trigger) + field1.WithHeight(5) + field2.WithHeight(5) + f := NewForm(NewGroup(field1, field2)).WithHeight(10) + + doAllUpdates(f, f.Init()) + + view := viewModel(f) + + expectedStrings := []string{ + "field1 title initial", + "field1 desc initial", + "field1 opt initial", + "field2 title initial", + "field2 desc initial", + "field2 opt initial", + } + for _, expected := range expectedStrings { + if !strings.Contains(view, expected) { + t.Log(pretty.Render(view)) + t.Error("Expected view to contain " + expected) + } + } + + if field1.GetValue() != "field1 opt initial" { + t.Errorf("Expected field1 value to be field1 opt initial but was %s", field1.GetValue()) + } + if field2.GetValue() != "field2 opt initial" { + t.Errorf("Expected field2 value to be field2 opt initial but was %s", field2.GetValue()) + } + + trigger = "updated" + _, cmd := f.Update(nil) + doAllUpdates(f, cmd) + view = viewModel(f) + + expectedStrings = []string{ + "field1 title updated", + "field1 desc updated", + "field1 opt updated", + "field2 title updated", + "field2 desc updated", + "field2 opt updated", + } + for _, expected := range expectedStrings { + if !strings.Contains(view, expected) { + t.Log(pretty.Render(view)) + t.Error("Expected view to contain " + expected) + } + } + + if field1.GetValue() != "field1 opt updated" { + t.Errorf("Expected field1 value to be field1 opt updated but was %s", field1.GetValue()) + } + if field2.GetValue() != "field2 opt updated" { + t.Errorf("Expected field2 value to be field2 opt updated but was %s", field1.GetValue()) + } +} + +func TestMultiSelect(t *testing.T) { + field := NewMultiSelect[string](). + Options(NewOptions( + "Foo\nLine2", + "Bar\nLine2", + "Baz\nLine2", + "Ban\nLine2", + )...). + Title("Which one?") + f := NewForm(NewGroup(field)). + WithHeight(5) + f.Update(f.Init()) + + view := viewModel(f) + + if !strings.Contains(view, "Foo") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain Foo.") + } + + if !strings.Contains(view, "Which one?") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain Which one?.") + } + + if !strings.Contains(view, "> • Foo") { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Foo.") + } + + // Move selection cursor down + m := batchUpdate(f.Update(keypress('j'))) + view = viewModel(m) + + if got, ok := field.Hovered(); !ok || got != "Bar\nLine2" { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Bar.") + } + + if strings.Contains(view, "> • Foo") { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Bar.") + } + + if !strings.Contains(view, "> • Bar") { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Bar.") + } + + // Toggle + m = batchUpdate(f.Update(keypress('x'))) + view = viewModel(m) + + if !strings.Contains(view, "> ✓ Bar") { + t.Log(pretty.Render(view)) + t.Error("Expected cursor to be on Bar.") + } + + if !strings.Contains(view, "x toggle • ↑ up • ↓ down • / filter • enter submit") { + t.Log(pretty.Render(view)) + t.Error("Expected field to contain help.") + } + + // Submit + f.Update(codeKeypress(tea.KeyEnter)) + + value := field.GetValue() + v, ok := value.([]string) + if !ok { + t.Error("Expected field value to a slice of string") + return + } + if len(v) != 1 { + t.Error("Expected field value length to be 1") + } else { + if v[0] != "Bar\nLine2" { + t.Error("Expected first field value to be Bar") + } + } +} + +func TestMultiSelectFiltering(t *testing.T) { + tests := []struct { + name string + filtering bool + }{ + {"Filtering off", false}, + {"Filtering on", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(tc.filtering) + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + // Filter for values starting with a 'B' only. + f.Update(keypress('/')) + f.Update(keypress('B')) + + view := viewModel(f) + // When we're filtering, the list should change. + if tc.filtering && strings.Contains(view, "Foo") { + t.Log(pretty.Render(view)) + t.Error("Foo should not in filtered list.") + } + // When we're not filtering, the list shouldn't change. + if !tc.filtering && !strings.Contains(view, "Foo") { + t.Log(pretty.Render(view)) + t.Error("Expected list to contain Foo.") + } + }) + } + t.Run("Remove filter option from help menu.", func(t *testing.T) { + field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(false) + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + view := viewModel(f) + if strings.Contains(view, "filter") { + t.Log(pretty.Render(view)) + t.Error("Expected list to hide filtering in help menu.") + } + }) +} + +func TestSelectFilteringShowsMatchesAboveTheCursor(t *testing.T) { + // Bar and Baz sit at opposite ends of the list, so filtering for "ba" + // while the cursor is at the bottom leaves Bar above the visible window. + opts := NewOptions("Bar", "Qux", "Quux", "Foo", "Corge", "Grault", "Garply", "Waldo", "Fred", "Baz") + + field := NewSelect[string]().Options(opts...).Title("Choose") + f := NewForm(NewGroup(field)).WithHeight(6) + f.Update(f.Init()) + + // Move to the end of the list, scrolling the viewport away from the top. + batchUpdate(f.Update(keypress('G'))) + + batchUpdate(f.Update(keypress('/'))) + batchUpdate(f.Update(keypress('b'))) + m := batchUpdate(f.Update(keypress('a'))) + + view := viewModel(m) + for _, want := range []string{"Bar", "Baz"} { + if !strings.Contains(view, want) { + t.Log(pretty.Render(view)) + t.Errorf("filtered list is missing %q, which matches the filter", want) + } + } +} + +func TestSelectPageNavigation(t *testing.T) { + opts := NewOptions( + "Qux", + "Quux", + "Foo", + "Bar", + "Baz", + "Corge", + "Grault", + "Garply", + "Waldo", + "Fred", + "Plugh", + "Xyzzy", + "Thud", + "Norf", + "Blip", + "Flob", + "Zorp", + "Smurf", + "Bloop", + "Ping", + ) + + reFirst := regexp.MustCompile(`>( •)? Qux`) + reLast := regexp.MustCompile(`>( •)? Ping`) + reHalfDown := regexp.MustCompile(`>( •)? Baz`) + + for name, field := range map[string]Field{ + "multiselect": NewMultiSelect[string]().Options(opts...).Title("Choose"), + "select": NewSelect[string]().Options(opts...).Title("Choose"), + } { + t.Run(name, func(t *testing.T) { + f := NewForm(NewGroup(field)).WithHeight(10) + f.Update(f.Init()) + + view := viewModel(f) + if !reFirst.MatchString(view) { + t.Log(pretty.Render(view)) + t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) + } + + m := batchUpdate(f.Update(keypress('G'))) + // if name == "multiselect" { + // mm := field.(*MultiSelect[string]) + // t.Logf("AQUI: height=%d offset=%d", mm.viewport.Height(), mm.viewport.YOffset()) + // t.Log("LOOK AT THIS SHIT", ansi.Strip(mm.viewport.View())) + // } + view = viewModel(m) + if !reLast.MatchString(view) { + t.Log(pretty.Render(view)) + t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String()) + } + + m = batchUpdate(f.Update(keypress('g'))) + view = viewModel(m) + if !reFirst.MatchString(view) { + t.Log(pretty.Render(view)) + t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) + } + + m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'}))) + view = viewModel(m) + if !reHalfDown.MatchString(view) { + t.Log(pretty.Render(view)) + t.Errorf("Wrong item selected, should have matched %q (half down item)", reHalfDown.String()) + } + + // sends multiple to verify it stays within boundaries + for range 10 { + m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'u'}))) + } + view = viewModel(m) + if !reFirst.MatchString(view) { + t.Log(pretty.Render(view)) + t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String()) + } + + // verify it stays within boundaries + for range 10 { + m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'}))) + } + view = viewModel(m) + if !reLast.MatchString(view) { + t.Log(pretty.Render(view)) + t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String()) + } + }) + } +} + +func TestFile(t *testing.T) { + field := NewFilePicker().Title("Which file?") + cmd := field.Init() + field.Update(cmd()) + + view := viewModel(field) + + if !strings.Contains(view, "No file selected") { + t.Log(pretty.Render(view)) + t.Error("Expected file picker to show no file selected.") + } + + if !strings.Contains(view, "Which file?") { + t.Log(pretty.Render(view)) + t.Error("Expected file picker to show title.") + } +} + +func TestHideGroup(t *testing.T) { + f := NewForm( + NewGroup(NewNote().Description("Foo")). + WithHide(true), + NewGroup(NewNote().Description("Bar")), + NewGroup(NewNote().Description("Baz")), + NewGroup(NewNote().Description("Qux")). + WithHideFunc(func() bool { return false }). + WithHide(true), + ) + + f = batchUpdate(f, f.NextGroup()).(*Form) + + if v := f.View(); !strings.Contains(v, "Bar") { + t.Log(pretty.Render(v)) + t.Error("expected Bar to be visible") + } + + // should have no effect as previous group is hidden + f.Update(prevGroup()) + + if v := f.View(); !strings.Contains(v, "Bar") { + t.Log(pretty.Render(v)) + t.Error("expected Bar to be visible") + } + + f.Update(nextGroup()) + + if v := f.View(); !strings.Contains(v, "Baz") { + t.Log(pretty.Render(v)) + t.Error("expected Baz to be visible") + } + + f.Update(nextGroup()) + + if v := f.View(); strings.Contains(v, "Qux") { + t.Log(pretty.Render(v)) + t.Error("expected Qux to be hidden") + } + + if v := f.State; v != StateCompleted { + t.Error("should have been completed") + } +} + +func TestHideGroupLastAndFirstGroupsNotHidden(t *testing.T) { + f := NewForm( + NewGroup(NewNote().Description("Bar")), + NewGroup(NewNote().Description("Foo")). + WithHide(true), + NewGroup(NewNote().Description("Baz")), + ) + + f = batchUpdate(f, f.Init()).(*Form) + + if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { + t.Log(pretty.Render(v)) + t.Error("expected Bar to not be hidden") + } + + // should have no effect as there isn't any + f.Update(prevGroup()) + + if v := f.View(); !strings.Contains(v, "Bar") { + t.Log(pretty.Render(v)) + t.Error("expected Bar to not be hidden") + } + + f.Update(nextGroup()) + + if v := ansi.Strip(f.View()); !strings.Contains(v, "Baz") { + t.Log(pretty.Render(v)) + t.Error("expected Baz to not be hidden") + } + + // should submit the form + f.Update(nextGroup()) + if v := f.State; v != StateCompleted { + t.Error("should have been completed") + } +} + +func TestEmptyGroup(t *testing.T) { + // fields can all be filtered out before the group is built, e.g. when + // every value was already given on the command line. + var fields []Field + + f := NewForm(NewGroup(fields...)) + f = batchUpdate(f, f.Init()).(*Form) + + if v := ansi.Strip(f.View()); strings.TrimSpace(v) != "" { + t.Log(pretty.Render(v)) + t.Error("expected an empty group to render nothing") + } + + if v := f.GetFocusedField(); v != nil { + t.Error("expected no focused field") + } + + f.Update(nextGroup()) + + if v := f.State; v != StateCompleted { + t.Error("should have been completed") + } +} + +func TestEmptyForm(t *testing.T) { + // a form with no groups at all: nothing to ask, so it is already done. + f := NewForm() + f = batchUpdate(f, f.Init()).(*Form) + + if v := ansi.Strip(f.View()); strings.TrimSpace(v) != "" { + t.Log(pretty.Render(v)) + t.Error("expected an empty form to render nothing") + } + + if v := f.GetFocusedField(); v != nil { + t.Error("expected no focused field") + } + + if v := f.KeyBinds(); v != nil { + t.Error("expected no keybinds") + } + + if v := f.Errors(); v != nil { + t.Error("expected no errors") + } + + if v := f.State; v != StateCompleted { + t.Error("should have been completed") + } + + if err := f.Run(); err != nil { + t.Error(err) + } +} + +func TestEmptyGroupIsSkipped(t *testing.T) { + f := NewForm( + NewGroup(), + NewGroup(NewNote().Description("Bar")), + ) + + f = batchUpdate(f, f.Init()).(*Form) + f.Update(nextGroup()) + + if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { + t.Log(pretty.Render(v)) + t.Error("expected Bar to be visible") + } +} + +func TestPrevGroup(t *testing.T) { + f := NewForm( + NewGroup(NewNote().Description("Bar")), + NewGroup(NewNote().Description("Foo")), + NewGroup(NewNote().Description("Baz")), + ) + + f = batchUpdate(f, f.Init()).(*Form) + f.Update(nextGroup()) + f.Update(nextGroup()) + f.Update(prevGroup()) + f.Update(prevGroup()) + + if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") { + t.Log(pretty.Render(v)) + t.Error("expected Bar to not be hidden") + } +} + +func TestNote(t *testing.T) { + field := NewNote(). + Title("Taco"). + Description("How may we take your order?"). + Next(true) + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + + view := viewModel(f) + + if !strings.Contains(view, "Taco") { + t.Log(view) + t.Error("Expected field to contain Taco title.") + } + + if !strings.Contains(view, "order?") { + t.Log(view) + t.Error("Expected field to contain Taco description.") + } + + if !strings.Contains(view, "Next") { + t.Log(view) + t.Error("Expected field to contain next button") + } + + const expect = 7 + if h := lipgloss.Height(ansi.Strip(view)); h != expect { + t.Log(view) + t.Errorf("Expected field to have height %d, got %d", expect, h) + } + + if !strings.Contains(view, "enter submit") { + t.Log(view) + t.Error("Expected field to contain help.") + } +} + +func TestDynamicHelp(t *testing.T) { + f := NewForm( + NewGroup( + NewInput().Title("Dynamic Help"), + NewInput().Title("Dynamic Help"), + NewInput().Title("Dynamic Help"), + ), + ) + f.Update(f.Init()) + + view := viewModel(f) + + if !strings.Contains(view, "Dynamic Help") { + t.Log(pretty.Render(view)) + t.Fatal("Expected help to contain title.") + } + + if strings.Contains(view, "shift+tab") || strings.Contains(view, "submit") { + t.Log(pretty.Render(view)) + t.Error("Expected help not to contain shift+tab or submit.") + } +} + +func TestSkip(t *testing.T) { + f := NewForm( + NewGroup( + NewInput().Title("First"), + NewNote().Title("Skipped"), + NewNote().Title("Skipped"), + NewInput().Title("Second"), + ), + ).WithWidth(25) + + f = batchUpdate(f, f.Init()).(*Form) + view := viewModel(f) + + if !strings.Contains(view, "┃ First") { + t.Log(pretty.Render(view)) + t.Error("Expected first field to be focused") + } + + // next field should skip both of the notes and proceed to the last input. + f.Update(NextField()) + view = viewModel(f) + + if strings.Contains(view, "┃ First") { + t.Log(pretty.Render(view)) + t.Error("Expected first field to be blurred") + } + + if !strings.Contains(view, "┃ Second") { + t.Log(pretty.Render(view)) + t.Error("Expected second field to be focused") + } + + // previous field should skip both of the notes and focus the first input. + f.Update(PrevField()) + view = viewModel(f) + + if strings.Contains(view, "┃ Second") { + t.Log(pretty.Render(view)) + t.Error("Expected second field to be blurred") + } + + if !strings.Contains(view, "┃ First") { + t.Log(pretty.Render(view)) + t.Error("Expected first field to be focused") + } +} + +func TestTimeout(t *testing.T) { + // This test requires a real program, so make sure it doesn't interfere with our test runner. + f := formProgram() + + // Test that the form times out after 1ms and returns a timeout error. + err := f.WithTimeout(100 * time.Millisecond).Run() + if err == nil || !errors.Is(err, ErrTimeout) { + t.Errorf("expected timeout error, got %v", err) + } +} + +func TestAbort(t *testing.T) { + // This test requires a real program, so make sure it doesn't interfere with our test runner. + f := formProgram() + + // Test that the form aborts without throwing a timeout error when explicitly told to abort. + ctx, cancel := context.WithCancel(context.Background()) + // Since the context is cancelled, the program should exit immediately. + cancel() + // Tell the form to abort. + f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'c'})) + // Run the program. + err := f.RunWithContext(ctx) + if err == nil || !errors.Is(err, ErrUserAborted) { + t.Errorf("expected user aborted error, got %v", err) + } +} + +const ( + title = "A Title" + description = "A Description" +) + +var titleAndDescTests = map[string]struct { + Empty interface{ View() string } + EmptyHeight int + Title interface{ View() string } + Description interface{ View() string } +}{ + "Group": { + NewGroup(NewInput()), + 1, // > + NewGroup(NewInput()).Title(title), + NewGroup(NewInput()).Description(description), + }, + "Confirm": { + NewConfirm(), + 1, // yes | no + NewConfirm().Title(title), + NewConfirm().Description(description), + }, + "FilePicker": { + NewFilePicker(), + 1, // "no file selected" + NewFilePicker().Title(title), + NewFilePicker().Description(description), + }, + "Input": { + NewInput(), + 1, // > + NewInput().Title(title), + NewInput().Description(description), + }, + "Note": { + NewNote(), + 1, // | + NewNote().Title(title), + NewNote().Description(description), + }, + "Text": { + NewText(), + 6, // textarea + NewText().Title(title), + NewText().Description(description), + }, + "Select": { + NewSelect[string](), + 1, // > + NewSelect[string]().Title(title), + NewSelect[string]().Description(description), + }, + "MultiSelect": { + NewMultiSelect[string](), + 1, // > + NewMultiSelect[string]().Title(title), + NewMultiSelect[string]().Description(description), + }, +} + +func TestNoTitleOrDescription(t *testing.T) { + for name, tt := range titleAndDescTests { + t.Run(name, func(t *testing.T) { + view := tt.Empty.View() + got := lipgloss.Height(ansi.Strip(view)) + want := tt.EmptyHeight + if got != want { + t.Log(pretty.Render(view)) + t.Fatalf("got != want; height should be %d, got %d", want, got) + } + }) + } +} + +func TestTitleRowRender(t *testing.T) { + for name, tt := range titleAndDescTests { + t.Run(name, func(t *testing.T) { + view := tt.Title.View() + if !strings.Contains(view, title) { + t.Log(pretty.Render(view)) + t.Error("Expected title to be visible") + } + }) + } +} + +func TestDescriptionRowRender(t *testing.T) { + for name, tt := range titleAndDescTests { + t.Run(name, func(t *testing.T) { + view := tt.Description.View() + if !strings.Contains(view, description) { + t.Log(pretty.Render(view)) + t.Error("Expected description to be visible") + } + }) + } +} + +func TestGetFocusedField(t *testing.T) { + f := NewForm( + NewGroup( + NewInput().Title("First").Key("First"), + NewInput().Title("Second").Key("Second"), + NewInput().Title("Third").Key("Third"), + ), + ).WithWidth(25) + f = batchUpdate(f, f.Init()).(*Form) + + f.NextField() + field := f.GetFocusedField() + + if field.GetKey() != "Second" { + t.Error("Expected Second field to be focused but was '" + field.GetKey() + "'") + } +} + +// formProgram returns a new Form with a nil input and output, so it can be used as a test program. +func formProgram() *Form { + return NewForm(NewGroup(NewInput().Title("Foo"))). + WithInput(nil). + WithOutput(io.Discard). + WithAccessible(false) +} + +func batchUpdate(m Model, cmd tea.Cmd) Model { + if cmd == nil { + return m + } + msg := cmd() + m, cmd = m.Update(msg) + if cmd == nil { + return m + } + msg = cmd() + m, _ = m.Update(msg) + return m +} + +func codeKeypress(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{ + Code: r, + }) +} + +func keypress(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{ + Text: string(r), + Code: r, + ShiftedCode: r, + }) +} + +func typeText[T Model](m T, s string) T { + var tm Model = m + for _, r := range s { + tm, _ = tm.Update(keypress(r)) + } + return tm.(T) +} + +func TestAccessibleForm(t *testing.T) { + var out bytes.Buffer + + f := NewForm( + NewGroup( + NewInput().Title("Hello:"), + ), + ). + WithAccessible(true). + WithOutput(&out). + WithInput(strings.NewReader("carlos\n")) + + if err := f.Run(); err != nil { + t.Error(err) + } + + if !strings.Contains(out.String(), "Hello:") { + t.Error("invalid output:\n", out.String()) + } +} + +func TestAccessibleFields(t *testing.T) { + for name, test := range map[string]struct { + Field Field + FieldFn func() Field + Input string + CheckOutput func(tb testing.TB, output string) + CheckValue func(tb testing.TB, value any) + }{ + "input": { + Field: NewInput(), + Input: "Hello", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Input:") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "Hello", value.(string)) + }, + }, + "input with charlimit": { + Field: NewInput().CharLimit(2), + Input: "Hello", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Input cannot exceed 2 characters") + }, + }, + "input with default": { + FieldFn: func() Field { + v := "hi" + return NewInput().Value(&v) + }, + Input: "\n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Input:") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "hi", value.(string)) + }, + }, + "confirm": { + Field: NewConfirm(), + Input: "Y", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Choose [y/N]") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, true, value.(bool)) + }, + }, + "confirm with default": { + FieldFn: func() Field { + v := true + return NewConfirm().Value(&v) + }, + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Choose [Y/n]") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, true, value.(bool)) + }, + }, + "confirm with default choose": { + FieldFn: func() Field { + v := true + return NewConfirm().Value(&v) + }, + Input: "n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Y/n") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, false, value.(bool)) + }, + }, + "filepicker": { + Field: NewFilePicker(), + Input: "huh_test.go", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Choose a file:") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "huh_test.go", value.(string)) + }, + }, + "filepicker with default": { + FieldFn: func() Field { + v := "huh_test.go" + return NewFilePicker().Value(&v) + }, + Input: "\n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Choose a file:") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "huh_test.go", value.(string)) + }, + }, + "multiselect": { + Field: NewMultiSelect[string]().Options(NewOptions("a", "b")...), + Input: "2", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "2. ✓ b") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + got := value.([]string) + requireEqual(tb, 1, len(got)) + requireEqual(tb, "b", got[0]) + }, + }, + "multiselect default value": { + FieldFn: func() Field { + v := []string{"b", "c"} + return NewMultiSelect[string]().Options(NewOptions("a", "b", "c", "d")...).Value(&v) + }, + Input: "\n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "2. ✓ b") + requireContains(tb, output, "3. ✓ c") + }, + }, + "select": { + Field: NewSelect[string]().Options(NewOptions("a", "b")...), + Input: "2", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Select:") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "b", value.(string)) + }, + }, + "select default value": { + FieldFn: func() Field { + v := "c" + return NewSelect[string](). + Options(NewOptions("a", "b", "c", "d")...). + Value(&v) + }, + Input: "\n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Select:") + requireContains(tb, output, "Enter a number between 1 and 4") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "c", value.(string)) + }, + }, + "select no input": { + Field: NewSelect[string]().Options(NewOptions("a", "b")...), + Input: "\n2\n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Select:") + requireContains(tb, output, "Enter a number between 1 and 2") + requireContains(tb, output, "Invalid: must be a number between 1 and 2") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "b", value.(string)) + }, + }, + "select single option": { + Field: NewSelect[string]().Options(NewOptions("a")...), + Input: "\n1\n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Select:") + requireContains(tb, output, "There is only one option available; enter the number 1:") + requireContains(tb, output, "Invalid: must be 1") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "a", value.(string)) + }, + }, + "note": { + Field: NewNote().Title("Hi").Description("there"), + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Hi") + requireContains(tb, output, "there") + }, + }, + "text": { + Field: NewText().Title("Text:"), + Input: "hello world", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Text:") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "hello world", value.(string)) + }, + }, + "text with limit": { + Field: NewText().CharLimit(2).Title("Text"), + Input: "hello world", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Input cannot exceed 2 characters") + }, + }, + "text default value": { + FieldFn: func() Field { + v := "test" + return NewText().Title("Text:").Value(&v) + }, + Input: "\n", + CheckOutput: func(tb testing.TB, output string) { + tb.Helper() + requireContains(tb, output, "Text:") + }, + CheckValue: func(tb testing.TB, value any) { + tb.Helper() + requireEqual(tb, "test", value.(string)) + }, + }, + } { + t.Run(name, func(t *testing.T) { + field := test.Field + if test.FieldFn != nil { + field = test.FieldFn() + } + + var out bytes.Buffer + if err := field.RunAccessible( + &out, + strings.NewReader(test.Input), + ); err != nil { + t.Error(err) + } + if test.CheckOutput != nil { + test.CheckOutput(t, out.String()) + } + if test.CheckValue != nil { + test.CheckValue(t, field.GetValue()) + } + }) + } +} + +func TestInputPasswordAccessible(t *testing.T) { + t.Run("not a tty", func(t *testing.T) { + var out bytes.Buffer + if err := NewInput(). + EchoMode(EchoModeNone). + RunAccessible(&out, bytes.NewReader(nil)); err == nil { + t.Error("expected it to error") + } + if err := NewInput(). + EchoMode(EchoModePassword). + RunAccessible(&out, bytes.NewReader(nil)); err == nil { + t.Error("expected it to error") + } + }) + + t.Run("is a tty", func(t *testing.T) { + var out bytes.Buffer + pty, err := xpty.NewPty(50, 30) + if err != nil { + t.Skipf("could not open pty: %v", err) + } + upty, ok := pty.(*xpty.UnixPty) + if !ok { + t.Skipf("test only works on unix") + } + + input := NewInput().EchoMode(EchoModePassword) + + errs := make(chan error, 1) + go func() { + errs <- input.RunAccessible(&out, upty.Slave()) + }() + + _, _ = upty.Master().Write([]byte("a password\n")) + + if err := <-errs; err != nil { + t.Errorf("expected no error, got %v", err) + } + + t.Logf("%q", out.String()) + requireContains(t, out.String(), "Password:") + requireEqual(t, "a password", input.GetValue().(string)) + }) +} + +func requireEqual[T comparable](tb testing.TB, a, b T) { + tb.Helper() + if a != b { + tb.Fatalf("expected %v to be equal to %v", a, b) + } +} + +func requireContains(tb testing.TB, s, subtr string) { + tb.Helper() + if !strings.Contains(s, subtr) { + tb.Fatalf("%q does not contain %q", s, subtr) + } +} + +func viewModel(m Model) string { return ansi.Strip(m.View()) }