on main help sign in
chore(spinner): re-implement color cycling
414dc19Christian Rocha
internal/tui/components/anim/anim.go+166−45
@@ -2,7 +2,6 @@
2package anim
2package anim
3
3
4import (
4import (
5− "fmt"
6 "image/color"
5 "image/color"
7 "math/rand/v2"
6 "math/rand/v2"
8 "strings"
7 "strings"
@@ -10,7 +9,6 @@ import (
10 "time"
9 "time"
11
10
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"
16)
14)
@@ -32,8 +30,19 @@ const (
32 maxBirthOffset = time.Second
30 maxBirthOffset = time.Second
33
31
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
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}
37)
46)
38
47
39var (
48var (
@@ -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 }
54
63
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+
55// Anim is a Bubble for an animated spinner.
77// Anim is a Bubble for an animated spinner.
56type Anim struct {
78type Anim struct {
57 width int
79 width int
@@ -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 {
70}
92}
71
93
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.
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+
74 a.id = nextID()
110 a.id = nextID()
75
111
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)
79
115
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)
84 }
120 }
85
121
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]))
95 }
131 }
96
132
@@ -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)
103 }
139 }
104 }
140 }
105
141
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
148+ } else {
149+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
150+ }
108
151
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
160+ }
161+
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
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++
117 }
177 }
118− a.initialChars[i] = lipgloss.NewStyle().
119− Foreground(c).
120− Render(string(initialChar))
121 }
178 }
122
179
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)
182+ offset = 0
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
188+ }
189+
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))
134 }
196 }
197+ if opts.CycleColors {
198+ offset++
199+ }
135 }
200 }
136
201
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) {
143 return a
208 return a
144}
209}
145
210
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+
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 {
184 switch {
267 switch {
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 {
211 })
294 })
212}
295}
213
296
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 {
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
217}
345}
218
346
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
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− }
350+ }
351+ _, _, _, a := c.RGBA()
231− return c
352+ return a == 0
232}
353}
internal/tui/components/anim/example/main.go+26−4
@@ -8,12 +8,14 @@ import (
8 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 anim "github.com/charmbracelet/crush/internal/tui/components/anim"
9 anim "github.com/charmbracelet/crush/internal/tui/components/anim"
10 "github.com/charmbracelet/crush/internal/tui/styles"
10 "github.com/charmbracelet/crush/internal/tui/styles"
11+ "github.com/charmbracelet/lipgloss/v2"
11)
12)
12
13
13type model struct {
14type model struct {
14 anim tea.Model
15 anim tea.Model
15 bgColor color.Color
16 bgColor color.Color
16 quitting bool
17 quitting bool
18+ w, h int
17}
19}
18
20
19func (m model) Init() tea.Cmd {
21func (m model) Init() tea.Cmd {
@@ -22,6 +24,9 @@ func (m model) Init() tea.Cmd {
22
24
23func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
25func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
24 switch msg := msg.(type) {
26 switch msg := msg.(type) {
27+ case tea.WindowSizeMsg:
28+ m.w, m.h = msg.Width, msg.Height
29+ return m, nil
25 case tea.KeyMsg:
30 case tea.KeyMsg:
26 switch msg.String() {
31 switch msg.String() {
27 case "q", "ctrl+c":
32 case "q", "ctrl+c":
@@ -40,14 +45,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
40}
45}
41
46
42func (m model) View() tea.View {
47func (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+
44 v := tea.NewView("")
52 v := tea.NewView("")
45 v.SetBackgroundColor(m.bgColor)
53 v.SetBackgroundColor(m.bgColor)
54+
46 if m.quitting {
55 if m.quitting {
47 return v
56 return v
48 }
57 }
58+
49 if a, ok := m.anim.(anim.Anim); ok {
59 if a, ok := m.anim.(anim.Anim); ok {
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+
50− v = tea.NewView(a.View().String() + "\n")
65+ v = tea.NewView(lipgloss.NewCanvas(l))
51 v.SetBackgroundColor(m.bgColor)
66 v.SetBackgroundColor(m.bgColor)
52 return v
67 return v
53 }
68 }
@@ -58,8 +73,15 @@ func main() {
58 t := styles.CurrentTheme()
73 t := styles.CurrentTheme()
59 p := tea.NewProgram(model{
74 p := tea.NewProgram(model{
60 bgColor: t.BgBase,
75 bgColor: t.BgBase,
61− anim: anim.New(50, "Hello", t),
76+ anim: anim.New(anim.Settings{
62− })
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())
63
85
64 if _, err := p.Run(); err != nil {
86 if _, err := p.Run(); err != nil {
65 fmt.Fprintf(os.Stderr, "Uh oh: %v\n", err)
87 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{
49
49
50// NewMessageCmp creates a new message component with the given message and options
50// NewMessageCmp creates a new message component with the given message and options
51func NewMessageCmp(msg message.Message) MessageCmp {
51func NewMessageCmp(msg message.Message) MessageCmp {
52+ t := styles.CurrentTheme()
52 m := &messageCmp{
53 m := &messageCmp{
53 message: msg,
54 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+ }),
55 }
61 }
56 return m
62 return m
57}
63}
internal/tui/components/chat/messages/tool.go+14−2
@@ -91,9 +91,21 @@ func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCal
91 opt(m)
91 opt(m)
92 }
92 }
93 t := styles.CurrentTheme()
93 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+ })
95 if m.isNested {
102 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+ })
97 }
109 }
98 return m
110 return m
99}
111}