Skip to content

Commit d52eaf6

Browse files
authored
Merge pull request #1669 from rumpl/fix-tick-leak
Fix tick leak
2 parents cb2ed7d + b159776 commit d52eaf6

2 files changed

Lines changed: 32 additions & 10 deletions

File tree

pkg/tui/components/spinner/spinner.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ const (
1919
ModeSpinnerOnly
2020
)
2121

22-
type Spinner struct {
23-
animSub animation.Subscription // manages animation tick subscription
22+
type Spinner interface {
23+
layout.Model
24+
Reset() Spinner
25+
Stop()
26+
}
27+
type spinner struct {
28+
animSub *animation.Subscription // manages animation tick subscription
2429
dotsStyle lipgloss.Style
2530
styledSpinnerFrames []string // pre-rendered spinner frames
2631
mode Mode
@@ -69,7 +74,9 @@ func New(mode Mode, dotsStyle lipgloss.Style) Spinner {
6974
styledFrames[i] = dotsStyle.Render(char)
7075
}
7176

72-
return Spinner{
77+
sub := &animation.Subscription{}
78+
return &spinner{
79+
animSub: sub,
7380
dotsStyle: dotsStyle,
7481
styledSpinnerFrames: styledFrames,
7582
mode: mode,
@@ -79,11 +86,11 @@ func New(mode Mode, dotsStyle lipgloss.Style) Spinner {
7986
}
8087
}
8188

82-
func (s Spinner) Reset() Spinner {
89+
func (s *spinner) Reset() Spinner {
8390
return New(s.mode, s.dotsStyle)
8491
}
8592

86-
func (s Spinner) Update(message tea.Msg) (layout.Model, tea.Cmd) {
93+
func (s *spinner) Update(message tea.Msg) (layout.Model, tea.Cmd) {
8794
if msg, ok := message.(animation.TickMsg); ok {
8895
// Respond to global animation tick (all spinners advance together)
8996
s.frame = msg.Frame
@@ -107,25 +114,25 @@ func (s Spinner) Update(message tea.Msg) (layout.Model, tea.Cmd) {
107114
return s, nil
108115
}
109116

110-
func (s Spinner) View() string {
117+
func (s *spinner) View() string {
111118
spinner := s.styledSpinnerFrames[s.frame%len(s.styledSpinnerFrames)]
112119
if s.mode == ModeSpinnerOnly {
113120
return spinner
114121
}
115122
return spinner + " " + s.renderMessage()
116123
}
117124

118-
func (s Spinner) SetSize(_, _ int) tea.Cmd { return nil }
125+
func (s *spinner) SetSize(_, _ int) tea.Cmd { return nil }
119126

120127
// Init registers the spinner with the animation coordinator.
121128
// If this is the first active animation, it starts the global tick.
122-
func (s Spinner) Init() tea.Cmd {
129+
func (s *spinner) Init() tea.Cmd {
123130
return s.animSub.Start()
124131
}
125132

126133
// Stop unregisters the spinner from the animation coordinator.
127134
// Call this when the spinner is no longer active/visible.
128-
func (s Spinner) Stop() {
135+
func (s *spinner) Stop() {
129136
s.animSub.Stop()
130137
}
131138

@@ -139,7 +146,7 @@ var lightStyles = []lipgloss.Style{
139146
styles.SpinnerTextDimmestStyle,
140147
}
141148

142-
func (s Spinner) renderMessage() string {
149+
func (s *spinner) renderMessage() string {
143150
var out strings.Builder
144151
for i, char := range s.currentMessage {
145152
dist := min(max(i-s.lightPosition, s.lightPosition-i), len(lightStyles)-1)

pkg/tui/components/spinner/spinner_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,23 @@ import (
44
"testing"
55

66
"charm.land/lipgloss/v2"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/cagent/pkg/tui/animation"
710
)
811

12+
func TestSpinnerCopyDoesNotLeakAnimationSubscription(t *testing.T) {
13+
s1 := New(ModeSpinnerOnly, lipgloss.NewStyle())
14+
cmd := s1.Init()
15+
require.NotNil(t, cmd)
16+
require.True(t, animation.HasActive())
17+
18+
// Copy the spinner value and stop via the copy; should still stop the shared subscription.
19+
s2 := s1
20+
s2.Stop()
21+
require.False(t, animation.HasActive())
22+
}
23+
924
func BenchmarkSpinner_ModeSpinnerOnly(b *testing.B) {
1025
s := New(ModeSpinnerOnly, lipgloss.NewStyle())
1126
for b.Loop() {

0 commit comments

Comments
 (0)