1package components
2
3import (
4 "fmt"
5 "strings"
6
7 "gordon/internal/adapters/in/cli/ui/styles"
8
9 "github.com/charmbracelet/bubbles/textinput"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
12)
13
14// FieldType defines the type of form field.
15type FieldType int
16
17const (
18 FieldTypeText FieldType = iota
19 FieldTypePassword
20 FieldTypeSelect
21)
22
23// FormField defines a form field configuration.
24type FormField struct {
25 Key string
26 Label string
27 Placeholder string
28 Default string
29 Required bool
30 Type FieldType
31 Options []string // For select fields
32 Validator func(string) error
33}
34
35// FormModel is an interactive form component.
36type FormModel struct {
37 title string
38 description string
39 fields []FormField
40 inputs []textinput.Model
41 focused int
42 submitted bool
43 cancelled bool
44 errors []string
45 width int
46
47 // Styles
48 titleStyle lipgloss.Style
49 descriptionStyle lipgloss.Style
50 labelStyle lipgloss.Style
51 errorStyle lipgloss.Style
52}
53
54// FormOption configures a FormModel.
55type FormOption func(*FormModel)
56
57// NewForm creates a new form with the given fields.
58func NewForm(fields []FormField, opts ...FormOption) FormModel {
59 inputs := make([]textinput.Model, len(fields))
60 errors := make([]string, len(fields))
61
62 for i, field := range fields {
63 ti := textinput.New()
64 ti.Placeholder = field.Placeholder
65 ti.SetValue(field.Default)
66 ti.CharLimit = 256
67 ti.Width = 40
68
69 if field.Type == FieldTypePassword {
70 ti.EchoMode = textinput.EchoPassword
71 ti.EchoCharacter = '*'
72 }
73
74 if i == 0 {
75 ti.Focus()
76 }
77
78 inputs[i] = ti
79 }
80
81 m := FormModel{
82 fields: fields,
83 inputs: inputs,
84 focused: 0,
85 errors: errors,
86 width: 50,
87 titleStyle: styles.Theme.Title,
88 descriptionStyle: styles.Theme.Muted,
89 labelStyle: styles.Theme.FormLabel,
90 errorStyle: styles.Theme.FormError,
91 }
92
93 for _, opt := range opts {
94 opt(&m)
95 }
96
97 return m
98}
99
100// WithFormTitle sets the form title.
101func WithFormTitle(title string) FormOption {
102 return func(m *FormModel) {
103 m.title = title
104 }
105}
106
107// WithFormDescription sets the form description.
108func WithFormDescription(desc string) FormOption {
109 return func(m *FormModel) {
110 m.description = desc
111 }
112}
113
114// WithFormWidth sets the form width.
115func WithFormWidth(w int) FormOption {
116 return func(m *FormModel) {
117 m.width = w
118 for i := range m.inputs {
119 m.inputs[i].Width = w - 4 // Account for padding
120 }
121 }
122}
123
124// Init implements tea.Model.
125func (m FormModel) Init() tea.Cmd {
126 return textinput.Blink
127}
128
129// Update implements tea.Model.
130func (m FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131 var cmds []tea.Cmd
132
133 switch msg := msg.(type) {
134 case tea.KeyMsg:
135 switch msg.String() {
136 case "ctrl+c", "esc":
137 m.cancelled = true
138 return m, tea.Quit
139 case "tab", "down":
140 m.focusNext()
141 case "shift+tab", "up":
142 m.focusPrev()
143 case "enter":
144 if m.focused == len(m.fields)-1 {
145 // On last field, submit
146 if m.validate() {
147 m.submitted = true
148 return m, tea.Quit
149 }
150 } else {
151 m.focusNext()
152 }
153 }
154 }
155
156 // Update the focused input
157 var cmd tea.Cmd
158 m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
159 cmds = append(cmds, cmd)
160
161 return m, tea.Batch(cmds...)
162}
163
164// focusNext moves focus to the next field.
165func (m *FormModel) focusNext() {
166 m.inputs[m.focused].Blur()
167 m.focused = (m.focused + 1) % len(m.fields)
168 m.inputs[m.focused].Focus()
169}
170
171// focusPrev moves focus to the previous field.
172func (m *FormModel) focusPrev() {
173 m.inputs[m.focused].Blur()
174 m.focused--
175 if m.focused < 0 {
176 m.focused = len(m.fields) - 1
177 }
178 m.inputs[m.focused].Focus()
179}
180
181// validate validates all fields.
182func (m *FormModel) validate() bool {
183 valid := true
184 for i, field := range m.fields {
185 value := m.inputs[i].Value()
186 m.errors[i] = ""
187
188 // Check required
189 if field.Required && strings.TrimSpace(value) == "" {
190 m.errors[i] = "This field is required"
191 valid = false
192 continue
193 }
194
195 // Run custom validator
196 if field.Validator != nil && value != "" {
197 if err := field.Validator(value); err != nil {
198 m.errors[i] = err.Error()
199 valid = false
200 }
201 }
202 }
203 return valid
204}
205
206// View implements tea.Model.
207func (m FormModel) View() string {
208 var b strings.Builder
209
210 // Title
211 if m.title != "" {
212 b.WriteString(m.titleStyle.Render(m.title))
213 b.WriteString("\n")
214 }
215
216 // Description
217 if m.description != "" {
218 b.WriteString(m.descriptionStyle.Render(m.description))
219 b.WriteString("\n")
220 }
221
222 if m.title != "" || m.description != "" {
223 b.WriteString("\n")
224 }
225
226 // Fields
227 for i, field := range m.fields {
228 // Label
229 label := field.Label
230 if field.Required {
231 label += " *"
232 }
233 b.WriteString(m.labelStyle.Render(label))
234 b.WriteString("\n")
235
236 // Input
237 b.WriteString(m.inputs[i].View())
238 b.WriteString("\n")
239
240 // Error
241 if m.errors[i] != "" {
242 b.WriteString(m.errorStyle.Render(styles.IconError + " " + m.errors[i]))
243 b.WriteString("\n")
244 }
245
246 b.WriteString("\n")
247 }
248
249 // Help
250 help := styles.RenderKeyHelp("tab/shift+tab", "navigate") + " " +
251 styles.RenderKeyHelp("enter", "submit") + " " +
252 styles.RenderKeyHelp("esc", "cancel")
253 b.WriteString(help)
254
255 return b.String()
256}
257
258// Values returns the form values as a map.
259func (m FormModel) Values() map[string]string {
260 values := make(map[string]string)
261 for i, field := range m.fields {
262 values[field.Key] = m.inputs[i].Value()
263 }
264 return values
265}
266
267// Value returns the value for a specific field.
268func (m FormModel) Value(key string) string {
269 for i, field := range m.fields {
270 if field.Key == key {
271 return m.inputs[i].Value()
272 }
273 }
274 return ""
275}
276
277// Submitted returns true if the form was submitted.
278func (m FormModel) Submitted() bool {
279 return m.submitted
280}
281
282// Cancelled returns true if the form was cancelled.
283func (m FormModel) Cancelled() bool {
284 return m.cancelled
285}
286
287// RunForm runs a form and returns the values.
288// This is a convenience function for simple forms.
289func RunForm(title string, fields []FormField) (map[string]string, error) {
290 m := NewForm(fields, WithFormTitle(title))
291 p := tea.NewProgram(m)
292 finalModel, err := p.Run()
293 if err != nil {
294 return nil, fmt.Errorf("error running form: %w", err)
295 }
296 result := finalModel.(FormModel)
297 if result.Cancelled() {
298 return nil, nil
299 }
300 if !result.Submitted() {
301 return nil, nil
302 }
303 return result.Values(), nil
304}
305
306// TextField creates a text field configuration.
307func TextField(key, label string, opts ...func(*FormField)) FormField {
308 f := FormField{
309 Key: key,
310 Label: label,
311 Type: FieldTypeText,
312 }
313 for _, opt := range opts {
314 opt(&f)
315 }
316 return f
317}
318
319// PasswordField creates a password field configuration.
320func PasswordField(key, label string, opts ...func(*FormField)) FormField {
321 f := FormField{
322 Key: key,
323 Label: label,
324 Type: FieldTypePassword,
325 }
326 for _, opt := range opts {
327 opt(&f)
328 }
329 return f
330}
331
332// Required marks a field as required.
333func Required() func(*FormField) {
334 return func(f *FormField) {
335 f.Required = true
336 }
337}
338
339// WithPlaceholder sets the field placeholder.
340func WithPlaceholder(p string) func(*FormField) {
341 return func(f *FormField) {
342 f.Placeholder = p
343 }
344}
345
346// WithDefault sets the field default value.
347func WithDefault(d string) func(*FormField) {
348 return func(f *FormField) {
349 f.Default = d
350 }
351}
352
353// WithValidator sets the field validator.
354func WithValidator(v func(string) error) func(*FormField) {
355 return func(f *FormField) {
356 f.Validator = v
357 }
358}