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

317 lines
6.6 KiB
Go

package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// ConfirmResult represents dialog outcome
type ConfirmResult uint8
const (
ConfirmPending ConfirmResult = iota
ConfirmYes
ConfirmNo
ConfirmCancel
)
// ConfirmState holds confirmation dialog state
type ConfirmState struct {
FocusYes bool // true = Yes focused, false = No focused
Result ConfirmResult
}
// NewConfirmState creates dialog state with default selection
func NewConfirmState(defaultYes bool) *ConfirmState {
return &ConfirmState{
FocusYes: defaultYes,
Result: ConfirmPending,
}
}
// Toggle switches focus between Yes and No
func (c *ConfirmState) Toggle() {
c.FocusYes = !c.FocusYes
}
// Confirm selects currently focused button
func (c *ConfirmState) Confirm() {
if c.FocusYes {
c.Result = ConfirmYes
} else {
c.Result = ConfirmNo
}
}
// SelectYes directly selects Yes
func (c *ConfirmState) SelectYes() {
c.Result = ConfirmYes
}
// SelectNo directly selects No
func (c *ConfirmState) SelectNo() {
c.Result = ConfirmNo
}
// Cancel cancels the dialog
func (c *ConfirmState) Cancel() {
c.Result = ConfirmCancel
}
// HandleKey processes input, returns true if dialog should close
func (c *ConfirmState) HandleKey(key terminal.Key, r rune) bool {
switch key {
case terminal.KeyLeft, terminal.KeyRight, terminal.KeyTab:
c.Toggle()
return false
case terminal.KeyEnter:
c.Confirm()
return true
case terminal.KeyEscape:
c.Cancel()
return true
case terminal.KeyRune:
switch r {
case 'y', 'Y':
c.SelectYes()
return true
case 'n', 'N':
c.SelectNo()
return true
case 'h':
c.FocusYes = true
return false
case 'l':
c.FocusYes = false
return false
}
}
return false
}
// ConfirmOpts configures confirmation dialog
type ConfirmOpts struct {
Title string
Message string
YesLabel string // Default "Yes"
NoLabel string // Default "No"
Destructive bool // Style Yes as warning
Style ConfirmStyle
}
// ConfirmStyle defines dialog colors
type ConfirmStyle struct {
BorderFg color.RGB
TitleFg color.RGB
MessageFg color.RGB
Bg color.RGB
ButtonFg color.RGB
ButtonBg color.RGB
ButtonFocusFg color.RGB
ButtonFocusBg color.RGB
DestructiveFg color.RGB
DestructiveBg color.RGB
}
// DefaultConfirmStyle returns default dialog colors
func DefaultConfirmStyle() ConfirmStyle {
return ConfirmStyle{
BorderFg: color.RGB{R: 100, G: 100, B: 120},
TitleFg: color.RGB{R: 255, G: 255, B: 255},
MessageFg: color.RGB{R: 200, G: 200, B: 200},
Bg: color.RGB{R: 30, G: 30, B: 40},
ButtonFg: color.RGB{R: 180, G: 180, B: 180},
ButtonBg: color.RGB{R: 50, G: 50, B: 60},
ButtonFocusFg: color.RGB{R: 255, G: 255, B: 255},
ButtonFocusBg: color.RGB{R: 60, G: 80, B: 120},
DestructiveFg: color.RGB{R: 255, G: 255, B: 255},
DestructiveBg: color.RGB{R: 180, G: 60, B: 60},
}
}
// ConfirmDialog renders confirmation dialog centered in region
// Returns content region for additional content if needed
func (r Region) ConfirmDialog(state *ConfirmState, opts ConfirmOpts) Region {
style := opts.Style
if style == (ConfirmStyle{}) {
style = DefaultConfirmStyle()
}
if opts.YesLabel == "" {
opts.YesLabel = "Yes"
}
if opts.NoLabel == "" {
opts.NoLabel = "No"
}
// Calculate dialog size
msgLines := WrapText(opts.Message, r.W-8)
if len(msgLines) == 0 {
msgLines = []string{""}
}
dialogW := 40
msgMaxW := 0
for _, line := range msgLines {
if w := RuneLen(line); w > msgMaxW {
msgMaxW = w
}
}
if msgMaxW+6 > dialogW {
dialogW = msgMaxW + 6
}
if dialogW > r.W-4 {
dialogW = r.W - 4
}
if dialogW < 20 {
dialogW = 20
}
dialogH := 3 + len(msgLines) + 3 // border + message + spacing + buttons + border
if dialogH > r.H-2 {
dialogH = r.H - 2
}
// Center dialog
dialog := Center(r, dialogW, dialogH)
// Draw modal frame
content := dialog.Modal(ModalOpts{
Title: opts.Title,
Border: LineDouble,
BorderFg: style.BorderFg,
TitleFg: style.TitleFg,
Bg: style.Bg,
})
// Message
y := 0
for _, line := range msgLines {
if y >= content.H-2 {
break
}
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
y++
}
// Buttons row
buttonY := content.H - 1
if buttonY < y+1 {
buttonY = y + 1
}
yesLabel := " " + opts.YesLabel + " "
noLabel := " " + opts.NoLabel + " "
yesW := RuneLen(yesLabel)
noW := RuneLen(noLabel)
buttonGap := 4
totalButtonW := yesW + buttonGap + noW
buttonX := (content.W - totalButtonW) / 2
if buttonX < 0 {
buttonX = 0
}
// Yes button
yesFg := style.ButtonFg
yesBg := style.ButtonBg
if state.FocusYes {
if opts.Destructive {
yesFg = style.DestructiveFg
yesBg = style.DestructiveBg
} else {
yesFg = style.ButtonFocusFg
yesBg = style.ButtonFocusBg
}
}
content.Text(buttonX, buttonY, yesLabel, yesFg, yesBg, terminal.AttrNone)
// No button
noX := buttonX + yesW + buttonGap
noFg := style.ButtonFg
noBg := style.ButtonBg
if !state.FocusYes {
noFg = style.ButtonFocusFg
noBg = style.ButtonFocusBg
}
content.Text(noX, buttonY, noLabel, noFg, noBg, terminal.AttrNone)
return content.Sub(0, 0, content.W, buttonY-1)
}
// AlertOpts configures single-button alert dialog
type AlertOpts struct {
Title string
Message string
Button string // Default "OK"
Style ConfirmStyle
}
// AlertDialog renders single-button alert, returns true when dismissed
func (r Region) AlertDialog(opts AlertOpts) Region {
style := opts.Style
if style == (ConfirmStyle{}) {
style = DefaultConfirmStyle()
}
if opts.Button == "" {
opts.Button = "OK"
}
msgLines := WrapText(opts.Message, r.W-8)
if len(msgLines) == 0 {
msgLines = []string{""}
}
dialogW := 36
msgMaxW := 0
for _, line := range msgLines {
if w := RuneLen(line); w > msgMaxW {
msgMaxW = w
}
}
if msgMaxW+6 > dialogW {
dialogW = msgMaxW + 6
}
if dialogW > r.W-4 {
dialogW = r.W - 4
}
dialogH := 3 + len(msgLines) + 3
if dialogH > r.H-2 {
dialogH = r.H - 2
}
dialog := Center(r, dialogW, dialogH)
content := dialog.Modal(ModalOpts{
Title: opts.Title,
Border: LineDouble,
BorderFg: style.BorderFg,
TitleFg: style.TitleFg,
Bg: style.Bg,
})
// Message
y := 0
for _, line := range msgLines {
if y >= content.H-2 {
break
}
content.TextCenter(y, line, style.MessageFg, style.Bg, terminal.AttrNone)
y++
}
// Button
buttonY := content.H - 1
buttonLabel := " " + opts.Button + " "
buttonW := RuneLen(buttonLabel)
buttonX := (content.W - buttonW) / 2
content.Text(buttonX, buttonY, buttonLabel, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
return content.Sub(0, 0, content.W, buttonY-1)
}