zsvo/pkg/ui/statusbar.go

395 lines
9.6 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ui
import (
"fmt"
"math"
"os"
"strings"
"time"
"zsvo/pkg/i18n"
)
// StatusBar represents an animated status bar
type StatusBar struct {
pkgName string
total int
current int
startedAt time.Time
lastUpdate time.Time
lastStep int
enabled bool
frameIdx int
lastLineLen int
theme Theme
}
// Theme defines color scheme for status bar
type Theme struct {
Spinner string
ProgressFull string
ProgressEmpty string
Text string
Accent string
Success string
Error string
Warning string
Info string
}
// Predefined themes
var Themes = map[string]Theme{
"neon": {
Spinner: "36;1", // Bright cyan
ProgressFull: "92", // Bright green
ProgressEmpty: "90", // Dark gray
Text: "37", // White
Accent: "93;1", // Bright magenta
Success: "32;1", // Bright green
Error: "31;1", // Bright red
Warning: "33;1", // Bright yellow
Info: "34;1", // Bright blue
},
"matrix": {
Spinner: "32;1", // Bright green
ProgressFull: "32", // Green
ProgressEmpty: "2", // Dark green
Text: "37", // White
Accent: "92;1", // Bright green
Success: "32;1", // Bright green
Error: "31;1", // Bright red
Warning: "33", // Yellow
Info: "36", // Cyan
},
"fire": {
Spinner: "33;1", // Bright yellow
ProgressFull: "91", // Bright red
ProgressEmpty: "90", // Dark gray
Text: "37", // White
Accent: "93;1", // Bright magenta
Success: "32;1", // Bright green
Error: "31;1", // Bright red
Warning: "33;1", // Bright yellow
Info: "34;1", // Bright blue
},
"ocean": {
Spinner: "36;1", // Bright cyan
ProgressFull: "94", // Bright blue
ProgressEmpty: "90", // Dark gray
Text: "37", // White
Accent: "96;1", // Bright cyan
Success: "32;1", // Bright green
Error: "31;1", // Bright red
Warning: "33;1", // Bright yellow
Info: "34;1", // Bright blue
},
}
// Advanced spinner animations
var spinners = map[string][]string{
"classic": {"|", "/", "-", "\\"},
"dots": {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
"arrows": {"←", "↖", "↑", "↗", "→", "↘", "↓", "↙"},
"blocks": {"▖", "▘", "▝", "▗"},
"pulse": {"⚡", "✨", "🔥", "💫", "⭐"},
"hearts": {"❤️", "💙", "💚", "💛", "💜", "🧡"},
"matrix": {"⚈", "⚉", "⚊", "⚋", "⚌", "⚍", "⚎", "⚏"},
}
func NewStatusBar(pkgName string, total int) *StatusBar {
return &StatusBar{
pkgName: pkgName,
total: total,
startedAt: time.Now(),
lastUpdate: time.Now(),
enabled: supportsANSIAndTTY(),
theme: Themes["neon"], // Default theme
}
}
// SetTheme changes the status bar theme
func (s *StatusBar) SetTheme(themeName string) {
if theme, exists := Themes[themeName]; exists {
s.theme = theme
}
}
// SetSpinner changes the spinner animation
func (s *StatusBar) SetSpinner(spinnerName string) {
if _, exists := spinners[spinnerName]; exists {
s.frameIdx = 0 // Reset animation
}
}
func (s *StatusBar) Update(current int, message string) {
if current < 0 {
current = 0
}
if current > s.total {
current = s.total
}
s.current = current
now := time.Now()
if !s.enabled {
fmt.Printf("[%d/%d] %s\n", current, s.total, message)
return
}
// Calculate progress
percent := float64(current) / float64(s.total)
// Get current spinner frame
spinnerFrames := spinners["dots"]
spinner := spinnerFrames[s.frameIdx%len(spinnerFrames)]
s.frameIdx++
// Build progress bar
bar := s.renderProgressBar(percent)
// Calculate metrics
elapsed := time.Since(s.startedAt)
var speed, eta string
if current > s.lastStep && !s.lastUpdate.IsZero() {
timeDiff := now.Sub(s.lastUpdate).Seconds()
if timeDiff > 0 {
stepsPerSec := float64(current-s.lastStep) / timeDiff
if stepsPerSec > 0 {
remaining := s.total - current
etaSeconds := float64(remaining) / stepsPerSec
speed = fmt.Sprintf("%.1f %s", stepsPerSec, i18n.T("steps_per_sec"))
eta = fmt.Sprintf("%s %s", i18n.T("eta"), formatDuration(time.Duration(etaSeconds)*time.Second))
}
}
}
s.lastUpdate = now
s.lastStep = current
// Build status line
line := s.buildStatusLine(spinner, bar, percent, message, speed, eta, elapsed)
// Clear previous line and print new one
if pad := s.lastLineLen - visibleLen(line); pad > 0 {
line += strings.Repeat(" ", pad)
}
s.lastLineLen = visibleLen(line)
fmt.Print("\r" + line)
}
func (s *StatusBar) Finish(success bool, message string) {
if !s.enabled {
status := i18n.T("failed")
if success {
status = i18n.T("success")
}
fmt.Printf("%s: %s\n", s.pkgName, status)
return
}
// Show final status
statusColor := s.theme.Error
statusText := i18n.T("failed")
if success {
statusColor = s.theme.Success
statusText = i18n.T("completed")
}
elapsed := time.Since(s.startedAt)
fmt.Printf("\n%s %s %s (%s)\n",
colorize(statusColor, "✓"),
colorize(s.theme.Accent, s.pkgName),
colorize(statusColor, statusText),
colorize(s.theme.Text, formatDuration(elapsed)),
)
}
func (s *StatusBar) renderProgressBar(percent float64) string {
const width = 25
filled := int(math.Floor(percent * float64(width)))
if filled > width {
filled = width
}
// Create gradient effect
fullChars := []string{"█", "▓", "▒", "░"}
var bar strings.Builder
for i := 0; i < width; i++ {
if i < filled {
// Gradient effect
charIdx := int(float64(i) / float64(width) * float64(len(fullChars)))
if charIdx >= len(fullChars) {
charIdx = len(fullChars) - 1
}
bar.WriteString(colorize(s.theme.ProgressFull, fullChars[charIdx]))
} else {
bar.WriteString(colorize(s.theme.ProgressEmpty, "░"))
}
}
return fmt.Sprintf("[%s]", bar.String())
}
func (s *StatusBar) buildStatusLine(spinner, bar string, percent float64, message, speed, eta string, elapsed time.Duration) string {
percentStr := fmt.Sprintf("%.0f%%", percent*100)
// Truncate message if too long
maxMsgLen := 30
if len(message) > maxMsgLen {
message = message[:maxMsgLen-3] + "..."
}
var parts []string
// Main info
parts = append(parts, colorize(s.theme.Spinner, spinner))
parts = append(parts, colorize(s.theme.Accent, s.pkgName))
parts = append(parts, bar)
parts = append(parts, colorize(s.theme.Text, percentStr))
parts = append(parts, colorize(s.theme.Info, "| "+message))
// Speed and ETA if available
if speed != "" && eta != "" {
parts = append(parts, colorize(s.theme.Warning, speed))
parts = append(parts, colorize(s.theme.Info, eta))
}
// Always show elapsed time
parts = append(parts, colorize(s.theme.Text, formatDuration(elapsed)))
return strings.Join(parts, " ")
}
func (s *StatusBar) PrintHeader(title string) {
if !s.enabled {
fmt.Printf("=== %s ===\n", title)
return
}
border := colorize(s.theme.Accent, "╔")
titleColored := colorize(s.theme.Text, fmt.Sprintf(" %s ", title))
borderEnd := colorize(s.theme.Accent, "╗")
fmt.Printf("%s%s%s\n", border, titleColored, borderEnd)
}
func (s *StatusBar) PrintFooter() {
if !s.enabled {
fmt.Printf("===================\n")
return
}
border := colorize(s.theme.Accent, "╚")
borderEnd := colorize(s.theme.Accent, "╝")
content := colorize(s.theme.Text, strings.Repeat("═", 50))
fmt.Printf("%s%s%s\n", border, content, borderEnd)
}
func (s *StatusBar) PrintInfo(message string) {
if !s.enabled {
fmt.Printf(" %s\n", message)
return
}
icon := colorize(s.theme.Info, "")
text := colorize(s.theme.Text, message)
fmt.Printf(" %s %s\n", icon, text)
}
func (s *StatusBar) PrintSuccess(message string) {
if !s.enabled {
fmt.Printf("✅ %s\n", message)
return
}
icon := colorize(s.theme.Success, "✅")
text := colorize(s.theme.Text, message)
fmt.Printf(" %s %s\n", icon, text)
}
func (s *StatusBar) PrintWarning(message string) {
if !s.enabled {
fmt.Printf("⚠️ %s\n", message)
return
}
icon := colorize(s.theme.Warning, "⚠️")
text := colorize(s.theme.Text, message)
fmt.Printf(" %s %s\n", icon, text)
}
func (s *StatusBar) PrintError(message string) {
if !s.enabled {
fmt.Printf("❌ %s\n", message)
return
}
icon := colorize(s.theme.Error, "❌")
text := colorize(s.theme.Text, message)
fmt.Printf(" %s %s\n", icon, text)
}
// Helper functions
func colorize(color, text string) string {
if !supportsANSIAndTTY() || os.Getenv("NO_COLOR") != "" {
return text
}
return fmt.Sprintf("\033[%sm%s\033[0m", color, text)
}
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
if d < time.Hour {
return fmt.Sprintf("%.0fm %.0fs",
d.Minutes(),
float64(int(d.Seconds())%60))
}
return fmt.Sprintf("%.0fh %.0fm",
d.Hours(),
float64(int(d.Minutes())%60))
}
func visibleLen(s string) int {
// Remove ANSI escape codes for length calculation
result := 0
inEscape := false
for _, r := range s {
if r == '\033' {
inEscape = true
} else if inEscape && r == 'm' {
inEscape = false
} else if !inEscape {
result++
}
}
return result
}
func supportsANSIAndTTY() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
term := strings.TrimSpace(strings.ToLower(os.Getenv("TERM")))
if term == "" || term == "dumb" {
return false
}
info, err := os.Stdout.Stat()
if err != nil {
return false
}
return (info.Mode() & os.ModeCharDevice) != 0
}