Files
terminal/tui/form.go
T
Lixen d4cc9f2407 v0.5.2 tui: titles and labels drawn a column a rune; an unchanged frame writes nothing
* tui: titles, labels and buttons drawn a column a rune

Ten places drew each rune of a title or label at its byte offset, so a
string holding runes of more than one byte drew with holes and ran past the
width it was measured and truncated to: the Pane title, the Modal title and
hint, the Divider label, TabBar titles, Table cells, Form labels, the
TextField placeholder, the ConfirmDialog and AlertDialog buttons, and the
ProgressOverlay title. Each now draws through Region.Text, which counts
columns by rune, within the bound its loop kept; a Form label stays within
its label column, as the byte count meant it to.

* an unchanged frame writes nothing

flush closed every frame with SGR 0, so a frame that changed no cell still
sent four bytes, and an app drawing an idle screen kept writing to the
terminal. The reset now follows only a frame that wrote cells: the next
cell written restates its whole style, and Fini resets on its own.

Each widget, given a title such as "● é · ñ", draws it whole where it drew
it with holes before; a frame flushed twice writes nothing the second time,
where it wrote ESC [ 0 m.
2026-10-24 19:00:37 -04:00

228 lines
4.8 KiB
Go

package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// FormField pairs a label with an editable text field
type FormField struct {
Label string
State *TextFieldState
}
// FormState holds state for a multi-field form with focus tracking
type FormState struct {
Fields []FormField
Focus int
}
// NewFormState creates a form with labeled fields initialized to empty values
func NewFormState(labels ...string) *FormState {
fields := make([]FormField, len(labels))
for i, label := range labels {
fields[i] = FormField{
Label: label,
State: NewTextFieldState(""),
}
}
return &FormState{Fields: fields}
}
// Value returns the text value of the field at idx
func (f *FormState) Value(idx int) string {
if idx >= 0 && idx < len(f.Fields) {
return f.Fields[idx].State.Value()
}
return ""
}
// SetValue replaces the text of the field at idx
func (f *FormState) SetValue(idx int, val string) {
if idx >= 0 && idx < len(f.Fields) {
f.Fields[idx].State.SetValue(val)
}
}
// Clear empties all fields in the form
func (f *FormState) Clear() {
for i := range f.Fields {
f.Fields[i].State.Clear()
}
}
// FocusNext moves focus to the next field, wrapping around
func (f *FormState) FocusNext() {
if len(f.Fields) > 0 {
f.Focus = (f.Focus + 1) % len(f.Fields)
}
}
// FocusPrev moves focus to the previous field, wrapping around
func (f *FormState) FocusPrev() {
if len(f.Fields) > 0 {
f.Focus = (f.Focus - 1 + len(f.Fields)) % len(f.Fields)
}
}
// CurrentField returns the TextFieldState of the focused field, or nil
func (f *FormState) CurrentField() *TextFieldState {
if f.Focus >= 0 && f.Focus < len(f.Fields) {
return f.Fields[f.Focus].State
}
return nil
}
// HandleKey processes keyboard input for form navigation and field editing, returns true if state changed
func (f *FormState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
switch key {
case terminal.KeyTab:
if mod&terminal.ModShift != 0 {
f.FocusPrev()
} else {
f.FocusNext()
}
return true
case terminal.KeyUp:
f.FocusPrev()
return true
case terminal.KeyDown:
f.FocusNext()
return true
default:
if field := f.CurrentField(); field != nil {
return field.HandleKey(key, r, mod)
}
}
return false
}
// FormOpts configures form rendering
type FormOpts struct {
LabelWidth int
Spacing int
Style FormStyle
}
// FormStyle defines form colors
type FormStyle struct {
LabelFg color.RGB
FieldFg color.RGB
FieldBg color.RGB
FocusBg color.RGB
CursorFg color.RGB
CursorBg color.RGB
Bg color.RGB
}
// DefaultFormStyle returns default form colors
func DefaultFormStyle() FormStyle {
return FormStyle{
LabelFg: color.RGB{R: 150, G: 150, B: 180},
FieldFg: color.RGB{R: 220, G: 220, B: 220},
FieldBg: color.RGB{R: 35, G: 35, B: 45},
FocusBg: color.RGB{R: 45, G: 45, B: 60},
CursorFg: color.RGB{R: 0, G: 0, B: 0},
CursorBg: color.RGB{R: 200, G: 200, B: 200},
Bg: color.RGB{R: 25, G: 25, B: 35},
}
}
// Form renders a multi-field form with labels and editable text fields, returns height used
func (r Region) Form(state *FormState, opts FormOpts) int {
if len(state.Fields) == 0 || r.H < 1 {
return 0
}
style := opts.Style
if style == (FormStyle{}) {
style = DefaultFormStyle()
}
labelW := opts.LabelWidth
if labelW <= 0 {
for _, f := range state.Fields {
if w := RuneLen(f.Label); w > labelW {
labelW = w
}
}
labelW += 2
}
spacing := opts.Spacing
if spacing < 1 {
spacing = 1
}
y := 0
for i, field := range state.Fields {
if y >= r.H {
break
}
isFocused := i == state.Focus
// Label
label := field.Label + ":"
r.Sub(0, y, labelW, 1).Text(0, 0, label, style.LabelFg, style.Bg, terminal.AttrNone)
// Field area
fieldX := labelW
fieldW := r.W - labelW
if fieldW < 1 {
y += spacing
continue
}
fieldBg := style.FieldBg
if isFocused {
fieldBg = style.FocusBg
}
for x := fieldX; x < fieldX+fieldW && x < r.W; x++ {
r.Cell(x, y, ' ', style.FieldFg, fieldBg, terminal.AttrNone)
}
// Render text using existing TextField logic
text := field.State.Text
scroll := field.State.Scroll
cursor := field.State.Cursor
field.State.AdjustScroll(fieldW)
scroll = field.State.Scroll
for x := 0; x < fieldW; x++ {
runeIdx := scroll + x
ch := ' '
if runeIdx < len(text) {
ch = text[runeIdx]
}
fg := style.FieldFg
bg := fieldBg
if isFocused && runeIdx == cursor {
fg = style.CursorFg
bg = style.CursorBg
}
if fieldX+x < r.W {
r.Cell(fieldX+x, y, ch, fg, bg, terminal.AttrNone)
}
}
// Cursor at end of text
if isFocused && cursor == len(text) {
cursorX := fieldX + cursor - scroll
if cursorX >= fieldX && cursorX < fieldX+fieldW && cursorX < r.W {
r.Cell(cursorX, y, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
y += spacing
}
return y
}