From a6c4855fb55f2eb289e6d12ef351004b4e555e86 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 23 Jul 2025 14:50:06 +0200 Subject: [PATCH] chore: improve anim performance --- internal/tui/components/anim/anim.go | 172 ++++++++++++++++++--------- 1 file changed, 114 insertions(+), 58 deletions(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 241522c8989c89bf8eb877c69b9a72f01508c5f4..b1dab3c4b816e1ace67a4caa8d69c8273a188928 100644 --- a/internal/tui/components/anim/anim.go +++ b/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.