mirror of
https://github.com/Maks1mS/bubbles.git
synced 2025-03-14 04:53:43 +03:00
Use Harmonica to animate progress bar
This commit is contained in:
parent
5ae3b7b822
commit
2cd3b16343
@ -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 (
|
||||||
defaultWidth = 40
|
fps = 60
|
||||||
defaultFPS = time.Second / 60
|
defaultWidth = 40
|
||||||
defaultTransitionDuration = time.Millisecond * 350
|
defaultFrequency = 18.0
|
||||||
|
defaultDamping = 1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
var color func(string) termenv.Color = termenv.ColorProfile().Color
|
var color func(string) termenv.Color = termenv.ColorProfile().Color
|
||||||
@ -100,14 +102,19 @@ 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
|
||||||
@ -148,18 +151,18 @@ type Model struct {
|
|||||||
// NewModel returns a model with default values.
|
// NewModel returns a model with default values.
|
||||||
func NewModel(opts ...Option) (Model, error) {
|
func NewModel(opts ...Option) (Model, error) {
|
||||||
m := Model{
|
m := Model{
|
||||||
id: nextID(),
|
id: nextID(),
|
||||||
Width: defaultWidth,
|
Width: defaultWidth,
|
||||||
Full: '█',
|
Full: '█',
|
||||||
FullColor: "#7571F9",
|
FullColor: "#7571F9",
|
||||||
Empty: '░',
|
Empty: '░',
|
||||||
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
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user