From 42b659d1a26e2e628914c834f54777477cbede81 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 26 Jul 2025 12:38:00 +0200 Subject: [PATCH] chore: make anim concurrent safe --- internal/format/spinner.go | 4 +- internal/tui/components/anim/anim.go | 111 ++++++++++-------- internal/tui/components/anim/example/main.go | 2 +- .../tui/components/chat/messages/messages.go | 4 +- .../tui/components/chat/sidebar/sidebar.go | 1 + 5 files changed, 67 insertions(+), 55 deletions(-) diff --git a/internal/format/spinner.go b/internal/format/spinner.go index da64fb93ce262e04a0b5fb9da8c4aea8403d10d8..69e443d0f67adadd1e3f9b9a13129850324b6184 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -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 } diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 6f69e7b5332c7bbbb1aee6f1191acda5c7ddcfd8..05ac4da98281248d1774a10e95f4d8e2f177e048 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -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} }) diff --git a/internal/tui/components/anim/example/main.go b/internal/tui/components/anim/example/main.go index 23e2eef1f354ec473e52f54f817de4a2480d82ed..0bf47654ecbeeb3293c8ad59b40ec35016607b1c 100644 --- a/internal/tui/components/anim/example/main.go +++ b/internal/tui/components/anim/example/main.go @@ -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). diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 9f70691aa9843b8d823b26be247636b31212d2eb..ae8d8fdc4f7b0a8eaca777ffca406d976356e22c 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -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 } } diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 1aa239bdc15cec6898a4cba1e4dc7a867b5e4ce0..ee6985b6803ad21b600e17c3d2c90cd59f54a827 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -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(