Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
233 changes: 159 additions & 74 deletions progress/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import (
"time"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/harmonica"
"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
Expand All @@ -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 {
return func(m *Model) {
m.setRamp(colorA, colorB, false)
// 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
}
}
}

// 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 {
return func(m *Model) {
m.setRamp(colorA, colorB, true)
m.blend = colors
}
}

// WithSolidFill sets the progress to use a solid fill with the given color.
func WithSolidFill(color color.Color) 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.FullColor = color
m.useRamp = false
m.colorFunc = fn
m.blend = nil
}
}

// 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
Expand All @@ -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)
}
}

Expand All @@ -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
Expand Down Expand Up @@ -147,26 +215,28 @@ 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 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

// 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
// colorFunc is used to dynamically fill the progress bar based on the
// current percentage.
colorFunc ColorFunc
}

// New returns a model with default values.
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%%",
}
Expand Down Expand Up @@ -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).
Expand All @@ -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)
Expand Down
Loading
Loading