Files
tasksquire/components/input/select.go
2024-06-24 16:36:11 +02:00

619 lines
15 KiB
Go

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
}