Use Harmonica to animate progress bar

This commit is contained in:
Christian Rocha 2021-07-21 12:04:37 -04:00
parent 5ae3b7b822
commit 2cd3b16343

View File

@ -2,12 +2,13 @@ package progress
import ( import (
"fmt" "fmt"
"math"
"strings" "strings"
"sync" "sync"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/fogleman/ease" "github.com/charmbracelet/harmonica"
"github.com/lucasb-eyer/go-colorful" "github.com/lucasb-eyer/go-colorful"
"github.com/muesli/reflow/ansi" "github.com/muesli/reflow/ansi"
"github.com/muesli/termenv" "github.com/muesli/termenv"
@ -29,9 +30,10 @@ func nextID() int {
} }
const ( const (
fps = 60
defaultWidth = 40 defaultWidth = 40
defaultFPS = time.Second / 60 defaultFrequency = 18.0
defaultTransitionDuration = time.Millisecond * 350 defaultDamping = 1.0
) )
var color func(string) termenv.Color = termenv.ColorProfile().Color var color func(string) termenv.Color = termenv.ColorProfile().Color
@ -101,13 +103,18 @@ func WithWidth(w int) Option {
// FrameMsg indicates that an animation step should occur. // FrameMsg indicates that an animation step should occur.
type FrameMsg struct { type FrameMsg struct {
id int id int
tag int
} }
// Model stores values we'll use when rendering the progress bar. // Model stores values we'll use when rendering the progress bar.
type Model struct { type Model struct {
// The internal identifier for this model. // An identifier to keep us from receiving messages intended for other
// progress bars.
id int id int
// An identifier to keep us from receiving frame messages too quickly.
tag int
// Total width of the progress bar, including percentage, if set. // Total width of the progress bar, including percentage, if set.
Width int Width int
@ -124,15 +131,11 @@ type Model struct {
PercentFormat string // a fmt string for a float PercentFormat string // a fmt string for a float
PercentageStyle *termenv.Style PercentageStyle *termenv.Style
// Animation options. // Members for animated transitons.
FPS time.Duration spring harmonica.Spring
TransitionDuration time.Duration percent float64
targetPercent float64
// Values for the internal animation state. velocity float64
progress float64
startPercent float64
endPercent float64
transitionStart time.Time
// Gradient settings // Gradient settings
useRamp bool useRamp bool
@ -156,10 +159,10 @@ func NewModel(opts ...Option) (Model, error) {
EmptyColor: "#606060", EmptyColor: "#606060",
ShowPercentage: true, ShowPercentage: true,
PercentFormat: " %3.0f%%", PercentFormat: " %3.0f%%",
FPS: defaultFPS,
TransitionDuration: defaultTransitionDuration,
} }
m.SetSpringOptions(defaultFrequency, defaultDamping)
for _, opt := range opts { for _, opt := range opts {
if err := opt(&m); err != nil { if err := opt(&m); err != nil {
return Model{}, err return Model{}, err
@ -169,50 +172,24 @@ func NewModel(opts ...Option) (Model, error) {
return m, nil return m, nil
} }
// View renders the an animated progress bar in its current state. To render // Update is used to animation the progress bar during transitons. Use
// a static progress bar based on your own calculations use ViewAs instead. // SetPercent to create the command you'll need to trigger the animation.
func (m Model) View() string { //
return m.ViewAs(m.progress) // If you're rendering with ViewAs you won't need this.
}
// 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) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case FrameMsg: case FrameMsg:
if msg.id != m.id { if msg.id != m.id || msg.tag != m.tag {
return m, nil return m, nil
} }
elapsed := time.Since(m.transitionStart) // If we've more or less reached equilibrium, stop updating.
totalDuration := m.transitionStart. dist := math.Abs(m.percent - m.targetPercent)
Add(m.TransitionDuration). if dist < 0.001 && m.velocity < 0.01 {
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 return m, nil
} }
segmentSize := m.endPercent - m.startPercent m.spring.Update(&m.percent, &m.velocity, m.targetPercent)
m.progress = ease.OutQuad(segmentProgress)*segmentSize + m.startPercent
return m, m.nextFrame() return m, m.nextFrame()
default: default:
@ -220,26 +197,77 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
} }
// SetSpringOptions sets the frequency and damping for the current spring.
// Frequency corresponds to speed, and damping to bounciness. For details see:
// https://github.com/charmbracelet/harmonica.
func (m *Model) SetSpringOptions(frequency, damping float64) {
m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
}
// Percent returns the current percentage state of the model. This is only
// relevant when you're animating the progress bar.
//
// If you're rendering with ViewAs you won't need this.
func (m Model) Percent() float64 {
return m.targetPercent
}
// SetPercent sets the percentage state of the model as well as a command
// necessary for animating the progress bar to this new percentage.
//
// If you're rendering with ViewAs you won't need this.
func (m *Model) SetPercent(p float64) tea.Cmd { func (m *Model) SetPercent(p float64) tea.Cmd {
m.startPercent = m.endPercent m.targetPercent = math.Max(0, math.Min(1, p))
m.endPercent = p m.tag++
m.transitionStart = time.Now()
return m.nextFrame() return m.nextFrame()
} }
func (m Model) nextFrame() tea.Cmd { // IncrPercent increments the percentage by a given amount, returning a command
return tea.Tick(m.FPS, func(time.Time) tea.Msg { // necessary to animate the progress bar to the new percentage.
return FrameMsg{m.id} //
// If you're rendering with ViewAs you won't need this.
func (m *Model) IncrPercent(v float64) tea.Cmd {
return m.SetPercent(m.Percent() + v)
}
// DecrPercent decrements the percentage by a given amount, returning a command
// necessary to animate the progress bar to the new percentage.
//
// If you're rendering with ViewAs you won't need this.
func (m *Model) DecrPercent(v float64) tea.Cmd {
return m.SetPercent(m.Percent() - v)
}
// 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.percent)
}
// ViewAs renders the progress bar with a given percentage.
func (m Model) ViewAs(percent float64) string {
b := strings.Builder{}
percentView := m.percentageView(percent)
m.barView(&b, percent, ansi.PrintableRuneWidth(percentView))
b.WriteString(percentView)
return b.String()
}
func (m *Model) nextFrame() tea.Cmd {
return tea.Tick(time.Second/time.Duration(fps), func(time.Time) tea.Msg {
return FrameMsg{id: m.id, tag: m.tag}
}) })
} }
func (m Model) bar(b *strings.Builder, percent float64, textWidth int) { func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
var ( var (
tw = m.Width - textWidth // total width tw = max(0, m.Width-textWidth) // total width
fw = int(float64(tw) * percent) // filled width fw = int(math.Round((float64(tw) * percent))) // filled width
p float64 p float64
) )
fw = max(0, min(tw, fw))
if m.useRamp { if m.useRamp {
// Gradient fill // Gradient fill
for i := 0; i < fw; i++ { for i := 0; i < fw; i++ {
@ -263,7 +291,20 @@ func (m Model) bar(b *strings.Builder, percent float64, textWidth int) {
// Empty fill // Empty fill
e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String() e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String()
b.WriteString(strings.Repeat(e, tw-fw)) n := max(0, tw-fw)
b.WriteString(strings.Repeat(e, n))
}
func (m Model) percentageView(percent float64) string {
if !m.ShowPercentage {
return ""
}
percent = math.Max(0, math.Min(1, percent))
percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd
if m.PercentageStyle != nil {
percentage = m.PercentageStyle.Styled(percentage)
}
return percentage
} }
func (m *Model) setRamp(colorA, colorB string, scaled bool) error { func (m *Model) setRamp(colorA, colorB string, scaled bool) error {
@ -283,3 +324,17 @@ func (m *Model) setRamp(colorA, colorB string, scaled bool) error {
m.rampColorB = b m.rampColorB = b
return nil return nil
} }
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}