chore: improve anim performance

Kujtim Hoxha created

Change summary

internal/tui/components/anim/anim.go | 172 +++++++++++++++++++----------
1 file changed, 114 insertions(+), 58 deletions(-)

Detailed changes

internal/tui/components/anim/anim.go 🔗

@@ -2,9 +2,12 @@
 package anim
 
 import (
+	"crypto/sha256"
+	"fmt"
 	"image/color"
 	"math/rand/v2"
 	"strings"
+	"sync"
 	"sync/atomic"
 	"time"
 
@@ -58,6 +61,29 @@ func nextID() int {
 	return int(atomic.AddInt64(&lastID, 1))
 }
 
+// Cache for expensive animation calculations
+type animCache struct {
+	initialFrames  [][]string
+	cyclingFrames  [][]string
+	width          int
+	labelWidth     int
+	label          []string
+	ellipsisFrames []string
+}
+
+var (
+	animCacheMutex sync.RWMutex
+	animCacheMap   = make(map[string]*animCache)
+)
+
+// settingsHash creates a hash key for the settings to use for caching
+func settingsHash(opts Settings) string {
+	h := sha256.New()
+	fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
+		opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
+
 // StepMsg is a message type used to trigger the next step in the animation.
 type StepMsg struct{ id int }
 
@@ -109,79 +135,109 @@ func New(opts Settings) (a Anim) {
 	}
 
 	a.id = nextID()
-
 	a.startTime = time.Now()
 	a.cyclingCharWidth = opts.Size
-	a.labelWidth = lipgloss.Width(opts.Label)
 	a.labelColor = opts.LabelColor
 
-	// Total width of anim, in cells.
-	a.width = opts.Size
-	if opts.Label != "" {
-		a.width += labelGapWidth + lipgloss.Width(opts.Label)
-	}
-
-	// Render the label
-	a.renderLabel(opts.Label)
-
-	// Pre-generate gradient.
-	var ramp []color.Color
-	numFrames := prerenderedFrames
-	if opts.CycleColors {
-		ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
-		numFrames = a.width * 2
+	// Check cache first
+	cacheKey := settingsHash(opts)
+	animCacheMutex.RLock()
+	cached, exists := animCacheMap[cacheKey]
+	animCacheMutex.RUnlock()
+
+	if exists {
+		// Use cached values
+		a.width = cached.width
+		a.labelWidth = cached.labelWidth
+		a.label = cached.label
+		a.ellipsisFrames = cached.ellipsisFrames
+		a.initialFrames = cached.initialFrames
+		a.cyclingFrames = cached.cyclingFrames
 	} else {
-		ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
-	}
+		// Generate new values and cache them
+		a.labelWidth = lipgloss.Width(opts.Label)
 
-	// Pre-render initial characters.
-	a.initialFrames = make([][]string, numFrames)
-	offset := 0
-	for i := range a.initialFrames {
-		a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
-		for j := range a.initialFrames[i] {
-			if j+offset >= len(ramp) {
-				continue // skip if we run out of colors
-			}
+		// Total width of anim, in cells.
+		a.width = opts.Size
+		if opts.Label != "" {
+			a.width += labelGapWidth + lipgloss.Width(opts.Label)
+		}
 
-			var c color.Color
-			if j <= a.cyclingCharWidth {
-				c = ramp[j+offset]
-			} else {
-				c = opts.LabelColor
-			}
+		// Render the label
+		a.renderLabel(opts.Label)
 
-			// Also prerender the initial character with Lip Gloss to avoid
-			// processing in the render loop.
-			a.initialFrames[i][j] = lipgloss.NewStyle().
-				Foreground(c).
-				Render(string(initialChar))
-		}
+		// Pre-generate gradient.
+		var ramp []color.Color
+		numFrames := prerenderedFrames
 		if opts.CycleColors {
-			offset++
+			ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
+			numFrames = a.width * 2
+		} else {
+			ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
 		}
-	}
 
-	// Prerender scrambled rune frames for the animation.
-	a.cyclingFrames = make([][]string, numFrames)
-	offset = 0
-	for i := range a.cyclingFrames {
-		a.cyclingFrames[i] = make([]string, a.width)
-		for j := range a.cyclingFrames[i] {
-			if j+offset >= len(ramp) {
-				continue // skip if we run out of colors
+		// Pre-render initial characters.
+		a.initialFrames = make([][]string, numFrames)
+		offset := 0
+		for i := range a.initialFrames {
+			a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
+			for j := range a.initialFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				var c color.Color
+				if j <= a.cyclingCharWidth {
+					c = ramp[j+offset]
+				} else {
+					c = opts.LabelColor
+				}
+
+				// Also prerender the initial character with Lip Gloss to avoid
+				// processing in the render loop.
+				a.initialFrames[i][j] = lipgloss.NewStyle().
+					Foreground(c).
+					Render(string(initialChar))
+			}
+			if opts.CycleColors {
+				offset++
 			}
+		}
 
-			// Also prerender the color with Lip Gloss here to avoid processing
-			// in the render loop.
-			r := availableRunes[rand.IntN(len(availableRunes))]
-			a.cyclingFrames[i][j] = lipgloss.NewStyle().
-				Foreground(ramp[j+offset]).
-				Render(string(r))
+		// Prerender scrambled rune frames for the animation.
+		a.cyclingFrames = make([][]string, numFrames)
+		offset = 0
+		for i := range a.cyclingFrames {
+			a.cyclingFrames[i] = make([]string, a.width)
+			for j := range a.cyclingFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				// Also prerender the color with Lip Gloss here to avoid processing
+				// in the render loop.
+				r := availableRunes[rand.IntN(len(availableRunes))]
+				a.cyclingFrames[i][j] = lipgloss.NewStyle().
+					Foreground(ramp[j+offset]).
+					Render(string(r))
+			}
+			if opts.CycleColors {
+				offset++
+			}
 		}
-		if opts.CycleColors {
-			offset++
+
+		// Cache the results
+		cached = &animCache{
+			initialFrames:  a.initialFrames,
+			cyclingFrames:  a.cyclingFrames,
+			width:          a.width,
+			labelWidth:     a.labelWidth,
+			label:          a.label,
+			ellipsisFrames: a.ellipsisFrames,
 		}
+		animCacheMutex.Lock()
+		animCacheMap[cacheKey] = cached
+		animCacheMutex.Unlock()
 	}
 
 	// Random assign a birth to each character for a stagged entrance effect.