package tui import ( "fmt" "strings" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" ) type FieldType int const ( FieldTypeText FieldType = iota FieldTypeSelect FieldTypePassword FieldTypeNumber ) type FormField struct { Label string Placeholder string Default string Type FieldType Options []string // for FieldTypeSelect optionIdx int // for FieldTypeSelect CharLimit int input textinput.Model } type FormStep struct { Title string Fields []FormField focus int } func NewFormStep(title string, fields []FormField) FormStep { f := FormStep{Title: title, Fields: fields} for i := range f.Fields { if f.Fields[i].Type == FieldTypeSelect { for j, opt := range f.Fields[i].Options { // Apply default option if opt == f.Fields[i].Default { f.Fields[i].optionIdx = j break } } continue } ti := textinput.New() ti.Placeholder = f.Fields[i].Placeholder if f.Fields[i].Default != "" { ti.SetValue(f.Fields[i].Default) } if f.Fields[i].CharLimit > 0 { ti.CharLimit = f.Fields[i].CharLimit } if f.Fields[i].Type == FieldTypePassword { ti.EchoMode = textinput.EchoPassword } if i == 0 { ti.Focus() } f.Fields[i].input = ti } return f } // Update func (f *FormStep) Update(msg tea.Msg) (done bool, cmd tea.Cmd) { focused := &f.Fields[f.focus] isSelect := focused.Type == FieldTypeSelect switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { // Select specific inputs case "left", "h": if isSelect && focused.optionIdx > 0 { focused.optionIdx-- } case "right", "l": if isSelect && focused.optionIdx < len(focused.Options)-1 { focused.optionIdx++ } // Generic inputs case "tab", "down": f.blurCurrent() f.focus = (f.focus + 1) % len(f.Fields) f.focusCurrent() case "shift+tab", "up": f.blurCurrent() f.focus = (f.focus - 1 + len(f.Fields)) % len(f.Fields) f.focusCurrent() case "enter": if f.focus == len(f.Fields)-1 { return true, nil } f.blurCurrent() f.focus++ f.focusCurrent() } } var c tea.Cmd f.Fields[f.focus].input, c = f.Fields[f.focus].input.Update(msg) return false, c } func (f *FormStep) Values() map[string]string { out := make(map[string]string) for _, field := range f.Fields { if field.Type == FieldTypeSelect { out[field.Label] = field.Options[field.optionIdx] } else { out[field.Label] = field.input.Value() } } return out } // View func (f FormStep) View() string { pad := strings.Repeat(" ", padding) var sb strings.Builder sb.WriteString(pad + TitleStyle.Render(f.Title) + "") for i, field := range f.Fields { sb.WriteString(renderField(field, i == f.focus)) } return sb.String() } func renderField(field FormField, focused bool) string { pad := strings.Repeat(" ", padding) label := " " + field.Label if focused { label = CursorStyle.Render("> " + field.Label) } var value string switch field.Type { case FieldTypeSelect: var opts []string for i, opt := range field.Options { if i == field.optionIdx { opts = append(opts, SelectedStyle.Render("[ "+opt+" ]")) } else { opts = append(opts, DimStyle.Render(" "+opt+" ")) } } value = strings.Join(opts, " ") if focused { value += HelpStyle.Render(" ← →") } default: value = field.input.View() } return fmt.Sprintf("\n\n%s%s\n%s %s", pad, label, pad, value) } // Helpers func (f *FormStep) blurCurrent() { if f.Fields[f.focus].Type != FieldTypeSelect { f.Fields[f.focus].input.Blur() } } func (f *FormStep) focusCurrent() { if f.Fields[f.focus].Type != FieldTypeSelect { f.Fields[f.focus].input.Focus() } }