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 "github.com/charmbracelet/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}