forked from Gitlink/gitlink-cli
318 lines
7.5 KiB
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()
|
|
}
|