on main help sign in
Merge pull request #92 from charmbracelet/anim-ii
a0cec91Raphael Amorim
internal/tui/components/anim/anim.go+165−247
@@ -1,3 +1,4 @@
1+// Package anim provides an animated spinner.
1package anim
2package anim
2
3
3import (
4import (
@@ -5,302 +6,219 @@ import (
5 "image/color"
6 "image/color"
6 "math/rand/v2"
7 "math/rand/v2"
7 "strings"
8 "strings"
9+ "sync/atomic"
8 "time"
10 "time"
9
11
10− "github.com/charmbracelet/bubbles/v2/spinner"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/lipgloss/v2"
15− "github.com/google/uuid"
16 "github.com/lucasb-eyer/go-colorful"
15 "github.com/lucasb-eyer/go-colorful"
17)
16)
18
17
19const (
18const (
20− charCyclingFPS = time.Second / 22
19+ fps = 20
21− colorCycleFPS = time.Second / 5
20+ initialChar = '.'
22− maxCyclingChars = 60
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
23)
37)
24
38
25var (
39var (
26− charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
40+ availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
27− charRunePool = make([]rune, 1000) // Pre-generated pool of random characters
41+ ellipsisFrames = []string{".", "..", "...", ""}
28− poolIndex = 0
29)
42)
30
43
31−func init() {
44+// Internal ID management. Used during animating to ensure that frame messages
32− // Pre-populate the character pool to avoid runtime random generation.
45+// are received only by spinner components that sent them.
33− for i := range charRunePool {
46+var lastID int64
34− charRunePool[i] = charRunes[rand.IntN(len(charRunes))]
47+
35− }
48+func nextID() int {
49+ return int(atomic.AddInt64(&lastID, 1))
36−}
50+}
37−
51+
38−type charState int
52+// StepMsg is a message type used to trigger the next step in the animation.
39−
53+type StepMsg struct{ id int }
40−const (
54+
41− charInitialState charState = iota
55+// Anim is a Bubble for an animated spinner.
42− charCyclingState
43− charEndOfLifeState
44−)
45−
46−// cyclingChar is a single animated character.
47−type cyclingChar struct {
56+type Anim struct {
48− finalValue rune // if < 0 cycle forever
57+ width int
49− currentValue rune
58+ cyclingCharWidth int
50− initialDelay time.Duration
51− lifetime time.Duration
52−}
53−
54−func (c cyclingChar) randomRune() rune {
55− // Use pre-generated pool instead of runtime random generation.
56− poolIndex = (poolIndex + 1) % len(charRunePool)
57− return charRunePool[poolIndex]
58−}
59−
60−func (c cyclingChar) state(start time.Time) charState {
61− now := time.Now()
62− if now.Before(start.Add(c.initialDelay)) {
63− return charInitialState
64− }
65− if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) {
66− return charEndOfLifeState
67− }
68− return charCyclingState
69−}
70−
71−type StepCharsMsg struct {
72− id string
59+ label []string
73−}
60+ labelWidth int
74−
61+ startTime time.Time
75−func stepChars(id string) tea.Cmd {
62+ birthOffsets []time.Duration
76− return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
63+ initialChars []string
77− return StepCharsMsg{id}
64+ initialized bool
78− })
65+ cyclingFrames [][]string // frames for the cycling characters
79−}
66+ step int // current main frame step
80−
67+ ellipsisStep int // current ellipsis frame step
81−type ColorCycleMsg struct {
68+ ellipsisFrames []string // ellipsis animation frames
82− id string
69+ id int
83−}
84−
85−func cycleColors(id string) tea.Cmd {
86− return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
87− return ColorCycleMsg{id}
88− })
89−}
90−
91−type Animation interface {
92− util.Model
93− ID() string
94−}
95−
96−// anim is the model that manages the animation that displays while the
97−// output is being generated.
98−type anim struct {
99− start time.Time
100− cyclingChars []cyclingChar
101− labelChars []cyclingChar
102− ramp []lipgloss.Style
103− label []rune
104− ellipsis spinner.Model
105− ellipsisStarted bool
106− id string
107−}
108−
109−type animOption func(*anim)
110−
111−func WithId(id string) animOption {
112− return func(a *anim) {
113− a.id = id
114− }
115−}
116−
117−func New(cyclingCharsSize uint, label string, opts ...animOption) Animation {
118− // #nosec G115
119− n := min(int(cyclingCharsSize), maxCyclingChars)
120−
121− gap := " "
122− if n == 0 {
123− gap = ""
124− }
125−
126− id := uuid.New()
127− c := anim{
128− start: time.Now(),
129− label: []rune(gap + label),
130− ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
131− id: id.String(),
132− }
70+}
133−
71+
134− for _, opt := range opts {
72+// New creates a new Anim instance with the specified width and label.
135− opt(&c)
73+func New(numChars int, label string, t *styles.Theme) (a Anim) {
136− }
74+ a.id = nextID()
137−
75+
138− // If we're in truecolor mode (and there are enough cycling characters)
76+ a.startTime = time.Now()
139− // color the cycling characters with a gradient ramp.
77+ a.cyclingCharWidth = numChars
140− const minRampSize = 3
78+ a.labelWidth = lipgloss.Width(label)
141− if n >= minRampSize {
79+
142− // Optimized: single capacity allocation for color cycling.
80+ // Total width of anim, in cells.
143− c.ramp = make([]lipgloss.Style, 0, n*2)
81+ a.width = numChars
144− ramp := makeGradientRamp(n)
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))
145− for _, color := range ramp {
91+ for i := range a.label {
146− c.ramp = append(c.ramp, lipgloss.NewStyle().Foreground(color))
92+ a.label[i] = lipgloss.NewStyle().
147− }
93+ Foreground(t.FgBase).
148− // Create reversed copy for seamless color cycling.
94+ Render(string(labelRunes[i]))
149− reversed := make([]lipgloss.Style, len(c.ramp))
150− for i, style := range c.ramp {
151− reversed[len(c.ramp)-1-i] = style
152 }
95 }
153− c.ramp = append(c.ramp, reversed...)
154− }
155−
156− makeDelay := func(a int32, b time.Duration) time.Duration {
157− return time.Duration(rand.Int32N(a)) * (time.Millisecond * b) //nolint:gosec
158− }
159
96
160− makeInitialDelay := func() time.Duration {
97+ // Pre-render the ellipsis frames which come after the label.
161− return makeDelay(8, 60) //nolint:mnd
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+ }
162 }
104 }
163
105
164− // Initial characters that cycle forever.
106+ // Pre-generate gradient.
165− c.cyclingChars = make([]cyclingChar, n)
107+ ramp := makeGradientRamp(a.width, t.Primary, t.Secondary)
166
108
167− for i := range n {
109+ // Pre-render initial characters.
168− c.cyclingChars[i] = cyclingChar{
110+ a.initialChars = make([]string, a.width)
169− finalValue: -1, // cycle forever
111+ for i := range a.initialChars {
170− initialDelay: makeInitialDelay(),
112+ var c color.Color
113+ if i <= a.cyclingCharWidth {
114+ c = ramp[i]
115+ } else {
116+ c = t.FgBase
117+ }
118+ a.initialChars[i] = lipgloss.NewStyle().
119+ Foreground(c).
120+ Render(string(initialChar))
121+ }
122+
123+ // Prerender scrambled rune frames for the animation.
124+ a.cyclingFrames = make([][]string, prerenderedFrames)
125+ for i := range a.cyclingFrames {
126+ a.cyclingFrames[i] = make([]string, a.width)
127+ 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.
130+ r := availableRunes[rand.IntN(len(availableRunes))]
131+ a.cyclingFrames[i][j] = lipgloss.NewStyle().
132+ Foreground(ramp[j]).
133+ Render(string(r))
171 }
134 }
172 }
135 }
173
136
174− // Label text that only cycles for a little while.
137+ // Random assign a birth to each character for a stagged entrance effect.
175− c.labelChars = make([]cyclingChar, len(c.label))
138+ a.birthOffsets = make([]time.Duration, a.width)
176−
177− for i, r := range c.label {
139+ for i := range a.birthOffsets {
178− c.labelChars[i] = cyclingChar{
140+ a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
179− finalValue: r,
180− initialDelay: makeInitialDelay(),
181− lifetime: makeDelay(5, 180), //nolint:mnd
182− }
183 }
141 }
184
142
185− return c
143+ return a
186}
144}
187
145
188−// Init initializes the animation.
146+// Init starts the animation.
189−func (a anim) Init() tea.Cmd {
147+func (a Anim) Init() tea.Cmd {
190− return tea.Batch(stepChars(a.id), cycleColors(a.id))
148+ return a.Step()
191}
149}
192
150
193−// Update handles messages.
151+// Update processes animation steps (or not).
194−func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152+func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195− var cmd tea.Cmd
196 switch msg := msg.(type) {
153 switch msg := msg.(type) {
197− case StepCharsMsg:
154+ case StepMsg:
198 if msg.id != a.id {
155 if msg.id != a.id {
156+ // Reject messages that are not for this instance.
199 return a, nil
157 return a, nil
200 }
158 }
201− a.updateChars(&a.cyclingChars)
202− a.updateChars(&a.labelChars)
203
159
204− if !a.ellipsisStarted {
160+ a.step++
205− var eol int
161+ if a.step >= len(a.cyclingFrames) {
206− for _, c := range a.labelChars {
162+ a.step = 0
207− if c.state(a.start) == charEndOfLifeState {
208− eol++
209− }
210− }
211− if eol == len(a.label) {
212− // If our entire label has reached end of life, start the
213− // ellipsis "spinner" after a short pause.
214− a.ellipsisStarted = true
215− cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd
216− return a.ellipsis.Tick()
217− })
218− }
219 }
163 }
220
164
221− return a, tea.Batch(stepChars(a.id), cmd)
165+ if a.initialized && a.labelWidth > 0 {
222− case ColorCycleMsg:
166+ // Manage the ellipsis animation.
223− if msg.id != a.id {
167+ a.ellipsisStep++
224− return a, nil
168+ if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
225− }
169+ a.ellipsisStep = 0
226− const minColorCycleSize = 2
170+ }
227− if len(a.ramp) < minColorCycleSize {
171+ } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
228− return a, nil
172+ a.initialized = true
229 }
173 }
230− a.ramp = append(a.ramp[1:], a.ramp[0])
231− return a, cycleColors(a.id)
174+ return a, a.Step()
232− case spinner.TickMsg:
233− var cmd tea.Cmd
234− a.ellipsis, cmd = a.ellipsis.Update(msg)
235− return a, cmd
236 default:
175 default:
237 return a, nil
176 return a, nil
238 }
177 }
239}
178}
240
179
241−func (a anim) ID() string {
180+// View renders the current state of the animation.
242− return a.id
243−}
244−
245−func (a *anim) updateChars(chars *[]cyclingChar) {
246− charSlice := *chars // dereference to avoid repeated pointer access
247− for i, c := range charSlice {
248− switch c.state(a.start) {
249− case charInitialState:
250− charSlice[i].currentValue = '.'
251− case charCyclingState:
252− charSlice[i].currentValue = c.randomRune()
253− case charEndOfLifeState:
254− charSlice[i].currentValue = c.finalValue
255− }
256− }
257−}
258−
259−// View renders the animation.
260−func (a anim) View() tea.View {
181+func (a Anim) View() tea.View {
261− var (
262− t = styles.CurrentTheme()
263− b strings.Builder
182+ var b strings.Builder
264− )
183+ for i := range a.width {
265−
184+ switch {
266− // Optimized capacity calculation to reduce allocations.
185+ case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
267− const (
186+ // Birth offset not reached: render initial character.
268− bytesPerChar = 15 // Reduced estimate for ANSI styling
187+ b.WriteString(a.initialChars[i])
269− bufferSize = 30 // Reduced safety margin
188+ case i < a.cyclingCharWidth:
270− )
189+ // Render a cycling character.
271− estimatedCap := len(a.cyclingChars)*bytesPerChar + len(a.labelChars)*bytesPerChar + bufferSize
190+ b.WriteString(a.cyclingFrames[a.step][i])
272− b.Grow(estimatedCap)
191+ case i == a.cyclingCharWidth:
273−
192+ // Render label gap.
274− // Render cycling characters with gradient (if available).
193+ b.WriteString(labelGap)
275− for i, c := range a.cyclingChars {
194+ case i > a.cyclingCharWidth:
276− if len(a.ramp) > i {
195+ // Label.
277− b.WriteString(a.ramp[i].Render(string(c.currentValue)))
196+ b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
278− } else {
279− b.WriteRune(c.currentValue)
280 }
197 }
281 }
198 }
282−
283− // Render label characters and ellipsis.
199+ // Render animated ellipsis at the end of the label if all characters
284− if len(a.labelChars) > 1 {
200+ // have been initialized.
285− textStyle := t.S().Text
201+ if a.initialized && a.labelWidth > 0 {
286− for _, c := range a.labelChars {
202+ b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
287− b.WriteString(textStyle.Render(string(c.currentValue)))
288− }
289− b.WriteString(textStyle.Render(a.ellipsis.View()))
290 }
203 }
291−
292 return tea.NewView(b.String())
204 return tea.NewView(b.String())
293}
205}
294
206
207+// Step is a command that triggers the next step in the animation.
208+func (a Anim) Step() tea.Cmd {
209+ return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
210+ return StepMsg{id: a.id}
211+ })
212+}
213+
295−func GetColor(c color.Color) string {
214+func colorToHex(c color.Color) string {
296− rgba := color.RGBAModel.Convert(c).(color.RGBA)
215+ r, g, b, _ := c.RGBA()
297− return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
216+ return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
298}
217}
299
218
300−func makeGradientRamp(length int) []color.Color {
219+func makeGradientRamp(length int, from, to color.Color) []color.Color {
301− t := styles.CurrentTheme()
302− startColor := GetColor(t.Primary)
220+ startColor := colorToHex(from)
303− endColor := GetColor(t.Secondary)
221+ endColor := colorToHex(to)
304 var (
222 var (
305 c = make([]color.Color, length)
223 c = make([]color.Color, length)
306 start, _ = colorful.Hex(startColor)
224 start, _ = colorful.Hex(startColor)
internal/tui/components/anim/example/main.gonew file+68
@@ -0,0 +1,68 @@
1+package main
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "os"
7+
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ anim "github.com/charmbracelet/crush/internal/tui/components/anim"
10+ "github.com/charmbracelet/crush/internal/tui/styles"
11+)
12+
13+type model struct {
14+ anim tea.Model
15+ bgColor color.Color
16+ quitting bool
17+}
18+
19+func (m model) Init() tea.Cmd {
20+ return m.anim.Init()
21+}
22+
23+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
24+ switch msg := msg.(type) {
25+ case tea.KeyMsg:
26+ switch msg.String() {
27+ case "q", "ctrl+c":
28+ m.quitting = true
29+ return m, tea.Quit
30+ default:
31+ return m, nil
32+ }
33+ case anim.StepMsg:
34+ var cmd tea.Cmd
35+ m.anim, cmd = m.anim.Update(msg)
36+ return m, cmd
37+ default:
38+ return m, nil
39+ }
40+}
41+
42+func (m model) View() tea.View {
43+ // XXX tea.View() needs a content setter.
44+ v := tea.NewView("")
45+ v.SetBackgroundColor(m.bgColor)
46+ if m.quitting {
47+ return v
48+ }
49+ if a, ok := m.anim.(anim.Anim); ok {
50+ v = tea.NewView(a.View().String() + "\n")
51+ v.SetBackgroundColor(m.bgColor)
52+ return v
53+ }
54+ return v
55+}
56+
57+func main() {
58+ t := styles.CurrentTheme()
59+ p := tea.NewProgram(model{
60+ bgColor: t.BgBase,
61+ anim: anim.New(50, "Hello", t),
62+ })
63+
64+ if _, err := p.Run(); err != nil {
65+ fmt.Fprintf(os.Stderr, "Uh oh: %v\n", err)
66+ os.Exit(1)
67+ }
68+}
internal/tui/components/chat/messages/messages.go+2−3
@@ -6,7 +6,6 @@ import (
6 "strings"
6 "strings"
7 "time"
7 "time"
8
8
9− "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/lipgloss/v2"
12
11
@@ -52,7 +51,7 @@ var focusedMessageBorder = lipgloss.Border{
52func NewMessageCmp(msg message.Message) MessageCmp {
51func NewMessageCmp(msg message.Message) MessageCmp {
53 m := &messageCmp{
52 m := &messageCmp{
54 message: msg,
53 message: msg,
55− anim: anim.New(15, ""),
54+ anim: anim.New(15, "", styles.CurrentTheme()),
56 }
55 }
57 return m
56 return m
58}
57}
@@ -71,7 +70,7 @@ func (m *messageCmp) Init() tea.Cmd {
71// Manages animation updates for spinning messages and stops animation when appropriate.
70// Manages animation updates for spinning messages and stops animation when appropriate.
72func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73 switch msg := msg.(type) {
72 switch msg := msg.(type) {
74− case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
73+ case anim.StepMsg:
75 m.spinning = m.shouldSpin()
74 m.spinning = m.shouldSpin()
76 if m.spinning {
75 if m.spinning {
77 u, cmd := m.anim.Update(msg)
76 u, cmd := m.anim.Update(msg)
internal/tui/components/chat/messages/tool.go+4−4
@@ -3,7 +3,6 @@ package messages
3import (
3import (
4 "fmt"
4 "fmt"
5
5
6− "github.com/charmbracelet/bubbles/v2/spinner"
7 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/message"
7 "github.com/charmbracelet/crush/internal/message"
9 "github.com/charmbracelet/crush/internal/tui/components/anim"
8 "github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -91,9 +90,10 @@ func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCal
91 for _, opt := range opts {
90 for _, opt := range opts {
92 opt(m)
91 opt(m)
93 }
92 }
93+ t := styles.CurrentTheme()
94− m.anim = anim.New(15, "Working")
94+ m.anim = anim.New(15, "Working", t)
95 if m.isNested {
95 if m.isNested {
96− m.anim = anim.New(10, "")
96+ m.anim = anim.New(10, "", t)
97 }
97 }
98 return m
98 return m
99}
99}
@@ -112,7 +112,7 @@ func (m *toolCallCmp) Init() tea.Cmd {
112// Manages animation updates for pending tool calls.
112// Manages animation updates for pending tool calls.
113func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114 switch msg := msg.(type) {
114 switch msg := msg.(type) {
115− case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
115+ case anim.StepMsg:
116 var cmds []tea.Cmd
116 var cmds []tea.Cmd
117 for i, nested := range m.nestedToolCalls {
117 for i, nested := range m.nestedToolCalls {
118 if nested.Spinning() {
118 if nested.Spinning() {
internal/tui/components/core/list/list.go+1−2
@@ -7,7 +7,6 @@ import (
7
7
8 "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/help"
9 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
10− "github.com/charmbracelet/bubbles/v2/spinner"
11 "github.com/charmbracelet/bubbles/v2/textinput"
10 "github.com/charmbracelet/bubbles/v2/textinput"
12 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/tui/components/anim"
12 "github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -271,7 +270,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
271 switch msg := msg.(type) {
270 switch msg := msg.(type) {
272 case tea.KeyPressMsg:
271 case tea.KeyPressMsg:
273 return m.handleKeyPress(msg)
272 return m.handleKeyPress(msg)
274− case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
273+ case anim.StepMsg:
275 return m.handleAnimationMsg(msg)
274 return m.handleAnimationMsg(msg)
276 }
275 }
277 if m.selectionState.isValidIndex(len(m.filteredItems)) {
276 if m.selectionState.isValidIndex(len(m.filteredItems)) {