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() tea.View {
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 return tea.NewView(b.String())
288}
289
290// Step is a command that triggers the next step in the animation.
291func (a Anim) Step() tea.Cmd {
292 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
293 return StepMsg{id: a.id}
294 })
295}
296
297// makeGradientRamp() returns a slice of colors blended between the given keys.
298// Blending is done as Hcl to stay in gamut.
299func makeGradientRamp(size int, stops ...color.Color) []color.Color {
300 if len(stops) < 2 {
301 return nil
302 }
303
304 points := make([]colorful.Color, len(stops))
305 for i, k := range stops {
306 points[i], _ = colorful.MakeColor(k)
307 }
308
309 numSegments := len(stops) - 1
310 if numSegments == 0 {
311 return nil
312 }
313 blended := make([]color.Color, 0, size)
314
315 // Calculate how many colors each segment should have.
316 segmentSizes := make([]int, numSegments)
317 baseSize := size / numSegments
318 remainder := size % numSegments
319
320 // Distribute the remainder across segments.
321 for i := range numSegments {
322 segmentSizes[i] = baseSize
323 if i < remainder {
324 segmentSizes[i]++
325 }
326 }
327
328 // Generate colors for each segment.
329 for i := range numSegments {
330 c1 := points[i]
331 c2 := points[i+1]
332 segmentSize := segmentSizes[i]
333
334 for j := range segmentSize {
335 if segmentSize == 0 {
336 continue
337 }
338 t := float64(j) / float64(segmentSize)
339 c := c1.BlendHcl(c2, t)
340 blended = append(blended, c)
341 }
342 }
343
344 return blended
345}
346
347func colorIsUnset(c color.Color) bool {
348 if c == nil {
349 return true
350 }
351 _, _, _, a := c.RGBA()
352 return a == 0
353}