mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-16 01:43:21 +00:00
feat(progress): support multiple stops and improved blend algorithm (#838)
This commit is contained in:
+159
-74
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user