Skip to content

Commit 8526bbb

Browse files
authored
Merge pull request #1430 from dgageot/faster-spinner
Faster spinner
2 parents 9775565 + 2faf60a commit 8526bbb

File tree

2 files changed

+85
-112
lines changed

2 files changed

+85
-112
lines changed

pkg/tui/components/spinner/spinner.go

Lines changed: 64 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package spinner
22

33
import (
44
"math/rand/v2"
5+
"strings"
56
"sync/atomic"
67
"time"
78

@@ -17,32 +18,26 @@ type Mode int
1718
const (
1819
ModeBoth Mode = iota
1920
ModeSpinnerOnly
20-
ModeMessageOnly
2121
)
2222

23-
var lastID int64
24-
25-
func nextID() int {
26-
return int(atomic.AddInt64(&lastID, 1))
27-
}
23+
var lastID atomic.Int64
2824

2925
type tickMsg struct {
30-
Time time.Time
31-
tag int
32-
ID int
26+
tag int
27+
id int
3328
}
3429

3530
type Spinner struct {
36-
dotsStyle lipgloss.Style
37-
messages []string
38-
mode Mode
39-
currentMessage string
40-
lightPosition int
41-
frame int
42-
id int
43-
tag int
44-
direction int // 1 for forward, -1 for backward
45-
pauseFrames int
31+
dotsStyle lipgloss.Style
32+
styledSpinnerFrames []string // pre-rendered spinner frames
33+
mode Mode
34+
currentMessage string
35+
lightPosition int
36+
frame int
37+
id int
38+
tag int
39+
direction int // 1 for forward, -1 for backward
40+
pauseFrames int
4641
}
4742

4843
// Default messages for the spinner
@@ -70,21 +65,21 @@ var defaultMessages = []string{
7065
}
7166

7267
func New(mode Mode, dotsStyle lipgloss.Style) Spinner {
73-
return Spinner{
74-
dotsStyle: dotsStyle,
75-
messages: defaultMessages,
76-
mode: mode,
77-
currentMessage: defaultMessages[rand.IntN(len(defaultMessages))],
78-
lightPosition: -3,
79-
frame: 0,
80-
id: nextID(),
81-
direction: 1,
82-
pauseFrames: 0,
68+
// Pre-render all spinner frames for fast lookup during render
69+
styledFrames := make([]string, len(spinnerChars))
70+
for i, char := range spinnerChars {
71+
styledFrames[i] = dotsStyle.Render(char)
8372
}
84-
}
8573

86-
func (s Spinner) Init() tea.Cmd {
87-
return s.Tick()
74+
return Spinner{
75+
dotsStyle: dotsStyle,
76+
styledSpinnerFrames: styledFrames,
77+
mode: mode,
78+
currentMessage: defaultMessages[rand.IntN(len(defaultMessages))],
79+
lightPosition: -3,
80+
id: int(lastID.Add(1)),
81+
direction: 1,
82+
}
8883
}
8984

9085
func (s Spinner) Reset() Spinner {
@@ -93,107 +88,64 @@ func (s Spinner) Reset() Spinner {
9388

9489
func (s Spinner) Update(message tea.Msg) (layout.Model, tea.Cmd) {
9590
msg, ok := message.(tickMsg)
96-
if !ok {
97-
return s, nil
98-
}
99-
100-
if msg.ID > 0 && msg.ID != s.id {
101-
return s, nil
102-
}
103-
if msg.tag > 0 && msg.tag != s.tag {
91+
if !ok || (msg.id > 0 && msg.id != s.id) || (msg.tag > 0 && msg.tag != s.tag) {
10492
return s, nil
10593
}
10694

10795
s.tag++
10896
s.frame++
10997

110-
if s.pauseFrames > 0 {
111-
s.pauseFrames--
112-
if s.pauseFrames == 0 {
113-
s.direction = -1
114-
}
115-
} else {
116-
s.lightPosition += s.direction
117-
118-
// Use rune count for proper Unicode character handling in light animation
119-
messageRuneCount := len([]rune(s.currentMessage))
120-
if s.direction == 1 && s.lightPosition > messageRuneCount+2 {
121-
s.pauseFrames = 6
122-
}
123-
124-
if s.direction == -1 && s.lightPosition < -3 {
125-
s.direction = 1
98+
// Light animation only needed for ModeBoth
99+
if s.mode == ModeBoth {
100+
if s.pauseFrames > 0 {
101+
s.pauseFrames--
102+
if s.pauseFrames == 0 {
103+
s.direction = -1
104+
}
105+
} else {
106+
s.lightPosition += s.direction
107+
if s.direction == 1 && s.lightPosition > len([]rune(s.currentMessage))+2 {
108+
s.pauseFrames = 6
109+
} else if s.direction == -1 && s.lightPosition < -3 {
110+
s.direction = 1
111+
}
126112
}
127113
}
128114

129115
return s, s.Tick()
130116
}
131117

132118
func (s Spinner) View() string {
133-
return s.render()
134-
}
135-
136-
func (s Spinner) SetSize(_, _ int) tea.Cmd {
137-
return nil
119+
spinner := s.styledSpinnerFrames[s.frame%len(s.styledSpinnerFrames)]
120+
if s.mode == ModeSpinnerOnly {
121+
return spinner
122+
}
123+
return spinner + " " + s.renderMessage()
138124
}
139125

126+
func (s Spinner) SetSize(_, _ int) tea.Cmd { return nil }
127+
func (s Spinner) Init() tea.Cmd { return s.Tick() }
140128
func (s Spinner) Tick() tea.Cmd {
141-
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
142-
return tickMsg{
143-
Time: t,
144-
ID: s.id,
145-
tag: s.tag,
146-
}
129+
return tea.Tick(50*time.Millisecond, func(time.Time) tea.Msg {
130+
return tickMsg{id: s.id, tag: s.tag}
147131
})
148132
}
149133

150-
func (s Spinner) render() string {
151-
message := s.currentMessage
152-
output := make([]rune, 0, len(message))
153-
154-
for i, char := range message {
155-
distance := abs(i - s.lightPosition)
156-
157-
var style lipgloss.Style
158-
switch distance {
159-
case 0:
160-
style = styles.SpinnerTextBrightestStyle
161-
case 1:
162-
style = styles.SpinnerTextBrightStyle
163-
case 2:
164-
style = styles.SpinnerTextDimStyle
165-
default:
166-
style = styles.SpinnerTextDimmestStyle
167-
}
168-
169-
output = append(output, []rune(style.Render(string(char)))...)
170-
}
171-
172-
spinnerChars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
173-
spinnerChar := spinnerChars[s.frame%len(spinnerChars)]
174-
spinnerStyled := s.dotsStyle.Render(spinnerChar)
175-
176-
switch s.mode {
177-
case ModeSpinnerOnly:
178-
return spinnerStyled
179-
case ModeMessageOnly:
180-
return string(output)
181-
}
182-
183-
return spinnerStyled + " " + string(output)
184-
}
185-
186-
func (s *Spinner) Render() string {
187-
return s.render()
188-
}
134+
var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
189135

190-
func (s *Spinner) SetMessage(message string) {
191-
s.currentMessage = message
136+
// lightStyles maps distance from light position to style (0=brightest, 1=bright, 2=dim, 3+=dimmest).
137+
var lightStyles = []lipgloss.Style{
138+
styles.SpinnerTextBrightestStyle,
139+
styles.SpinnerTextBrightStyle,
140+
styles.SpinnerTextDimStyle,
141+
styles.SpinnerTextDimmestStyle,
192142
}
193143

194-
func abs(x int) int {
195-
if x < 0 {
196-
return -x
144+
func (s Spinner) renderMessage() string {
145+
var out strings.Builder
146+
for i, char := range s.currentMessage {
147+
dist := min(max(i-s.lightPosition, s.lightPosition-i), len(lightStyles)-1)
148+
out.WriteString(lightStyles[dist].Render(string(char)))
197149
}
198-
return x
150+
return out.String()
199151
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package spinner
2+
3+
import (
4+
"testing"
5+
6+
"charm.land/lipgloss/v2"
7+
)
8+
9+
func BenchmarkSpinner_ModeSpinnerOnly(b *testing.B) {
10+
s := New(ModeSpinnerOnly, lipgloss.NewStyle())
11+
for b.Loop() {
12+
_ = s.View()
13+
}
14+
}
15+
16+
func BenchmarkSpinner_ModeBoth(b *testing.B) {
17+
s := New(ModeBoth, lipgloss.NewStyle())
18+
for b.Loop() {
19+
_ = s.View()
20+
}
21+
}

0 commit comments

Comments
 (0)