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	// Pre-render the label.
 87	// XXX: We should really get the graphemes for the label, not the runes.
 88	labelRunes := []rune(label)
 89	a.label = make([]string, len(labelRunes))
 90	for i := range a.label {
 91		a.label[i] = lipgloss.NewStyle().
 92			Foreground(t.FgBase).
 93			Render(string(labelRunes[i]))
 94	}
 95
 96	// Pre-generate gradient.
 97	ramp := makeGradientRamp(a.width, t.Primary, t.Secondary)
 98
 99	// Pre-render initial characters.
100	a.initialChars = make([]string, a.width)
101	for i := range a.initialChars {
102		a.initialChars[i] = lipgloss.NewStyle().
103			Foreground(ramp[i]).
104			Render(string(initialChar))
105	}
106
107	// Pre-render the ellipsis frames.
108	a.ellipsisFrames = make([]string, len(ellipsisFrames))
109	for i, frame := range ellipsisFrames {
110		a.ellipsisFrames[i] = lipgloss.NewStyle().
111			Foreground(t.FgBase).
112			Render(frame)
113	}
114
115	// Prerender scrambled rune frames for the animation.
116	a.cyclingFrames = make([][]string, prerenderedFrames)
117	for i := range a.cyclingFrames {
118		a.cyclingFrames[i] = make([]string, a.width)
119		for j := range a.cyclingFrames[i] {
120			// NB: we also prerender the color with Lip Gloss here to avoid
121			// processing in the render loop.
122			r := availableRunes[rand.IntN(len(availableRunes))]
123			a.cyclingFrames[i][j] = lipgloss.NewStyle().
124				Foreground(ramp[j]).
125				Render(string(r))
126		}
127	}
128
129	// Random assign a birth to each character for a stagged entrance effect.
130	a.birthOffsets = make([]time.Duration, a.width)
131	for i := range a.birthOffsets {
132		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
133	}
134
135	return a
136}
137
138// Init starts the animation.
139func (a Anim) Init() tea.Cmd {
140	return a.Step()
141}
142
143// Update processes animation steps (or not).
144func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145	switch msg := msg.(type) {
146	case StepMsg:
147		if msg.id != a.id {
148			// Reject messages that are not for this instance.
149			return a, nil
150		}
151
152		a.step++
153		if a.step >= len(a.cyclingFrames) {
154			a.step = 0
155		}
156
157		if a.initialized {
158			// Manage the ellipsis animation.
159			a.ellipsisStep++
160			if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
161				a.ellipsisStep = 0
162			}
163		} else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
164			a.initialized = true
165		}
166		return a, a.Step()
167	default:
168		return a, nil
169	}
170}
171
172// View renders the current state of the animation.
173func (a Anim) View() tea.View {
174	var b strings.Builder
175	for i := range a.width {
176		switch {
177		case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
178			// Birth offset not reached: render initial character.
179			b.WriteString(a.initialChars[i])
180		case i < a.cyclingCharWidth:
181			// Render a cycling character.
182			b.WriteString(a.cyclingFrames[a.step][i])
183		case i == a.cyclingCharWidth:
184			// Render label gap.
185			b.WriteString(labelGap)
186		case i > a.cyclingCharWidth:
187			// Label.
188			b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
189		}
190	}
191	// Render animated ellipsis at the end of the label if all characters
192	// have been initialized.
193	if a.initialized {
194		b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
195	}
196	return tea.NewView(b.String())
197}
198
199// Step is a command that triggers the next step in the animation.
200func (a Anim) Step() tea.Cmd {
201	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
202		return StepMsg{id: a.id}
203	})
204}
205
206func colorToHex(c color.Color) string {
207	r, g, b, _ := c.RGBA()
208	return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
209}
210
211func makeGradientRamp(length int, from, to color.Color) []color.Color {
212	startColor := colorToHex(from)
213	endColor := colorToHex(to)
214	var (
215		c        = make([]color.Color, length)
216		start, _ = colorful.Hex(startColor)
217		end, _   = colorful.Hex(endColor)
218	)
219	for i := range length {
220		step := start.BlendLuv(end, float64(i)/float64(length))
221		c[i] = lipgloss.Color(step.Hex())
222	}
223	return c
224}