@@ -2,9 +2,12 @@
2package anim
2package anim
5 "image/color"
7 "image/color"
6 "math/rand/v2"
8 "math/rand/v2"
8 "sync/atomic"
11 "sync/atomic"
@@ -58,6 +61,29 @@ func nextID() int {
58 return int(atomic.AddInt64(&lastID, 1))
61 return int(atomic.AddInt64(&lastID, 1))
64+// Cache for expensive animation calculations
65+type animCache struct {
66+ initialFrames [][]string
67+ cyclingFrames [][]string
71+ ellipsisFrames []string
75+ animCacheMutex sync.RWMutex
76+ animCacheMap = make(map[string]*animCache)
79+// settingsHash creates a hash key for the settings to use for caching
80+func settingsHash(opts Settings) string {
82+ fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
83+ opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
84+ return fmt.Sprintf("%x", h.Sum(nil))
61// StepMsg is a message type used to trigger the next step in the animation.
87// StepMsg is a message type used to trigger the next step in the animation.
62type StepMsg struct{ id int }
88type StepMsg struct{ id int }
@@ -109,79 +135,109 @@ func New(opts Settings) (a Anim) {
111 a.id = nextID()
137 a.id = nextID()
113 a.startTime = time.Now()
138 a.startTime = time.Now()
114 a.cyclingCharWidth = opts.Size
139 a.cyclingCharWidth = opts.Size
115− a.labelWidth = lipgloss.Width(opts.Label)
116 a.labelColor = opts.LabelColor
140 a.labelColor = opts.LabelColor
118− // Total width of anim, in cells.
142+ // Check cache first
119− a.width = opts.Size
143+ cacheKey := settingsHash(opts)
120− if opts.Label != "" {
144+ animCacheMutex.RLock()
121− a.width += labelGapWidth + lipgloss.Width(opts.Label)
145+ cached, exists := animCacheMap[cacheKey]
122− }
146+ animCacheMutex.RUnlock()
124− // Render the label
148+ if exists {
125− a.renderLabel(opts.Label)
149+ // Use cached values
126−
150+ a.width = cached.width
127− // Pre-generate gradient.
151+ a.labelWidth = cached.labelWidth
128− var ramp []color.Color
152+ a.label = cached.label
129− numFrames := prerenderedFrames
153+ a.ellipsisFrames = cached.ellipsisFrames
130− if opts.CycleColors {
154+ a.initialFrames = cached.initialFrames
131− ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
155+ a.cyclingFrames = cached.cyclingFrames
132− numFrames = a.width * 2
134− ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
157+ // Generate new values and cache them
135− }
158+ a.labelWidth = lipgloss.Width(opts.Label)
137− // Pre-render initial characters.
160+ // Total width of anim, in cells.
138− a.initialFrames = make([][]string, numFrames)
161+ a.width = opts.Size
139− offset := 0
162+ if opts.Label != "" {
140− for i := range a.initialFrames {
163+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
141− a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
142− for j := range a.initialFrames[i] {
143− if j+offset >= len(ramp) {
144− continue // skip if we run out of colors
147− var c color.Color
166+ // Render the label
148− if j <= a.cyclingCharWidth {
167+ a.renderLabel(opts.Label)
154− // Also prerender the initial character with Lip Gloss to avoid
169+ // Pre-generate gradient.
155− // processing in the render loop.
170+ var ramp []color.Color
156− a.initialFrames[i][j] = lipgloss.NewStyle().
171+ numFrames := prerenderedFrames
158− Render(string(initialChar))
160 if opts.CycleColors {
172 if opts.CycleColors {
161− offset++
173+ ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
174+ numFrames = a.width * 2
176+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
165− // Prerender scrambled rune frames for the animation.
179+ // Pre-render initial characters.
166− a.cyclingFrames = make([][]string, numFrames)
180+ a.initialFrames = make([][]string, numFrames)
167− offset = 0
181+ offset := 0
168− for i := range a.cyclingFrames {
182+ for i := range a.initialFrames {
169− a.cyclingFrames[i] = make([]string, a.width)
183+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
170− for j := range a.cyclingFrames[i] {
184+ for j := range a.initialFrames[i] {
171− if j+offset >= len(ramp) {
185+ if j+offset >= len(ramp) {
172− continue // skip if we run out of colors
186+ continue // skip if we run out of colors
190+ if j <= a.cyclingCharWidth {
196+ // Also prerender the initial character with Lip Gloss to avoid
197+ // processing in the render loop.
198+ a.initialFrames[i][j] = lipgloss.NewStyle().
200+ Render(string(initialChar))
202+ if opts.CycleColors {
207+ // Prerender scrambled rune frames for the animation.
208+ a.cyclingFrames = make([][]string, numFrames)
210+ for i := range a.cyclingFrames {
211+ a.cyclingFrames[i] = make([]string, a.width)
212+ for j := range a.cyclingFrames[i] {
213+ if j+offset >= len(ramp) {
214+ continue // skip if we run out of colors
175− // Also prerender the color with Lip Gloss here to avoid processing
217+ // Also prerender the color with Lip Gloss here to avoid processing
176− // in the render loop.
218+ // in the render loop.
177− r := availableRunes[rand.IntN(len(availableRunes))]
219+ r := availableRunes[rand.IntN(len(availableRunes))]
178− a.cyclingFrames[i][j] = lipgloss.NewStyle().
220+ a.cyclingFrames[i][j] = lipgloss.NewStyle().
179− Foreground(ramp[j+offset]).
221+ Foreground(ramp[j+offset]).
180− Render(string(r))
222+ Render(string(r))
224+ if opts.CycleColors {
182− if opts.CycleColors {
228+
183− offset++
229+ // Cache the results
230+ cached = &animCache{
231+ initialFrames: a.initialFrames,
232+ cyclingFrames: a.cyclingFrames,
234+ labelWidth: a.labelWidth,
236+ ellipsisFrames: a.ellipsisFrames,
238+ animCacheMutex.Lock()
239+ animCacheMap[cacheKey] = cached
240+ animCacheMutex.Unlock()
187 // Random assign a birth to each character for a stagged entrance effect.
243 // Random assign a birth to each character for a stagged entrance effect.