Files
terminal/tui/textfield.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

169 lines
3.9 KiB
Go

package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// TextFieldOpts configures text field rendering
type TextFieldOpts struct {
Placeholder string // Shown when empty
Prefix string // Left prompt (e.g., "> ")
Mask rune // Password mask, 0 = none
MaxLen int // Max runes, 0 = unlimited
Border LineType // Border style, LineNone = no border
Focused bool // Show cursor and accept input
Style TextFieldStyle
}
// DefaultTextFieldStyle returns default colors
func DefaultTextFieldStyle() TextFieldStyle {
return TextFieldStyle{
TextFg: color.RGB{R: 220, G: 220, B: 220},
TextBg: color.RGB{R: 30, G: 30, B: 40},
CursorFg: color.RGB{R: 0, G: 0, B: 0},
CursorBg: color.RGB{R: 200, G: 200, B: 200},
PlaceholderFg: color.RGB{R: 100, G: 100, B: 110},
PrefixFg: color.RGB{R: 150, G: 150, B: 180},
BorderFg: color.RGB{R: 80, G: 80, B: 100},
}
}
// TextFieldStyle defines text field colors
type TextFieldStyle struct {
TextFg color.RGB
TextBg color.RGB
CursorFg color.RGB
CursorBg color.RGB
PlaceholderFg color.RGB
PrefixFg color.RGB
BorderFg color.RGB
}
// TextField renders text field and returns content height used
func (r Region) TextField(state *TextFieldState, opts TextFieldOpts) int {
if r.W < 3 || r.H < 1 {
return 0
}
style := opts.Style
if style == (TextFieldStyle{}) {
style = DefaultTextFieldStyle()
}
// Calculate content area
contentY := 0
contentX := 0
contentW := r.W
contentH := 1
if opts.Border != LineNone {
if r.H < 3 {
return 0
}
r.Box(opts.Border, style.BorderFg)
contentY = 1
contentX = 1
contentW = r.W - 2
contentH = r.H - 2
if contentH > 1 {
contentH = 1
}
}
// Fill background
for x := contentX; x < contentX+contentW; x++ {
r.Cell(x, contentY, ' ', style.TextFg, style.TextBg, terminal.AttrNone)
}
x := contentX
// Prefix
if opts.Prefix != "" {
for _, ch := range opts.Prefix {
if x >= contentX+contentW {
break
}
r.Cell(x, contentY, ch, style.PrefixFg, style.TextBg, terminal.AttrNone)
x++
}
}
// Calculate viewport
viewportW := contentX + contentW - x
if viewportW < 1 {
return contentH + 2*boolToInt(opts.Border != LineNone)
}
// Adjust scroll
state.AdjustScroll(viewportW)
// Render text or placeholder
text := state.Text
isEmpty := len(text) == 0
if isEmpty && opts.Placeholder != "" && !opts.Focused {
// Placeholder
placeholder := opts.Placeholder
if RuneLen(placeholder) > viewportW {
placeholder = Truncate(placeholder, viewportW)
}
r.Text(x, contentY, placeholder, style.PlaceholderFg, style.TextBg, terminal.AttrDim)
} else {
// Scroll indicators
if state.Scroll > 0 && x > contentX {
r.Cell(x-1, contentY, '◀', style.PlaceholderFg, style.TextBg, terminal.AttrNone)
}
// Text content
for i := 0; i < viewportW; i++ {
runeIdx := state.Scroll + i
ch := ' '
if runeIdx < len(text) {
ch = text[runeIdx]
if opts.Mask != 0 {
ch = opts.Mask
}
}
fg := style.TextFg
bg := style.TextBg
// Cursor highlighting
if opts.Focused && runeIdx == state.Cursor {
fg = style.CursorFg
bg = style.CursorBg
}
r.Cell(x+i, contentY, ch, fg, bg, terminal.AttrNone)
}
// Cursor at end
if opts.Focused && state.Cursor == len(text) {
cursorX := x + state.Cursor - state.Scroll
if cursorX >= x && cursorX < contentX+contentW {
r.Cell(cursorX, contentY, ' ', style.CursorFg, style.CursorBg, terminal.AttrNone)
}
}
// Right scroll indicator
if state.Scroll+viewportW < len(text) {
r.Cell(contentX+contentW-1, contentY, '▶', style.PlaceholderFg, style.TextBg, terminal.AttrNone)
}
}
if opts.Border != LineNone {
return 3
}
return 1
}
// boolToInt converts boolean to integer (0 or 1)
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}