Files
charmbracelet-huh/field_multiselect.go
T
dualfroz ba0822d4e9 fix(multiselect): enforce selection limit while filtering
The toggle handler's limit guard indexed the unfiltered options slice
with the cursor, but the cursor tracks the filtered slice. When a filter
was applied the two indices diverged, so the guard inspected the wrong
option's selected state and allowed toggling past the configured Limit.

Index the option that is actually being toggled instead, and add a
regression test that selects up to the limit, filters, then verifies a
further toggle is a no-op.
2026-09-02 10:59:11 +02:00

848 lines
22 KiB
Go

package huh
import (
"cmp"
"fmt"
"io"
"slices"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2/internal/accessibility"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/exp/ordered"
)
// MultiSelect is a form multi-select field.
type MultiSelect[T comparable] struct {
accessor Accessor[[]T]
key string
id int
// customization
title Eval[string]
description Eval[string]
options Eval[[]Option[T]]
filterable bool
filteredOptions []Option[T]
limit int
// error handling
validate func([]T) error
err error
// state
cursor int
focused bool
filtering bool
filter textinput.Model
viewport viewport.Model
spinner spinner.Model
// options
width int
height int
theme Theme
hasDarkBg bool
keymap MultiSelectKeyMap
}
// NewMultiSelect returns a new multi-select field.
func NewMultiSelect[T comparable]() *MultiSelect[T] {
filter := textinput.New()
filter.Prompt = "/"
s := spinner.New(spinner.WithSpinner(spinner.Line))
return &MultiSelect[T]{
accessor: &EmbeddedAccessor[[]T]{},
validate: func([]T) error { return nil },
filtering: false,
filter: filter,
id: nextID(),
options: Eval[[]Option[T]]{cache: make(map[uint64][]Option[T])},
title: Eval[string]{cache: make(map[uint64]string)},
description: Eval[string]{cache: make(map[uint64]string)},
spinner: s,
filterable: true,
}
}
// Value sets the value of the multi-select field.
func (m *MultiSelect[T]) Value(value *[]T) *MultiSelect[T] {
return m.Accessor(NewPointerAccessor(value))
}
// Accessor sets the accessor of the input field.
func (m *MultiSelect[T]) Accessor(accessor Accessor[[]T]) *MultiSelect[T] {
m.accessor = accessor
for i, o := range m.options.val {
if slices.Contains(m.accessor.Get(), o.Value) {
m.options.val[i].selected = true
}
}
return m
}
// Key sets the key of the select field which can be used to retrieve the value
// after submission.
func (m *MultiSelect[T]) Key(key string) *MultiSelect[T] {
m.key = key
return m
}
// Title sets the title of the multi-select field.
func (m *MultiSelect[T]) Title(title string) *MultiSelect[T] {
m.title.val = title
m.title.fn = nil
return m
}
// TitleFunc sets the title func of the multi-select field.
func (m *MultiSelect[T]) TitleFunc(f func() string, bindings any) *MultiSelect[T] {
m.title.fn = f
m.title.bindings = bindings
return m
}
// Description sets the description of the multi-select field.
func (m *MultiSelect[T]) Description(description string) *MultiSelect[T] {
m.description.val = description
return m
}
// DescriptionFunc sets the description func of the multi-select field.
func (m *MultiSelect[T]) DescriptionFunc(f func() string, bindings any) *MultiSelect[T] {
m.description.fn = f
m.description.bindings = bindings
return m
}
// Options sets the options of the multi-select field.
func (m *MultiSelect[T]) Options(options ...Option[T]) *MultiSelect[T] {
if len(options) <= 0 {
return m
}
m.options.val = options
m.filteredOptions = options
m.selectOptions()
m.updateViewportSize()
return m
}
func (m *MultiSelect[T]) selectOptions() {
// Set the cursor to the existing value or the last selected option.
for i, o := range m.options.val {
for _, v := range m.accessor.Get() {
if o.Value == v {
m.options.val[i].selected = true
}
}
}
for i, o := range m.options.val {
if !o.selected {
continue
}
m.cursor = i
m.ensureCursorVisible()
break
}
}
// OptionsFunc sets the options func of the multi-select field.
func (m *MultiSelect[T]) OptionsFunc(f func() []Option[T], bindings any) *MultiSelect[T] {
m.options.fn = f
m.options.bindings = bindings
m.filteredOptions = make([]Option[T], 0)
// If there is no height set, we should attach a static height since these
// options are possibly dynamic.
if m.height <= 0 {
m.height = defaultHeight
m.updateViewportSize()
}
if m.width <= 0 {
m.Width(20)
}
return m
}
// Filterable sets the multi-select field as filterable.
func (m *MultiSelect[T]) Filterable(filterable bool) *MultiSelect[T] {
m.filterable = filterable
return m
}
// Filtering sets the filtering state of the multi-select field.
func (m *MultiSelect[T]) Filtering(filtering bool) *MultiSelect[T] {
m.filtering = filtering
m.filter.Focus()
return m
}
// Limit sets the limit of the multi-select field.
func (m *MultiSelect[T]) Limit(limit int) *MultiSelect[T] {
m.limit = limit
m.setSelectAllHelp()
return m
}
// Width sets the width of the multi-select field.
func (m *MultiSelect[T]) Width(width int) *MultiSelect[T] {
// What we really want to do is set the width of the viewport, but we
// need a theme applied before we can calcualate its width.
m.width = width
m.updateViewportSize()
return m
}
// Height sets the height of the multi-select field.
func (m *MultiSelect[T]) Height(height int) *MultiSelect[T] {
// What we really want to do is set the height of the viewport, but we
// need a theme applied before we can calcualate its height.
m.height = height
m.updateViewportSize()
return m
}
// Validate sets the validation function of the multi-select field.
func (m *MultiSelect[T]) Validate(validate func([]T) error) *MultiSelect[T] {
m.validate = validate
return m
}
// Error returns the error of the multi-select field.
func (m *MultiSelect[T]) Error() error {
return m.err
}
// Skip returns whether the multiselect should be skipped or should be blocking.
func (*MultiSelect[T]) Skip() bool {
return false
}
// Zoom returns whether the multiselect should be zoomed.
func (*MultiSelect[T]) Zoom() bool {
return false
}
// Focus focuses the multi-select field.
func (m *MultiSelect[T]) Focus() tea.Cmd {
m.updateValue()
m.focused = true
return nil
}
// Blur blurs the multi-select field.
func (m *MultiSelect[T]) Blur() tea.Cmd {
m.updateValue()
m.focused = false
return nil
}
// Hovered returns the value of the option under the cursor, and a bool
// indicating whether one was found. If there are no visible options, returns
// a zero-valued T and false.
func (m *MultiSelect[T]) Hovered() (T, bool) {
if len(m.filteredOptions) == 0 || m.cursor >= len(m.filteredOptions) {
var zero T
return zero, false
}
return m.filteredOptions[m.cursor].Value, true
}
// KeyBinds returns the help message for the multi-select field.
func (m *MultiSelect[T]) KeyBinds() []key.Binding {
m.setSelectAllHelp()
binds := []key.Binding{
m.keymap.Toggle,
m.keymap.Up,
m.keymap.Down,
}
if m.filterable {
binds = append(
binds,
m.keymap.Filter,
m.keymap.SetFilter,
m.keymap.ClearFilter,
)
}
binds = append(
binds,
m.keymap.Prev,
m.keymap.Submit,
m.keymap.Next,
m.keymap.SelectAll,
m.keymap.SelectNone,
)
return binds
}
// Init initializes the multi-select field.
func (m *MultiSelect[T]) Init() tea.Cmd {
return nil
}
// Update updates the multi-select field.
func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
// Enforce height on the viewport during update as we need themes to
// be applied before we can calculate the height.
m.updateViewportSize()
var cmd tea.Cmd
if m.filtering {
m.filter, cmd = m.filter.Update(msg)
m.setSelectAllHelp()
cmds = append(cmds, cmd)
}
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.hasDarkBg = msg.IsDark()
case updateFieldMsg:
var fieldCmds []tea.Cmd
if ok, hash := m.title.shouldUpdate(); ok {
m.title.bindingsHash = hash
if !m.title.loadFromCache() {
m.title.loading = true
fieldCmds = append(fieldCmds, func() tea.Msg {
return updateTitleMsg{id: m.id, title: m.title.fn(), hash: hash}
})
}
}
if ok, hash := m.description.shouldUpdate(); ok {
m.description.bindingsHash = hash
if !m.description.loadFromCache() {
m.description.loading = true
fieldCmds = append(fieldCmds, func() tea.Msg {
return updateDescriptionMsg{id: m.id, description: m.description.fn(), hash: hash}
})
}
}
if ok, hash := m.options.shouldUpdate(); ok {
m.options.bindingsHash = hash
if m.options.loadFromCache() {
m.filteredOptions = m.options.val
m.updateValue()
m.cursor = ordered.Clamp(m.cursor, 0, len(m.filteredOptions)-1)
} else {
m.options.loading = true
m.options.loadingStart = time.Now()
fieldCmds = append(fieldCmds, func() tea.Msg {
return updateOptionsMsg[T]{id: m.id, options: m.options.fn(), hash: hash}
}, m.spinner.Tick)
}
}
return m, tea.Batch(fieldCmds...)
case spinner.TickMsg:
if !m.options.loading {
break
}
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case updateTitleMsg:
if msg.id == m.id && msg.hash == m.title.bindingsHash {
m.title.update(msg.title)
}
case updateDescriptionMsg:
if msg.id == m.id && msg.hash == m.description.bindingsHash {
m.description.update(msg.description)
}
case updateOptionsMsg[T]:
if msg.id == m.id && msg.hash == m.options.bindingsHash {
m.options.update(msg.options)
m.selectOptions()
// since we're updating the options, we need to reset the cursor.
m.filteredOptions = m.options.val
m.updateValue()
m.cursor = ordered.Clamp(m.cursor, 0, len(m.filteredOptions)-1)
}
case tea.KeyPressMsg:
m.err = nil
switch {
case key.Matches(msg, m.keymap.Filter):
m.setFilter(true)
return m, m.filter.Focus()
case key.Matches(msg, m.keymap.SetFilter):
if len(m.filteredOptions) <= 0 {
m.filter.SetValue("")
m.filteredOptions = m.options.val
}
m.setFilter(false)
case key.Matches(msg, m.keymap.ClearFilter):
m.filter.SetValue("")
m.filteredOptions = m.options.val
m.setFilter(false)
case key.Matches(msg, m.keymap.Up):
//nolint:godox
// FIXME: should use keys in keymap
if m.filtering && msg.String() == "k" {
break
}
m.cursor = max(m.cursor-1, 0)
m.ensureCursorVisible()
case key.Matches(msg, m.keymap.Down):
//nolint:godox
// FIXME: should use keys in keymap
if m.filtering && msg.String() == "j" {
break
}
m.cursor = min(m.cursor+1, len(m.filteredOptions)-1)
m.ensureCursorVisible()
case key.Matches(msg, m.keymap.GotoTop):
if m.filtering {
break
}
m.cursor = 0
m.viewport.GotoTop()
case key.Matches(msg, m.keymap.GotoBottom):
if m.filtering {
break
}
m.cursor = len(m.filteredOptions) - 1
m.viewport.GotoBottom()
case key.Matches(msg, m.keymap.HalfPageUp):
m.cursor = max(m.cursor-m.viewport.Height()/2, 0)
m.ensureCursorVisible()
case key.Matches(msg, m.keymap.HalfPageDown):
m.cursor = min(m.cursor+m.viewport.Height()/2, len(m.filteredOptions)-1)
m.ensureCursorVisible()
case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
for i, option := range m.options.val {
if option.Key == m.filteredOptions[m.cursor].Key {
if !m.options.val[i].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
}