go/pkg/cli/textinput.go

184 lines
4 KiB
Go
Raw Normal View History

package cli
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
// textInputModel is the internal bubbletea model for text input.
type textInputModel struct {
title string
placeholder string
value string
masked bool
submitted bool
cancelled bool
cursorPos int
validator func(string) error
err error
}
func newTextInputModel(title, placeholder string) *textInputModel {
return &textInputModel{
title: title,
placeholder: placeholder,
}
}
func (m *textInputModel) insertChar(ch rune) {
m.value = m.value[:m.cursorPos] + string(ch) + m.value[m.cursorPos:]
m.cursorPos++
}
func (m *textInputModel) backspace() {
if m.cursorPos > 0 {
m.value = m.value[:m.cursorPos-1] + m.value[m.cursorPos:]
m.cursorPos--
}
}
func (m *textInputModel) Init() tea.Cmd {
return nil
}
func (m *textInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
if m.validator != nil {
if err := m.validator(m.value); err != nil {
m.err = err
return m, nil
}
}
if m.value == "" && m.placeholder != "" {
m.value = m.placeholder
}
m.submitted = true
return m, tea.Quit
case tea.KeyEscape, tea.KeyCtrlC:
m.cancelled = true
return m, tea.Quit
case tea.KeyBackspace:
m.backspace()
m.err = nil
case tea.KeyLeft:
if m.cursorPos > 0 {
m.cursorPos--
}
case tea.KeyRight:
if m.cursorPos < len(m.value) {
m.cursorPos++
}
case tea.KeyRunes:
for _, ch := range msg.Runes {
m.insertChar(ch)
}
m.err = nil
}
}
return m, nil
}
func (m *textInputModel) View() string {
var sb strings.Builder
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
display := m.value
if m.masked {
display = strings.Repeat("*", len(m.value))
}
if display == "" && m.placeholder != "" {
sb.WriteString(DimStyle.Render(m.placeholder))
} else {
sb.WriteString(display)
}
sb.WriteString(AccentStyle.Render("\u2588")) // Cursor block
if m.err != nil {
sb.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf(" %s", m.err)))
}
sb.WriteString("\n\n" + DimStyle.Render("enter submit \u2022 esc cancel"))
return sb.String()
}
// TextInputOption configures TextInput behaviour.
type TextInputOption func(*textInputConfig)
type textInputConfig struct {
placeholder string
masked bool
validator func(string) error
}
// WithTextPlaceholder sets placeholder text shown when input is empty.
func WithTextPlaceholder(text string) TextInputOption {
return func(c *textInputConfig) {
c.placeholder = text
}
}
// WithMask hides input characters (for passwords).
func WithMask() TextInputOption {
return func(c *textInputConfig) {
c.masked = true
}
}
// WithInputValidator adds a validation function for the input.
func WithInputValidator(fn func(string) error) TextInputOption {
return func(c *textInputConfig) {
c.validator = fn
}
}
// TextInput presents a styled text input prompt and returns the entered value.
// Returns empty string if cancelled.
//
// Falls back to Question() when stdin is not a terminal.
//
// name, err := cli.TextInput("Enter your name:", cli.WithTextPlaceholder("Anonymous"))
// pass, err := cli.TextInput("Password:", cli.WithMask())
func TextInput(title string, opts ...TextInputOption) (string, error) {
cfg := &textInputConfig{}
for _, opt := range opts {
opt(cfg)
}
// Fall back to simple Question if not a terminal
if !term.IsTerminal(0) {
var qopts []QuestionOption
if cfg.placeholder != "" {
qopts = append(qopts, WithDefault(cfg.placeholder))
}
if cfg.validator != nil {
qopts = append(qopts, WithValidator(cfg.validator))
}
return Question(title, qopts...), nil
}
m := newTextInputModel(title, cfg.placeholder)
m.masked = cfg.masked
m.validator = cfg.validator
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return "", err
}
final := finalModel.(*textInputModel)
if final.cancelled {
return "", nil
}
return final.value, nil
}