on main help sign in
chore(spinner): re-implement color cycling

* chore(spinner): make gradient ramp generator variadic * chore(spinner): re-implement color cycling * chore(spinner): fix logic * chore(spinner): fix typo in var name * chore(spinner): center anim example on screen

commit414dc195ca07f28bb3ef99f1e627cffa11f9ef9b authorChristian Rocha <christian@rocha.is> dateFri 4 Jul 2025, 15:15 (1 year ago) parentb0ad508
4 files changed+213−52
internal/tui/components/anim/anim.go+166−45 internal/tui/components/anim/example/main.go+26−4 internal/tui/components/chat/messages/messages.go+7−1 internal/tui/components/chat/messages/tool.go+14−2
internal/tui/components/anim/anim.go+166−45
@@ -2,7 +2,6 @@
22package anim
33
44import (
5− "fmt"
65 "image/color"
76 "math/rand/v2"
87 "strings"
@@ -10,7 +9,6 @@ import (
109 "time"
1110
1211 tea "github.com/charmbracelet/bubbletea/v2"
13− "github.com/charmbracelet/crush/internal/tui/styles"
1412 "github.com/charmbracelet/lipgloss/v2"
1513 "github.com/lucasb-eyer/go-colorful"
1614)
@@ -32,8 +30,19 @@ const (
3230 maxBirthOffset = time.Second
3331
3432 // 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.
3635 prerenderedFrames = 10
36+
37+ // Default number of cycling chars.
38+ defaultNumCyclingChars = 10
39+)
40+
41+// Default colors for gradient.
42+var (
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}
3746)
3847
3948var (
@@ -52,6 +61,19 @@ func nextID() int {
5261// StepMsg is a message type used to trigger the next step in the animation.
5362type StepMsg struct{ id int }
5463
64+// Settings defines settings for the animation.
65+type Settings struct {
66+ Size int
67+ Label string
68+ LabelColor color.Color
69+ GradColorA color.Color
70+ GradColorB color.Color
71+ CycleColors bool
72+}
73+
74+// Default settings.
75+const ()
76+
5577// Anim is a Bubble for an animated spinner.
5678type Anim struct {
5779 width int
@@ -60,7 +82,7 @@ type Anim struct {
6082 labelWidth int
6183 startTime time.Time
6284 birthOffsets []time.Duration
63− initialChars []string
85+ initialFrames [][]string // frames for the initial characters
6486 initialized bool
6587 cyclingFrames [][]string // frames for the cycling characters
6688 step int // current main frame step
@@ -70,27 +92,41 @@ type Anim struct {
7092}
7193
7294// 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.
97+ if opts.Size < 1 {
98+ opts.Size = defaultNumCyclingChars
99+ }
100+ if colorIsUnset(opts.GradColorA) {
101+ opts.GradColorA = defaultGradColorA
102+ }
103+ if colorIsUnset(opts.GradColorB) {
104+ opts.GradColorB = defaultGradColorB
105+ }
106+ if colorIsUnset(opts.LabelColor) {
107+ opts.LabelColor = defaultLabelColor
108+ }
109+
74110 a.id = nextID()
75111
76112 a.startTime = time.Now()
77− a.cyclingCharWidth = numChars
78− a.labelWidth = lipgloss.Width(label)
113+ a.cyclingCharWidth = opts.Size
114+ a.labelWidth = lipgloss.Width(opts.Label)
79115
80116 // Total width of anim, in cells.
81− a.width = numChars
82− if label != "" {
83− a.width += labelGapWidth + lipgloss.Width(label)
117+ a.width = opts.Size
118+ if opts.Label != "" {
119+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
84120 }
85121
86122 if a.labelWidth > 0 {
87123 // Pre-render the label.
88124 // XXX: We should really get the graphemes for the label, not the runes.
89− labelRunes := []rune(label)
125+ labelRunes := []rune(opts.Label)
90126 a.label = make([]string, len(labelRunes))
91127 for i := range a.label {
92128 a.label[i] = lipgloss.NewStyle().
93− Foreground(t.FgBase).
129+ Foreground(opts.LabelColor).
94130 Render(string(labelRunes[i]))
95131 }
96132
@@ -98,40 +134,69 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) {
98134 a.ellipsisFrames = make([]string, len(ellipsisFrames))
99135 for i, frame := range ellipsisFrames {
100136 a.ellipsisFrames[i] = lipgloss.NewStyle().
101− Foreground(t.FgBase).
137+ Foreground(opts.LabelColor).
102138 Render(frame)
103139 }
104140 }
105141
106142 // 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
148+ } else {
149+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
150+ }
108151
109152 // Pre-render initial characters.
110− a.initialChars = make([]string, a.width)
111− for i := range a.initialChars {
112− var c color.Color
113− if i <= a.cyclingCharWidth {
114− c = ramp[i]
115− } else {
116− c = t.FgBase
153+ a.initialFrames = make([][]string, numFrames)
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
160+ }
161+
162+ var c color.Color
163+ if j <= a.cyclingCharWidth {
164+ c = ramp[j+offset]
165+ } else {
166+ c = opts.LabelColor
167+ }
168+
169+ // Also prerender the initial character with Lip Gloss to avoid
170+ // processing in the render loop.
171+ a.initialFrames[i][j] = lipgloss.NewStyle().
172+ Foreground(c).
173+ Render(string(initialChar))
174+ }
175+ if opts.CycleColors {
176+ offset++
117177 }
118− a.initialChars[i] = lipgloss.NewStyle().
119− Foreground(c).
120− Render(string(initialChar))
121178 }
122179
123180 // Prerender scrambled rune frames for the animation.
124− a.cyclingFrames = make([][]string, prerenderedFrames)
181+ a.cyclingFrames = make([][]string, numFrames)
182+ offset = 0
125183 for i := range a.cyclingFrames {
126184 a.cyclingFrames[i] = make([]string, a.width)
127185 for j := range a.cyclingFrames[i] {
128− // NB: we also prerender the color with Lip Gloss here to avoid
129− // processing in the render loop.
186+ if j+offset >= len(ramp) {
187+ continue // skip if we run out of colors
188+ }
189+
190+ // Also prerender the color with Lip Gloss here to avoid processing
191+ // in the render loop.
130192 r := availableRunes[rand.IntN(len(availableRunes))]
131193 a.cyclingFrames[i][j] = lipgloss.NewStyle().
132− Foreground(ramp[j]).
194+ Foreground(ramp[j+offset]).
133195 Render(string(r))
134196 }
197+ if opts.CycleColors {
198+ offset++
199+ }
135200 }
136201
137202 // 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) {
143208 return a
144209}
145210
211+// Width returns the total width of the animation.
212+func (a Anim) Width() (w int) {
213+ w = a.width
214+ if a.labelWidth > 0 {
215+ w += labelGapWidth + a.labelWidth
216+
217+ var widestEllipsisFrame int
218+ for _, f := range ellipsisFrames {
219+ fw := lipgloss.Width(f)
220+ if fw > widestEllipsisFrame {
221+ widestEllipsisFrame = fw
222+ }
223+ }
224+ w += widestEllipsisFrame
225+ }
226+ return w
227+}
228+
146229// Init starts the animation.
147230func (a Anim) Init() tea.Cmd {
148231 return a.Step()
@@ -184,7 +267,7 @@ func (a Anim) View() tea.View {
184267 switch {
185268 case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
186269 // Birth offset not reached: render initial character.
187− b.WriteString(a.initialChars[i])
270+ b.WriteString(a.initialFrames[a.step][i])
188271 case i < a.cyclingCharWidth:
189272 // Render a cycling character.
190273 b.WriteString(a.cyclingFrames[a.step][i])
@@ -211,22 +294,60 @@ func (a Anim) Step() tea.Cmd {
211294 })
212295}
213296
214−func colorToHex(c color.Color) string {
215− r, g, b, _ := c.RGBA()
216− return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
297+// makeGradientRamp() returns a slice of colors blended between the given keys.
298+// Blending is done as Hcl to stay in gamut.
299+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
300+ if len(stops) < 2 {
301+ return nil
302+ }
303+
304+ points := make([]colorful.Color, len(stops))
305+ for i, k := range stops {
306+ points[i], _ = colorful.MakeColor(k)
307+ }
308+
309+ numSegments := len(stops) - 1
310+ if numSegments == 0 {
311+ return nil
312+ }
313+ blended := make([]color.Color, 0, size)
314+
315+ // Calculate how many colors each segment should have.
316+ segmentSizes := make([]int, numSegments)
317+ baseSize := size / numSegments
318+ remainder := size % numSegments
319+
320+ // Distribute the remainder across segments.
321+ for i := range numSegments {
322+ segmentSizes[i] = baseSize
323+ if i < remainder {
324+ segmentSizes[i]++
325+ }
326+ }
327+
328+ // Generate colors for each segment.
329+ for i := range numSegments {
330+ c1 := points[i]
331+ c2 := points[i+1]
332+ segmentSize := segmentSizes[i]
333+
334+ for j := range segmentSize {
335+ if segmentSize == 0 {
336+ continue
337+ }
338+ t := float64(j) / float64(segmentSize)
339+ c := c1.BlendHcl(c2, t)
340+ blended = append(blended, c)
341+ }
342+ }
343+
344+ return blended
217345}
218346
219−func makeGradientRamp(length int, from, to color.Color) []color.Color {
220− startColor := colorToHex(from)
221− endColor := colorToHex(to)
222− var (
223− c = make([]color.Color, length)
224− start, _ = colorful.Hex(startColor)
225− end, _ = colorful.Hex(endColor)
226− )
227− for i := range length {
228− step := start.BlendLuv(end, float64(i)/float64(length))
229− c[i] = lipgloss.Color(step.Hex())
230− }
231− return c
347+func colorIsUnset(c color.Color) bool {
348+ if c == nil {
349+ return true
350+ }
351+ _, _, _, a := c.RGBA()
352+ return a == 0
232353}
internal/tui/components/anim/example/main.go+26−4
@@ -8,12 +8,14 @@ import (
88 tea "github.com/charmbracelet/bubbletea/v2"
99 anim "github.com/charmbracelet/crush/internal/tui/components/anim"
1010 "github.com/charmbracelet/crush/internal/tui/styles"
11+ "github.com/charmbracelet/lipgloss/v2"
1112)
1213
1314type model struct {
1415 anim tea.Model
1516 bgColor color.Color
1617 quitting bool
18+ w, h int
1719}
1820
1921func (m model) Init() tea.Cmd {
@@ -22,6 +24,9 @@ func (m model) Init() tea.Cmd {
2224
2325func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2426 switch msg := msg.(type) {
27+ case tea.WindowSizeMsg:
28+ m.w, m.h = msg.Width, msg.Height
29+ return m, nil
2530 case tea.KeyMsg:
2631 switch msg.String() {
2732 case "q", "ctrl+c":
@@ -40,14 +45,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4045}
4146
4247func (m model) View() tea.View {
43− // XXX tea.View() needs a content setter.
48+ if m.w == 0 || m.h == 0 {
49+ return tea.NewView("")
50+ }
51+
4452 v := tea.NewView("")
4553 v.SetBackgroundColor(m.bgColor)
54+
4655 if m.quitting {
4756 return v
4857 }
58+
4959 if a, ok := m.anim.(anim.Anim); ok {
50− v = tea.NewView(a.View().String() + "\n")
60+ l := lipgloss.NewLayer(a.View().String()).
61+ Width(a.Width()).
62+ X(m.w/2 - a.Width()/2).
63+ Y(m.h / 2)
64+
65+ v = tea.NewView(lipgloss.NewCanvas(l))
5166 v.SetBackgroundColor(m.bgColor)
5267 return v
5368 }
@@ -58,8 +73,15 @@ func main() {
5873 t := styles.CurrentTheme()
5974 p := tea.NewProgram(model{
6075 bgColor: t.BgBase,
61− anim: anim.New(50, "Hello", t),
62− })
76+ anim: anim.New(anim.Settings{
77+ Label: "Hello",
78+ Size: 50,
79+ LabelColor: t.FgBase,
80+ GradColorA: t.Primary,
81+ GradColorB: t.Secondary,
82+ CycleColors: true,
83+ }),
84+ }, tea.WithAltScreen())
6385
6486 if _, err := p.Run(); err != nil {
6587 fmt.Fprintf(os.Stderr, "Uh oh: %v\n", err)
internal/tui/components/chat/messages/messages.go+7−1
@@ -49,9 +49,15 @@ var focusedMessageBorder = lipgloss.Border{
4949
5050// NewMessageCmp creates a new message component with the given message and options
5151func NewMessageCmp(msg message.Message) MessageCmp {
52+ t := styles.CurrentTheme()
5253 m := &messageCmp{
5354 message: msg,
54− anim: anim.New(15, "", styles.CurrentTheme()),
55+ anim: anim.New(anim.Settings{
56+ Size: 15,
57+ GradColorA: t.Primary,
58+ GradColorB: t.Secondary,
59+ CycleColors: true,
60+ }),
5561 }
5662 return m
5763}
internal/tui/components/chat/messages/tool.go+14−2
@@ -91,9 +91,21 @@ func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCal
9191 opt(m)
9292 }
9393 t := styles.CurrentTheme()
94− m.anim = anim.New(15, "Working", t)
94+ m.anim = anim.New(anim.Settings{
95+ Size: 15,
96+ Label: "Working",
97+ GradColorA: t.Primary,
98+ GradColorB: t.Secondary,
99+ LabelColor: t.FgBase,
100+ CycleColors: true,
101+ })
95102 if m.isNested {
96− m.anim = anim.New(10, "", t)
103+ m.anim = anim.New(anim.Settings{
104+ Size: 10,
105+ GradColorA: t.Primary,
106+ GradColorB: t.Secondary,
107+ CycleColors: true,
108+ })
97109 }
98110 return m
99111}