package tui import "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 terminal.RGB Bg terminal.RGB BarFg terminal.RGB BarBg terminal.RGB AccentFg terminal.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: terminal.RGB{R: 220, G: 220, B: 220}, Bg: terminal.RGB{R: 30, G: 30, B: 40}, BarFg: terminal.RGB{R: 80, G: 160, B: 255}, BarBg: terminal.RGB{R: 50, G: 50, B: 60}, AccentFg: terminal.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(terminal.RGB{R: 10, G: 10, B: 15}) borderLine = LineSingle case ProgressStyleNeon: borderLine = LineDouble opts.AccentFg = terminal.RGB{R: 0, G: 255, B: 200} opts.BarFg = terminal.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) } titleX := (overlayW - titleLen) / 2 for i, ch := range title { overlay.Cell(titleX+i, 0, ch, 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, terminal.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 terminal.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 terminal.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 terminal.RGB if x < filled { ch = chars[0] // Pulse the color fg = terminal.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, terminal.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 }