@@ -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.
2126var lastID int64
@@ -25,55 +30,107 @@ func nextID() int {
2530}
2631
2732const (
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+ // )
4070type 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.
77134func 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.
94151func 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.
113181type 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.
162232func 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.
350435func (m * Model ) IsAnimating () bool {
351436 dist := math .Abs (m .percentShown - m .targetPercent )
352437 return ! (dist < 0.001 && m .velocity < 0.01 )
0 commit comments