Files
terminal/tui/dialog.go
T

329 lines
6.8 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
}
}
for i, ch := range yesLabel {
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, 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
}
for i, ch := range noLabel {
if noX+i < content.W {
content.Cell(noX+i, buttonY, ch, 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
for i, ch := range buttonLabel {
if buttonX+i < content.W {
content.Cell(buttonX+i, buttonY, ch, style.ButtonFocusFg, style.ButtonFocusBg, terminal.AttrNone)
}
}
return content.Sub(0, 0, content.W, buttonY-1)
}