main
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}