package tui
import (
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
// form is a column of text fields with one focused; the MCP and webhook
// forms both build on it.
type form struct {
Inputs map[string]*textinput.Model
Focus int
}
// newField is a text input that follows a label instead of a prompt.
func newField() textinput.Model {
input := newTextInput()
input.Prompt = ""
return input
}
// ask re-arms input for the next question, masking a secret, and reports
// the blink command that shows its cursor.
func ask(input *textinput.Model, value string, secret bool) tea.Cmd {
input.Reset()
input.EchoMode = echo(secret)
input.SetValue(value)
input.Focus()
return textinput.Blink
}
func echo(secret bool) textinput.EchoMode {
if secret {
return textinput.EchoPassword
}
return textinput.EchoNormal
}
// newForm makes a field for each key, the masked ones echoing dots, with a
// length limit no pasted secret reaches.
func newForm(keys []string, masked ...string) form {
f := form{Inputs: map[string]*textinput.Model{}}
for _, key := range keys {
input := newField()
input.CharLimit, input.EchoMode = 4096, echo(slices.Contains(masked, key))
f.Inputs[key] = &input
}
return f
}
// focus focuses the field named current and blurs the rest.
func (f *form) focus(current string) {
for key, input := range f.Inputs {
if key == current {
input.Focus()
} else {
input.Blur()
}
}
}
// key applies the movement keys every form shares, cycling through fields
// from first on; submit reports that the form should be saved.
func (f *form) key(msg tea.Msg, fields []string, first int) (submit, handled bool) {
key, ok := msg.(tea.KeyPressMsg)
if !ok {
return false, false
}
delta := 0
switch key.String() {
case "tab", "down":
delta = 1
case "shift+tab", "up":
delta = -1
case "ctrl+s":
return true, true
case "enter":
if f.Focus == len(fields)-1 {
return true, true
}
delta = 1
default:
return false, false
}
n := len(fields) - first
f.Focus = first + (f.Focus-first+delta+n)%n
f.focus(fields[f.Focus])
return false, true
}
// value is a field's text without surrounding space.
func (f form) value(key string) string { return strings.TrimSpace(f.Inputs[key].Value()) }
// fit sizes the fields to the columns their rows leave for a value; a
// textinput without a width shows only the first rune of its placeholder.
func (f form) fit(width int) {
for _, input := range f.Inputs {
input.SetWidth(max(1, width))
}
}
// formRow is one labelled field, marked when focused.
func formRow(focused bool, label string, pad int, value string, width int) string {
lead := " "
if focused {
lead = promptLead()
}
return ansi.Truncate(lead+padRight(label, pad)+value, width, "…")
}
// formFooter explains the focused field, then offers the keys every form
// shares after any of its own.
func formFooter(note string, width int, own ...hint) string {
line := keyHints(append(own, hint{"tab/↑↓", "move"}, hint{"enter", "next"}, hint{"ctrl+s", "save"}, hint{"esc", "cancel"})...)
if note != "" {
line = DefaultStyles.Faint.Render(note) + DefaultStyles.Decor.Render(" · ") + line
}
return ansi.Truncate(line, width, "…")
}
func padRight(s string, n int) string {
if len(s) >= n {
return s + " "
}
return s + strings.Repeat(" ", n-len(s))
}