main
Raw Download raw file
  1// Package progress provides a simple progress bar for Bubble Tea applications.
  2package progress
  3
  4import (
  5	"fmt"
  6	"math"
  7	"strings"
  8	"sync/atomic"
  9	"time"
 10
 11	tea "github.com/charmbracelet/bubbletea"
 12	"github.com/charmbracelet/harmonica"
 13	"github.com/charmbracelet/lipgloss"
 14	"github.com/charmbracelet/x/ansi"
 15	"github.com/lucasb-eyer/go-colorful"
 16	"github.com/muesli/termenv"
 17)
 18
 19// Internal ID management. Used during animating to assure that frame messages
 20// can only be received by progress components that sent them.
 21var lastID int64
 22
 23func nextID() int {
 24	return int(atomic.AddInt64(&lastID, 1))
 25}
 26
 27const (
 28	fps              = 60
 29	defaultWidth     = 40
 30	defaultFrequency = 18.0
 31	defaultDamping   = 1.0
 32)
 33
 34// Option is used to set options in New. For example:
 35//
 36//	    progress := New(
 37//		       WithRamp("#ff0000", "#0000ff"),
 38//		       WithoutPercentage(),
 39//	    )
 40type Option func(*Model)
 41
 42// WithDefaultGradient sets a gradient fill with default colors.
 43func WithDefaultGradient() Option {
 44	return WithGradient("#5A56E0", "#EE6FF8")
 45}
 46
 47// WithGradient sets a gradient fill blending between two colors.
 48func WithGradient(colorA, colorB string) Option {
 49	return func(m *Model) {
 50		m.setRamp(colorA, colorB, false)
 51	}
 52}
 53
 54// WithDefaultScaledGradient sets a gradient with default colors, and scales the
 55// gradient to fit the filled portion of the ramp.
 56func WithDefaultScaledGradient() Option {
 57	return WithScaledGradient("#5A56E0", "#EE6FF8")
 58}
 59
 60// WithScaledGradient scales the gradient to fit the width of the filled portion of
 61// the progress bar.
 62func WithScaledGradient(colorA, colorB string) Option {
 63	return func(m *Model) {
 64		m.setRamp(colorA, colorB, true)
 65	}
 66}
 67
 68// WithSolidFill sets the progress to use a solid fill with the given color.
 69func WithSolidFill(color string) Option {
 70	return func(m *Model) {
 71		m.FullColor = color
 72		m.useRamp = false
 73	}
 74}
 75
 76// WithFillCharacters sets the characters used to construct the full and empty components of the progress bar.
 77func WithFillCharacters(full rune, empty rune) Option {
 78	return func(m *Model) {
 79		m.Full = full
 80		m.Empty = empty
 81	}
 82}
 83
 84// WithoutPercentage hides the numeric percentage.
 85func WithoutPercentage() Option {
 86	return func(m *Model) {
 87		m.ShowPercentage = false
 88	}
 89}
 90
 91// WithWidth sets the initial width of the progress bar. Note that you can also
 92// set the width via the Width property, which can come in handy if you're
 93// waiting for a tea.WindowSizeMsg.
 94func WithWidth(w int) Option {
 95	return func(m *Model) {
 96		m.Width = w
 97	}
 98}
 99
100// WithSpringOptions sets the initial frequency and damping options for the
101// progress bar's built-in spring-based animation. Frequency corresponds to
102// speed, and damping to bounciness. For details see:
103//
104// https://github.com/charmbracelet/harmonica
105func WithSpringOptions(frequency, damping float64) Option {
106	return func(m *Model) {
107		m.SetSpringOptions(frequency, damping)
108		m.springCustomized = true
109	}
110}
111
112// WithColorProfile sets the color profile to use for the progress bar.
113func WithColorProfile(p termenv.Profile) Option {
114	return func(m *Model) {
115		m.colorProfile = p
116	}
117}
118
119// FrameMsg indicates that an animation step should occur.
120type FrameMsg struct {
121	id  int
122	tag int
123}
124
125// Model stores values we'll use when rendering the progress bar.
126type Model struct {
127	// An identifier to keep us from receiving messages intended for other
128	// progress bars.
129	id int
130
131	// An identifier to keep us from receiving frame messages too quickly.
132	tag int
133
134	// Total width of the progress bar, including percentage, if set.
135	Width int
136
137	// "Filled" sections of the progress bar.
138	Full      rune
139	FullColor string
140
141	// "Empty" sections of the progress bar.
142	Empty      rune
143	EmptyColor string
144
145	// Settings for rendering the numeric percentage.
146	ShowPercentage  bool
147	PercentFormat   string // a fmt string for a float
148	PercentageStyle lipgloss.Style
149
150	// Members for animated transitions.
151	spring           harmonica.Spring
152	springCustomized bool
153	percentShown     float64 // percent currently displaying
154	targetPercent    float64 // percent to which we're animating
155	velocity         float64
156
157	// Gradient settings
158	useRamp    bool
159	rampColorA colorful.Color
160	rampColorB colorful.Color
161
162	// When true, we scale the gradient to fit the width of the filled section
163	// of the progress bar. When false, the width of the gradient will be set
164	// to the full width of the progress bar.
165	scaleRamp bool
166
167	// Color profile for the progress bar.
168	colorProfile termenv.Profile
169}
170
171// New returns a model with default values.
172func New(opts ...Option) Model {
173	m := Model{
174		id:             nextID(),
175		Width:          defaultWidth,
176		Full:           'â–ˆ',
177		FullColor:      "#7571F9",
178		Empty:          'â–‘',
179		EmptyColor:     "#606060",
180		ShowPercentage: true,
181		PercentFormat:  " %3.0f%%",
182		colorProfile:   termenv.ColorProfile(),
183	}
184
185	for _, opt := range opts {
186		opt(&m)
187	}
188
189	if !m.springCustomized {
190		m.SetSpringOptions(defaultFrequency, defaultDamping)
191	}
192
193	return m
194}
195
196// NewModel returns a model with default values.
197//
198// Deprecated: use [New] instead.
199var NewModel = New
200
201// Init exists to satisfy the tea.Model interface.
202func (m Model) Init() tea.Cmd {
203	return nil
204}
205
206// Update is used to animate the progress bar during transitions. Use
207// SetPercent to create the command you'll need to trigger the animation.
208//
209// If you're rendering with ViewAs you won't need this.
210func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
211	switch msg := msg.(type) {
212	case FrameMsg:
213		if msg.id != m.id || msg.tag != m.tag {
214			return m, nil
215		}
216
217		// If we've more or less reached equilibrium, stop updating.
218		if !m.IsAnimating() {
219			return m, nil
220		}
221
222		m.percentShown, m.velocity = m.spring.Update(m.percentShown, m.velocity, m.targetPercent)
223		return m, m.nextFrame()
224
225	default:
226		return m, nil
227	}
228}
229
230// SetSpringOptions sets the frequency and damping for the current spring.
231// Frequency corresponds to speed, and damping to bounciness. For details see:
232//
233// https://github.com/charmbracelet/harmonica
234func (m *Model) SetSpringOptions(frequency, damping float64) {
235	m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
236}
237
238// Percent returns the current visible percentage on the model. This is only
239// relevant when you're animating the progress bar.
240//
241// If you're rendering with ViewAs you won't need this.
242func (m Model) Percent() float64 {
243	return m.targetPercent
244}
245
246// SetPercent sets the percentage state of the model as well as a command
247// necessary for animating the progress bar to this new percentage.
248//
249// If you're rendering with ViewAs you won't need this.
250func (m *Model) SetPercent(p float64) tea.Cmd {
251	m.targetPercent = math.Max(0, math.Min(1, p))
252	m.tag++
253	return m.nextFrame()
254}
255
256// IncrPercent increments the percentage by a given amount, returning a command
257// necessary to animate the progress bar to the new percentage.
258//
259// If you're rendering with ViewAs you won't need this.
260func (m *Model) IncrPercent(v float64) tea.Cmd {
261	return m.SetPercent(m.Percent() + v)
262}
263
264// DecrPercent decrements the percentage by a given amount, returning a command
265// necessary to animate the progress bar to the new percentage.
266//
267// If you're rendering with ViewAs you won't need this.
268func (m *Model) DecrPercent(v float64) tea.Cmd {
269	return m.SetPercent(m.Percent() - v)
270}
271
272// View renders an animated progress bar in its current state. To render
273// a static progress bar based on your own calculations use ViewAs instead.
274func (m Model) View() string {
275	return m.ViewAs(m.percentShown)
276}
277
278// ViewAs renders the progress bar with a given percentage.
279func (m Model) ViewAs(percent float64) string {
280	b := strings.Builder{}
281	percentView := m.percentageView(percent)
282	m.barView(&b, percent, ansi.StringWidth(percentView))
283	b.WriteString(percentView)
284	return b.String()
285}
286
287func (m *Model) nextFrame() tea.Cmd {
288	return tea.Tick(time.Second/time.Duration(fps), func(time.Time) tea.Msg {
289		return FrameMsg{id: m.id, tag: m.tag}
290	})
291}
292
293func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
294	var (
295		tw = max(0, m.Width-textWidth)                // total width
296		fw = int(math.Round((float64(tw) * percent))) // filled width
297		p  float64
298	)
299
300	fw = max(0, min(tw, fw))
301
302	if m.useRamp {
303		// Gradient fill
304		for i := 0; i < fw; i++ {
305			if fw == 1 {
306				// this is up for debate: in a gradient of width=1, should the
307				// single character rendered be the first color, the last color
308				// or exactly 50% in between? I opted for 50%
309				p = 0.5
310			} else if m.scaleRamp {
311				p = float64(i) / float64(fw-1)
312			} else {
313				p = float64(i) / float64(tw-1)
314			}
315			c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
316			b.WriteString(termenv.
317				String(string(m.Full)).
318				Foreground(m.color(c)).
319				String(),
320			)
321		}
322	} else {
323		// Solid fill
324		s := termenv.String(string(m.Full)).Foreground(m.color(m.FullColor)).String()
325		b.WriteString(strings.Repeat(s, fw))
326	}
327
328	// Empty fill
329	e := termenv.String(string(m.Empty)).Foreground(m.color(m.EmptyColor)).String()
330	n := max(0, tw-fw)
331	b.WriteString(strings.Repeat(e, n))
332}
333
334func (m Model) percentageView(percent float64) string {
335	if !m.ShowPercentage {
336		return ""
337	}
338	percent = math.Max(0, math.Min(1, percent))
339	percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:mnd
340	percentage = m.PercentageStyle.Inline(true).Render(percentage)
341	return percentage
342}
343
344func (m *Model) setRamp(colorA, colorB string, scaled bool) {
345	// In the event of an error colors here will default to black. For
346	// usability's sake, and because such an error is only cosmetic, we're
347	// ignoring the error.
348	a, _ := colorful.Hex(colorA)
349	b, _ := colorful.Hex(colorB)
350
351	m.useRamp = true
352	m.scaleRamp = scaled
353	m.rampColorA = a
354	m.rampColorB = b
355}
356
357func (m Model) color(c string) termenv.Color {
358	return m.colorProfile.Color(c)
359}
360
361// IsAnimating returns false if the progress bar reached equilibrium and is no longer animating.
362func (m *Model) IsAnimating() bool {
363	dist := math.Abs(m.percentShown - m.targetPercent)
364	return !(dist < 0.001 && m.velocity < 0.01)
365}