@@ -2,7 +2,6 @@
2package anim
2package anim
6 "image/color"
5 "image/color"
7 "math/rand/v2"
6 "math/rand/v2"
@@ -10,7 +9,6 @@ import (
12 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
13− "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/lucasb-eyer/go-colorful"
13 "github.com/lucasb-eyer/go-colorful"
@@ -32,8 +30,19 @@ const (
32 maxBirthOffset = time.Second
30 maxBirthOffset = time.Second
34 // Number of frames to prerender for the animation. After this number
32 // Number of frames to prerender for the animation. After this number
35− // of frames, the animation will loop.
33+ // of frames, the animation will loop. This only applies when color
34+ // cycling is disabled.
36 prerenderedFrames = 10
35 prerenderedFrames = 10
37+ // Default number of cycling chars.
38+ defaultNumCyclingChars = 10
41+// Default colors for gradient.
43+ defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
44+ defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
45+ defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
@@ -52,6 +61,19 @@ func nextID() int {
52// StepMsg is a message type used to trigger the next step in the animation.
61// StepMsg is a message type used to trigger the next step in the animation.
53type StepMsg struct{ id int }
62type StepMsg struct{ id int }
64+// Settings defines settings for the animation.
65+type Settings struct {
68+ LabelColor color.Color
69+ GradColorA color.Color
70+ GradColorB color.Color
55// Anim is a Bubble for an animated spinner.
77// Anim is a Bubble for an animated spinner.
56type Anim struct {
78type Anim struct {
@@ -60,7 +82,7 @@ type Anim struct {
60 labelWidth int
82 labelWidth int
61 startTime time.Time
83 startTime time.Time
62 birthOffsets []time.Duration
84 birthOffsets []time.Duration
63− initialChars []string
85+ initialFrames [][]string // frames for the initial characters
64 initialized bool
86 initialized bool
65 cyclingFrames [][]string // frames for the cycling characters
87 cyclingFrames [][]string // frames for the cycling characters
66 step int // current main frame step
88 step int // current main frame step
@@ -70,27 +92,41 @@ type Anim struct {
72// New creates a new Anim instance with the specified width and label.
94// New creates a new Anim instance with the specified width and label.
73−func New(numChars int, label string, t *styles.Theme) (a Anim) {
95+func New(opts Settings) (a Anim) {
96+ // Validate settings.
98+ opts.Size = defaultNumCyclingChars
100+ if colorIsUnset(opts.GradColorA) {
101+ opts.GradColorA = defaultGradColorA
103+ if colorIsUnset(opts.GradColorB) {
104+ opts.GradColorB = defaultGradColorB
106+ if colorIsUnset(opts.LabelColor) {
107+ opts.LabelColor = defaultLabelColor
74 a.id = nextID()
110 a.id = nextID()
76 a.startTime = time.Now()
112 a.startTime = time.Now()
77− a.cyclingCharWidth = numChars
113+ a.cyclingCharWidth = opts.Size
78− a.labelWidth = lipgloss.Width(label)
114+ a.labelWidth = lipgloss.Width(opts.Label)
80 // Total width of anim, in cells.
116 // Total width of anim, in cells.
81− a.width = numChars
117+ a.width = opts.Size
82− if label != "" {
118+ if opts.Label != "" {
83− a.width += labelGapWidth + lipgloss.Width(label)
119+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
86 if a.labelWidth > 0 {
122 if a.labelWidth > 0 {
87 // Pre-render the label.
123 // Pre-render the label.
88 // XXX: We should really get the graphemes for the label, not the runes.
124 // XXX: We should really get the graphemes for the label, not the runes.
89− labelRunes := []rune(label)
125+ labelRunes := []rune(opts.Label)
90 a.label = make([]string, len(labelRunes))
126 a.label = make([]string, len(labelRunes))
91 for i := range a.label {
127 for i := range a.label {
92 a.label[i] = lipgloss.NewStyle().
128 a.label[i] = lipgloss.NewStyle().
93− Foreground(t.FgBase).
129+ Foreground(opts.LabelColor).
94 Render(string(labelRunes[i]))
130 Render(string(labelRunes[i]))
@@ -98,40 +134,69 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) {
98 a.ellipsisFrames = make([]string, len(ellipsisFrames))
134 a.ellipsisFrames = make([]string, len(ellipsisFrames))
99 for i, frame := range ellipsisFrames {
135 for i, frame := range ellipsisFrames {
100 a.ellipsisFrames[i] = lipgloss.NewStyle().
136 a.ellipsisFrames[i] = lipgloss.NewStyle().
101− Foreground(t.FgBase).
137+ Foreground(opts.LabelColor).
102 Render(frame)
138 Render(frame)
106 // Pre-generate gradient.
142 // Pre-generate gradient.
107− ramp := makeGradientRamp(a.width, t.Primary, t.Secondary)
143+ var ramp []color.Color
144+ numFrames := prerenderedFrames
145+ if opts.CycleColors {
146+ ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
147+ numFrames = a.width * 2
149+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
109 // Pre-render initial characters.
152 // Pre-render initial characters.
110− a.initialChars = make([]string, a.width)
153+ a.initialFrames = make([][]string, numFrames)
111− for i := range a.initialChars {
154+ offset := 0
155+ for i := range a.initialFrames {
156+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
157+ for j := range a.initialFrames[i] {
158+ if j+offset >= len(ramp) {
159+ continue // skip if we run out of colors
112− var c color.Color
162+ var c color.Color
113− if i <= a.cyclingCharWidth {
163+ if j <= a.cyclingCharWidth {
114− c = ramp[i]
164+ c = ramp[j+offset]
115− } else {
165+ } else {
116− c = t.FgBase
166+ c = opts.LabelColor
169+ // Also prerender the initial character with Lip Gloss to avoid
170+ // processing in the render loop.
171+ a.initialFrames[i][j] = lipgloss.NewStyle().
173+ Render(string(initialChar))
175+ if opts.CycleColors {
118− a.initialChars[i] = lipgloss.NewStyle().
120− Render(string(initialChar))
123 // Prerender scrambled rune frames for the animation.
180 // Prerender scrambled rune frames for the animation.
124− a.cyclingFrames = make([][]string, prerenderedFrames)
181+ a.cyclingFrames = make([][]string, numFrames)
125 for i := range a.cyclingFrames {
183 for i := range a.cyclingFrames {
126 a.cyclingFrames[i] = make([]string, a.width)
184 a.cyclingFrames[i] = make([]string, a.width)
127 for j := range a.cyclingFrames[i] {
185 for j := range a.cyclingFrames[i] {
186+ if j+offset >= len(ramp) {
187+ continue // skip if we run out of colors
128− // NB: we also prerender the color with Lip Gloss here to avoid
190+ // Also prerender the color with Lip Gloss here to avoid processing
129− // processing in the render loop.
191+ // in the render loop.
130 r := availableRunes[rand.IntN(len(availableRunes))]
192 r := availableRunes[rand.IntN(len(availableRunes))]
131 a.cyclingFrames[i][j] = lipgloss.NewStyle().
193 a.cyclingFrames[i][j] = lipgloss.NewStyle().
132− Foreground(ramp[j]).
194+ Foreground(ramp[j+offset]).
133 Render(string(r))
195 Render(string(r))
197+ if opts.CycleColors {
137 // Random assign a birth to each character for a stagged entrance effect.
202 // Random assign a birth to each character for a stagged entrance effect.
@@ -143,6 +208,24 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) {
211+// Width returns the total width of the animation.
212+func (a Anim) Width() (w int) {
214+ if a.labelWidth > 0 {
215+ w += labelGapWidth + a.labelWidth
217+ var widestEllipsisFrame int
218+ for _, f := range ellipsisFrames {
219+ fw := lipgloss.Width(f)
220+ if fw > widestEllipsisFrame {
221+ widestEllipsisFrame = fw
224+ w += widestEllipsisFrame
146// Init starts the animation.
229// Init starts the animation.
147func (a Anim) Init() tea.Cmd {
230func (a Anim) Init() tea.Cmd {
148 return a.Step()
231 return a.Step()
@@ -184,7 +267,7 @@ func (a Anim) View() tea.View {
185 case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
268 case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
186 // Birth offset not reached: render initial character.
269 // Birth offset not reached: render initial character.
187− b.WriteString(a.initialChars[i])
270+ b.WriteString(a.initialFrames[a.step][i])
188 case i < a.cyclingCharWidth:
271 case i < a.cyclingCharWidth:
189 // Render a cycling character.
272 // Render a cycling character.
190 b.WriteString(a.cyclingFrames[a.step][i])
273 b.WriteString(a.cyclingFrames[a.step][i])
@@ -211,22 +294,60 @@ func (a Anim) Step() tea.Cmd {
214−func colorToHex(c color.Color) string {
297+// makeGradientRamp() returns a slice of colors blended between the given keys.
215− r, g, b, _ := c.RGBA()
298+// Blending is done as Hcl to stay in gamut.
216− return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
299+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
304+ points := make([]colorful.Color, len(stops))
305+ for i, k := range stops {
306+ points[i], _ = colorful.MakeColor(k)
309+ numSegments := len(stops) - 1
310+ if numSegments == 0 {
313+ blended := make([]color.Color, 0, size)
315+ // Calculate how many colors each segment should have.
316+ segmentSizes := make([]int, numSegments)
317+ baseSize := size / numSegments
318+ remainder := size % numSegments
320+ // Distribute the remainder across segments.
321+ for i := range numSegments {
322+ segmentSizes[i] = baseSize
328+ // Generate colors for each segment.
329+ for i := range numSegments {
332+ segmentSize := segmentSizes[i]
334+ for j := range segmentSize {
335+ if segmentSize == 0 {
338+ t := float64(j) / float64(segmentSize)
339+ c := c1.BlendHcl(c2, t)
340+ blended = append(blended, c)
219−func makeGradientRamp(length int, from, to color.Color) []color.Color {
347+func colorIsUnset(c color.Color) bool {
220− startColor := colorToHex(from)
348+ if c == nil {
221− endColor := colorToHex(to)
349+ return true
223− c = make([]color.Color, length)
224− start, _ = colorful.Hex(startColor)
225− end, _ = colorful.Hex(endColor)
227− for i := range length {
228− step := start.BlendLuv(end, float64(i)/float64(length))
229− c[i] = lipgloss.Color(step.Hex())
351+ _, _, _, a := c.RGBA()
231− return c
352+ return a == 0