anim.go

  1// Package anim provides an animated spinner.
  2package anim
  3
  4import (
  5	"crypto/sha256"
  6	"fmt"
  7	"image/color"
  8	"math/rand/v2"
  9	"strings"
 10	"sync"
 11	"sync/atomic"
 12	"time"
 13
 14	tea "github.com/charmbracelet/bubbletea/v2"
 15	"github.com/charmbracelet/lipgloss/v2"
 16	"github.com/lucasb-eyer/go-colorful"
 17)
 18
 19const (
 20	fps           = 20
 21	initialChar   = '.'
 22	labelGap      = " "
 23	labelGapWidth = 1
 24
 25	// Periods of ellipsis animation speed in steps.
 26	//
 27	// If the FPS is 20 (50 milliseconds) this means that the ellipsis will
 28	// change every 8 frames (400 milliseconds).
 29	ellipsisAnimSpeed = 8
 30
 31	// The maximum amount of time that can pass before a character appears.
 32	// This is used to create a staggered entrance effect.
 33	maxBirthOffset = time.Second
 34
 35	// Number of frames to prerender for the animation. After this number
 36	// of frames, the animation will loop. This only applies when color
 37	// cycling is disabled.
 38	prerenderedFrames = 10
 39
 40	// Default number of cycling chars.
 41	defaultNumCyclingChars = 10
 42)
 43
 44// Default colors for gradient.
 45var (
 46	defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
 47	defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
 48	defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
 49)
 50
 51var (
 52	availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
 53	ellipsisFrames = []string{".", "..", "...", ""}
 54)
 55
 56// Internal ID management. Used during animating to ensure that frame messages
 57// are received only by spinner components that sent them.
 58var lastID int64
 59
 60func nextID() int {
 61	return int(atomic.AddInt64(&lastID, 1))
 62}
 63
 64// Cache for expensive animation calculations
 65type animCache struct {
 66	initialFrames  [][]string
 67	cyclingFrames  [][]string
 68	width          int
 69	labelWidth     int
 70	label          []string
 71	ellipsisFrames []string
 72}
 73
 74var (
 75	animCacheMutex sync.RWMutex
 76	animCacheMap   = make(map[string]*animCache)
 77)
 78
 79// settingsHash creates a hash key for the settings to use for caching
 80func settingsHash(opts Settings) string {
 81	h := sha256.New()
 82	fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
 83		opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
 84	return fmt.Sprintf("%x", h.Sum(nil))
 85}
 86
 87// StepMsg is a message type used to trigger the next step in the animation.
 88type StepMsg struct{ id int }
 89
 90// Settings defines settings for the animation.
 91type Settings struct {
 92	Size        int
 93	Label       string
 94	LabelColor  color.Color
 95	GradColorA  color.Color
 96	GradColorB  color.Color
 97	CycleColors bool
 98}
 99
100// Default settings.
101const ()
102
103// Anim is a Bubble for an animated spinner.
104type Anim struct {
105	width            int
106	cyclingCharWidth int
107	label            []string
108	labelWidth       int
109	labelColor       color.Color
110	startTime        time.Time
111	birthOffsets     []time.Duration
112	initialFrames    [][]string // frames for the initial characters
113	initialized      bool
114	cyclingFrames    [][]string // frames for the cycling characters
115	step             int        // current main frame step
116	ellipsisStep     int        // current ellipsis frame step
117	ellipsisFrames   []string   // ellipsis animation frames
118	id               int
119}
120
121// New creates a new Anim instance with the specified width and label.
122func New(opts Settings) (a Anim) {
123	// Validate settings.
124	if opts.Size < 1 {
125		opts.Size = defaultNumCyclingChars
126	}
127	if colorIsUnset(opts.GradColorA) {
128		opts.GradColorA = defaultGradColorA
129	}
130	if colorIsUnset(opts.GradColorB) {
131		opts.GradColorB = defaultGradColorB
132	}
133	if colorIsUnset(opts.LabelColor) {
134		opts.LabelColor = defaultLabelColor
135	}
136
137	a.id = nextID()
138	a.startTime = time.Now()
139	a.cyclingCharWidth = opts.Size
140	a.labelColor = opts.LabelColor
141
142	// Check cache first
143	cacheKey := settingsHash(opts)
144	animCacheMutex.RLock()
145	cached, exists := animCacheMap[cacheKey]
146	animCacheMutex.RUnlock()
147
148	if exists {
149		// Use cached values
150		a.width = cached.width
151		a.labelWidth = cached.labelWidth
152		a.label = cached.label
153		a.ellipsisFrames = cached.ellipsisFrames
154		a.initialFrames = cached.initialFrames
155		a.cyclingFrames = cached.cyclingFrames
156	} else {
157		// Generate new values and cache them
158		a.labelWidth = lipgloss.Width(opts.Label)
159
160		// Total width of anim, in cells.
161		a.width = opts.Size
162		if opts.Label != "" {
163			a.width += labelGapWidth + lipgloss.Width(opts.Label)
164		}
165
166		// Render the label
167		a.renderLabel(opts.Label)
168
169		// Pre-generate gradient.
170		var ramp []color.Color
171		numFrames := prerenderedFrames
172		if opts.CycleColors {
173			ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
174			numFrames = a.width * 2
175		} else {
176			ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
177		}
178
179		// Pre-render initial characters.
180		a.initialFrames = make([][]string, numFrames)
181		offset := 0
182		for i := range a.initialFrames {
183			a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
184			for j := range a.initialFrames[i] {
185				if j+offset >= len(ramp) {
186					continue // skip if we run out of colors
187				}
188
189				var c color.Color
190				if j <= a.cyclingCharWidth {
191					c = ramp[j+offset]
192				} else {
193					c = opts.LabelColor
194				}
195
196				// Also prerender the initial character with Lip Gloss to avoid
197				// processing in the render loop.
198				a.initialFrames[i][j] = lipgloss.NewStyle().
199					Foreground(c).
200					Render(string(initialChar))
201			}
202			if opts.CycleColors {
203				offset++
204			}
205		}
206
207		// Prerender scrambled rune frames for the animation.
208		a.cyclingFrames = make([][]string, numFrames)
209		offset = 0
210		for i := range a.cyclingFrames {
211			a.cyclingFrames[i] = make([]string, a.width)
212			for j := range a.cyclingFrames[i] {
213				if j+offset >= len(ramp) {
214					continue // skip if we run out of colors
215				}
216
217				// Also prerender the color with Lip Gloss here to avoid processing
218				// in the render loop.
219				r := availableRunes[rand.IntN(len(availableRunes))]
220				a.cyclingFrames[i][j] = lipgloss.NewStyle().
221					Foreground(ramp[j+offset]).
222					Render(string(r))
223			}
224			if opts.CycleColors {
225				offset++
226			}
227		}
228
229		// Cache the results
230		cached = &animCache{
231			initialFrames:  a.initialFrames,
232			cyclingFrames:  a.cyclingFrames,
233			width:          a.width,
234			labelWidth:     a.labelWidth,
235			label:          a.label,
236			ellipsisFrames: a.ellipsisFrames,
237		}
238		animCacheMutex.Lock()
239		animCacheMap[cacheKey] = cached
240		animCacheMutex.Unlock()
241	}
242
243	// Random assign a birth to each character for a stagged entrance effect.
244	a.birthOffsets = make([]time.Duration, a.width)
245	for i := range a.birthOffsets {
246		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
247	}
248
249	return a
250}
251
252// SetLabel updates the label text and re-renders it.
253func (a *Anim) SetLabel(newLabel string) {
254	a.labelWidth = lipgloss.Width(newLabel)
255
256	// Update total width
257	a.width = a.cyclingCharWidth
258	if newLabel != "" {
259		a.width += labelGapWidth + a.labelWidth
260	}
261
262	// Re-render the label
263	a.renderLabel(newLabel)
264}
265
266// renderLabel renders the label with the current label color.
267func (a *Anim) renderLabel(label string) {
268	if a.labelWidth > 0 {
269		// Pre-render the label.
270		labelRunes := []rune(label)
271		a.label = make([]string, len(labelRunes))
272		for i := range a.label {
273			a.label[i] = lipgloss.NewStyle().
274				Foreground(a.labelColor).
275				Render(string(labelRunes[i]))
276		}
277
278		// Pre-render the ellipsis frames which come after the label.
279		a.ellipsisFrames = make([]string, len(ellipsisFrames))
280		for i, frame := range ellipsisFrames {
281			a.ellipsisFrames[i] = lipgloss.NewStyle().
282				Foreground(a.labelColor).
283				Render(frame)
284		}
285	} else {
286		a.label = nil
287		a.ellipsisFrames = nil
288	}
289}
290
291// Width returns the total width of the animation.
292func (a Anim) Width() (w int) {
293	w = a.width
294	if a.labelWidth > 0 {
295		w += labelGapWidth + a.labelWidth
296
297		var widestEllipsisFrame int
298		for _, f := range ellipsisFrames {
299			fw := lipgloss.Width(f)
300			if fw > widestEllipsisFrame {
301				widestEllipsisFrame = fw
302			}
303		}
304		w += widestEllipsisFrame
305	}
306	return w
307}
308
309// Init starts the animation.
310func (a Anim) Init() tea.Cmd {
311	return a.Step()
312}
313
314// Update processes animation steps (or not).
315func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
316	switch msg := msg.(type) {
317	case StepMsg:
318		if msg.id != a.id {
319			// Reject messages that are not for this instance.
320			return a, nil
321		}
322
323		a.step++
324		if a.step >= len(a.cyclingFrames) {
325			a.step = 0
326		}
327
328		if a.initialized && a.labelWidth > 0 {
329			// Manage the ellipsis animation.
330			a.ellipsisStep++
331			if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
332				a.ellipsisStep = 0
333			}
334		} else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
335			a.initialized = true
336		}
337		return a, a.Step()
338	default:
339		return a, nil
340	}
341}
342
343// View renders the current state of the animation.
344func (a Anim) View() string {
345	var b strings.Builder
346	for i := range a.width {
347		switch {
348		case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
349			// Birth offset not reached: render initial character.
350			b.WriteString(a.initialFrames[a.step][i])
351		case i < a.cyclingCharWidth:
352			// Render a cycling character.
353			b.WriteString(a.cyclingFrames[a.step][i])
354		case i == a.cyclingCharWidth:
355			// Render label gap.
356			b.WriteString(labelGap)
357		case i > a.cyclingCharWidth:
358			// Label.
359			b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
360		}
361	}
362	// Render animated ellipsis at the end of the label if all characters
363	// have been initialized.
364	if a.initialized && a.labelWidth > 0 {
365		b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
366	}
367
368	return b.String()
369}
370
371// Step is a command that triggers the next step in the animation.
372func (a Anim) Step() tea.Cmd {
373	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
374		return StepMsg{id: a.id}
375	})
376}
377
378// makeGradientRamp() returns a slice of colors blended between the given keys.
379// Blending is done as Hcl to stay in gamut.
380func makeGradientRamp(size int, stops ...color.Color) []color.Color {
381	if len(stops) < 2 {
382		return nil
383	}
384
385	points := make([]colorful.Color, len(stops))
386	for i, k := range stops {
387		points[i], _ = colorful.MakeColor(k)
388	}
389
390	numSegments := len(stops) - 1
391	if numSegments == 0 {
392		return nil
393	}
394	blended := make([]color.Color, 0, size)
395
396	// Calculate how many colors each segment should have.
397	segmentSizes := make([]int, numSegments)
398	baseSize := size / numSegments
399	remainder := size % numSegments
400
401	// Distribute the remainder across segments.
402	for i := range numSegments {
403		segmentSizes[i] = baseSize
404		if i < remainder {
405			segmentSizes[i]++
406		}
407	}
408
409	// Generate colors for each segment.
410	for i := range numSegments {
411		c1 := points[i]
412		c2 := points[i+1]
413		segmentSize := segmentSizes[i]
414
415		for j := range segmentSize {
416			if segmentSize == 0 {
417				continue
418			}
419			t := float64(j) / float64(segmentSize)
420			c := c1.BlendHcl(c2, t)
421			blended = append(blended, c)
422		}
423	}
424
425	return blended
426}
427
428func colorIsUnset(c color.Color) bool {
429	if c == nil {
430		return true
431	}
432	_, _, _, a := c.RGBA()
433	return a == 0
434}