1package anim
2
3import (
4 "fmt"
5 "image/color"
6 "math/rand/v2"
7 "strings"
8 "time"
9
10 "github.com/charmbracelet/bubbles/v2/spinner"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/google/uuid"
16 "github.com/lucasb-eyer/go-colorful"
17)
18
19const (
20 charCyclingFPS = time.Second / 8 // 8 FPS for better CPU efficiency
21 colorCycleFPS = time.Second / 3 // 3 FPS
22 maxCyclingChars = 60 // 60 characters
23)
24
25var (
26 charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
27 charRunePool = make([]rune, 1000) // Pre-generated pool of random characters
28 poolIndex = 0
29)
30
31func init() {
32 // Pre-populate the character pool to avoid runtime random generation
33 for i := range charRunePool {
34 charRunePool[i] = charRunes[rand.IntN(len(charRunes))]
35 }
36}
37
38type charState int
39
40const (
41 charInitialState charState = iota
42 charCyclingState
43 charEndOfLifeState
44)
45
46// cyclingChar is a single animated character.
47type cyclingChar struct {
48 finalValue rune // if < 0 cycle forever
49 currentValue rune
50 initialDelay time.Duration
51 lifetime time.Duration
52}
53
54func (c cyclingChar) randomRune() rune {
55 // Use pre-generated pool instead of runtime random generation
56 poolIndex = (poolIndex + 1) % len(charRunePool)
57 return charRunePool[poolIndex]
58}
59
60func (c cyclingChar) state(start time.Time) charState {
61 now := time.Now()
62 if now.Before(start.Add(c.initialDelay)) {
63 return charInitialState
64 }
65 if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) {
66 return charEndOfLifeState
67 }
68 return charCyclingState
69}
70
71type StepCharsMsg struct {
72 id string
73}
74
75func stepChars(id string) tea.Cmd {
76 return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
77 return StepCharsMsg{id}
78 })
79}
80
81type ColorCycleMsg struct {
82 id string
83}
84
85func cycleColors(id string) tea.Cmd {
86 return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
87 return ColorCycleMsg{id}
88 })
89}
90
91type Animation interface {
92 util.Model
93 ID() string
94}
95
96// anim is the model that manages the animation that displays while the
97// output is being generated.
98type anim struct {
99 start time.Time
100 cyclingChars []cyclingChar
101 labelChars []cyclingChar
102 ramp []lipgloss.Style
103 label []rune
104 ellipsis spinner.Model
105 ellipsisStarted bool
106 id string
107}
108
109type animOption func(*anim)
110
111func WithId(id string) animOption {
112 return func(a *anim) {
113 a.id = id
114 }
115}
116
117func New(cyclingCharsSize uint, label string, opts ...animOption) Animation {
118 // #nosec G115
119 n := min(int(cyclingCharsSize), maxCyclingChars)
120
121 gap := " "
122 if n == 0 {
123 gap = ""
124 }
125
126 id := uuid.New()
127 c := anim{
128 start: time.Now(),
129 label: []rune(gap + label),
130 ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
131 id: id.String(),
132 }
133
134 for _, opt := range opts {
135 opt(&c)
136 }
137
138 // If we're in truecolor mode (and there are enough cycling characters)
139 // color the cycling characters with a gradient ramp.
140 const minRampSize = 3
141 if n >= minRampSize {
142 // Optimized: single capacity allocation for color cycling
143 c.ramp = make([]lipgloss.Style, 0, n*2)
144 ramp := makeGradientRamp(n)
145 for _, color := range ramp {
146 c.ramp = append(c.ramp, lipgloss.NewStyle().Foreground(color))
147 }
148 // Create reversed copy for seamless color cycling
149 reversed := make([]lipgloss.Style, len(c.ramp))
150 for i, style := range c.ramp {
151 reversed[len(c.ramp)-1-i] = style
152 }
153 c.ramp = append(c.ramp, reversed...)
154 }
155
156 makeDelay := func(a int32, b time.Duration) time.Duration {
157 return time.Duration(rand.Int32N(a)) * (time.Millisecond * b) //nolint:gosec
158 }
159
160 makeInitialDelay := func() time.Duration {
161 return makeDelay(8, 60) //nolint:mnd
162 }
163
164 // Initial characters that cycle forever.
165 c.cyclingChars = make([]cyclingChar, n)
166
167 for i := range n {
168 c.cyclingChars[i] = cyclingChar{
169 finalValue: -1, // cycle forever
170 initialDelay: makeInitialDelay(),
171 }
172 }
173
174 // Label text that only cycles for a little while.
175 c.labelChars = make([]cyclingChar, len(c.label))
176
177 for i, r := range c.label {
178 c.labelChars[i] = cyclingChar{
179 finalValue: r,
180 initialDelay: makeInitialDelay(),
181 lifetime: makeDelay(5, 180), //nolint:mnd
182 }
183 }
184
185 return c
186}
187
188// Init initializes the animation.
189func (a anim) Init() tea.Cmd {
190 return tea.Batch(stepChars(a.id), cycleColors(a.id))
191}
192
193// Update handles messages.
194func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195 var cmd tea.Cmd
196 switch msg := msg.(type) {
197 case StepCharsMsg:
198 if msg.id != a.id {
199 return a, nil
200 }
201 a.updateChars(&a.cyclingChars)
202 a.updateChars(&a.labelChars)
203
204 if !a.ellipsisStarted {
205 var eol int
206 for _, c := range a.labelChars {
207 if c.state(a.start) == charEndOfLifeState {
208 eol++
209 }
210 }
211 if eol == len(a.label) {
212 // If our entire label has reached end of life, start the
213 // ellipsis "spinner" after a short pause.
214 a.ellipsisStarted = true
215 cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd
216 return a.ellipsis.Tick()
217 })
218 }
219 }
220
221 return a, tea.Batch(stepChars(a.id), cmd)
222 case ColorCycleMsg:
223 if msg.id != a.id {
224 return a, nil
225 }
226 const minColorCycleSize = 2
227 if len(a.ramp) < minColorCycleSize {
228 return a, nil
229 }
230 a.ramp = append(a.ramp[1:], a.ramp[0])
231 return a, cycleColors(a.id)
232 case spinner.TickMsg:
233 var cmd tea.Cmd
234 a.ellipsis, cmd = a.ellipsis.Update(msg)
235 return a, cmd
236 default:
237 return a, nil
238 }
239}
240
241func (a anim) ID() string {
242 return a.id
243}
244
245func (a *anim) updateChars(chars *[]cyclingChar) {
246 charSlice := *chars // dereference to avoid repeated pointer access
247 for i, c := range charSlice {
248 switch c.state(a.start) {
249 case charInitialState:
250 charSlice[i].currentValue = '.'
251 case charCyclingState:
252 charSlice[i].currentValue = c.randomRune()
253 case charEndOfLifeState:
254 charSlice[i].currentValue = c.finalValue
255 }
256 }
257}
258
259// View renders the animation.
260func (a anim) View() tea.View {
261 var (
262 t = styles.CurrentTheme()
263 b strings.Builder
264 )
265
266 // Optimized capacity calculation to reduce allocations
267 const (
268 bytesPerChar = 15 // Reduced estimate for ANSI styling
269 bufferSize = 30 // Reduced safety margin
270 )
271 estimatedCap := len(a.cyclingChars)*bytesPerChar + len(a.labelChars)*bytesPerChar + bufferSize
272 b.Grow(estimatedCap)
273
274 // Render cycling characters with gradient (if available)
275 for i, c := range a.cyclingChars {
276 if len(a.ramp) > i {
277 b.WriteString(a.ramp[i].Render(string(c.currentValue)))
278 } else {
279 b.WriteRune(c.currentValue)
280 }
281 }
282
283 // Render label characters and ellipsis
284 if len(a.labelChars) > 1 {
285 textStyle := t.S().Text
286 for _, c := range a.labelChars {
287 b.WriteString(textStyle.Render(string(c.currentValue)))
288 }
289 b.WriteString(textStyle.Render(a.ellipsis.View()))
290 }
291
292 return tea.NewView(b.String())
293}
294
295func GetColor(c color.Color) string {
296 rgba := color.RGBAModel.Convert(c).(color.RGBA)
297 return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
298}
299
300func makeGradientRamp(length int) []color.Color {
301 t := styles.CurrentTheme()
302 startColor := GetColor(t.Primary)
303 endColor := GetColor(t.Secondary)
304 var (
305 c = make([]color.Color, length)
306 start, _ = colorful.Hex(startColor)
307 end, _ = colorful.Hex(endColor)
308 )
309 for i := range length {
310 step := start.BlendLuv(end, float64(i)/float64(length))
311 c[i] = lipgloss.Color(step.Hex())
312 }
313 return c
314}