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 }