on main help sign in
chore(ui): make spinner deterministic for testing
30f9c6eChristian Rocha
internal/ui/anim/anim.go+38−16
@@ -30,9 +30,14 @@ const (
30 // change every 8 frames (400 milliseconds).
30 // change every 8 frames (400 milliseconds).
31 ellipsisAnimSpeed = 8
31 ellipsisAnimSpeed = 8
32
32
33− // The maximum amount of time that can pass before a character appears.
33+ // The maximum number of animation steps that can pass before a
34− // This is used to create a staggered entrance effect.
34+ // character appears. With fps == 20 this is ~1s of staggered
35− maxBirthOffset = time.Second
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
36
41
37 // Number of frames to prerender for the animation. After this number
42 // Number of frames to prerender for the animation. After this number
38 // of frames, the animation will loop. This only applies when color
43 // of frames, the animation will loop. This only applies when color
@@ -107,12 +112,12 @@ type Anim struct {
107 label *csync.Slice[string]
112 label *csync.Slice[string]
108 labelWidth int
113 labelWidth int
109 labelColor color.Color
114 labelColor color.Color
110− startTime time.Time
115+ birthSteps []int
111− birthOffsets []time.Duration
112 initialFrames [][]string // frames for the initial characters
116 initialFrames [][]string // frames for the initial characters
113 initialized atomic.Bool
117 initialized atomic.Bool
114 cyclingFrames [][]string // frames for the cycling characters
118 cyclingFrames [][]string // frames for the cycling characters
115− step atomic.Int64 // current main frame step
119+ step atomic.Int64 // current main frame step (wraps)
120+ framesSinceStart atomic.Int64 // total Animate ticks (does not wrap)
116 ellipsisStep atomic.Int64 // current ellipsis frame step
121 ellipsisStep atomic.Int64 // current ellipsis frame step
117 ellipsisFrames *csync.Slice[string] // ellipsis animation frames
122 ellipsisFrames *csync.Slice[string] // ellipsis animation frames
118 id string
123 id string
@@ -140,7 +145,6 @@ func New(opts Settings) *Anim {
140 } else {
145 } else {
141 a.id = fmt.Sprintf("%d", nextID())
146 a.id = fmt.Sprintf("%d", nextID())
142 }
147 }
143− a.startTime = time.Now()
144 a.cyclingCharWidth = opts.Size
148 a.cyclingCharWidth = opts.Size
145 a.labelColor = opts.LabelColor
149 a.labelColor = opts.LabelColor
146
150
@@ -207,7 +211,13 @@ func New(opts Settings) *Anim {
207 }
211 }
208 }
212 }
209
213
210− // Prerender scrambled rune frames for the animation.
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))
211 a.cyclingFrames = make([][]string, numFrames)
221 a.cyclingFrames = make([][]string, numFrames)
212 offset = 0
222 offset = 0
213 for i := range a.cyclingFrames {
223 for i := range a.cyclingFrames {
@@ -219,7 +229,7 @@ func New(opts Settings) *Anim {
219
229
220 // Also prerender the color with Lip Gloss here to avoid processing
230 // Also prerender the color with Lip Gloss here to avoid processing
221 // in the render loop.
231 // in the render loop.
222− r := availableRunes[rand.IntN(len(availableRunes))]
232+ r := availableRunes[rng.IntN(len(availableRunes))]
223 a.cyclingFrames[i][j] = lipgloss.NewStyle().
233 a.cyclingFrames[i][j] = lipgloss.NewStyle().
224 Foreground(ramp[j+offset]).
234 Foreground(ramp[j+offset]).
225 Render(string(r))
235 Render(string(r))
@@ -249,10 +259,20 @@ func New(opts Settings) *Anim {
249 animCacheMap.Set(cacheKey, cached)
259 animCacheMap.Set(cacheKey, cached)
250 }
260 }
251
261
252− // Random assign a birth to each character for a stagged entrance effect.
262+ // Assign a deterministic birth step to each column for a
253− a.birthOffsets = make([]time.Duration, a.width)
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)
254− for i := range a.birthOffsets {
274+ for i := range a.birthSteps {
255− a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
275+ a.birthSteps[i] = birthRng.IntN(maxBirthSteps)
256 }
276 }
257
277
258 return a
278 return a
@@ -333,13 +353,14 @@ func (a *Anim) Animate(msg StepMsg) tea.Cmd {
333 a.step.Store(0)
353 a.step.Store(0)
334 }
354 }
335
355
356+ frames := a.framesSinceStart.Add(1)
336 if a.initialized.Load() && a.labelWidth > 0 {
357 if a.initialized.Load() && a.labelWidth > 0 {
337 // Manage the ellipsis animation.
358 // Manage the ellipsis animation.
338 ellipsisStep := a.ellipsisStep.Add(1)
359 ellipsisStep := a.ellipsisStep.Add(1)
339 if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
360 if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
340 a.ellipsisStep.Store(0)
361 a.ellipsisStep.Store(0)
341 }
362 }
342− } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
363+ } else if !a.initialized.Load() && int(frames) >= maxBirthSteps {
343 a.initialized.Store(true)
364 a.initialized.Store(true)
344 }
365 }
345 return a.Step()
366 return a.Step()
@@ -349,10 +370,11 @@ func (a *Anim) Animate(msg StepMsg) tea.Cmd {
349func (a *Anim) Render() string {
370func (a *Anim) Render() string {
350 var b strings.Builder
371 var b strings.Builder
351 step := int(a.step.Load())
372 step := int(a.step.Load())
373+ frames := int(a.framesSinceStart.Load())
352 for i := range a.width {
374 for i := range a.width {
353 switch {
375 switch {
354− case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
376+ case !a.initialized.Load() && i < len(a.birthSteps) && frames < a.birthSteps[i]:
355− // Birth offset not reached: render initial character.
377+ // Birth step not reached: render initial character.
356 b.WriteString(a.initialFrames[step][i])
378 b.WriteString(a.initialFrames[step][i])
357 case i < a.cyclingCharWidth:
379 case i < a.cyclingCharWidth:
358 // Render a cycling character.
380 // Render a cycling character.