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