package progress import ( "fmt" "strings" "sync" "time" tea "github.com/charmbracelet/bubbletea" "github.com/fogleman/ease" "github.com/lucasb-eyer/go-colorful" "github.com/muesli/reflow/ansi" "github.com/muesli/termenv" ) // Internal ID management. Used during animating to assure that frame messages // can only be received by progress components that sent them. var ( lastID int idMtx sync.Mutex ) // Return the next ID we should use on the model. func nextID() int { idMtx.Lock() defer idMtx.Unlock() lastID++ return lastID } const ( defaultWidth = 40 defaultFPS = time.Second / 60 defaultTransitionDuration = time.Millisecond * 350 ) var color func(string) termenv.Color = termenv.ColorProfile().Color // Option is used to set options in NewModel. For example: // // progress := NewModel( // WithRamp("#ff0000", "#0000ff"), // WithoutPercentage(), // ) // type Option func(*Model) error // WithDefaultGradient sets a gradient fill with default colors. func WithDefaultGradient() Option { return WithGradient("#5A56E0", "#EE6FF8") } // WithGradient sets a gradient fill blending between two colors. func WithGradient(colorA, colorB string) Option { return func(m *Model) error { return m.setRamp(colorA, colorB, false) } } // WithDefaultScaledGradient sets a gradient with default colors, and scales the // gradient to fit the filled portion of the ramp. func WithDefaultScaledGradient() Option { return WithScaledGradient("#5A56E0", "#EE6FF8") } // WithScaledGradient scales the gradient to fit the width of the filled portion of // the progress bar. func WithScaledGradient(colorA, colorB string) Option { return func(m *Model) error { return m.setRamp(colorA, colorB, true) } } // WithSolidFill sets the progress to use a solid fill with the given color. func WithSolidFill(color string) Option { return func(m *Model) error { m.FullColor = color m.useRamp = false return nil } } // WithoutPercentage hides the numeric percentage. func WithoutPercentage() Option { return func(m *Model) error { m.ShowPercentage = false return nil } } // WithWidth sets the initial width of the progress bar. Note that you can also // set the width via the Width property, which can come in handy if you're // waiting for a tea.WindowSizeMsg. func WithWidth(w int) Option { return func(m *Model) error { m.Width = w return nil } } // FrameMsg indicates that an animation step should occur. type FrameMsg struct { id int } // Model stores values we'll use when rendering the progress bar. type Model struct { // The internal identifier for this model. id int // Total width of the progress bar, including percentage, if set. Width int // "Filled" sections of the progress bar. Full rune FullColor string // "Empty" sections of progress bar. Empty rune EmptyColor string // Settings for rendering the numeric percentage. ShowPercentage bool PercentFormat string // a fmt string for a float PercentageStyle *termenv.Style // Animation options. FPS time.Duration TransitionDuration time.Duration // Values for the internal animation state. progress float64 startPercent float64 endPercent float64 transitionStart time.Time // Gradient settings useRamp bool rampColorA colorful.Color rampColorB colorful.Color // When true, we scale the gradient to fit the width of the filled section // of the progress bar. When false, the width of the gradient will be set // to the full width of the progress bar. scaleRamp bool } // NewModel returns a model with default values. func NewModel(opts ...Option) (Model, error) { m := Model{ id: nextID(), Width: defaultWidth, Full: '█', FullColor: "#7571F9", Empty: '░', EmptyColor: "#606060", ShowPercentage: true, PercentFormat: " %3.0f%%", FPS: defaultFPS, TransitionDuration: defaultTransitionDuration, } for _, opt := range opts { if err := opt(&m); err != nil { return Model{}, err } } return m, nil } // View renders the an animated progress bar in its current state. To render // a static progress bar based on your own calculations use ViewAs instead. func (m Model) View() string { return m.ViewAs(m.progress) } // ViewAs renders the progress bar with a given percentage. func (m Model) ViewAs(percent float64) string { b := strings.Builder{} if m.ShowPercentage { percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd if m.PercentageStyle != nil { percentage = m.PercentageStyle.Styled(percentage) } m.bar(&b, percent, ansi.PrintableRuneWidth(percentage)) b.WriteString(percentage) } else { m.bar(&b, percent, 0) } return b.String() } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case FrameMsg: if msg.id != m.id { return m, nil } elapsed := time.Since(m.transitionStart) totalDuration := m.transitionStart. Add(m.TransitionDuration). Sub(m.transitionStart) f := func(d time.Duration) float64 { return float64(int64(d)) } segmentProgress := f(elapsed) / f(totalDuration) if segmentProgress >= 1.0 { m.progress = m.endPercent return m, nil } segmentSize := m.endPercent - m.startPercent m.progress = ease.OutQuad(segmentProgress)*segmentSize + m.startPercent return m, m.nextFrame() default: return m, nil } } func (m *Model) SetPercent(p float64) tea.Cmd { m.startPercent = m.endPercent m.endPercent = p m.transitionStart = time.Now() return m.nextFrame() } func (m Model) nextFrame() tea.Cmd { return tea.Tick(m.FPS, func(time.Time) tea.Msg { return FrameMsg{m.id} }) } func (m Model) bar(b *strings.Builder, percent float64, textWidth int) { var ( tw = m.Width - textWidth // total width fw = int(float64(tw) * percent) // filled width p float64 ) if m.useRamp { // Gradient fill for i := 0; i < fw; i++ { if m.scaleRamp { p = float64(i) / float64(fw) } else { p = float64(i) / float64(tw) } c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex() b.WriteString(termenv. String(string(m.Full)). Foreground(color(c)). String(), ) } } else { // Solid fill s := termenv.String(string(m.Full)).Foreground(color(m.FullColor)).String() b.WriteString(strings.Repeat(s, fw)) } // Empty fill e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String() b.WriteString(strings.Repeat(e, tw-fw)) } func (m *Model) setRamp(colorA, colorB string, scaled bool) error { a, err := colorful.Hex(colorA) if err != nil { return err } b, err := colorful.Hex(colorB) if err != nil { return err } m.useRamp = true m.scaleRamp = scaled m.rampColorA = a m.rampColorB = b return nil }