Skip to content

Commit c24e9e1

Browse files
committed
feat(progress): support multiple stops and improved blend algorithm
Signed-off-by: Liam Stanley <liam@liam.sh>
1 parent c2b8227 commit c24e9e1

9 files changed

+248
-127
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ require (
1111
github.com/charmbracelet/x/ansi v0.11.0
1212
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
1313
github.com/dustin/go-humanize v1.0.1
14-
github.com/lucasb-eyer/go-colorful v1.3.0
1514
github.com/mattn/go-runewidth v0.0.19
1615
github.com/rivo/uniseg v0.4.7
1716
github.com/sahilm/fuzzy v0.1.1
@@ -28,6 +27,7 @@ require (
2827
github.com/clipperhouse/stringish v0.1.1 // indirect
2928
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
3029
github.com/kylelemons/godebug v1.1.0 // indirect
30+
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3131
github.com/muesli/cancelreader v0.2.2 // indirect
3232
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3333
golang.org/x/sync v0.17.0 // indirect

progress/progress.go

Lines changed: 159 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ import (
1010
"time"
1111

1212
tea "charm.land/bubbletea/v2"
13-
"github.com/charmbracelet/harmonica"
1413
"charm.land/lipgloss/v2"
14+
"github.com/charmbracelet/harmonica"
1515
"github.com/charmbracelet/x/ansi"
16-
"github.com/lucasb-eyer/go-colorful"
1716
)
1817

18+
// ColorFunc is a function that can be used to dynamically fill the progress
19+
// bar based on the current percentage. total is the total filled percentage,
20+
// and current is the current percentage that is actively being filled with a
21+
// color.
22+
type ColorFunc func(total, current float64) color.Color
23+
1924
// Internal ID management. Used during animating to assure that frame messages
2025
// can only be received by progress components that sent them.
2126
var lastID int64
@@ -25,55 +30,107 @@ func nextID() int {
2530
}
2631

2732
const (
33+
// DefaultFullCharHalfBlock is the default character used to fill the progress
34+
// bar. It is a half block, which allows more granular color blending control,
35+
// by having a different foreground and background color, doubling blending
36+
// resolution.
37+
DefaultFullCharHalfBlock = '▌'
38+
39+
// DefaultFullCharFullBlock can also be used as a fill character for the
40+
// progress bar. Use this to disable the higher resolution blending which is
41+
// enabled when using [DefaultFullCharHalfBlock].
42+
DefaultFullCharFullBlock = '█'
43+
44+
// DefaultEmptyCharBlock is the default character used to fill the empty
45+
// portion of the progress bar.
46+
DefaultEmptyCharBlock = '░'
47+
2848
fps = 60
2949
defaultWidth = 40
3050
defaultFrequency = 18.0
3151
defaultDamping = 1.0
3252
)
3353

34-
// Option is used to set options in New. For example:
54+
var (
55+
defaultBlendStart = lipgloss.Color("#5A56E0") // Purple haze.
56+
defaultBlendEnd = lipgloss.Color("#EE6FF8") // Neon pink.
57+
defaultFullColor = lipgloss.Color("#7571F9") // Blueberry.
58+
defaultEmptyColor = lipgloss.Color("#606060") // Slate gray.
59+
)
60+
61+
// Option is used to set options in [New]. For example:
3562
//
36-
// progress := New(
37-
// WithRamp("#ff0000", "#0000ff"),
38-
// WithoutPercentage(),
39-
// )
63+
// progress := New(
64+
// WithColors(
65+
// lipgloss.Color("#5A56E0"),
66+
// lipgloss.Color("#EE6FF8"),
67+
// ),
68+
// WithoutPercentage(),
69+
// )
4070
type Option func(*Model)
4171

42-
// WithDefaultGradient sets a gradient fill with default colors.
43-
func WithDefaultGradient() Option {
44-
return WithGradient("#5A56E0", "#EE6FF8")
72+
// WithDefaultBlend sets a default blend of colors, which is a blend of purple
73+
// haze to neon pink.
74+
func WithDefaultBlend() Option {
75+
return WithColors(
76+
defaultBlendStart,
77+
defaultBlendEnd,
78+
)
4579
}
4680

47-
// WithGradient sets a gradient fill blending between two colors.
48-
func WithGradient(colorA, colorB string) Option {
49-
return func(m *Model) {
50-
m.setRamp(colorA, colorB, false)
81+
// WithColors sets the colors to use to fill the progress bar. Depending on the
82+
// number of colors passed in, will determine whether to use a solid fill or a
83+
// blend of colors.
84+
//
85+
// - 0 colors: clears all previously set colors, setting them back to defaults.
86+
// - 1 color: uses a solid fill with the given color.
87+
// - 2+ colors: uses a blend of the provided colors.
88+
func WithColors(colors ...color.Color) Option {
89+
if len(colors) == 0 {
90+
return func(m *Model) {
91+
m.FullColor = defaultFullColor
92+
m.blend = nil
93+
m.colorFunc = nil
94+
}
95+
}
96+
if len(colors) == 1 {
97+
return func(m *Model) {
98+
m.FullColor = colors[0]
99+
m.colorFunc = nil
100+
m.blend = nil
101+
}
51102
}
52-
}
53-
54-
// WithDefaultScaledGradient sets a gradient with default colors, and scales the
55-
// gradient to fit the filled portion of the ramp.
56-
func 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.
62-
func WithScaledGradient(colorA, colorB string) Option {
63103
return func(m *Model) {
64-
m.setRamp(colorA, colorB, true)
104+
m.blend = colors
65105
}
66106
}
67107

68-
// WithSolidFill sets the progress to use a solid fill with the given color.
69-
func WithSolidFill(color color.Color) Option {
108+
// WithColorFunc sets a function that can be used to dynamically fill the progress
109+
// bar based on the current percentage. total is the total filled percentage, and
110+
// current is the current percentage that is actively being filled with a color.
111+
// When specified, this overrides any other defined colors and scaling.
112+
//
113+
// Example: A progress bar that changes color based on the total completed
114+
// percentage:
115+
//
116+
// WithColorFunc(func(total, current float64) color.Color {
117+
// if total <= 0.3 {
118+
// return lipgloss.Color("#FF0000")
119+
// }
120+
// if total <= 0.7 {
121+
// return lipgloss.Color("#00FF00")
122+
// }
123+
// return lipgloss.Color("#0000FF")
124+
// }),
125+
func WithColorFunc(fn ColorFunc) Option {
70126
return func(m *Model) {
71-
m.FullColor = color
72-
m.useRamp = false
127+
m.colorFunc = fn
128+
m.blend = nil
73129
}
74130
}
75131

76-
// WithFillCharacters sets the characters used to construct the full and empty components of the progress bar.
132+
// WithFillCharacters sets the characters used to construct the full and empty
133+
// components of the progress bar.
77134
func WithFillCharacters(full rune, empty rune) Option {
78135
return func(m *Model) {
79136
m.Full = full
@@ -93,7 +150,7 @@ func WithoutPercentage() Option {
93150
// waiting for a tea.WindowSizeMsg.
94151
func WithWidth(w int) Option {
95152
return func(m *Model) {
96-
m.width = w
153+
m.SetWidth(w)
97154
}
98155
}
99156

@@ -109,6 +166,17 @@ func WithSpringOptions(frequency, damping float64) Option {
109166
}
110167
}
111168

169+
// WithScaled sets whether to scale the blend/gradient to fit the width of only
170+
// the filled portion of the progress bar. The default is false, which means the
171+
// percentage must be 100% to see the full color blend/gradient.
172+
//
173+
// This is ignored when not using blending/multiple colors.
174+
func WithScaled(enabled bool) Option {
175+
return func(m *Model) {
176+
m.scaleBlend = enabled
177+
}
178+
}
179+
112180
// FrameMsg indicates that an animation step should occur.
113181
type FrameMsg struct {
114182
id int
@@ -147,26 +215,28 @@ type Model struct {
147215
targetPercent float64 // percent to which we're animating
148216
velocity float64
149217

150-
// Gradient settings
151-
useRamp bool
152-
rampColorA colorful.Color
153-
rampColorB colorful.Color
218+
// Blend of colors to use. When len < 1, we use FullColor.
219+
blend []color.Color
220+
221+
// When true, we scale the blended colors to fit the width of the filled
222+
// section of the progress bar. When false, the width of the blend will be
223+
// set to the full width of the progress bar.
224+
scaleBlend bool
154225

155-
// When true, we scale the gradient to fit the width of the filled section
156-
// of the progress bar. When false, the width of the gradient will be set
157-
// to the full width of the progress bar.
158-
scaleRamp bool
226+
// colorFunc is used to dynamically fill the progress bar based on the
227+
// current percentage.
228+
colorFunc ColorFunc
159229
}
160230

161231
// New returns a model with default values.
162232
func New(opts ...Option) Model {
163233
m := Model{
164234
id: nextID(),
165235
width: defaultWidth,
166-
Full: '█',
167-
FullColor: lipgloss.Color("#7571F9"),
168-
Empty: '░',
169-
EmptyColor: lipgloss.Color("#606060"),
236+
Full: DefaultFullCharHalfBlock,
237+
FullColor: defaultFullColor,
238+
Empty: DefaultEmptyCharBlock,
239+
EmptyColor: defaultEmptyColor,
170240
ShowPercentage: true,
171241
PercentFormat: " %3.0f%%",
172242
}
@@ -288,35 +358,62 @@ func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
288358
var (
289359
tw = max(0, m.width-textWidth) // total width
290360
fw = int(math.Round((float64(tw) * percent))) // filled width
291-
p float64
292361
)
293362

294363
fw = max(0, min(tw, fw))
295364

296-
if m.useRamp {
297-
// Gradient fill
365+
isHalfBlock := m.Full == DefaultFullCharHalfBlock
366+
367+
if m.colorFunc != nil { //nolint:nestif
368+
var style lipgloss.Style
369+
var current float64
370+
halfBlockPerc := 0.5 / float64(tw)
298371
for i := range fw {
299-
if fw == 1 {
300-
// this is up for debate: in a gradient of width=1, should the
301-
// single character rendered be the first color, the last color
302-
// or exactly 50% in between? I opted for 50%
303-
p = 0.5
304-
} else if m.scaleRamp {
305-
p = float64(i) / float64(fw-1)
306-
} else {
307-
p = float64(i) / float64(tw-1)
372+
current = float64(i) / float64(tw)
373+
style = style.Foreground(m.colorFunc(percent, current))
374+
if isHalfBlock {
375+
style = style.Background(m.colorFunc(percent, min(current+halfBlockPerc, 1)))
308376
}
309-
c := m.rampColorA.BlendLuv(m.rampColorB, p)
310-
b.WriteString(lipgloss.NewStyle().Foreground(c).Render(string(m.Full)))
377+
b.WriteString(style.Render(string(m.Full)))
378+
}
379+
} else if len(m.blend) > 0 {
380+
var blend []color.Color
381+
382+
multiplier := 1
383+
if isHalfBlock {
384+
multiplier = 2
385+
}
386+
387+
if m.scaleBlend {
388+
blend = lipgloss.Blend1D(fw*multiplier, m.blend...)
389+
} else {
390+
blend = lipgloss.Blend1D(tw*multiplier, m.blend...)
391+
}
392+
393+
// Blend fill.
394+
var blendIndex int
395+
for i := range fw {
396+
if !isHalfBlock {
397+
b.WriteString(lipgloss.NewStyle().
398+
Foreground(blend[i]).
399+
Render(string(m.Full)))
400+
continue
401+
}
402+
403+
b.WriteString(lipgloss.NewStyle().
404+
Foreground(blend[blendIndex]).
405+
Background(blend[blendIndex+1]).
406+
Render(string(m.Full)))
407+
blendIndex += 2
311408
}
312409
} else {
313-
// Solid fill
410+
// Solid fill.
314411
b.WriteString(lipgloss.NewStyle().
315412
Foreground(m.FullColor).
316413
Render(strings.Repeat(string(m.Full), fw)))
317414
}
318415

319-
// Empty fill
416+
// Empty fill.
320417
n := max(0, tw-fw)
321418
b.WriteString(lipgloss.NewStyle().
322419
Foreground(m.EmptyColor).
@@ -333,20 +430,8 @@ func (m Model) percentageView(percent float64) string {
333430
return percentage
334431
}
335432

336-
func (m *Model) setRamp(colorA, colorB string, scaled bool) {
337-
// In the event of an error colors here will default to black. For
338-
// usability's sake, and because such an error is only cosmetic, we're
339-
// ignoring the error.
340-
a, _ := colorful.Hex(colorA)
341-
b, _ := colorful.Hex(colorB)
342-
343-
m.useRamp = true
344-
m.scaleRamp = scaled
345-
m.rampColorA = a
346-
m.rampColorB = b
347-
}
348-
349-
// IsAnimating returns false if the progress bar reached equilibrium and is no longer animating.
433+
// IsAnimating returns false if the progress bar reached equilibrium and is no
434+
// longer animating.
350435
func (m *Model) IsAnimating() bool {
351436
dist := math.Abs(m.percentShown - m.targetPercent)
352437
return !(dist < 0.001 && m.velocity < 0.01)

0 commit comments

Comments
 (0)