diff options
-rw-r--r-- | go.mod | 8 | ||||
-rw-r--r-- | internal/picker/defaults.go | 2 | ||||
-rw-r--r-- | internal/progress/progress.go | 415 | ||||
-rw-r--r-- | internal/slider/slider.go | 2 |
4 files changed, 421 insertions, 6 deletions
@@ -6,25 +6,25 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.3 + github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/x/ansi v0.4.5 github.com/hashicorp/go-uuid v1.0.3 + github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/muesli/termenv v0.15.2 github.com/urfave/cli/v2 v2.27.5 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.4.5 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 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" ) |