| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447 |
- // Package anim provides an animated spinner.
- package anim
- import (
- "fmt"
- "image/color"
- "math/rand/v2"
- "strings"
- "sync/atomic"
- "time"
- "github.com/zeebo/xxh3"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/lucasb-eyer/go-colorful"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/tui/util"
- )
- const (
- fps = 20
- initialChar = '.'
- labelGap = " "
- labelGapWidth = 1
- // Periods of ellipsis animation speed in steps.
- //
- // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
- // change every 8 frames (400 milliseconds).
- ellipsisAnimSpeed = 8
- // The maximum amount of time that can pass before a character appears.
- // This is used to create a staggered entrance effect.
- maxBirthOffset = time.Second
- // Number of frames to prerender for the animation. After this number
- // of frames, the animation will loop. This only applies when color
- // cycling is disabled.
- prerenderedFrames = 10
- // Default number of cycling chars.
- defaultNumCyclingChars = 10
- )
- // Default colors for gradient.
- var (
- defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
- defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
- defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
- )
- var (
- availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
- ellipsisFrames = []string{".", "..", "...", ""}
- )
- // Internal ID management. Used during animating to ensure that frame messages
- // are received only by spinner components that sent them.
- var lastID int64
- func nextID() int {
- return int(atomic.AddInt64(&lastID, 1))
- }
- // Cache for expensive animation calculations
- type animCache struct {
- initialFrames [][]string
- cyclingFrames [][]string
- width int
- labelWidth int
- label []string
- ellipsisFrames []string
- }
- var animCacheMap = csync.NewMap[string, *animCache]()
- // settingsHash creates a hash key for the settings to use for caching
- func settingsHash(opts Settings) string {
- h := xxh3.New()
- fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
- opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
- return fmt.Sprintf("%x", h.Sum(nil))
- }
- // StepMsg is a message type used to trigger the next step in the animation.
- type StepMsg struct{ id int }
- // Settings defines settings for the animation.
- type Settings struct {
- Size int
- Label string
- LabelColor color.Color
- GradColorA color.Color
- GradColorB color.Color
- CycleColors bool
- }
- // Default settings.
- const ()
- // Anim is a Bubble for an animated spinner.
- type Anim struct {
- width int
- cyclingCharWidth int
- label *csync.Slice[string]
- labelWidth int
- labelColor color.Color
- startTime time.Time
- birthOffsets []time.Duration
- initialFrames [][]string // frames for the initial characters
- initialized atomic.Bool
- cyclingFrames [][]string // frames for the cycling characters
- step atomic.Int64 // current main frame step
- ellipsisStep atomic.Int64 // current ellipsis frame step
- ellipsisFrames *csync.Slice[string] // ellipsis animation frames
- id int
- }
- // New creates a new Anim instance with the specified width and label.
- func New(opts Settings) *Anim {
- a := &Anim{}
- // Validate settings.
- if opts.Size < 1 {
- opts.Size = defaultNumCyclingChars
- }
- if colorIsUnset(opts.GradColorA) {
- opts.GradColorA = defaultGradColorA
- }
- if colorIsUnset(opts.GradColorB) {
- opts.GradColorB = defaultGradColorB
- }
- if colorIsUnset(opts.LabelColor) {
- opts.LabelColor = defaultLabelColor
- }
- a.id = nextID()
- a.startTime = time.Now()
- a.cyclingCharWidth = opts.Size
- a.labelColor = opts.LabelColor
- // Check cache first
- cacheKey := settingsHash(opts)
- cached, exists := animCacheMap.Get(cacheKey)
- if exists {
- // Use cached values
- a.width = cached.width
- a.labelWidth = cached.labelWidth
- a.label = csync.NewSliceFrom(cached.label)
- a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
- a.initialFrames = cached.initialFrames
- a.cyclingFrames = cached.cyclingFrames
- } else {
- // Generate new values and cache them
- a.labelWidth = lipgloss.Width(opts.Label)
- // Total width of anim, in cells.
- a.width = opts.Size
- if opts.Label != "" {
- a.width += labelGapWidth + lipgloss.Width(opts.Label)
- }
- // Render the label
- a.renderLabel(opts.Label)
- // Pre-generate gradient.
- var ramp []color.Color
- numFrames := prerenderedFrames
- if opts.CycleColors {
- ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
- numFrames = a.width * 2
- } else {
- ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
- }
- // Pre-render initial characters.
- a.initialFrames = make([][]string, numFrames)
- offset := 0
- for i := range a.initialFrames {
- a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
- for j := range a.initialFrames[i] {
- if j+offset >= len(ramp) {
- continue // skip if we run out of colors
- }
- var c color.Color
- if j <= a.cyclingCharWidth {
- c = ramp[j+offset]
- } else {
- c = opts.LabelColor
- }
- // Also prerender the initial character with Lip Gloss to avoid
- // processing in the render loop.
- a.initialFrames[i][j] = lipgloss.NewStyle().
- Foreground(c).
- Render(string(initialChar))
- }
- if opts.CycleColors {
- offset++
- }
- }
- // Prerender scrambled rune frames for the animation.
- a.cyclingFrames = make([][]string, numFrames)
- offset = 0
- for i := range a.cyclingFrames {
- a.cyclingFrames[i] = make([]string, a.width)
- for j := range a.cyclingFrames[i] {
- if j+offset >= len(ramp) {
- continue // skip if we run out of colors
- }
- // Also prerender the color with Lip Gloss here to avoid processing
- // in the render loop.
- r := availableRunes[rand.IntN(len(availableRunes))]
- a.cyclingFrames[i][j] = lipgloss.NewStyle().
- Foreground(ramp[j+offset]).
- Render(string(r))
- }
- if opts.CycleColors {
- offset++
- }
- }
- // Cache the results
- labelSlice := make([]string, a.label.Len())
- for i, v := range a.label.Seq2() {
- labelSlice[i] = v
- }
- ellipsisSlice := make([]string, a.ellipsisFrames.Len())
- for i, v := range a.ellipsisFrames.Seq2() {
- ellipsisSlice[i] = v
- }
- cached = &animCache{
- initialFrames: a.initialFrames,
- cyclingFrames: a.cyclingFrames,
- width: a.width,
- labelWidth: a.labelWidth,
- label: labelSlice,
- ellipsisFrames: ellipsisSlice,
- }
- animCacheMap.Set(cacheKey, cached)
- }
- // Random assign a birth to each character for a stagged entrance effect.
- a.birthOffsets = make([]time.Duration, a.width)
- for i := range a.birthOffsets {
- a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
- }
- return a
- }
- // SetLabel updates the label text and re-renders it.
- func (a *Anim) SetLabel(newLabel string) {
- a.labelWidth = lipgloss.Width(newLabel)
- // Update total width
- a.width = a.cyclingCharWidth
- if newLabel != "" {
- a.width += labelGapWidth + a.labelWidth
- }
- // Re-render the label
- a.renderLabel(newLabel)
- }
- // renderLabel renders the label with the current label color.
- func (a *Anim) renderLabel(label string) {
- if a.labelWidth > 0 {
- // Pre-render the label.
- labelRunes := []rune(label)
- a.label = csync.NewSlice[string]()
- for i := range labelRunes {
- rendered := lipgloss.NewStyle().
- Foreground(a.labelColor).
- Render(string(labelRunes[i]))
- a.label.Append(rendered)
- }
- // Pre-render the ellipsis frames which come after the label.
- a.ellipsisFrames = csync.NewSlice[string]()
- for _, frame := range ellipsisFrames {
- rendered := lipgloss.NewStyle().
- Foreground(a.labelColor).
- Render(frame)
- a.ellipsisFrames.Append(rendered)
- }
- } else {
- a.label = csync.NewSlice[string]()
- a.ellipsisFrames = csync.NewSlice[string]()
- }
- }
- // Width returns the total width of the animation.
- func (a *Anim) Width() (w int) {
- w = a.width
- if a.labelWidth > 0 {
- w += labelGapWidth + a.labelWidth
- var widestEllipsisFrame int
- for _, f := range ellipsisFrames {
- fw := lipgloss.Width(f)
- if fw > widestEllipsisFrame {
- widestEllipsisFrame = fw
- }
- }
- w += widestEllipsisFrame
- }
- return w
- }
- // Init starts the animation.
- func (a *Anim) Init() tea.Cmd {
- return a.Step()
- }
- // Update processes animation steps (or not).
- func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case StepMsg:
- if msg.id != a.id {
- // Reject messages that are not for this instance.
- return a, nil
- }
- step := a.step.Add(1)
- if int(step) >= len(a.cyclingFrames) {
- a.step.Store(0)
- }
- if a.initialized.Load() && a.labelWidth > 0 {
- // Manage the ellipsis animation.
- ellipsisStep := a.ellipsisStep.Add(1)
- if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
- a.ellipsisStep.Store(0)
- }
- } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
- a.initialized.Store(true)
- }
- return a, a.Step()
- default:
- return a, nil
- }
- }
- // View renders the current state of the animation.
- func (a *Anim) View() string {
- var b strings.Builder
- step := int(a.step.Load())
- for i := range a.width {
- switch {
- case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
- // Birth offset not reached: render initial character.
- b.WriteString(a.initialFrames[step][i])
- case i < a.cyclingCharWidth:
- // Render a cycling character.
- b.WriteString(a.cyclingFrames[step][i])
- case i == a.cyclingCharWidth:
- // Render label gap.
- b.WriteString(labelGap)
- case i > a.cyclingCharWidth:
- // Label.
- if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
- b.WriteString(labelChar)
- }
- }
- }
- // Render animated ellipsis at the end of the label if all characters
- // have been initialized.
- if a.initialized.Load() && a.labelWidth > 0 {
- ellipsisStep := int(a.ellipsisStep.Load())
- if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
- b.WriteString(ellipsisFrame)
- }
- }
- return b.String()
- }
- // Step is a command that triggers the next step in the animation.
- func (a *Anim) Step() tea.Cmd {
- return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
- return StepMsg{id: a.id}
- })
- }
- // makeGradientRamp() returns a slice of colors blended between the given keys.
- // Blending is done as Hcl to stay in gamut.
- func makeGradientRamp(size int, stops ...color.Color) []color.Color {
- if len(stops) < 2 {
- return nil
- }
- points := make([]colorful.Color, len(stops))
- for i, k := range stops {
- points[i], _ = colorful.MakeColor(k)
- }
- numSegments := len(stops) - 1
- if numSegments == 0 {
- return nil
- }
- blended := make([]color.Color, 0, size)
- // Calculate how many colors each segment should have.
- segmentSizes := make([]int, numSegments)
- baseSize := size / numSegments
- remainder := size % numSegments
- // Distribute the remainder across segments.
- for i := range numSegments {
- segmentSizes[i] = baseSize
- if i < remainder {
- segmentSizes[i]++
- }
- }
- // Generate colors for each segment.
- for i := range numSegments {
- c1 := points[i]
- c2 := points[i+1]
- segmentSize := segmentSizes[i]
- for j := range segmentSize {
- if segmentSize == 0 {
- continue
- }
- t := float64(j) / float64(segmentSize)
- c := c1.BlendHcl(c2, t)
- blended = append(blended, c)
- }
- }
- return blended
- }
- func colorIsUnset(c color.Color) bool {
- if c == nil {
- return true
- }
- _, _, _, a := c.RGBA()
- return a == 0
- }
|