Files
tuio/internal/tui/form.go
T
jbandClaude Opus 4.8 9943d5f7b7 fix: render full placeholder text in form inputs
bubbles/v2 textinput truncates the placeholder to its first rune when the
input width is 0 (the default): it sizes the placeholder buffer to
Width()+1 and early-returns after the first character. Set a non-zero
width on every text input (defaultInputWidth) and resize inputs
responsively to the terminal on WindowSizeMsg via FormStep.SetWidth, so
placeholders render in full and inputs fill the available width.

Adds a regression test asserting the placeholder renders past its first
rune.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:28:34 -03:00

207 lines
4.4 KiB
Go

package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
)
type FieldType int
const (
FieldTypeText FieldType = iota
FieldTypeSelect
FieldTypePassword
FieldTypeNumber
)
// defaultInputWidth is the visible width of text inputs before the first window-size
// message arrives. It must be > 0 (and ideally >= the longest placeholder) so the
// placeholder renders in full; it is overridden responsively via FormStep.SetWidth.
const defaultInputWidth = 50
type FormField struct {
Id string
Label string
Placeholder string
Default string
Type FieldType
Options []string // for FieldTypeSelect
OptionIdx int // for FieldTypeSelect
CharLimit int
input textinput.Model
}
type FormStep struct {
Title string
Fields []FormField
focus int
}
func NewFormStep(title string, fields []FormField) FormStep {
f := FormStep{Title: title, Fields: fields}
for i := range f.Fields {
if f.Fields[i].Type == FieldTypeSelect {
for j, opt := range f.Fields[i].Options {
// Apply default option
if opt == f.Fields[i].Default {
f.Fields[i].OptionIdx = j
break
}
}
continue
}
ti := textinput.New()
ti.Placeholder = f.Fields[i].Placeholder
// A non-zero width is required for the placeholder to render in full: with the
// default width of 0 the textinput truncates the placeholder to its first rune.
ti.SetWidth(defaultInputWidth)
if f.Fields[i].Default != "" {
ti.SetValue(f.Fields[i].Default)
}
if f.Fields[i].CharLimit > 0 {
ti.CharLimit = f.Fields[i].CharLimit
}
if f.Fields[i].Type == FieldTypePassword {
ti.EchoMode = textinput.EchoPassword
}
if i == 0 {
ti.Focus()
}
f.Fields[i].input = ti
}
return f
}
// Update
func (f *FormStep) Update(msg tea.Msg) (done bool, cmd tea.Cmd) {
focused := &f.Fields[f.focus]
isSelect := focused.Type == FieldTypeSelect
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
// Select specific inputs
case "left", "h":
if isSelect && focused.OptionIdx > 0 {
focused.OptionIdx--
}
case "right", "l":
if isSelect && focused.OptionIdx < len(focused.Options)-1 {
focused.OptionIdx++
}
// Generic inputs
case "tab", "down":
f.blurCurrent()
f.focus = (f.focus + 1) % len(f.Fields)
f.focusCurrent()
case "shift+tab", "up":
f.blurCurrent()
f.focus = (f.focus - 1 + len(f.Fields)) % len(f.Fields)
f.focusCurrent()
case "enter":
if f.focus == len(f.Fields)-1 {
return true, nil
}
f.blurCurrent()
f.focus++
f.focusCurrent()
}
}
var c tea.Cmd
f.Fields[f.focus].input, c = f.Fields[f.focus].input.Update(msg)
return false, c
}
func (f *FormStep) Values() map[string]string {
out := make(map[string]string)
for _, field := range f.Fields {
if field.Type == FieldTypeSelect {
out[field.Id] = field.Options[field.OptionIdx]
} else {
out[field.Id] = field.input.Value()
}
}
return out
}
// SetWidth resizes every text input to w (selects are unaffected). Called on window
// resize so inputs fill the available terminal width and placeholders render in full.
func (f *FormStep) SetWidth(w int) {
for i := range f.Fields {
if f.Fields[i].Type == FieldTypeSelect {
continue
}
f.Fields[i].input.SetWidth(w)
}
}
// View
func (f FormStep) View() string {
pad := strings.Repeat(" ", padding)
var sb strings.Builder
sb.WriteString(pad + TitleStyle.Render(f.Title) + "")
for i, field := range f.Fields {
sb.WriteString(renderField(field, i == f.focus))
}
return sb.String()
}
func renderField(field FormField, focused bool) string {
pad := strings.Repeat(" ", padding)
label := " " + field.Label
if focused {
label = CursorStyle.Render("> " + field.Label)
}
var value string
switch field.Type {
case FieldTypeSelect:
var opts []string
for i, opt := range field.Options {
if i == field.OptionIdx {
opts = append(opts, SelectedStyle.Render("[ "+opt+" ]"))
} else {
opts = append(opts, DimStyle.Render(" "+opt+" "))
}
}
value = strings.Join(opts, " ")
if focused {
value += HelpStyle.Render(" ← →")
}
default:
value = field.input.View()
}
return fmt.Sprintf("\n\n%s%s\n%s %s", pad, label, pad, value)
}
// Helpers
func (f *FormStep) blurCurrent() {
if f.Fields[f.focus].Type != FieldTypeSelect {
f.Fields[f.focus].input.Blur()
}
}
func (f *FormStep) focusCurrent() {
if f.Fields[f.focus].Type != FieldTypeSelect {
f.Fields[f.focus].input.Focus()
}
}