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 tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/lucasb-eyer/go-colorful"
16)
17
18const (
19 fps = 20
20 initialChar = '.'
21 labelGap = " "
22 labelGapWidth = 1
23
24 // Periods of ellipsis animation speed in steps.
25 //
26 // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
27 // change every 8 frames (400 milliseconds).
28 ellipsisAnimSpeed = 8
29
30 // The maximum amount of time that can pass before a character appears.
31 // This is used to create a staggered entrance effect.
32 maxBirthOffset = time.Second
33
34 // Number of frames to prerender for the animation. After this number
35 // of frames, the animation will loop.
36 prerenderedFrames = 10
37)
38
39var (
40 availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
41 ellipsisFrames = []string{".", "..", "...", ""}
42)
43
44// Internal ID management. Used during animating to ensure that frame messages
45// are received only by spinner components that sent them.
46var lastID int64
47
48func nextID() int {
49 return int(atomic.AddInt64(&lastID, 1))
50}
51
52// StepMsg is a message type used to trigger the next step in the animation.
53type StepMsg struct{ id int }
54
55// Anim is a Bubble for an animated spinner.
56type Anim struct {
57 width int
58 cyclingCharWidth int
59 label []string
60 labelWidth int
61 startTime time.Time
62 birthOffsets []time.Duration
63 initialChars []string
64 initialized bool
65 cyclingFrames [][]string // frames for the cycling characters
66 step int // current main frame step
67 ellipsisStep int // current ellipsis frame step
68 ellipsisFrames []string // ellipsis animation frames
69 id int
70}
71
72// New creates a new Anim instance with the specified width and label.
73func New(numChars int, label string, t *styles.Theme) (a Anim) {
74 a.id = nextID()
75
76 a.startTime = time.Now()
77 a.cyclingCharWidth = numChars
78 a.labelWidth = lipgloss.Width(label)
79
80 // Total width of anim, in cells.
81 a.width = numChars
82 if label != "" {
83 a.width += labelGapWidth + lipgloss.Width(label)
84 }
85
86 if a.labelWidth > 0 {
87 // Pre-render the label.
88 // XXX: We should really get the graphemes for the label, not the runes.
89 labelRunes := []rune(label)
90 a.label = make([]string, len(labelRunes))
91 for i := range a.label {
92 a.label[i] = lipgloss.NewStyle().
93 Foreground(t.FgBase).
94 Render(string(labelRunes[i]))
95 }
96
97 // Pre-render the ellipsis frames which come after the label.
98 a.ellipsisFrames = make([]string, len(ellipsisFrames))
99 for i, frame := range ellipsisFrames {
100 a.ellipsisFrames[i] = lipgloss.NewStyle().
101 Foreground(t.FgBase).
102 Render(frame)
103 }
104 }
105
106 // Pre-generate gradient.
107 ramp := makeGradientRamp(a.width, t.Primary, t.Secondary)
108
109 // Pre-render initial characters.
110 a.initialChars = make([]string, a.width)
111 for i := range a.initialChars {
112 var c color.Color
113 if i <= a.cyclingCharWidth {
114 c = ramp[i]
115 } else {
116 c = t.FgBase
117 }
118 a.initialChars[i] = lipgloss.NewStyle().
119 Foreground(c).
120 Render(string(initialChar))
121 }
122
123 // Prerender scrambled rune frames for the animation.
124 a.cyclingFrames = make([][]string, prerenderedFrames)
125 for i := range a.cyclingFrames {
126 a.cyclingFrames[i] = make([]string, a.width)
127 for j := range a.cyclingFrames[i] {
128 // NB: we also prerender the color with Lip Gloss here to avoid
129 // processing in the render loop.
130 r := availableRunes[rand.IntN(len(availableRunes))]
131 a.cyclingFrames[i][j] = lipgloss.NewStyle().
132 Foreground(ramp[j]).
133 Render(string(r))
134 }
135 }
136
137 // Random assign a birth to each character for a stagged entrance effect.
138 a.birthOffsets = make([]time.Duration, a.width)
139 for i := range a.birthOffsets {
140 a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
141 }
142
143 return a
144}
145
146// Init starts the animation.
147func (a Anim) Init() tea.Cmd {
148 return a.Step()
149}
150
151// Update processes animation steps (or not).
152func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153 switch msg := msg.(type) {
154 case StepMsg:
155 if msg.id != a.id {
156 // Reject messages that are not for this instance.
157 return a, nil
158 }
159
160 a.step++
161 if a.step >= len(a.cyclingFrames) {
162 a.step = 0
163 }
164
165 if a.initialized && a.labelWidth > 0 {
166 // Manage the ellipsis animation.
167 a.ellipsisStep++
168 if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
169 a.ellipsisStep = 0
170 }
171 } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
172 a.initialized = true
173 }
174 return a, a.Step()
175 default:
176 return a, nil
177 }
178}
179
180// View renders the current state of the animation.
181func (a Anim) View() string {
182 var b strings.Builder
183 for i := range a.width {
184 switch {
185 case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
186 // Birth offset not reached: render initial character.
187 b.WriteString(a.initialChars[i])
188 case i < a.cyclingCharWidth:
189 // Render a cycling character.
190 b.WriteString(a.cyclingFrames[a.step][i])
191 case i == a.cyclingCharWidth:
192 // Render label gap.
193 b.WriteString(labelGap)
194 case i > a.cyclingCharWidth:
195 // Label.
196 b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
197 }
198 }
199 // Render animated ellipsis at the end of the label if all characters
200 // have been initialized.
201 if a.initialized && a.labelWidth > 0 {
202 b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
203 }
204
205 return b.String()
206}
207
208// Step is a command that triggers the next step in the animation.
209func (a Anim) Step() tea.Cmd {
210 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
211 return StepMsg{id: a.id}
212 })
213}
214
215func colorToHex(c color.Color) string {
216 r, g, b, _ := c.RGBA()
217 return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
218}
219
220func makeGradientRamp(length int, from, to color.Color) []color.Color {
221 startColor := colorToHex(from)
222 endColor := colorToHex(to)
223 var (
224 c = make([]color.Color, length)
225 start, _ = colorful.Hex(startColor)
226 end, _ = colorful.Hex(endColor)
227 )
228 for i := range length {
229 step := start.BlendLuv(end, float64(i)/float64(length))
230 c[i] = lipgloss.Color(step.Hex())
231 }
232 return c
233}