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