Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Granular progress bar completion #680

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
137 changes: 100 additions & 37 deletions progress/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package progress
import (
"fmt"
"math"
"sort"
"strings"
"sync/atomic"
"time"
Expand Down Expand Up @@ -30,6 +31,29 @@ const (
defaultDamping = 1.0
)

// FillStep define each possible step for the completion
// of a single block in the progress bar. An array of FillStep
// is used to define the full range of possible completions.
// in the progress bar.
type FillStep struct {
rune rune
completion float64 // 0% to 100% of that particular block
}

func defaultFillSteps() []FillStep {
return []FillStep{
{' ', 0.0},
{'▏', 1.0 / 8.0},
{'▎', 2.0 / 8.0},
{'▍', 3.0 / 8.0},
{'▌', 4.0 / 8.0},
{'▋', 5.0 / 8.0},
{'▊', 6.0 / 8.0},
{'▉', 7.0 / 8.0},
{'█', 1.0},
}
}

// Option is used to set options in New. For example:
//
// progress := New(
Expand Down Expand Up @@ -75,11 +99,40 @@ func WithSolidFill(color string) Option {
// 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
m.Empty = empty
m.FillSteps = []FillStep{
{empty, 0.0},
{full, 1.0},
}
}
}

// WithGranularFill sets the characters used to construct the full and empty components of the progress bar.
func WithGranularFill(steps []FillStep) Option {
sort.Slice(steps, func(i, j int) bool {
return steps[i].completion < steps[j].completion
})
return func(m *Model) {
m.FillSteps = steps
}
}

// WithBinaryFill results in a less granular but possible more widely compatible
// progress bar as only two characters are used to represent completion of a
// single block (full/complete and empty/incomplete).
func WithBinaryFill() Option {
return func(m *Model) {
m.FillSteps = []FillStep{
{' ', 0.0},
{'█', 1.0},
}
}
}

// WithDefaultFill sets the progress bar to use the default fill resolution/characters.
func WithDefaultFill() Option {
return WithGranularFill(defaultFillSteps())
}

// WithoutPercentage hides the numeric percentage.
func WithoutPercentage() Option {
return func(m *Model) {
Expand Down Expand Up @@ -133,12 +186,11 @@ type Model struct {
// Total width of the progress bar, including percentage, if set.
Width int

FillSteps []FillStep

// "Filled" sections of the progress bar.
Full rune
FullColor string

// "Empty" sections of the progress bar.
Empty rune
EmptyColor string

// Settings for rendering the numeric percentage.
Expand Down Expand Up @@ -172,9 +224,8 @@ func New(opts ...Option) Model {
m := Model{
id: nextID(),
Width: defaultWidth,
Full: '█',
FillSteps: defaultFillSteps(),
FullColor: "#7571F9",
Empty: '░',
EmptyColor: "#606060",
ShowPercentage: true,
PercentFormat: " %3.0f%%",
Expand Down Expand Up @@ -291,43 +342,55 @@ func (m *Model) nextFrame() tea.Cmd {

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
tw = max(0, m.Width-textWidth) // total width of the progress bar
fw = percent * float64(tw) // filled width in exact units
)

fw = max(0, min(tw, fw))

if m.useRamp {
// Gradient fill
for i := 0; i < fw; i++ {
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)
for i := 0; i < tw; i++ {
cellPercent := float64(i) / float64(tw) // percentage of each cell
if cellPercent < percent {
// Filled cell: calculate the closest FillStep
step := interpolateFillStep(m.FillSteps, fw-float64(i))
color := m.FullColor
if m.useRamp {
color = m.interpolateRamp(i, tw, true)
}
c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
b.WriteString(termenv.
String(string(m.Full)).
Foreground(m.color(c)).
String(),
b.WriteString(
termenv.String(string(step.rune)).
Foreground(m.color(color)).
Background(m.color(m.EmptyColor)).
String(),
)
} else {
// Empty cell
emptyStep := m.FillSteps[0]
b.WriteString(
termenv.String(string(emptyStep.rune)).
Foreground(m.color(m.EmptyColor)).
Background(m.color(m.EmptyColor)).
String(),
)
}
} else {
// Solid fill
s := termenv.String(string(m.Full)).Foreground(m.color(m.FullColor)).String()
b.WriteString(strings.Repeat(s, fw))
}
}

// Helper: Interpolate between FillSteps
func interpolateFillStep(steps []FillStep, remaining float64) FillStep {
for i := len(steps) - 1; i >= 0; i-- {
if remaining >= steps[i].completion {
return steps[i]
}
}
return steps[0]
}

// Empty fill
e := termenv.String(string(m.Empty)).Foreground(m.color(m.EmptyColor)).String()
n := max(0, tw-fw)
b.WriteString(strings.Repeat(e, n))
// Helper: Interpolate ramp color
func (m Model) interpolateRamp(pos, total int, isFilled bool) string {
p := float64(pos) / float64(total-1)
if m.scaleRamp && isFilled {
p = float64(pos) / float64(total-1)
}
return m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
}

func (m Model) percentageView(percent float64) string {
Expand Down