* 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.
169 lines
3.9 KiB
Go
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
|
|
}
|
|
|