gitlink-cli/cmd/interactive/form.go

318 lines
7.5 KiB
Go

package interactive
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// formField represents a single field in the argument form.
type formField struct {
flag common.Flag
input textinput.Model
boolValue bool
isBool bool
}
// formModel is the parameter form sub-component.
type formModel struct {
group string
shortcut *common.Shortcut
fields []formField
focusIdx int
submitted bool
cancelled bool
values map[string]string
width int
height int
errors []string
}
var formTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
var formErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
var formHelpStyle = lipgloss.NewStyle().Faint(true)
var formHintStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("11"))
var formLabelStyle = lipgloss.NewStyle().Bold(true)
var formRequiredMark = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
var formRequiredTag = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Faint(true)
var formOptionalTag = lipgloss.NewStyle().Faint(true)
var formDefaultTag = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Faint(true)
// newFormModel creates a new form model from a shortcut's flags.
func newFormModel(group string, shortcut *common.Shortcut, width, height int, prefill map[string]string) formModel {
fields := make([]formField, 0, len(shortcut.Flags))
for _, f := range shortcut.Flags {
if f.Bool {
// Bool field: no text input, just toggle
val := f.Default
if v, ok := prefill[f.Name]; ok {
val = v
}
fields = append(fields, formField{
flag: f,
boolValue: val == "true",
isBool: true,
})
} else {
// Text field
ti := textinput.New()
ti.Placeholder = f.Usage
if f.Default != "" {
ti.Placeholder = f.Default
}
ti.Width = width - 30
if ti.Width < 20 {
ti.Width = 20
}
// Pre-fill from provided values
if v, ok := prefill[f.Name]; ok {
ti.SetValue(v)
}
fields = append(fields, formField{
flag: f,
input: ti,
isBool: false,
})
}
}
m := formModel{
group: group,
shortcut: shortcut,
fields: fields,
width: width,
height: height,
errors: []string{},
}
// Focus the first non-bool field, or first field
m.focusFirstField()
return m
}
// focusFirstField sets focus to the first focusable field.
func (m *formModel) focusFirstField() {
for i := range m.fields {
m.focusIdx = i
if !m.fields[i].isBool {
m.fields[i].input.Focus()
}
return
}
}
// validate checks that all required fields have values.
func (m formModel) validate() []string {
var errs []string
for _, f := range m.fields {
if f.flag.Required {
if f.isBool {
// Bool fields are always valid
continue
}
if strings.TrimSpace(f.input.Value()) == "" {
errs = append(errs, fmt.Sprintf("--%s 为必填参数,不能为空", f.flag.Name))
}
}
}
return errs
}
// collectValues gathers all field values into a map.
func (m *formModel) collectValues() map[string]string {
values := make(map[string]string)
for _, f := range m.fields {
if f.isBool {
if f.boolValue {
values[f.flag.Name] = "true"
}
} else {
v := f.input.Value()
if v == "" && f.flag.Default != "" {
v = f.flag.Default
}
if v != "" {
values[f.flag.Name] = v
}
}
}
return values
}
// Update handles messages for the form.
func (m formModel) Update(msg tea.Msg) (formModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
m.cancelled = true
return m, nil
case tea.KeyTab, tea.KeyDown:
m.nextField()
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
m.prevField()
return m, nil
case tea.KeyEnter:
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
return m, nil
}
// Validate
errs := m.validate()
if len(errs) > 0 {
m.errors = errs
return m, nil
}
m.values = m.collectValues()
m.submitted = true
return m, nil
case tea.KeySpace:
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
return m, nil
}
// Fall through to text input
}
// Forward to focused text input
if len(m.fields) > 0 && !m.fields[m.focusIdx].isBool {
var cmd tea.Cmd
m.fields[m.focusIdx].input, cmd = m.fields[m.focusIdx].input.Update(msg)
return m, cmd
}
}
return m, nil
}
// nextField moves focus to the next field.
func (m *formModel) nextField() {
if len(m.fields) == 0 {
return
}
// Blur current
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Blur()
}
m.focusIdx = (m.focusIdx + 1) % len(m.fields)
// Focus new
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Focus()
}
}
// prevField moves focus to the previous field.
func (m *formModel) prevField() {
if len(m.fields) == 0 {
return
}
// Blur current
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Blur()
}
m.focusIdx--
if m.focusIdx < 0 {
m.focusIdx = len(m.fields) - 1
}
// Focus new
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Focus()
}
}
// fieldMetaTag builds the metadata tag string for a flag (required/optional, default).
func fieldMetaTag(f formField) string {
var parts []string
if f.flag.Required {
parts = append(parts, formRequiredTag.Render("[必填]"))
} else {
parts = append(parts, formOptionalTag.Render("[可选]"))
}
if !f.isBool && f.flag.Default != "" {
parts = append(parts, formDefaultTag.Render(fmt.Sprintf("默认=%s", f.flag.Default)))
}
return strings.Join(parts, " ")
}
// View renders the form.
func (m formModel) View() string {
var sb strings.Builder
// Title
title := formTitleStyle.Render(fmt.Sprintf("%s +%s", m.group, m.shortcut.Name))
sb.WriteString(title)
sb.WriteString("\n\n")
// Fields
for i, f := range m.fields {
label := f.flag.Name
if f.flag.Required {
label = label + formRequiredMark.Render(" *")
}
meta := fieldMetaTag(f)
if f.isBool {
// Bool field
check := "[ ]"
if f.boolValue {
check = "[✓]"
}
line := fmt.Sprintf(" %s %s %s", check, label, meta)
if i == m.focusIdx {
line = selectedStyle.Render(line)
}
sb.WriteString(line)
sb.WriteString("\n")
if f.flag.Usage != "" {
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
sb.WriteString("\n")
}
} else {
// Text field — show label with meta on first line, input on second
// First line: name * [必填] 默认=1
header := fmt.Sprintf(" %s %s:", label, meta)
if i == m.focusIdx {
header = formLabelStyle.Render(header)
}
sb.WriteString(header)
sb.WriteString("\n")
// Second line: input field, indented
sb.WriteString(fmt.Sprintf(" %s", f.input.View()))
sb.WriteString("\n")
if f.flag.Usage != "" {
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
sb.WriteString("\n")
}
}
sb.WriteString("\n")
}
// Errors
if len(m.errors) > 0 {
sb.WriteString("\n")
for _, e := range m.errors {
sb.WriteString(formErrorStyle.Render(fmt.Sprintf(" ✗ %s", e)))
sb.WriteString("\n")
}
}
// Help
sb.WriteString("\n")
sb.WriteString(formHelpStyle.Render(" Enter submit · Tab/↑↓ next field · Shift+Tab prev field · Space toggle bool · Esc cancel"))
return sb.String()
}