package input import ( "fmt" "strings" "tasksquire/common" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/huh/accessibility" "github.com/charmbracelet/lipgloss" ) // Select is a form select field. type Select struct { common *common.Common value *string key string viewport viewport.Model // customization title string description string options []Option[string] filteredOptions []Option[string] height int // error handling validate func(string) error err error // state selected int focused bool filtering bool filter textinput.Model // options inline bool width int accessible bool theme *huh.Theme keymap huh.SelectKeyMap // new hasNewOption bool newInput textinput.Model newInputActive bool } // NewSelect returns a new select field. func NewSelect(com *common.Common) *Select { filter := textinput.New() filter.Prompt = "/" newInput := textinput.New() newInput.Prompt = "New: " return &Select{ common: com, options: []Option[string]{}, value: new(string), validate: func(string) error { return nil }, filtering: false, filter: filter, newInput: newInput, newInputActive: false, } } // Value sets the value of the select field. func (s *Select) Value(value *string) *Select { s.value = value s.selectValue(*value) return s } func (s *Select) selectValue(value string) { for i, o := range s.options { if o.Value == value { s.selected = i break } } } // Key sets the key of the select field which can be used to retrieve the value // after submission. func (s *Select) Key(key string) *Select { s.key = key return s } // Title sets the title of the select field. func (s *Select) Title(title string) *Select { s.title = title return s } // Description sets the description of the select field. func (s *Select) Description(description string) *Select { s.description = description return s } // Options sets the options of the select field. func (s *Select) Options(hasNewOption bool, options ...Option[string]) *Select { s.hasNewOption = hasNewOption if s.hasNewOption { newOption := []Option[string]{ {Key: "(new)", Value: ""}, } options = append(newOption, options...) } if len(options) <= 0 { return s } s.options = options s.filteredOptions = options // Set the cursor to the existing value or the last selected option. for i, option := range options { if option.Value == *s.value { s.selected = i break } else if option.selected { s.selected = i } } s.updateViewportHeight() return s } // Inline sets whether the select input should be inline. func (s *Select) Inline(v bool) *Select { s.inline = v if v { s.Height(1) } s.keymap.Left.SetEnabled(v) s.keymap.Right.SetEnabled(v) s.keymap.Up.SetEnabled(!v) s.keymap.Down.SetEnabled(!v) return s } // Height sets the height of the select field. If the number of options // exceeds the height, the select field will become scrollable. func (s *Select) Height(height int) *Select { s.height = height s.updateViewportHeight() return s } // Validate sets the validation function of the select field. func (s *Select) Validate(validate func(string) error) *Select { s.validate = validate return s } // Error returns the error of the select field. func (s *Select) Error() error { return s.err } // Skip returns whether the select should be skipped or should be blocking. func (*Select) Skip() bool { return false } // Zoom returns whether the input should be zoomed. func (*Select) Zoom() bool { return false } // Focus focuses the select field. func (s *Select) Focus() tea.Cmd { s.focused = true return nil } // Blur blurs the select field. func (s *Select) Blur() tea.Cmd { value := *s.value if s.inline { s.clearFilter() s.selectValue(value) } s.focused = false s.err = s.validate(value) return nil } // KeyBinds returns the help keybindings for the select field. func (s *Select) KeyBinds() []key.Binding { return []key.Binding{ s.keymap.Up, s.keymap.Down, s.keymap.Left, s.keymap.Right, s.keymap.Filter, s.keymap.SetFilter, s.keymap.ClearFilter, s.keymap.Prev, s.keymap.Next, s.keymap.Submit, } } // Init initializes the select field. func (s *Select) Init() tea.Cmd { return nil } // Update updates the select field. func (s *Select) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.updateViewportHeight() var cmd tea.Cmd if s.filtering { s.filter, cmd = s.filter.Update(msg) // Keep the selected item in view. if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height { s.viewport.SetYOffset(s.selected) } } if s.newInputActive { s.newInput, cmd = s.newInput.Update(msg) switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, s.common.Keymap.Ok): if s.newInput.Value() != "" { newOption := Option[string]{ Key: s.newInput.Value(), Value: s.newInput.Value(), selected: true, } s.options = append(s.options, newOption) if s.filterFunc(newOption.Key) { s.filteredOptions = append(s.filteredOptions, newOption) } s.selected = len(s.options) - 1 value := newOption.Value s.setFiltering(false) s.err = s.validate(value) if s.err != nil { return s, nil } *s.value = value } s.newInputActive = false s.newInput.SetValue("") s.newInput.Blur() return s, nil case key.Matches(msg, s.common.Keymap.Back): s.newInputActive = false s.newInput.Blur() return s, SuppressBack() } } } switch msg := msg.(type) { case tea.KeyMsg: s.err = nil switch { case key.Matches(msg, s.keymap.Filter): s.setFiltering(true) return s, s.filter.Focus() case key.Matches(msg, s.keymap.SetFilter): if len(s.filteredOptions) <= 0 { s.filter.SetValue("") s.filteredOptions = s.options } s.setFiltering(false) case key.Matches(msg, s.keymap.ClearFilter): s.clearFilter() case key.Matches(msg, s.keymap.Up, s.keymap.Left): // When filtering we should ignore j/k keybindings // // XXX: Currently, the below check doesn't account for keymap // changes. When making this fix it's worth considering ignoring // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 // may not be enough when international keyboards are considered. if s.filtering && (msg.String() == "k" || msg.String() == "h") { break } s.selected = max(s.selected-1, 0) if s.selected < s.viewport.YOffset { s.viewport.SetYOffset(s.selected) } case key.Matches(msg, s.keymap.GotoTop): if s.filtering { break } s.selected = 0 s.viewport.GotoTop() case key.Matches(msg, s.keymap.GotoBottom): if s.filtering { break } s.selected = len(s.filteredOptions) - 1 s.viewport.GotoBottom() case key.Matches(msg, s.keymap.HalfPageUp): s.selected = max(s.selected-s.viewport.Height/2, 0) s.viewport.HalfViewUp() case key.Matches(msg, s.keymap.HalfPageDown): s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1) s.viewport.HalfViewDown() case key.Matches(msg, s.keymap.Down, s.keymap.Right): // When filtering we should ignore j/k keybindings // // XXX: See note in the previous case match. if s.filtering && (msg.String() == "j" || msg.String() == "l") { break } s.selected = min(s.selected+1, len(s.filteredOptions)-1) if s.selected >= s.viewport.YOffset+s.viewport.Height { s.viewport.LineDown(1) } case key.Matches(msg, s.keymap.Prev): if s.selected >= len(s.filteredOptions) { break } value := s.filteredOptions[s.selected].Value s.err = s.validate(value) if s.err != nil { return s, nil } *s.value = value return s, huh.PrevField case key.Matches(msg, s.common.Keymap.Select): if s.hasNewOption && s.selected == 0 { s.newInputActive = true s.newInput.Focus() return s, nil } case key.Matches(msg, s.keymap.Next, s.keymap.Submit): if s.selected >= len(s.filteredOptions) { break } value := s.filteredOptions[s.selected].Value s.setFiltering(false) s.err = s.validate(value) if s.err != nil { return s, nil } *s.value = value return s, huh.NextField } if s.filtering { s.filteredOptions = s.options if s.filter.Value() != "" { s.filteredOptions = nil for _, option := range s.options { if s.filterFunc(option.Key) { s.filteredOptions = append(s.filteredOptions, option) } } } if len(s.filteredOptions) > 0 { s.selected = min(s.selected, len(s.filteredOptions)-1) s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height)) } } } return s, cmd } // updateViewportHeight updates the viewport size according to the Height setting // on this select field. func (s *Select) updateViewportHeight() { // If no height is set size the viewport to the number of options. if s.height <= 0 { s.viewport.Height = len(s.options) return } const minHeight = 1 s.viewport.Height = max(minHeight, s.height- lipgloss.Height(s.titleView())- lipgloss.Height(s.descriptionView())) } func (s *Select) activeStyles() *huh.FieldStyles { theme := s.theme if theme == nil { theme = huh.ThemeCharm() } if s.focused { return &theme.Focused } return &theme.Blurred } func (s *Select) titleView() string { if s.title == "" { return "" } var ( styles = s.activeStyles() sb = strings.Builder{} ) if s.filtering { sb.WriteString(styles.Title.Render(s.filter.View())) } else if s.filter.Value() != "" && !s.inline { sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value())) } else { sb.WriteString(styles.Title.Render(s.title)) } if s.err != nil { sb.WriteString(styles.ErrorIndicator.String()) } return sb.String() } func (s *Select) descriptionView() string { return s.activeStyles().Description.Render(s.description) } func (s *Select) choicesView() string { var ( styles = s.activeStyles() c = styles.SelectSelector.String() sb strings.Builder ) if s.inline { sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String()) if len(s.filteredOptions) > 0 { sb.WriteString(styles.SelectedOption.Render(s.filteredOptions[s.selected].Key)) } else { sb.WriteString(styles.TextInput.Placeholder.Render("No matches")) } sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String()) return sb.String() } for i, option := range s.filteredOptions { if s.newInputActive && i == 0 { sb.WriteString(c) sb.WriteString(s.newInput.View()) sb.WriteString("\n") continue } else if s.selected == i { sb.WriteString(c + styles.SelectedOption.Render(option.Key)) } else { sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key)) } if i < len(s.options)-1 { sb.WriteString("\n") } } for i := len(s.filteredOptions); i < len(s.options)-1; i++ { sb.WriteString("\n") } return sb.String() } // View renders the select field. func (s *Select) View() string { styles := s.activeStyles() s.viewport.SetContent(s.choicesView()) var sb strings.Builder if s.title != "" { sb.WriteString(s.titleView()) if !s.inline { sb.WriteString("\n") } } if s.description != "" { sb.WriteString(s.descriptionView()) if !s.inline { sb.WriteString("\n") } } sb.WriteString(s.viewport.View()) return styles.Base.Render(sb.String()) } // clearFilter clears the value of the filter. func (s *Select) clearFilter() { s.filter.SetValue("") s.filteredOptions = s.options s.setFiltering(false) } // setFiltering sets the filter of the select field. func (s *Select) setFiltering(filtering bool) { if s.inline && filtering { s.filter.Width = lipgloss.Width(s.titleView()) - 1 - 1 } s.filtering = filtering s.keymap.SetFilter.SetEnabled(filtering) s.keymap.Filter.SetEnabled(!filtering) s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") } // filterFunc returns true if the option matches the filter. func (s *Select) filterFunc(option string) bool { // XXX: remove diacritics or allow customization of filter function. return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) } // Run runs the select field. func (s *Select) Run() error { if s.accessible { return s.runAccessible() } return huh.Run(s) } // runAccessible runs an accessible select field. func (s *Select) runAccessible() error { var sb strings.Builder styles := s.activeStyles() sb.WriteString(styles.Title.Render(s.title) + "\n") for i, option := range s.options { sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key)) sb.WriteString("\n") } fmt.Println(sb.String()) for { choice := accessibility.PromptInt("Choose: ", 1, len(s.options)) option := s.options[choice-1] if err := s.validate(option.Value); err != nil { fmt.Println(err.Error()) continue } fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n")) *s.value = option.Value break } return nil } // WithTheme sets the theme of the select field. func (s *Select) WithTheme(theme *huh.Theme) huh.Field { if s.theme != nil { return s } s.theme = theme s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt s.updateViewportHeight() return s } // WithKeyMap sets the keymap on a select field. func (s *Select) WithKeyMap(k *huh.KeyMap) huh.Field { s.keymap = k.Select s.keymap.Left.SetEnabled(s.inline) s.keymap.Right.SetEnabled(s.inline) s.keymap.Up.SetEnabled(!s.inline) s.keymap.Down.SetEnabled(!s.inline) return s } // WithAccessible sets the accessible mode of the select field. func (s *Select) WithAccessible(accessible bool) huh.Field { s.accessible = accessible return s } // WithWidth sets the width of the select field. func (s *Select) WithWidth(width int) huh.Field { s.width = width return s } // WithHeight sets the height of the select field. func (s *Select) WithHeight(height int) huh.Field { return s.Height(height) } // WithPosition sets the position of the select field. func (s *Select) WithPosition(p huh.FieldPosition) huh.Field { if s.filtering { return s } s.keymap.Prev.SetEnabled(!p.IsFirst()) s.keymap.Next.SetEnabled(!p.IsLast()) s.keymap.Submit.SetEnabled(p.IsLast()) return s } // GetKey returns the key of the field. func (s *Select) GetKey() string { return s.key } // GetValue returns the value of the field. func (s *Select) GetValue() any { return *s.value }