anim.go

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