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