1// Package spinner implements a spinner used to indicate processing is occurring.
2package spinner
3
4import (
5 "image/color"
6 "strings"
7 "sync/atomic"
8 "time"
9
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/charmbracelet/x/exp/charmtone"
13)
14
15const (
16 fps = 24
17 decay = 12
18 pauseSteps = 48
19 lowChar = "ยท"
20 highChar = "โ"
21 ellipsisChar = "."
22 maxEllipsisDots = 3
23 ellipsisFPS = 8
24 ellipsisPause = 2 // frames to pause at max dots
25)
26
27// Internal ID management. Used during animating to ensure that frame messages
28// are received only by spinner components that sent them.
29var lastID int64
30
31func nextID() int {
32 return int(atomic.AddInt64(&lastID, 1))
33}
34
35type Config struct {
36 Width int
37 EmptyColor color.Color
38 Blend []color.Color
39 LabelColor color.Color
40}
41
42// DefaultConfig returns the default spinner configuration.
43func DefaultConfig() Config {
44 return Config{
45 Width: 14,
46 LabelColor: charmtone.Smoke,
47 EmptyColor: charmtone.Charcoal,
48 Blend: []color.Color{
49 charmtone.Charcoal,
50 charmtone.Charple,
51 charmtone.Dolly,
52 },
53 }
54}
55
56// StepMsg is a message sent to spinners to indicate it's time to update their
57// state.
58type StepMsg struct {
59 ID int
60 tag int
61}
62
63// Spinner is a spinner Bubble.
64type Spinner struct {
65 Label string
66 Config Config
67 id int
68 tag int
69 ellipsisStep int
70 index int
71 pause int
72 cells []int
73 maxAt []int // frame when cell reached max height
74 emptyChar string
75 blendStyles []lipgloss.Style
76 labelEllipsisDot string
77}
78
79// NewSpinner creates a new Spinner with the given label.
80func NewSpinner(label string) Spinner {
81 c := DefaultConfig()
82 blend := lipgloss.Blend1D(c.Width, c.Blend...)
83 blendStyles := make([]lipgloss.Style, len(blend))
84
85 for i, s := range blend {
86 blendStyles[i] = lipgloss.NewStyle().Foreground(s)
87 }
88
89 labelStyle := lipgloss.NewStyle().Foreground(c.LabelColor)
90
91 return Spinner{
92 Label: labelStyle.Render(label),
93 labelEllipsisDot: labelStyle.Render(ellipsisChar),
94 Config: c,
95 id: nextID(),
96 index: -1,
97 cells: make([]int, c.Width),
98 maxAt: make([]int, c.Width),
99 emptyChar: lipgloss.NewStyle().Foreground(c.EmptyColor).Render(string(lowChar)),
100 blendStyles: blendStyles,
101 }
102}
103
104// Init initializes the spinner. It satisfies tea.Model.
105func (s Spinner) Init() tea.Cmd {
106 return nil
107}
108
109// Update updates the spinner per incoming messages. It satisfies tea.Model.
110func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) {
111 if _, ok := msg.(StepMsg); ok {
112 if msg.(StepMsg).ID != s.id {
113 // Reject events from other spinners.
114 return s, nil
115 }
116
117 s.ellipsisStep++
118 if s.ellipsisStep > ellipsisFPS*(maxEllipsisDots+ellipsisPause) {
119 s.ellipsisStep = 0
120 }
121
122 if s.pause > 0 {
123 s.pause--
124 } else {
125 s.index++
126 if s.index > s.Config.Width {
127 s.pause = pauseSteps
128 s.index = -1
129 }
130
131 }
132
133 for i, c := range s.cells {
134 if s.index == i {
135 s.cells[i] = s.Config.Width - 1
136 s.maxAt[i] = s.tag
137 } else {
138 if s.maxAt[i] >= 0 && s.tag-s.maxAt[i] < decay {
139 continue
140 }
141 s.cells[i] = max(0, c-1)
142 }
143 }
144
145 s.tag++
146 return s, s.Start()
147 }
148 return s, nil
149}
150
151// Start starts the spinner animation.
152func (s Spinner) Start() tea.Cmd {
153 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
154 return StepMsg{ID: s.id}
155 })
156}
157
158// View renders the spinner to a string. It satisfies tea.Model.
159func (s Spinner) View() string {
160 if s.Config.Width == 0 {
161 return ""
162 }
163
164 var b strings.Builder
165 for i := range s.cells {
166 if s.cells[i] == 0 {
167 b.WriteString(s.emptyChar)
168 continue
169 }
170 b.WriteString(s.blendStyles[s.cells[i]-1].Render(highChar))
171 }
172
173 if s.Label != "" {
174 b.WriteString(" ")
175 b.WriteString(s.Label)
176
177 // Draw ellipsis.
178 dots := min(s.ellipsisStep/ellipsisFPS, maxEllipsisDots)
179 for range dots {
180 b.WriteString(s.labelEllipsisDot)
181 }
182 }
183
184 return b.String()
185}