diff --git a/go.mod b/go.mod index 4a571511..25a99344 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/progress/progress.go b/progress/progress.go index b5730bb2..e6fbfbd8 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -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 @@ -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 @@ -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 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. @@ -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) diff --git a/progress/progress_test.go b/progress/progress_test.go index 82dbaf48..660febeb 100644 --- a/progress/progress_test.go +++ b/progress/progress_test.go @@ -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 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]) - } +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, + }, + } - 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))) }) } } diff --git a/progress/testdata/TestBlend/10w-red-to-green-50perc-full-block.golden b/progress/testdata/TestBlend/10w-red-to-green-50perc-full-block.golden new file mode 100644 index 00000000..ad69b665 --- /dev/null +++ b/progress/testdata/TestBlend/10w-red-to-green-50perc-full-block.golden @@ -0,0 +1 @@ +█████░░░░░ \ No newline at end of file diff --git a/progress/testdata/TestBlend/10w-red-to-green-50perc.golden b/progress/testdata/TestBlend/10w-red-to-green-50perc.golden new file mode 100644 index 00000000..431829b2 --- /dev/null +++ b/progress/testdata/TestBlend/10w-red-to-green-50perc.golden @@ -0,0 +1 @@ +▌▌▌▌▌░░░░░ \ No newline at end of file diff --git a/progress/testdata/TestBlend/10w-red-to-green-scaled-50perc.golden b/progress/testdata/TestBlend/10w-red-to-green-scaled-50perc.golden new file mode 100644 index 00000000..51fb664e --- /dev/null +++ b/progress/testdata/TestBlend/10w-red-to-green-scaled-50perc.golden @@ -0,0 +1 @@ +▌▌▌▌▌░░░░░ \ No newline at end of file diff --git a/progress/testdata/TestBlend/30w-colorfunc-rgb-100perc.golden b/progress/testdata/TestBlend/30w-colorfunc-rgb-100perc.golden new file mode 100644 index 00000000..3dc7a686 --- /dev/null +++ b/progress/testdata/TestBlend/30w-colorfunc-rgb-100perc.golden @@ -0,0 +1 @@ +▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌ \ No newline at end of file diff --git a/progress/testdata/TestBlend/30w-red-to-green-100perc.golden b/progress/testdata/TestBlend/30w-red-to-green-100perc.golden new file mode 100644 index 00000000..e02be0b2 --- /dev/null +++ b/progress/testdata/TestBlend/30w-red-to-green-100perc.golden @@ -0,0 +1 @@ +▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌ \ No newline at end of file diff --git a/progress/testdata/TestBlend/30w-red-to-green-scaled-100perc.golden b/progress/testdata/TestBlend/30w-red-to-green-scaled-100perc.golden new file mode 100644 index 00000000..e02be0b2 --- /dev/null +++ b/progress/testdata/TestBlend/30w-red-to-green-scaled-100perc.golden @@ -0,0 +1 @@ +▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌ \ No newline at end of file diff --git a/textarea/textarea.go b/textarea/textarea.go index 463181ae..991299ec 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -18,8 +18,8 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" - "github.com/atotto/clipboard" "charm.land/lipgloss/v2" + "github.com/atotto/clipboard" "github.com/charmbracelet/x/ansi" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" diff --git a/textinput/textinput.go b/textinput/textinput.go index 56306d67..363089b2 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -8,12 +8,12 @@ import ( "strings" "unicode" - "github.com/atotto/clipboard" "charm.land/bubbles/v2/cursor" "charm.land/bubbles/v2/internal/runeutil" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/atotto/clipboard" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" )