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	startTime        time.Time
 84	birthOffsets     []time.Duration
 85	initialFrames    [][]string // frames for the initial characters
 86	initialized      bool
 87	cyclingFrames    [][]string // frames for the cycling characters
 88	step             int        // current main frame step
 89	ellipsisStep     int        // current ellipsis frame step
 90	ellipsisFrames   []string   // ellipsis animation frames
 91	id               int
 92}
 93
 94// New creates a new Anim instance with the specified width and label.
 95func New(opts Settings) (a Anim) {
 96	// Validate settings.
 97	if opts.Size < 1 {
 98		opts.Size = defaultNumCyclingChars
 99	}
100	if colorIsUnset(opts.GradColorA) {
101		opts.GradColorA = defaultGradColorA
102	}
103	if colorIsUnset(opts.GradColorB) {
104		opts.GradColorB = defaultGradColorB
105	}
106	if colorIsUnset(opts.LabelColor) {
107		opts.LabelColor = defaultLabelColor
108	}
109
110	a.id = nextID()
111
112	a.startTime = time.Now()
113	a.cyclingCharWidth = opts.Size
114	a.labelWidth = lipgloss.Width(opts.Label)
115
116	// Total width of anim, in cells.
117	a.width = opts.Size
118	if opts.Label != "" {
119		a.width += labelGapWidth + lipgloss.Width(opts.Label)
120	}
121
122	if a.labelWidth > 0 {
123		// Pre-render the label.
124		// XXX: We should really get the graphemes for the label, not the runes.
125		labelRunes := []rune(opts.Label)
126		a.label = make([]string, len(labelRunes))
127		for i := range a.label {
128			a.label[i] = lipgloss.NewStyle().
129				Foreground(opts.LabelColor).
130				Render(string(labelRunes[i]))
131		}
132
133		// Pre-render the ellipsis frames which come after the label.
134		a.ellipsisFrames = make([]string, len(ellipsisFrames))
135		for i, frame := range ellipsisFrames {
136			a.ellipsisFrames[i] = lipgloss.NewStyle().
137				Foreground(opts.LabelColor).
138				Render(frame)
139		}
140	}
141
142	// Pre-generate gradient.
143	var ramp []color.Color
144	numFrames := prerenderedFrames
145	if opts.CycleColors {
146		ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
147		numFrames = a.width * 2
148	} else {
149		ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
150	}
151
152	// Pre-render initial characters.
153	a.initialFrames = make([][]string, numFrames)
154	offset := 0
155	for i := range a.initialFrames {
156		a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
157		for j := range a.initialFrames[i] {
158			if j+offset >= len(ramp) {
159				continue // skip if we run out of colors
160			}
161
162			var c color.Color
163			if j <= a.cyclingCharWidth {
164				c = ramp[j+offset]
165			} else {
166				c = opts.LabelColor
167			}
168
169			// Also prerender the initial character with Lip Gloss to avoid
170			// processing in the render loop.
171			a.initialFrames[i][j] = lipgloss.NewStyle().
172				Foreground(c).
173				Render(string(initialChar))
174		}
175		if opts.CycleColors {
176			offset++
177		}
178	}
179
180	// Prerender scrambled rune frames for the animation.
181	a.cyclingFrames = make([][]string, numFrames)
182	offset = 0
183	for i := range a.cyclingFrames {
184		a.cyclingFrames[i] = make([]string, a.width)
185		for j := range a.cyclingFrames[i] {
186			if j+offset >= len(ramp) {
187				continue // skip if we run out of colors
188			}
189
190			// Also prerender the color with Lip Gloss here to avoid processing
191			// in the render loop.
192			r := availableRunes[rand.IntN(len(availableRunes))]
193			a.cyclingFrames[i][j] = lipgloss.NewStyle().
194				Foreground(ramp[j+offset]).
195				Render(string(r))
196		}
197		if opts.CycleColors {
198			offset++
199		}
200	}
201
202	// Random assign a birth to each character for a stagged entrance effect.
203	a.birthOffsets = make([]time.Duration, a.width)
204	for i := range a.birthOffsets {
205		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
206	}
207
208	return a
209}
210
211// Width returns the total width of the animation.
212func (a Anim) Width() (w int) {
213	w = a.width
214	if a.labelWidth > 0 {
215		w += labelGapWidth + a.labelWidth
216
217		var widestEllipsisFrame int
218		for _, f := range ellipsisFrames {
219			fw := lipgloss.Width(f)
220			if fw > widestEllipsisFrame {
221				widestEllipsisFrame = fw
222			}
223		}
224		w += widestEllipsisFrame
225	}
226	return w
227}
228
229// Init starts the animation.
230func (a Anim) Init() tea.Cmd {
231	return a.Step()
232}
233
234// Update processes animation steps (or not).
235func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
236	switch msg := msg.(type) {
237	case StepMsg:
238		if msg.id != a.id {
239			// Reject messages that are not for this instance.
240			return a, nil
241		}
242
243		a.step++
244		if a.step >= len(a.cyclingFrames) {
245			a.step = 0
246		}
247
248		if a.initialized && a.labelWidth > 0 {
249			// Manage the ellipsis animation.
250			a.ellipsisStep++
251			if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
252				a.ellipsisStep = 0
253			}
254		} else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
255			a.initialized = true
256		}
257		return a, a.Step()
258	default:
259		return a, nil
260	}
261}
262
263// View renders the current state of the animation.
264func (a Anim) View() string {
265	var b strings.Builder
266	for i := range a.width {
267		switch {
268		case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
269			// Birth offset not reached: render initial character.
270			b.WriteString(a.initialFrames[a.step][i])
271		case i < a.cyclingCharWidth:
272			// Render a cycling character.
273			b.WriteString(a.cyclingFrames[a.step][i])
274		case i == a.cyclingCharWidth:
275			// Render label gap.
276			b.WriteString(labelGap)
277		case i > a.cyclingCharWidth:
278			// Label.
279			b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
280		}
281	}
282	// Render animated ellipsis at the end of the label if all characters
283	// have been initialized.
284	if a.initialized && a.labelWidth > 0 {
285		b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
286	}
287
288	return b.String()
289}
290
291// Step is a command that triggers the next step in the animation.
292func (a Anim) Step() tea.Cmd {
293	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
294		return StepMsg{id: a.id}
295	})
296}
297
298// makeGradientRamp() returns a slice of colors blended between the given keys.
299// Blending is done as Hcl to stay in gamut.
300func makeGradientRamp(size int, stops ...color.Color) []color.Color {
301	if len(stops) < 2 {
302		return nil
303	}
304
305	points := make([]colorful.Color, len(stops))
306	for i, k := range stops {
307		points[i], _ = colorful.MakeColor(k)
308	}
309
310	numSegments := len(stops) - 1
311	if numSegments == 0 {
312		return nil
313	}
314	blended := make([]color.Color, 0, size)
315
316	// Calculate how many colors each segment should have.
317	segmentSizes := make([]int, numSegments)
318	baseSize := size / numSegments
319	remainder := size % numSegments
320
321	// Distribute the remainder across segments.
322	for i := range numSegments {
323		segmentSizes[i] = baseSize
324		if i < remainder {
325			segmentSizes[i]++
326		}
327	}
328
329	// Generate colors for each segment.
330	for i := range numSegments {
331		c1 := points[i]
332		c2 := points[i+1]
333		segmentSize := segmentSizes[i]
334
335		for j := range segmentSize {
336			if segmentSize == 0 {
337				continue
338			}
339			t := float64(j) / float64(segmentSize)
340			c := c1.BlendHcl(c2, t)
341			blended = append(blended, c)
342		}
343	}
344
345	return blended
346}
347
348func colorIsUnset(c color.Color) bool {
349	if c == nil {
350		return true
351	}
352	_, _, _, a := c.RGBA()
353	return a == 0
354}