chore: make anim concurrent safe

This commit is contained in:
Kujtim Hoxha
2025-07-26 12:38:00 +02:00
parent d38891e9ad
commit 42b659d1a2
5 changed files with 67 additions and 55 deletions

View File

@@ -20,7 +20,7 @@ type Spinner struct {
type model struct {
cancel context.CancelFunc
anim anim.Anim
anim *anim.Anim
}
func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
mm, cmd := m.anim.Update(msg)
m.anim = mm.(anim.Anim)
m.anim = mm.(*anim.Anim)
return m, cmd
}

View File

@@ -6,7 +6,6 @@ import (
"image/color"
"math/rand/v2"
"strings"
"sync"
"sync/atomic"
"time"
@@ -15,6 +14,8 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lucasb-eyer/go-colorful"
"github.com/charmbracelet/crush/internal/csync"
)
const (
@@ -72,10 +73,7 @@ type animCache struct {
ellipsisFrames []string
}
var (
animCacheMutex sync.RWMutex
animCacheMap = make(map[string]*animCache)
)
var animCacheMap = csync.NewMap[string, *animCache]()
// settingsHash creates a hash key for the settings to use for caching
func settingsHash(opts Settings) string {
@@ -105,22 +103,23 @@ const ()
type Anim struct {
width int
cyclingCharWidth int
label []string
label *csync.Slice[string]
labelWidth int
labelColor color.Color
startTime time.Time
birthOffsets []time.Duration
initialFrames [][]string // frames for the initial characters
initialized bool
cyclingFrames [][]string // frames for the cycling characters
step int // current main frame step
ellipsisStep int // current ellipsis frame step
ellipsisFrames []string // ellipsis animation frames
initialized atomic.Bool
cyclingFrames [][]string // frames for the cycling characters
step atomic.Int64 // current main frame step
ellipsisStep atomic.Int64 // current ellipsis frame step
ellipsisFrames *csync.Slice[string] // ellipsis animation frames
id int
}
// New creates a new Anim instance with the specified width and label.
func New(opts Settings) (a Anim) {
func New(opts Settings) *Anim {
a := &Anim{}
// Validate settings.
if opts.Size < 1 {
opts.Size = defaultNumCyclingChars
@@ -142,16 +141,14 @@ func New(opts Settings) (a Anim) {
// Check cache first
cacheKey := settingsHash(opts)
animCacheMutex.RLock()
cached, exists := animCacheMap[cacheKey]
animCacheMutex.RUnlock()
cached, exists := animCacheMap.Get(cacheKey)
if exists {
// Use cached values
a.width = cached.width
a.labelWidth = cached.labelWidth
a.label = cached.label
a.ellipsisFrames = cached.ellipsisFrames
a.label = csync.NewSliceFrom(cached.label)
a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
a.initialFrames = cached.initialFrames
a.cyclingFrames = cached.cyclingFrames
} else {
@@ -228,17 +225,23 @@ func New(opts Settings) (a Anim) {
}
// Cache the results
labelSlice := make([]string, a.label.Len())
for i, v := range a.label.Seq2() {
labelSlice[i] = v
}
ellipsisSlice := make([]string, a.ellipsisFrames.Len())
for i, v := range a.ellipsisFrames.Seq2() {
ellipsisSlice[i] = v
}
cached = &animCache{
initialFrames: a.initialFrames,
cyclingFrames: a.cyclingFrames,
width: a.width,
labelWidth: a.labelWidth,
label: a.label,
ellipsisFrames: a.ellipsisFrames,
label: labelSlice,
ellipsisFrames: ellipsisSlice,
}
animCacheMutex.Lock()
animCacheMap[cacheKey] = cached
animCacheMutex.Unlock()
animCacheMap.Set(cacheKey, cached)
}
// Random assign a birth to each character for a stagged entrance effect.
@@ -269,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
if a.labelWidth > 0 {
// Pre-render the label.
labelRunes := []rune(label)
a.label = make([]string, len(labelRunes))
for i := range a.label {
a.label[i] = lipgloss.NewStyle().
a.label = csync.NewSlice[string]()
for i := range labelRunes {
rendered := lipgloss.NewStyle().
Foreground(a.labelColor).
Render(string(labelRunes[i]))
a.label.Append(rendered)
}
// Pre-render the ellipsis frames which come after the label.
a.ellipsisFrames = make([]string, len(ellipsisFrames))
for i, frame := range ellipsisFrames {
a.ellipsisFrames[i] = lipgloss.NewStyle().
a.ellipsisFrames = csync.NewSlice[string]()
for _, frame := range ellipsisFrames {
rendered := lipgloss.NewStyle().
Foreground(a.labelColor).
Render(frame)
a.ellipsisFrames.Append(rendered)
}
} else {
a.label = nil
a.ellipsisFrames = nil
a.label = csync.NewSlice[string]()
a.ellipsisFrames = csync.NewSlice[string]()
}
}
// Width returns the total width of the animation.
func (a Anim) Width() (w int) {
func (a *Anim) Width() (w int) {
w = a.width
if a.labelWidth > 0 {
w += labelGapWidth + a.labelWidth
@@ -308,12 +313,12 @@ func (a Anim) Width() (w int) {
}
// Init starts the animation.
func (a Anim) Init() tea.Cmd {
func (a *Anim) Init() tea.Cmd {
return a.Step()
}
// Update processes animation steps (or not).
func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case StepMsg:
if msg.id != a.id {
@@ -321,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
a.step++
if a.step >= len(a.cyclingFrames) {
a.step = 0
step := a.step.Add(1)
if int(step) >= len(a.cyclingFrames) {
a.step.Store(0)
}
if a.initialized && a.labelWidth > 0 {
if a.initialized.Load() && a.labelWidth > 0 {
// Manage the ellipsis animation.
a.ellipsisStep++
if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
a.ellipsisStep = 0
ellipsisStep := a.ellipsisStep.Add(1)
if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
a.ellipsisStep.Store(0)
}
} else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
a.initialized = true
} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
a.initialized.Store(true)
}
return a, a.Step()
default:
@@ -342,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the current state of the animation.
func (a Anim) View() string {
func (a *Anim) View() string {
var b strings.Builder
step := int(a.step.Load())
for i := range a.width {
switch {
case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
// Birth offset not reached: render initial character.
b.WriteString(a.initialFrames[a.step][i])
b.WriteString(a.initialFrames[step][i])
case i < a.cyclingCharWidth:
// Render a cycling character.
b.WriteString(a.cyclingFrames[a.step][i])
b.WriteString(a.cyclingFrames[step][i])
case i == a.cyclingCharWidth:
// Render label gap.
b.WriteString(labelGap)
case i > a.cyclingCharWidth:
// Label.
b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
b.WriteString(labelChar)
}
}
}
// Render animated ellipsis at the end of the label if all characters
// have been initialized.
if a.initialized && a.labelWidth > 0 {
b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
if a.initialized.Load() && a.labelWidth > 0 {
ellipsisStep := int(a.ellipsisStep.Load())
if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
b.WriteString(ellipsisFrame)
}
}
return b.String()
}
// Step is a command that triggers the next step in the animation.
func (a Anim) Step() tea.Cmd {
func (a *Anim) Step() tea.Cmd {
return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
return StepMsg{id: a.id}
})

View File

@@ -56,7 +56,7 @@ func (m model) View() tea.View {
return v
}
if a, ok := m.anim.(anim.Anim); ok {
if a, ok := m.anim.(*anim.Anim); ok {
l := lipgloss.NewLayer(a.View()).
Width(a.Width()).
X(m.w/2 - a.Width()/2).

View File

@@ -45,7 +45,7 @@ type messageCmp struct {
// Core message data and state
message message.Message // The underlying message content
spinning bool // Whether to show loading animation
anim anim.Anim // Animation component for loading states
anim *anim.Anim // Animation component for loading states
// Thinking viewport for displaying reasoning content
thinkingViewport viewport.Model
@@ -91,7 +91,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinning = m.shouldSpin()
if m.spinning {
u, cmd := m.anim.Update(msg)
m.anim = u.(anim.Anim)
m.anim = u.(*anim.Anim)
return m, cmd
}
}

View File

@@ -614,6 +614,7 @@ func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
}
func (m *sidebarCmp) filesBlock() string {
return ""
t := styles.CurrentTheme()
section := t.S().Subtle.Render(