anim.go

  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}