* 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.
468 lines
11 KiB
Go
468 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"github.com/lixenwraith/color"
|
|
"github.com/lixenwraith/terminal"
|
|
)
|
|
|
|
// ProgressType specifies progress indicator variant
|
|
type ProgressType uint8
|
|
|
|
const (
|
|
ProgressSpinner ProgressType = iota // Animated spinner
|
|
ProgressDeterminate // Bar with percentage
|
|
ProgressIndeterminate // Marquee animation
|
|
ProgressPulse // Pulsing bar
|
|
ProgressDots // Animated dots
|
|
)
|
|
|
|
// ProgressStyle defines visual appearance
|
|
type ProgressStyle uint8
|
|
|
|
const (
|
|
ProgressStyleMinimal ProgressStyle = iota // Text only
|
|
ProgressStyleBox // Single border
|
|
ProgressStyleDouble // Double border
|
|
ProgressStyleRounded // Rounded border
|
|
ProgressStyleShadow // Box with shadow
|
|
ProgressStyleNeon // Bright colors
|
|
ProgressStyleRetro // Block characters
|
|
)
|
|
|
|
// SpinnerStyle defines spinner animation type
|
|
type SpinnerStyle uint8
|
|
|
|
const (
|
|
SpinnerBraille SpinnerStyle = iota // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
|
|
SpinnerDots // ⣾⣽⣻⢿⡿⣟⣯⣷
|
|
SpinnerLine // |/-\
|
|
SpinnerBlock // ▖▘▝▗
|
|
SpinnerCircle // ◐◓◑◒
|
|
SpinnerArc // ◜◠◝◞◡◟
|
|
SpinnerBounce // ⠁⠂⠄⠂
|
|
SpinnerGrow // ▁▃▄▅▆▇█▇▆▅▄▃
|
|
)
|
|
|
|
// Spinner frame sets
|
|
var spinnerSets = map[SpinnerStyle][]rune{
|
|
SpinnerBraille: {'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'},
|
|
SpinnerDots: {'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'},
|
|
SpinnerLine: {'|', '/', '-', '\\'},
|
|
SpinnerBlock: {'▖', '▘', '▝', '▗'},
|
|
SpinnerCircle: {'◐', '◓', '◑', '◒'},
|
|
SpinnerArc: {'◜', '◠', '◝', '◞', '◡', '◟'},
|
|
SpinnerBounce: {'⠁', '⠂', '⠄', '⠂'},
|
|
SpinnerGrow: {'▁', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃'},
|
|
}
|
|
|
|
// BarStyle defines progress bar appearance
|
|
type BarStyle uint8
|
|
|
|
const (
|
|
BarStyleBlock BarStyle = iota // █░
|
|
BarStyleShade // ▓▒░
|
|
BarStyleArrow // =>-
|
|
BarStyleDot // ●○
|
|
BarStyleBracket // [### ]
|
|
BarStylePipe // |=== |
|
|
BarStyleThin // ━╺
|
|
BarStyleThick // ▰▱
|
|
)
|
|
|
|
// Bar character sets: [filled, partial, empty]
|
|
var barCharSets = map[BarStyle][3]rune{
|
|
BarStyleBlock: {'█', '▌', '░'},
|
|
BarStyleShade: {'▓', '▒', '░'},
|
|
BarStyleArrow: {'=', '>', '-'},
|
|
BarStyleDot: {'●', '◐', '○'},
|
|
BarStyleBracket: {'#', '#', ' '},
|
|
BarStylePipe: {'=', '=', ' '},
|
|
BarStyleThin: {'━', '╸', '╺'},
|
|
BarStyleThick: {'▰', '▰', '▱'},
|
|
}
|
|
|
|
// ProgressOverlayOpts configures progress overlay
|
|
type ProgressOverlayOpts struct {
|
|
Title string
|
|
Message string
|
|
Type ProgressType
|
|
Style ProgressStyle
|
|
SpinnerStyle SpinnerStyle
|
|
BarStyle BarStyle
|
|
Progress float64 // 0.0-1.0 for determinate
|
|
Frame int // Animation frame counter
|
|
ShowPercent bool // Show percentage text
|
|
ShowETA string // Optional ETA string
|
|
Width int // Overlay width, 0 = auto
|
|
Cancelable bool // Show cancel hint
|
|
CancelKey string // e.g., "Esc"
|
|
Fg color.RGB
|
|
Bg color.RGB
|
|
BarFg color.RGB
|
|
BarBg color.RGB
|
|
AccentFg color.RGB // Spinner/highlight color
|
|
}
|
|
|
|
// DefaultProgressOpts returns sensible defaults
|
|
func DefaultProgressOpts(title, message string, ptype ProgressType) ProgressOverlayOpts {
|
|
return ProgressOverlayOpts{
|
|
Title: title,
|
|
Message: message,
|
|
Type: ptype,
|
|
Style: ProgressStyleBox,
|
|
SpinnerStyle: SpinnerBraille,
|
|
BarStyle: BarStyleBlock,
|
|
ShowPercent: true,
|
|
Width: 40,
|
|
Fg: color.RGB{R: 220, G: 220, B: 220},
|
|
Bg: color.RGB{R: 30, G: 30, B: 40},
|
|
BarFg: color.RGB{R: 80, G: 160, B: 255},
|
|
BarBg: color.RGB{R: 50, G: 50, B: 60},
|
|
AccentFg: color.RGB{R: 100, G: 200, B: 255},
|
|
}
|
|
}
|
|
|
|
// ProgressOverlay renders centered progress overlay
|
|
func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
|
|
if r.W < 10 || r.H < 5 {
|
|
return Region{}
|
|
}
|
|
|
|
// Calculate overlay dimensions
|
|
overlayW := opts.Width
|
|
if overlayW == 0 {
|
|
overlayW = 40
|
|
}
|
|
if overlayW > r.W-4 {
|
|
overlayW = r.W - 4
|
|
}
|
|
|
|
// Height: title + message + progress + cancel hint
|
|
overlayH := 3 // border top/bottom + 1 content
|
|
if opts.Title != "" {
|
|
overlayH++ // Title takes space on border
|
|
}
|
|
if opts.Message != "" {
|
|
overlayH++
|
|
}
|
|
if opts.Type != ProgressSpinner && opts.Type != ProgressDots {
|
|
overlayH++ // Progress bar row
|
|
}
|
|
if opts.Cancelable {
|
|
overlayH++
|
|
}
|
|
if overlayH > r.H-2 {
|
|
overlayH = r.H - 2
|
|
}
|
|
|
|
// Center overlay
|
|
overlay := Center(r, overlayW, overlayH)
|
|
|
|
// Determine border type
|
|
var borderLine LineType
|
|
switch opts.Style {
|
|
case ProgressStyleMinimal:
|
|
borderLine = LineNone
|
|
case ProgressStyleBox:
|
|
borderLine = LineSingle
|
|
case ProgressStyleDouble:
|
|
borderLine = LineDouble
|
|
case ProgressStyleRounded:
|
|
borderLine = LineRounded
|
|
case ProgressStyleShadow:
|
|
// Draw shadow first
|
|
shadow := r.Sub(overlay.X-r.X+1, overlay.Y-r.Y+1, overlayW, overlayH)
|
|
shadow.Fill(color.RGB{R: 10, G: 10, B: 15})
|
|
borderLine = LineSingle
|
|
case ProgressStyleNeon:
|
|
borderLine = LineDouble
|
|
opts.AccentFg = color.RGB{R: 0, G: 255, B: 200}
|
|
opts.BarFg = color.RGB{R: 255, G: 0, B: 255}
|
|
case ProgressStyleRetro:
|
|
borderLine = LineHeavy
|
|
opts.BarStyle = BarStyleBlock
|
|
}
|
|
|
|
// Draw frame
|
|
overlay.BoxFilled(borderLine, opts.Fg, opts.Bg)
|
|
|
|
// Title on border
|
|
if opts.Title != "" {
|
|
title := " " + opts.Title + " "
|
|
titleLen := RuneLen(title)
|
|
if titleLen > overlayW-4 {
|
|
title = Truncate(title, overlayW-4)
|
|
titleLen = RuneLen(title)
|
|
}
|
|
overlay.Text((overlayW-titleLen)/2, 0, title, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
|
}
|
|
|
|
// Content area
|
|
content := overlay.Inset(1)
|
|
y := 0
|
|
|
|
// Spinner/dots for spinner types - inline with message
|
|
if opts.Type == ProgressSpinner || opts.Type == ProgressDots {
|
|
spinnerChar := r.getSpinnerChar(opts)
|
|
if content.W > 2 {
|
|
content.Cell(0, y, spinnerChar, opts.AccentFg, opts.Bg, terminal.AttrBold)
|
|
}
|
|
|
|
// Message after spinner
|
|
if opts.Message != "" {
|
|
msg := opts.Message
|
|
availW := content.W - 2
|
|
if RuneLen(msg) > availW {
|
|
msg = Truncate(msg, availW)
|
|
}
|
|
content.Text(2, y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
|
|
}
|
|
y++
|
|
} else {
|
|
// Message on its own line
|
|
if opts.Message != "" {
|
|
msg := opts.Message
|
|
if RuneLen(msg) > content.W {
|
|
msg = Truncate(msg, content.W)
|
|
}
|
|
content.TextCenter(y, msg, opts.Fg, opts.Bg, terminal.AttrNone)
|
|
y++
|
|
}
|
|
|
|
// Progress bar
|
|
if y < content.H {
|
|
r.renderProgressBar(content.Sub(0, y, content.W, 1), opts)
|
|
y++
|
|
}
|
|
}
|
|
|
|
// Cancel hint
|
|
if opts.Cancelable && y < content.H {
|
|
hint := opts.CancelKey + " to cancel"
|
|
if opts.CancelKey == "" {
|
|
hint = "Esc to cancel"
|
|
}
|
|
content.TextCenter(y, hint, color.RGB{R: 120, G: 120, B: 130}, opts.Bg, terminal.AttrDim)
|
|
}
|
|
|
|
return overlay
|
|
}
|
|
|
|
func (r Region) getSpinnerChar(opts ProgressOverlayOpts) rune {
|
|
frames := spinnerSets[opts.SpinnerStyle]
|
|
if len(frames) == 0 {
|
|
frames = spinnerSets[SpinnerBraille]
|
|
}
|
|
idx := opts.Frame % len(frames)
|
|
if idx < 0 {
|
|
idx = -idx
|
|
}
|
|
return frames[idx]
|
|
}
|
|
|
|
func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
|
|
if bar.W < 3 || bar.H < 1 {
|
|
return
|
|
}
|
|
|
|
barW := bar.W
|
|
labelW := 0
|
|
|
|
// Reserve space for percentage
|
|
if opts.ShowPercent {
|
|
labelW = 5 // " 100%"
|
|
barW -= labelW
|
|
}
|
|
|
|
// Reserve space for ETA
|
|
if opts.ShowETA != "" {
|
|
etaW := RuneLen(opts.ShowETA) + 1
|
|
barW -= etaW
|
|
}
|
|
|
|
if barW < 3 {
|
|
barW = 3
|
|
}
|
|
|
|
chars := barCharSets[opts.BarStyle]
|
|
|
|
switch opts.Type {
|
|
case ProgressDeterminate:
|
|
pct := opts.Progress
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
if pct > 1 {
|
|
pct = 1
|
|
}
|
|
|
|
filled := int(float64(barW) * pct)
|
|
remainder := float64(barW)*pct - float64(filled)
|
|
|
|
for x := 0; x < barW; x++ {
|
|
var ch rune
|
|
var fg color.RGB
|
|
if x < filled {
|
|
ch = chars[0]
|
|
fg = opts.BarFg
|
|
} else if x == filled && remainder >= 0.5 {
|
|
ch = chars[1]
|
|
fg = opts.BarFg
|
|
} else {
|
|
ch = chars[2]
|
|
fg = opts.BarBg
|
|
}
|
|
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
|
|
}
|
|
|
|
// Percentage
|
|
if opts.ShowPercent {
|
|
pctStr := formatPercent(int(pct * 100))
|
|
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
|
|
}
|
|
|
|
case ProgressIndeterminate:
|
|
// Marquee effect
|
|
pos := opts.Frame % (barW * 2)
|
|
if pos >= barW {
|
|
pos = barW*2 - pos - 1
|
|
}
|
|
markerW := barW / 4
|
|
if markerW < 2 {
|
|
markerW = 2
|
|
}
|
|
|
|
for x := 0; x < barW; x++ {
|
|
var ch rune
|
|
var fg color.RGB
|
|
if x >= pos && x < pos+markerW {
|
|
ch = chars[0]
|
|
fg = opts.BarFg
|
|
} else {
|
|
ch = chars[2]
|
|
fg = opts.BarBg
|
|
}
|
|
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
|
|
}
|
|
|
|
case ProgressPulse:
|
|
// Pulsing intensity based on frame
|
|
pulsePhase := opts.Frame % 20
|
|
intensity := float64(pulsePhase) / 20.0
|
|
if pulsePhase > 10 {
|
|
intensity = 1.0 - float64(pulsePhase-10)/10.0
|
|
}
|
|
|
|
pct := opts.Progress
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
if pct > 1 {
|
|
pct = 1
|
|
}
|
|
|
|
filled := int(float64(barW) * pct)
|
|
|
|
for x := 0; x < barW; x++ {
|
|
var ch rune
|
|
var fg color.RGB
|
|
if x < filled {
|
|
ch = chars[0]
|
|
// Pulse the color
|
|
fg = color.RGB{
|
|
R: uint8(float64(opts.BarFg.R) * (0.5 + intensity*0.5)),
|
|
G: uint8(float64(opts.BarFg.G) * (0.5 + intensity*0.5)),
|
|
B: uint8(float64(opts.BarFg.B) * (0.5 + intensity*0.5)),
|
|
}
|
|
} else {
|
|
ch = chars[2]
|
|
fg = opts.BarBg
|
|
}
|
|
bar.Cell(x, 0, ch, fg, opts.Bg, terminal.AttrNone)
|
|
}
|
|
|
|
if opts.ShowPercent {
|
|
pctStr := formatPercent(int(pct * 100))
|
|
bar.Text(barW+1, 0, pctStr, opts.Fg, opts.Bg, terminal.AttrNone)
|
|
}
|
|
}
|
|
|
|
// ETA
|
|
if opts.ShowETA != "" {
|
|
etaX := bar.W - RuneLen(opts.ShowETA)
|
|
bar.Text(etaX, 0, opts.ShowETA, color.RGB{R: 150, G: 150, B: 160}, opts.Bg, terminal.AttrDim)
|
|
}
|
|
}
|
|
|
|
func formatPercent(pct int) string {
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
if pct == 100 {
|
|
return "100%"
|
|
}
|
|
if pct >= 10 {
|
|
return " " + string(rune('0'+pct/10)) + string(rune('0'+pct%10)) + "%"
|
|
}
|
|
return " " + string(rune('0'+pct)) + "%"
|
|
}
|
|
|
|
// ProgressState manages progress overlay state
|
|
type ProgressState struct {
|
|
Visible bool
|
|
Opts ProgressOverlayOpts
|
|
Frame int
|
|
Progress float64
|
|
}
|
|
|
|
// NewProgressState creates progress overlay state
|
|
func NewProgressState(opts ProgressOverlayOpts) *ProgressState {
|
|
return &ProgressState{
|
|
Visible: true,
|
|
Opts: opts,
|
|
Progress: opts.Progress,
|
|
}
|
|
}
|
|
|
|
// Tick advances animation frame
|
|
func (p *ProgressState) Tick() {
|
|
p.Frame++
|
|
p.Opts.Frame = p.Frame
|
|
}
|
|
|
|
// SetProgress updates progress value (0.0-1.0)
|
|
func (p *ProgressState) SetProgress(pct float64) {
|
|
p.Progress = pct
|
|
p.Opts.Progress = pct
|
|
}
|
|
|
|
// SetMessage updates message text
|
|
func (p *ProgressState) SetMessage(msg string) {
|
|
p.Opts.Message = msg
|
|
}
|
|
|
|
// SetETA updates ETA string
|
|
func (p *ProgressState) SetETA(eta string) {
|
|
p.Opts.ShowETA = eta
|
|
}
|
|
|
|
// Complete marks progress as done
|
|
func (p *ProgressState) Complete() {
|
|
p.Progress = 1.0
|
|
p.Opts.Progress = 1.0
|
|
}
|
|
|
|
// Dismiss hides the progress overlay
|
|
func (p *ProgressState) Dismiss() {
|
|
p.Visible = false
|
|
}
|
|
|
|
// Show displays the progress overlay
|
|
func (p *ProgressState) Show() {
|
|
p.Visible = true
|
|
}
|
|
|