on main help sign in
chore: make anim concurrent safe
commit42b659d1a26e2e628914c834f54777477cbede81 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 26 Jul 2025, 12:38 (1 year ago) parentd38891e
5 files changed+67−55
internal/format/spinner.go+2−2 internal/tui/components/anim/anim.go+61−50 internal/tui/components/anim/example/main.go+1−1 internal/tui/components/chat/messages/messages.go+2−2 internal/tui/components/chat/sidebar/sidebar.go+1
internal/format/spinner.go+2−2
@@ -20,7 +20,7 @@ type Spinner struct {
2020
2121type model struct {
2222 cancel context.CancelFunc
23− anim anim.Anim
23+ anim *anim.Anim
2424}
2525
2626func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3737 }
3838 }
3939 mm, cmd := m.anim.Update(msg)
40− m.anim = mm.(anim.Anim)
40+ m.anim = mm.(*anim.Anim)
4141 return m, cmd
4242}
4343
internal/tui/components/anim/anim.go+61−50
@@ -6,7 +6,6 @@ import (
66 "image/color"
77 "math/rand/v2"
88 "strings"
9− "sync"
109 "sync/atomic"
1110 "time"
1211
@@ -15,6 +14,8 @@ import (
1514 tea "github.com/charmbracelet/bubbletea/v2"
1615 "github.com/charmbracelet/lipgloss/v2"
1716 "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
1819)
1920
2021const (
@@ -72,10 +73,7 @@ type animCache struct {
7273 ellipsisFrames []string
7374}
7475
75−var (
76− animCacheMutex sync.RWMutex
77− animCacheMap = make(map[string]*animCache)
78−)
76+var animCacheMap = csync.NewMap[string, *animCache]()
7977
8078// settingsHash creates a hash key for the settings to use for caching
8179func settingsHash(opts Settings) string {
@@ -105,22 +103,23 @@ const ()
105103type Anim struct {
106104 width int
107105 cyclingCharWidth int
108− label []string
106+ label *csync.Slice[string]
109107 labelWidth int
110108 labelColor color.Color
111109 startTime time.Time
112110 birthOffsets []time.Duration
113111 initialFrames [][]string // frames for the initial characters
114− initialized bool
115− cyclingFrames [][]string // frames for the cycling characters
116− step int // current main frame step
117− ellipsisStep int // current ellipsis frame step
118− ellipsisFrames []string // ellipsis animation frames
112+ initialized atomic.Bool
113+ cyclingFrames [][]string // frames for the cycling characters
114+ step atomic.Int64 // current main frame step
115+ ellipsisStep atomic.Int64 // current ellipsis frame step
116+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
119117 id int
120118}
121119
122120// New creates a new Anim instance with the specified width and label.
123−func New(opts Settings) (a Anim) {
121+func New(opts Settings) *Anim {
122+ a := &Anim{}
124123 // Validate settings.
125124 if opts.Size < 1 {
126125 opts.Size = defaultNumCyclingChars
@@ -142,16 +141,14 @@ func New(opts Settings) (a Anim) {
142141
143142 // Check cache first
144143 cacheKey := settingsHash(opts)
145− animCacheMutex.RLock()
146− cached, exists := animCacheMap[cacheKey]
147− animCacheMutex.RUnlock()
144+ cached, exists := animCacheMap.Get(cacheKey)
148145
149146 if exists {
150147 // Use cached values
151148 a.width = cached.width
152149 a.labelWidth = cached.labelWidth
153− a.label = cached.label
154− a.ellipsisFrames = cached.ellipsisFrames
150+ a.label = csync.NewSliceFrom(cached.label)
151+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
155152 a.initialFrames = cached.initialFrames
156153 a.cyclingFrames = cached.cyclingFrames
157154 } else {
@@ -228,17 +225,23 @@ func New(opts Settings) (a Anim) {
228225 }
229226
230227 // Cache the results
228+ labelSlice := make([]string, a.label.Len())
229+ for i, v := range a.label.Seq2() {
230+ labelSlice[i] = v
231+ }
232+ ellipsisSlice := make([]string, a.ellipsisFrames.Len())
233+ for i, v := range a.ellipsisFrames.Seq2() {
234+ ellipsisSlice[i] = v
235+ }
231236 cached = &animCache{
232237 initialFrames: a.initialFrames,
233238 cyclingFrames: a.cyclingFrames,
234239 width: a.width,
235240 labelWidth: a.labelWidth,
236− label: a.label,
237− ellipsisFrames: a.ellipsisFrames,
241+ label: labelSlice,
242+ ellipsisFrames: ellipsisSlice,
238243 }
239− animCacheMutex.Lock()
240− animCacheMap[cacheKey] = cached
241− animCacheMutex.Unlock()
244+ animCacheMap.Set(cacheKey, cached)
242245 }
243246
244247 // Random assign a birth to each character for a stagged entrance effect.
@@ -269,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
269272 if a.labelWidth > 0 {
270273 // Pre-render the label.
271274 labelRunes := []rune(label)
272− a.label = make([]string, len(labelRunes))
273− for i := range a.label {
274− a.label[i] = lipgloss.NewStyle().
275+ a.label = csync.NewSlice[string]()
276+ for i := range labelRunes {
277+ rendered := lipgloss.NewStyle().
275278 Foreground(a.labelColor).
276279 Render(string(labelRunes[i]))
280+ a.label.Append(rendered)
277281 }
278282
279283 // Pre-render the ellipsis frames which come after the label.
280− a.ellipsisFrames = make([]string, len(ellipsisFrames))
281− for i, frame := range ellipsisFrames {
282− a.ellipsisFrames[i] = lipgloss.NewStyle().
284+ a.ellipsisFrames = csync.NewSlice[string]()
285+ for _, frame := range ellipsisFrames {
286+ rendered := lipgloss.NewStyle().
283287 Foreground(a.labelColor).
284288 Render(frame)
289+ a.ellipsisFrames.Append(rendered)
285290 }
286291 } else {
287− a.label = nil
288− a.ellipsisFrames = nil
292+ a.label = csync.NewSlice[string]()
293+ a.ellipsisFrames = csync.NewSlice[string]()
289294 }
290295}
291296
292297// Width returns the total width of the animation.
293−func (a Anim) Width() (w int) {
298+func (a *Anim) Width() (w int) {
294299 w = a.width
295300 if a.labelWidth > 0 {
296301 w += labelGapWidth + a.labelWidth
@@ -308,12 +313,12 @@ func (a Anim) Width() (w int) {
308313}
309314
310315// Init starts the animation.
311−func (a Anim) Init() tea.Cmd {
316+func (a *Anim) Init() tea.Cmd {
312317 return a.Step()
313318}
314319
315320// Update processes animation steps (or not).
316−func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321+func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
317322 switch msg := msg.(type) {
318323 case StepMsg:
319324 if msg.id != a.id {
@@ -321,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321326 return a, nil
322327 }
323328
324− a.step++
325− if a.step >= len(a.cyclingFrames) {
326− a.step = 0
329+ step := a.step.Add(1)
330+ if int(step) >= len(a.cyclingFrames) {
331+ a.step.Store(0)
327332 }
328333
329− if a.initialized && a.labelWidth > 0 {
334+ if a.initialized.Load() && a.labelWidth > 0 {
330335 // Manage the ellipsis animation.
331− a.ellipsisStep++
332− if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
333− a.ellipsisStep = 0
336+ ellipsisStep := a.ellipsisStep.Add(1)
337+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
338+ a.ellipsisStep.Store(0)
334339 }
335− } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
336− a.initialized = true
340+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
341+ a.initialized.Store(true)
337342 }
338343 return a, a.Step()
339344 default:
@@ -342,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342347}
343348
344349// View renders the current state of the animation.
345−func (a Anim) View() string {
350+func (a *Anim) View() string {
346351 var b strings.Builder
352+ step := int(a.step.Load())
347353 for i := range a.width {
348354 switch {
349− case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
355+ case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
350356 // Birth offset not reached: render initial character.
351− b.WriteString(a.initialFrames[a.step][i])
357+ b.WriteString(a.initialFrames[step][i])
352358 case i < a.cyclingCharWidth:
353359 // Render a cycling character.
354− b.WriteString(a.cyclingFrames[a.step][i])
360+ b.WriteString(a.cyclingFrames[step][i])
355361 case i == a.cyclingCharWidth:
356362 // Render label gap.
357363 b.WriteString(labelGap)
358364 case i > a.cyclingCharWidth:
359365 // Label.
360− b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
366+ if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
367+ b.WriteString(labelChar)
368+ }
361369 }
362370 }
363371 // Render animated ellipsis at the end of the label if all characters
364372 // have been initialized.
365− if a.initialized && a.labelWidth > 0 {
366− b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
373+ if a.initialized.Load() && a.labelWidth > 0 {
374+ ellipsisStep := int(a.ellipsisStep.Load())
375+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
376+ b.WriteString(ellipsisFrame)
377+ }
367378 }
368379
369380 return b.String()
370381}
371382
372383// Step is a command that triggers the next step in the animation.
373−func (a Anim) Step() tea.Cmd {
384+func (a *Anim) Step() tea.Cmd {
374385 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
375386 return StepMsg{id: a.id}
376387 })
internal/tui/components/anim/example/main.go+1−1
@@ -56,7 +56,7 @@ func (m model) View() tea.View {
5656 return v
5757 }
5858
59− if a, ok := m.anim.(anim.Anim); ok {
59+ if a, ok := m.anim.(*anim.Anim); ok {
6060 l := lipgloss.NewLayer(a.View()).
6161 Width(a.Width()).
6262 X(m.w/2 - a.Width()/2).
internal/tui/components/chat/messages/messages.go+2−2
@@ -45,7 +45,7 @@ type messageCmp struct {
4545 // Core message data and state
4646 message message.Message // The underlying message content
4747 spinning bool // Whether to show loading animation
48− anim anim.Anim // Animation component for loading states
48+ anim *anim.Anim // Animation component for loading states
4949
5050 // Thinking viewport for displaying reasoning content
5151 thinkingViewport viewport.Model
@@ -91,7 +91,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9191 m.spinning = m.shouldSpin()
9292 if m.spinning {
9393 u, cmd := m.anim.Update(msg)
94− m.anim = u.(anim.Anim)
94+ m.anim = u.(*anim.Anim)
9595 return m, cmd
9696 }
9797 }
internal/tui/components/chat/sidebar/sidebar.go+1
@@ -614,6 +614,7 @@ func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
614614}
615615
616616func (m *sidebarCmp) filesBlock() string {
617+ return ""
617618 t := styles.CurrentTheme()
618619
619620 section := t.S().Subtle.Render(