feat(progress): support multiple stops and improved blend algorithm (#838)

This commit is contained in:
Liam Stanley
2025-11-10 15:53:59 -05:00
committed by GitHub
parent 5fdd73f350
commit faa17cbbd6
9 changed files with 248 additions and 127 deletions
+1 -1
View File
@@ -11,7 +11,6 @@ require (
github.com/charmbracelet/x/ansi v0.11.0
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
github.com/dustin/go-humanize v1.0.1
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-runewidth v0.0.19
github.com/rivo/uniseg v0.4.7
github.com/sahilm/fuzzy v0.1.1
@@ -28,6 +27,7 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.17.0 // indirect
+159 -74
View File
@@ -13,9 +13,14 @@ import (
"charm.land/lipgloss/v2"
"github.com/charmbracelet/harmonica"
"github.com/charmbracelet/x/ansi"
"github.com/lucasb-eyer/go-colorful"
)
// ColorFunc is a function that can be used to dynamically fill the progress
// bar based on the current percentage. total is the total filled percentage,
// and current is the current percentage that is actively being filled with a
// color.
type ColorFunc func(total, current float64) color.Color
// Internal ID management. Used during animating to assure that frame messages
// can only be received by progress components that sent them.
var lastID int64
@@ -25,55 +30,107 @@ func nextID() int {
}
const (
// DefaultFullCharHalfBlock is the default character used to fill the progress
// bar. It is a half block, which allows more granular color blending control,
// by having a different foreground and background color, doubling blending
// resolution.
DefaultFullCharHalfBlock = '▌'
// DefaultFullCharFullBlock can also be used as a fill character for the
// progress bar. Use this to disable the higher resolution blending which is
// enabled when using [DefaultFullCharHalfBlock].
DefaultFullCharFullBlock = '█'
// DefaultEmptyCharBlock is the default character used to fill the empty
// portion of the progress bar.
DefaultEmptyCharBlock = '░'
fps = 60
defaultWidth = 40
defaultFrequency = 18.0
defaultDamping = 1.0
)
// Option is used to set options in New. For example:
var (
defaultBlendStart = lipgloss.Color("#5A56E0") // Purple haze.
defaultBlendEnd = lipgloss.Color("#EE6FF8") // Neon pink.
defaultFullColor = lipgloss.Color("#7571F9") // Blueberry.
defaultEmptyColor = lipgloss.Color("#606060") // Slate gray.
)
// Option is used to set options in [New]. For example:
//
// progress := New(
// WithRamp("#ff0000", "#0000ff"),
// WithoutPercentage(),
// )
// progress := New(
// WithColors(
// lipgloss.Color("#5A56E0"),
// lipgloss.Color("#EE6FF8"),
// ),
// WithoutPercentage(),
// )
type Option func(*Model)
// WithDefaultGradient sets a gradient fill with default colors.
func WithDefaultGradient() Option {
return WithGradient("#5A56E0", "#EE6FF8")
// WithDefaultBlend sets a default blend of colors, which is a blend of purple
// haze to neon pink.
func WithDefaultBlend() Option {
return WithColors(
defaultBlendStart,
defaultBlendEnd,
)
}
// WithGradient sets a gradient fill blending between two colors.
func WithGradient(colorA, colorB string) Option {
// WithColors sets the colors to use to fill the progress bar. Depending on the
// number of colors passed in, will determine whether to use a solid fill or a
// blend of colors.
//
// - 0 colors: clears all previously set colors, setting them back to defaults.
// - 1 color: uses a solid fill with the given color.
// - 2+ colors: uses a blend of the provided colors.
func WithColors(colors ...color.Color) Option {
if len(colors) == 0 {
return func(m *Model) {
m.FullColor = defaultFullColor
m.blend = nil
m.colorFunc = nil
}
}
if len(colors) == 1 {
return func(m *Model) {
m.FullColor = colors[0]
m.colorFunc = nil
m.blend = nil
}
}
return func(m *Model) {
m.setRamp(colorA, colorB, false)
m.blend = colors
}
}
// WithDefaultScaledGradient sets a gradient with default colors, and scales the
// gradient to fit the filled portion of the ramp.
func WithDefaultScaledGradient() Option {
return WithScaledGradient("#5A56E0", "#EE6FF8")
}
// WithScaledGradient scales the gradient to fit the width of the filled portion of
// the progress bar.
func WithScaledGradient(colorA, colorB string) Option {
// WithColorFunc sets a function that can be used to dynamically fill the progress
// bar based on the current percentage. total is the total filled percentage, and
// current is the current percentage that is actively being filled with a color.
// When specified, this overrides any other defined colors and scaling.
//
// Example: A progress bar that changes color based on the total completed
// percentage:
//
// WithColorFunc(func(total, current float64) color.Color {
// if total <= 0.3 {
// return lipgloss.Color("#FF0000")
// }
// if total <= 0.7 {
// return lipgloss.Color("#00FF00")
// }
// return lipgloss.Color("#0000FF")
// }),
func WithColorFunc(fn ColorFunc) Option {
return func(m *Model) {
m.setRamp(colorA, colorB, true)
m.colorFunc = fn
m.blend = nil
}
}
// WithSolidFill sets the progress to use a solid fill with the given color.
func WithSolidFill(color color.Color) Option {
return func(m *Model) {
m.FullColor = color
m.useRamp = false
}
}
// WithFillCharacters sets the characters used to construct the full and empty components of the progress bar.
// WithFillCharacters sets the characters used to construct the full and empty
// components of the progress bar.
func WithFillCharacters(full rune, empty rune) Option {
return func(m *Model) {
m.Full = full
@@ -93,7 +150,7 @@ func WithoutPercentage() Option {
// waiting for a tea.WindowSizeMsg.
func WithWidth(w int) Option {
return func(m *Model) {
m.width = w
m.SetWidth(w)
}
}
@@ -109,6 +166,17 @@ func WithSpringOptions(frequency, damping float64) Option {
}
}
// WithScaled sets whether to scale the blend/gradient to fit the width of only
// the filled portion of the progress bar. The default is false, which means the
// percentage must be 100% to see the full color blend/gradient.
//
// This is ignored when not using blending/multiple colors.
func WithScaled(enabled bool) Option {
return func(m *Model) {
m.scaleBlend = enabled
}
}
// FrameMsg indicates that an animation step should occur.
type FrameMsg struct {
id int
@@ -147,15 +215,17 @@ type Model struct {
targetPercent float64 // percent to which we're animating
velocity float64
// Gradient settings
useRamp bool
rampColorA colorful.Color
rampColorB colorful.Color
// Blend of colors to use. When len < 1, we use FullColor.
blend []color.Color
// When true, we scale the gradient to fit the width of the filled section
// of the progress bar. When false, the width of the gradient will be set
// to the full width of the progress bar.
scaleRamp bool
// When true, we scale the blended colors to fit the width of the filled
// section of the progress bar. When false, the width of the blend will be
// set to the full width of the progress bar.
scaleBlend bool
// colorFunc is used to dynamically fill the progress bar based on the
// current percentage.
colorFunc ColorFunc
}
// New returns a model with default values.
@@ -163,10 +233,10 @@ func New(opts ...Option) Model {
m := Model{
id: nextID(),
width: defaultWidth,
Full: '█',
FullColor: lipgloss.Color("#7571F9"),
Empty: '░',
EmptyColor: lipgloss.Color("#606060"),
Full: DefaultFullCharHalfBlock,
FullColor: defaultFullColor,
Empty: DefaultEmptyCharBlock,
EmptyColor: defaultEmptyColor,
ShowPercentage: true,
PercentFormat: " %3.0f%%",
}
@@ -288,35 +358,62 @@ func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
var (
tw = max(0, m.width-textWidth) // total width
fw = int(math.Round((float64(tw) * percent))) // filled width
p float64
)
fw = max(0, min(tw, fw))
if m.useRamp {
// Gradient fill
isHalfBlock := m.Full == DefaultFullCharHalfBlock
if m.colorFunc != nil { //nolint:nestif
var style lipgloss.Style
var current float64
halfBlockPerc := 0.5 / float64(tw)
for i := range fw {
if fw == 1 {
// this is up for debate: in a gradient of width=1, should the
// single character rendered be the first color, the last color
// or exactly 50% in between? I opted for 50%
p = 0.5
} else if m.scaleRamp {
p = float64(i) / float64(fw-1)
} else {
p = float64(i) / float64(tw-1)
current = float64(i) / float64(tw)
style = style.Foreground(m.colorFunc(percent, current))
if isHalfBlock {
style = style.Background(m.colorFunc(percent, min(current+halfBlockPerc, 1)))
}
c := m.rampColorA.BlendLuv(m.rampColorB, p)
b.WriteString(lipgloss.NewStyle().Foreground(c).Render(string(m.Full)))
b.WriteString(style.Render(string(m.Full)))
}
} else if len(m.blend) > 0 {
var blend []color.Color
multiplier := 1
if isHalfBlock {
multiplier = 2
}
if m.scaleBlend {
blend = lipgloss.Blend1D(fw*multiplier, m.blend...)
} else {
blend = lipgloss.Blend1D(tw*multiplier, m.blend...)
}
// Blend fill.
var blendIndex int
for i := range fw {
if !isHalfBlock {
b.WriteString(lipgloss.NewStyle().
Foreground(blend[i]).
Render(string(m.Full)))
continue
}
b.WriteString(lipgloss.NewStyle().
Foreground(blend[blendIndex]).
Background(blend[blendIndex+1]).
Render(string(m.Full)))
blendIndex += 2
}
} else {
// Solid fill
// Solid fill.
b.WriteString(lipgloss.NewStyle().
Foreground(m.FullColor).
Render(strings.Repeat(string(m.Full), fw)))
}
// Empty fill
// Empty fill.
n := max(0, tw-fw)
b.WriteString(lipgloss.NewStyle().
Foreground(m.EmptyColor).
@@ -333,20 +430,8 @@ func (m Model) percentageView(percent float64) string {
return percentage
}
func (m *Model) setRamp(colorA, colorB string, scaled bool) {
// In the event of an error colors here will default to black. For
// usability's sake, and because such an error is only cosmetic, we're
// ignoring the error.
a, _ := colorful.Hex(colorA)
b, _ := colorful.Hex(colorB)
m.useRamp = true
m.scaleRamp = scaled
m.rampColorA = a
m.rampColorB = b
}
// IsAnimating returns false if the progress bar reached equilibrium and is no longer animating.
// IsAnimating returns false if the progress bar reached equilibrium and is no
// longer animating.
func (m *Model) IsAnimating() bool {
dist := math.Abs(m.percentShown - m.targetPercent)
return !(dist < 0.001 && m.velocity < 0.01)
+82 -52
View File
@@ -1,64 +1,94 @@
package progress
import (
"strings"
"image/color"
"testing"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/exp/golden"
)
const (
AnsiReset = "\x1b[m"
)
func TestBlend(t *testing.T) {
tests := []struct {
name string
options []Option
width int
percent float64
}{
{
name: "10w-red-to-green-50perc",
options: []Option{
WithColors(lipgloss.Color("#FF0000"), lipgloss.Color("#00FF00")),
WithScaled(false),
WithoutPercentage(),
},
width: 10,
percent: 0.5,
},
{
name: "10w-red-to-green-50perc-full-block",
options: []Option{
WithColors(lipgloss.Color("#FF0000"), lipgloss.Color("#00FF00")),
WithFillCharacters('█', DefaultEmptyCharBlock),
WithoutPercentage(),
},
width: 10,
percent: 0.5,
},
{
name: "30w-red-to-green-100perc",
options: []Option{
WithColors(lipgloss.Color("#FF0000"), lipgloss.Color("#00FF00")),
WithScaled(false),
WithoutPercentage(),
},
width: 30,
percent: 1.0,
},
{
name: "10w-red-to-green-scaled-50perc",
options: []Option{
WithColors(lipgloss.Color("#FF0000"), lipgloss.Color("#00FF00")),
WithScaled(true),
WithoutPercentage(),
},
width: 10,
percent: 0.5,
},
{
name: "30w-red-to-green-scaled-100perc",
options: []Option{
WithColors(lipgloss.Color("#FF0000"), lipgloss.Color("#00FF00")),
WithScaled(true),
WithoutPercentage(),
},
width: 30,
percent: 1.0,
},
{
name: "30w-colorfunc-rgb-100perc",
options: []Option{
WithColorFunc(func(_, current float64) color.Color {
if current <= 0.3 {
return lipgloss.Color("#FF0000")
}
if current <= 0.7 {
return lipgloss.Color("#00FF00")
}
return lipgloss.Color("#0000FF")
}),
WithoutPercentage(),
},
width: 30,
percent: 1.0,
},
}
func TestGradient(t *testing.T) {
colA := "#FF0000"
colB := "#00FF00"
var p Model
var descr string
for _, scale := range []bool{false, true} {
opts := []Option{
WithoutPercentage(),
}
if scale {
descr = "progress bar with scaled gradient"
opts = append(opts, WithScaledGradient(colA, colB))
} else {
descr = "progress bar with gradient"
opts = append(opts, WithGradient(colA, colB))
}
t.Run(descr, func(t *testing.T) {
p = New(opts...)
// build the expected colors by colorizing an empty string and then cutting off the following reset sequence
sb := strings.Builder{}
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colA)).String())
expFirst := strings.Split(sb.String(), AnsiReset)[0]
sb.Reset()
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colB)).String())
expLast := strings.Split(sb.String(), AnsiReset)[0]
for _, width := range []int{3, 5, 50} {
p.SetWidth(width)
res := p.ViewAs(1.0)
// extract colors from the progrss bar by splitting at p.Full+AnsiReset, leaving us with just the color sequences
colors := strings.Split(res, string(p.Full)+AnsiReset)
// discard the last color, because it is empty (no new color comes after the last char of the bar)
colors = colors[0 : len(colors)-1]
if expFirst != colors[0] {
t.Errorf("expected first color of bar to be first gradient color %q, instead got %q", expFirst, colors[0])
}
if expLast != colors[len(colors)-1] {
t.Errorf("expected last color of bar to be second gradient color %q, instead got %q", expLast, colors[len(colors)-1])
}
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := New(tt.options...)
p.SetWidth(tt.width)
golden.RequireEqual(t, []byte(p.ViewAs(tt.percent)))
})
}
}
@@ -0,0 +1 @@
█████░░░░░
@@ -0,0 +1 @@
▌▌▌▌▌░░░░░
@@ -0,0 +1 @@
▌▌▌▌▌░░░░░
@@ -0,0 +1 @@
▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌
@@ -0,0 +1 @@
▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌
@@ -0,0 +1 @@
▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌