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 / 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}