anim.go

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