v2.14.0 5e11a00

form.go RawHistoryFolder

1package components
2
3import (
4	"fmt"
5	"strings"
6
7	"github.com/bnema/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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close