summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorBenjamin Chausse <benjamin@chausse.xyz>2024-11-26 15:38:39 -0500
committerGitHub <noreply@github.com>2024-11-26 15:38:39 -0500
commit0c36ae9d01739ad0f30f2b32efd33f93deadf71a (patch)
treefdd15d32cbc2cb997c35fdfd94574ce515712ece /internal
parent90bca5960cacd4b6971576fc643309827c87bf16 (diff)
fix: smoother progress bar (#21)
Diffstat (limited to 'internal')
-rw-r--r--internal/picker/defaults.go2
-rw-r--r--internal/progress/progress.go415
-rw-r--r--internal/slider/slider.go2
3 files changed, 417 insertions, 2 deletions
diff --git a/internal/picker/defaults.go b/internal/picker/defaults.go
index 3f22140..a7a0d89 100644
--- a/internal/picker/defaults.go
+++ b/internal/picker/defaults.go
@@ -1,8 +1,8 @@
package picker
import (
+ "github.com/ChausseBenjamin/termpicker/internal/progress"
"github.com/ChausseBenjamin/termpicker/internal/slider"
- "github.com/charmbracelet/bubbles/progress"
)
func RGB() *Model {
diff --git a/internal/progress/progress.go b/internal/progress/progress.go
new file mode 100644
index 0000000..1e00836
--- /dev/null
+++ b/internal/progress/progress.go
@@ -0,0 +1,415 @@
+package progress
+
+// Forked from the original github.com/charmbracelet/bubbles/progress as
+// my PR for the improvement is in review
+
+import (
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/harmonica"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/lucasb-eyer/go-colorful"
+ "github.com/muesli/termenv"
+)
+
+// Internal ID management. Used during animating to assure that frame messages
+// can only be received by progress components that sent them.
+var lastID int64
+
+func nextID() int {
+ return int(atomic.AddInt64(&lastID, 1))
+}
+
+const (
+ fps = 60
+ defaultWidth = 40
+ defaultFrequency = 18.0
+ defaultDamping = 1.0
+)
+
+// FillStep is the only thing I added, you must refactor
+// to make use of it
+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(
+// WithRamp("#ff0000", "#0000ff"),
+// WithoutPercentage(),
+// )
+type Option func(*Model)
+
+// WithDefaultGradient sets a gradient fill with default colors.
+func WithDefaultGradient() Option {
+ return WithGradient("#5A56E0", "#EE6FF8")
+}
+
+// WithGradient sets a gradient fill blending between two colors.
+func WithGradient(colorA, colorB string) Option {
+ return func(m *Model) {
+ m.setRamp(colorA, colorB, false)
+ }
+}
+
+// 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)
+ }
+}
+
+// WithSolidFill sets the progress to use a solid fill with the given color.
+func WithSolidFill(color string) 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.
+func WithFillCharacters(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
+ }
+}
+
+// WithoutPercentage hides the numeric percentage.
+func WithoutPercentage() Option {
+ return func(m *Model) {
+ m.ShowPercentage = false
+ }
+}
+
+// WithWidth sets the initial width of the progress bar. Note that you can also
+// set the width via the Width property, which can come in handy if you're
+// waiting for a tea.WindowSizeMsg.
+func WithWidth(w int) Option {
+ return func(m *Model) {
+ m.Width = w
+ }
+}
+
+// WithSpringOptions sets the initial frequency and damping options for the
+// progress bar's built-in spring-based animation. Frequency corresponds to
+// speed, and damping to bounciness. For details see:
+//
+// https://github.com/charmbracelet/harmonica
+func WithSpringOptions(frequency, damping float64) Option {
+ return func(m *Model) {
+ m.SetSpringOptions(frequency, damping)
+ m.springCustomized = true
+ }
+}
+
+// WithColorProfile sets the color profile to use for the progress bar.
+func WithColorProfile(p termenv.Profile) Option {
+ return func(m *Model) {
+ m.colorProfile = p
+ }
+}
+
+// FrameMsg indicates that an animation step should occur.
+type FrameMsg struct {
+ id int
+ tag int
+}
+
+// Model stores values we'll use when rendering the progress bar.
+type Model struct {
+ // An identifier to keep us from receiving messages intended for other
+ // progress bars.
+ id int
+
+ // An identifier to keep us from receiving frame messages too quickly.
+ tag int
+
+ // Total width of the progress bar, including percentage, if set.
+ Width int
+
+ FillSteps []FillStep
+
+ // "Filled" sections of the progress bar.
+ FullColor string
+
+ EmptyColor string
+
+ // Settings for rendering the numeric percentage.
+ ShowPercentage bool
+ PercentFormat string // a fmt string for a float
+ PercentageStyle lipgloss.Style
+
+ // Members for animated transitions.
+ spring harmonica.Spring
+ springCustomized bool
+ percentShown float64 // percent currently displaying
+ targetPercent float64 // percent to which we're animating
+ velocity float64
+
+ // Gradient settings
+ useRamp bool
+ rampColorA colorful.Color
+ rampColorB colorful.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
+
+ // Color profile for the progress bar.
+ colorProfile termenv.Profile
+}
+
+// New returns a model with default values.
+func New(opts ...Option) Model {
+ m := Model{
+ id: nextID(),
+ Width: defaultWidth,
+ FillSteps: defaultFillSteps(),
+ FullColor: "#7571F9",
+ EmptyColor: "#606060",
+ ShowPercentage: true,
+ PercentFormat: " %3.0f%%",
+ colorProfile: termenv.ColorProfile(),
+ }
+
+ for _, opt := range opts {
+ opt(&m)
+ }
+
+ if !m.springCustomized {
+ m.SetSpringOptions(defaultFrequency, defaultDamping)
+ }
+
+ return m
+}
+
+// NewModel returns a model with default values.
+//
+// Deprecated: use [New] instead.
+var NewModel = New
+
+// Init exists to satisfy the tea.Model interface.
+func (m Model) Init() tea.Cmd {
+ return nil
+}
+
+// Update is used to animate the progress bar during transitions. Use
+// SetPercent to create the command you'll need to trigger the animation.
+//
+// If you're rendering with ViewAs you won't need this.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case FrameMsg:
+ if msg.id != m.id || msg.tag != m.tag {
+ return m, nil
+ }
+
+ // If we've more or less reached equilibrium, stop updating.
+ if !m.IsAnimating() {
+ return m, nil
+ }
+
+ m.percentShown, m.velocity = m.spring.Update(m.percentShown, m.velocity, m.targetPercent)
+ return m, m.nextFrame()
+
+ default:
+ return m, nil
+ }
+}
+
+// SetSpringOptions sets the frequency and damping for the current spring.
+// Frequency corresponds to speed, and damping to bounciness. For details see:
+//
+// https://github.com/charmbracelet/harmonica
+func (m *Model) SetSpringOptions(frequency, damping float64) {
+ m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
+}
+
+// Percent returns the current visible percentage on the model. This is only
+// relevant when you're animating the progress bar.
+//
+// If you're rendering with ViewAs you won't need this.
+func (m Model) Percent() float64 {
+ return m.targetPercent
+}
+
+// SetPercent sets the percentage state of the model as well as a command
+// necessary for animating the progress bar to this new percentage.
+//
+// If you're rendering with ViewAs you won't need this.
+func (m *Model) SetPercent(p float64) tea.Cmd {
+ m.targetPercent = math.Max(0, math.Min(1, p))
+ m.tag++
+ return m.nextFrame()
+}
+
+// IncrPercent increments the percentage by a given amount, returning a command
+// necessary to animate the progress bar to the new percentage.
+//
+// If you're rendering with ViewAs you won't need this.
+func (m *Model) IncrPercent(v float64) tea.Cmd {
+ return m.SetPercent(m.Percent() + v)
+}
+
+// DecrPercent decrements the percentage by a given amount, returning a command
+// necessary to animate the progress bar to the new percentage.
+//
+// If you're rendering with ViewAs you won't need this.
+func (m *Model) DecrPercent(v float64) tea.Cmd {
+ return m.SetPercent(m.Percent() - v)
+}
+
+// View renders an animated progress bar in its current state. To render
+// a static progress bar based on your own calculations use ViewAs instead.
+func (m Model) View() string {
+ return m.ViewAs(m.percentShown)
+}
+
+// ViewAs renders the progress bar with a given percentage.
+func (m Model) ViewAs(percent float64) string {
+ b := strings.Builder{}
+ percentView := m.percentageView(percent)
+ m.barView(&b, percent, ansi.StringWidth(percentView))
+ b.WriteString(percentView)
+ return b.String()
+}
+
+func (m *Model) nextFrame() tea.Cmd {
+ return tea.Tick(time.Second/time.Duration(fps), func(time.Time) tea.Msg {
+ return FrameMsg{id: m.id, tag: m.tag}
+ })
+}
+
+func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
+ var (
+ tw = max(0, m.Width-textWidth) // total width of the progress bar
+ fw = percent * float64(tw) // filled width in exact units
+ )
+
+ 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)
+ }
+ 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(),
+ )
+ }
+ }
+}
+
+// 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]
+}
+
+// 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 {
+ if !m.ShowPercentage {
+ return ""
+ }
+ percent = math.Max(0, math.Min(1, percent))
+ percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:mnd
+ percentage = m.PercentageStyle.Inline(true).Render(percentage)
+ 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
+}
+
+func (m Model) color(c string) termenv.Color {
+ return m.colorProfile.Color(c)
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+// 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)
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/slider/slider.go b/internal/slider/slider.go
index bfbc069..2d47249 100644
--- a/internal/slider/slider.go
+++ b/internal/slider/slider.go
@@ -3,8 +3,8 @@ package slider
import (
"fmt"
+ "github.com/ChausseBenjamin/termpicker/internal/progress"
"github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
)