on main help sign in
chore: make anim concurrent safe
42b659dKujtim Hoxha
internal/format/spinner.go+2−2
@@ -20,7 +20,7 @@ type Spinner struct {
20
20
21type model struct {
21type model struct {
22 cancel context.CancelFunc
22 cancel context.CancelFunc
23− anim anim.Anim
23+ anim *anim.Anim
24}
24}
25
25
26func (m model) Init() tea.Cmd { return m.anim.Init() }
26func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
37 }
37 }
38 }
38 }
39 mm, cmd := m.anim.Update(msg)
39 mm, cmd := m.anim.Update(msg)
40− m.anim = mm.(anim.Anim)
40+ m.anim = mm.(*anim.Anim)
41 return m, cmd
41 return m, cmd
42}
42}
43
43
internal/tui/components/anim/anim.go+61−50
@@ -6,7 +6,6 @@ import (
6 "image/color"
6 "image/color"
7 "math/rand/v2"
7 "math/rand/v2"
8 "strings"
8 "strings"
9− "sync"
10 "sync/atomic"
9 "sync/atomic"
11 "time"
10 "time"
12
11
@@ -15,6 +14,8 @@ import (
15 tea "github.com/charmbracelet/bubbletea/v2"
14 tea "github.com/charmbracelet/bubbletea/v2"
16 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
17 "github.com/lucasb-eyer/go-colorful"
16 "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
18)
19)
19
20
20const (
21const (
@@ -72,10 +73,7 @@ type animCache struct {
72 ellipsisFrames []string
73 ellipsisFrames []string
73}
74}
74
75
75−var (
76− animCacheMutex sync.RWMutex
77− animCacheMap = make(map[string]*animCache)
76+var animCacheMap = csync.NewMap[string, *animCache]()
78−)
79
77
80// settingsHash creates a hash key for the settings to use for caching
78// settingsHash creates a hash key for the settings to use for caching
81func settingsHash(opts Settings) string {
79func settingsHash(opts Settings) string {
@@ -105,22 +103,23 @@ const ()
105type Anim struct {
103type Anim struct {
106 width int
104 width int
107 cyclingCharWidth int
105 cyclingCharWidth int
108− label []string
106+ label *csync.Slice[string]
109 labelWidth int
107 labelWidth int
110 labelColor color.Color
108 labelColor color.Color
111 startTime time.Time
109 startTime time.Time
112 birthOffsets []time.Duration
110 birthOffsets []time.Duration
113 initialFrames [][]string // frames for the initial characters
111 initialFrames [][]string // frames for the initial characters
114− initialized bool
112+ initialized atomic.Bool
115− cyclingFrames [][]string // frames for the cycling characters
113+ cyclingFrames [][]string // frames for the cycling characters
116− step int // current main frame step
114+ step atomic.Int64 // current main frame step
117− ellipsisStep int // current ellipsis frame step
115+ ellipsisStep atomic.Int64 // current ellipsis frame step
118− ellipsisFrames []string // ellipsis animation frames
116+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
119 id int
117 id int
120}
118}
121
119
122// New creates a new Anim instance with the specified width and label.
120// 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{}
124 // Validate settings.
123 // Validate settings.
125 if opts.Size < 1 {
124 if opts.Size < 1 {
126 opts.Size = defaultNumCyclingChars
125 opts.Size = defaultNumCyclingChars
@@ -142,16 +141,14 @@ func New(opts Settings) (a Anim) {
142
141
143 // Check cache first
142 // Check cache first
144 cacheKey := settingsHash(opts)
143 cacheKey := settingsHash(opts)
145− animCacheMutex.RLock()
146− cached, exists := animCacheMap[cacheKey]
144+ cached, exists := animCacheMap.Get(cacheKey)
147− animCacheMutex.RUnlock()
148
145
149 if exists {
146 if exists {
150 // Use cached values
147 // Use cached values
151 a.width = cached.width
148 a.width = cached.width
152 a.labelWidth = cached.labelWidth
149 a.labelWidth = cached.labelWidth
153− a.label = cached.label
150+ a.label = csync.NewSliceFrom(cached.label)
154− a.ellipsisFrames = cached.ellipsisFrames
151+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
155 a.initialFrames = cached.initialFrames
152 a.initialFrames = cached.initialFrames
156 a.cyclingFrames = cached.cyclingFrames
153 a.cyclingFrames = cached.cyclingFrames
157 } else {
154 } else {
@@ -228,17 +225,23 @@ func New(opts Settings) (a Anim) {
228 }
225 }
229
226
230 // Cache the results
227 // 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+ }
231 cached = &animCache{
236 cached = &animCache{
232 initialFrames: a.initialFrames,
237 initialFrames: a.initialFrames,
233 cyclingFrames: a.cyclingFrames,
238 cyclingFrames: a.cyclingFrames,
234 width: a.width,
239 width: a.width,
235 labelWidth: a.labelWidth,
240 labelWidth: a.labelWidth,
236− label: a.label,
241+ label: labelSlice,
237− ellipsisFrames: a.ellipsisFrames,
242+ ellipsisFrames: ellipsisSlice,
238 }
243 }
239− animCacheMutex.Lock()
240− animCacheMap[cacheKey] = cached
244+ animCacheMap.Set(cacheKey, cached)
241− animCacheMutex.Unlock()
242 }
245 }
243
246
244 // Random assign a birth to each character for a stagged entrance effect.
247 // Random assign a birth to each character for a stagged entrance effect.
@@ -269,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
269 if a.labelWidth > 0 {
272 if a.labelWidth > 0 {
270 // Pre-render the label.
273 // Pre-render the label.
271 labelRunes := []rune(label)
274 labelRunes := []rune(label)
272− a.label = make([]string, len(labelRunes))
275+ a.label = csync.NewSlice[string]()
273− for i := range a.label {
276+ for i := range labelRunes {
274− a.label[i] = lipgloss.NewStyle().
277+ rendered := lipgloss.NewStyle().
275 Foreground(a.labelColor).
278 Foreground(a.labelColor).
276 Render(string(labelRunes[i]))
279 Render(string(labelRunes[i]))
280+ a.label.Append(rendered)
277 }
281 }
278
282
279 // Pre-render the ellipsis frames which come after the label.
283 // Pre-render the ellipsis frames which come after the label.
280− a.ellipsisFrames = make([]string, len(ellipsisFrames))
284+ a.ellipsisFrames = csync.NewSlice[string]()
281− for i, frame := range ellipsisFrames {
285+ for _, frame := range ellipsisFrames {
282− a.ellipsisFrames[i] = lipgloss.NewStyle().
286+ rendered := lipgloss.NewStyle().
283 Foreground(a.labelColor).
287 Foreground(a.labelColor).
284 Render(frame)
288 Render(frame)
289+ a.ellipsisFrames.Append(rendered)
285 }
290 }
286 } else {
291 } else {
287− a.label = nil
292+ a.label = csync.NewSlice[string]()
288− a.ellipsisFrames = nil
293+ a.ellipsisFrames = csync.NewSlice[string]()
289 }
294 }
290}
295}
291
296
292// Width returns the total width of the animation.
297// Width returns the total width of the animation.
293−func (a Anim) Width() (w int) {
298+func (a *Anim) Width() (w int) {
294 w = a.width
299 w = a.width
295 if a.labelWidth > 0 {
300 if a.labelWidth > 0 {
296 w += labelGapWidth + a.labelWidth
301 w += labelGapWidth + a.labelWidth
@@ -308,12 +313,12 @@ func (a Anim) Width() (w int) {
308}
313}
309
314
310// Init starts the animation.
315// Init starts the animation.
311−func (a Anim) Init() tea.Cmd {
316+func (a *Anim) Init() tea.Cmd {
312 return a.Step()
317 return a.Step()
313}
318}
314
319
315// Update processes animation steps (or not).
320// 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) {
317 switch msg := msg.(type) {
322 switch msg := msg.(type) {
318 case StepMsg:
323 case StepMsg:
319 if msg.id != a.id {
324 if msg.id != a.id {
@@ -321,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321 return a, nil
326 return a, nil
322 }
327 }
323
328
324− a.step++
329+ step := a.step.Add(1)
325− if a.step >= len(a.cyclingFrames) {
330+ if int(step) >= len(a.cyclingFrames) {
326− a.step = 0
331+ a.step.Store(0)
327 }
332 }
328
333
329− if a.initialized && a.labelWidth > 0 {
334+ if a.initialized.Load() && a.labelWidth > 0 {
330 // Manage the ellipsis animation.
335 // Manage the ellipsis animation.
331− a.ellipsisStep++
336+ ellipsisStep := a.ellipsisStep.Add(1)
332− if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
337+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
333− a.ellipsisStep = 0
338+ a.ellipsisStep.Store(0)
334 }
339 }
335− } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
340+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
336− a.initialized = true
341+ a.initialized.Store(true)
337 }
342 }
338 return a, a.Step()
343 return a, a.Step()
339 default:
344 default:
@@ -342,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342}
347}
343
348
344// View renders the current state of the animation.
349// View renders the current state of the animation.
345−func (a Anim) View() string {
350+func (a *Anim) View() string {
346 var b strings.Builder
351 var b strings.Builder
352+ step := int(a.step.Load())
347 for i := range a.width {
353 for i := range a.width {
348 switch {
354 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]:
350 // Birth offset not reached: render initial character.
356 // Birth offset not reached: render initial character.
351− b.WriteString(a.initialFrames[a.step][i])
357+ b.WriteString(a.initialFrames[step][i])
352 case i < a.cyclingCharWidth:
358 case i < a.cyclingCharWidth:
353 // Render a cycling character.
359 // Render a cycling character.
354− b.WriteString(a.cyclingFrames[a.step][i])
360+ b.WriteString(a.cyclingFrames[step][i])
355 case i == a.cyclingCharWidth:
361 case i == a.cyclingCharWidth:
356 // Render label gap.
362 // Render label gap.
357 b.WriteString(labelGap)
363 b.WriteString(labelGap)
358 case i > a.cyclingCharWidth:
364 case i > a.cyclingCharWidth:
359 // Label.
365 // 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+ }
361 }
369 }
362 }
370 }
363 // Render animated ellipsis at the end of the label if all characters
371 // Render animated ellipsis at the end of the label if all characters
364 // have been initialized.
372 // have been initialized.
365− if a.initialized && a.labelWidth > 0 {
373+ if a.initialized.Load() && a.labelWidth > 0 {
374+ ellipsisStep := int(a.ellipsisStep.Load())
366− b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
375+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
376+ b.WriteString(ellipsisFrame)
377+ }
367 }
378 }
368
379
369 return b.String()
380 return b.String()
370}
381}
371
382
372// Step is a command that triggers the next step in the animation.
383// 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 {
374 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
385 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
375 return StepMsg{id: a.id}
386 return StepMsg{id: a.id}
376 })
387 })
internal/tui/components/anim/example/main.go+1−1
@@ -56,7 +56,7 @@ func (m model) View() tea.View {
56 return v
56 return v
57 }
57 }
58
58
59− if a, ok := m.anim.(anim.Anim); ok {
59+ if a, ok := m.anim.(*anim.Anim); ok {
60 l := lipgloss.NewLayer(a.View()).
60 l := lipgloss.NewLayer(a.View()).
61 Width(a.Width()).
61 Width(a.Width()).
62 X(m.w/2 - a.Width()/2).
62 X(m.w/2 - a.Width()/2).
internal/tui/components/chat/messages/messages.go+2−2
@@ -45,7 +45,7 @@ type messageCmp struct {
45 // Core message data and state
45 // Core message data and state
46 message message.Message // The underlying message content
46 message message.Message // The underlying message content
47 spinning bool // Whether to show loading animation
47 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
49
49
50 // Thinking viewport for displaying reasoning content
50 // Thinking viewport for displaying reasoning content
51 thinkingViewport viewport.Model
51 thinkingViewport viewport.Model
@@ -91,7 +91,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91 m.spinning = m.shouldSpin()
91 m.spinning = m.shouldSpin()
92 if m.spinning {
92 if m.spinning {
93 u, cmd := m.anim.Update(msg)
93 u, cmd := m.anim.Update(msg)
94− m.anim = u.(anim.Anim)
94+ m.anim = u.(*anim.Anim)
95 return m, cmd
95 return m, cmd
96 }
96 }
97 }
97 }
internal/tui/components/chat/sidebar/sidebar.go+1
@@ -614,6 +614,7 @@ func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
614}
614}
615
615
616func (m *sidebarCmp) filesBlock() string {
616func (m *sidebarCmp) filesBlock() string {
617+ return ""
617 t := styles.CurrentTheme()
618 t := styles.CurrentTheme()
618
619
619 section := t.S().Subtle.Render(
620 section := t.S().Subtle.Render(