1package anim
2
3import (
4 "image/color"
5 "math/rand"
6 "strings"
7 "time"
8
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/lucasb-eyer/go-colorful"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
14 "github.com/opencode-ai/opencode/internal/tui/theme"
15 "github.com/opencode-ai/opencode/internal/tui/util"
16)
17
18const (
19 charCyclingFPS = time.Second / 22
20 colorCycleFPS = time.Second / 5
21 maxCyclingChars = 120
22)
23
24var charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
25
26type charState int
27
28const (
29 charInitialState charState = iota
30 charCyclingState
31 charEndOfLifeState
32)
33
34// cyclingChar is a single animated character.
35type cyclingChar struct {
36 finalValue rune // if < 0 cycle forever
37 currentValue rune
38 initialDelay time.Duration
39 lifetime time.Duration
40}
41
42func (c cyclingChar) randomRune() rune {
43 return (charRunes)[rand.Intn(len(charRunes))] //nolint:gosec
44}
45
46func (c cyclingChar) state(start time.Time) charState {
47 now := time.Now()
48 if now.Before(start.Add(c.initialDelay)) {
49 return charInitialState
50 }
51 if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) {
52 return charEndOfLifeState
53 }
54 return charCyclingState
55}
56
57type StepCharsMsg struct{}
58
59func stepChars() tea.Cmd {
60 return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
61 return StepCharsMsg{}
62 })
63}
64
65type ColorCycleMsg struct{}
66
67func cycleColors() tea.Cmd {
68 return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
69 return ColorCycleMsg{}
70 })
71}
72
73// anim is the model that manages the animation that displays while the
74// output is being generated.
75type anim struct {
76 start time.Time
77 cyclingChars []cyclingChar
78 labelChars []cyclingChar
79 ramp []lipgloss.Style
80 label []rune
81 ellipsis spinner.Model
82 ellipsisStarted bool
83}
84
85func New(cyclingCharsSize uint, label string) util.Model {
86 // #nosec G115
87 n := min(int(cyclingCharsSize), maxCyclingChars)
88
89 gap := " "
90 if n == 0 {
91 gap = ""
92 }
93
94 c := anim{
95 start: time.Now(),
96 label: []rune(gap + label),
97 ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
98 }
99
100 // If we're in truecolor mode (and there are enough cycling characters)
101 // color the cycling characters with a gradient ramp.
102 const minRampSize = 3
103 if n >= minRampSize {
104 // Note: double capacity for color cycling as we'll need to reverse and
105 // append the ramp for seamless transitions.
106 c.ramp = make([]lipgloss.Style, n, n*2) //nolint:mnd
107 ramp := makeGradientRamp(n)
108 for i, color := range ramp {
109 c.ramp[i] = lipgloss.NewStyle().Foreground(color)
110 }
111 c.ramp = append(c.ramp, reverse(c.ramp)...) // reverse and append for color cycling
112 }
113
114 makeDelay := func(a int32, b time.Duration) time.Duration {
115 return time.Duration(rand.Int31n(a)) * (time.Millisecond * b) //nolint:gosec
116 }
117
118 makeInitialDelay := func() time.Duration {
119 return makeDelay(8, 60) //nolint:mnd
120 }
121
122 // Initial characters that cycle forever.
123 c.cyclingChars = make([]cyclingChar, n)
124
125 for i := range n {
126 c.cyclingChars[i] = cyclingChar{
127 finalValue: -1, // cycle forever
128 initialDelay: makeInitialDelay(),
129 }
130 }
131
132 // Label text that only cycles for a little while.
133 c.labelChars = make([]cyclingChar, len(c.label))
134
135 for i, r := range c.label {
136 c.labelChars[i] = cyclingChar{
137 finalValue: r,
138 initialDelay: makeInitialDelay(),
139 lifetime: makeDelay(5, 180), //nolint:mnd
140 }
141 }
142
143 return c
144}
145
146// Init initializes the animation.
147func (anim) Init() tea.Cmd {
148 return tea.Batch(stepChars(), cycleColors())
149}
150
151// Update handles messages.
152func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153 var cmd tea.Cmd
154 switch msg.(type) {
155 case StepCharsMsg:
156 a.updateChars(&a.cyclingChars)
157 a.updateChars(&a.labelChars)
158
159 if !a.ellipsisStarted {
160 var eol int
161 for _, c := range a.labelChars {
162 if c.state(a.start) == charEndOfLifeState {
163 eol++
164 }
165 }
166 if eol == len(a.label) {
167 // If our entire label has reached end of life, start the
168 // ellipsis "spinner" after a short pause.
169 a.ellipsisStarted = true
170 cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd
171 return a.ellipsis.Tick()
172 })
173 }
174 }
175
176 return a, tea.Batch(stepChars(), cmd)
177 case ColorCycleMsg:
178 const minColorCycleSize = 2
179 if len(a.ramp) < minColorCycleSize {
180 return a, nil
181 }
182 a.ramp = append(a.ramp[1:], a.ramp[0])
183 return a, cycleColors()
184 case spinner.TickMsg:
185 var cmd tea.Cmd
186 a.ellipsis, cmd = a.ellipsis.Update(msg)
187 return a, cmd
188 default:
189 return a, nil
190 }
191}
192
193func (a *anim) updateChars(chars *[]cyclingChar) {
194 for i, c := range *chars {
195 switch c.state(a.start) {
196 case charInitialState:
197 (*chars)[i].currentValue = '.'
198 case charCyclingState:
199 (*chars)[i].currentValue = c.randomRune()
200 case charEndOfLifeState:
201 (*chars)[i].currentValue = c.finalValue
202 }
203 }
204}
205
206// View renders the animation.
207func (a anim) View() string {
208 t := theme.CurrentTheme()
209 var b strings.Builder
210
211 for i, c := range a.cyclingChars {
212 if len(a.ramp) > i {
213 b.WriteString(a.ramp[i].Render(string(c.currentValue)))
214 continue
215 }
216 b.WriteRune(c.currentValue)
217 }
218
219 textStyle := styles.BaseStyle().
220 Foreground(t.Text())
221
222 for _, c := range a.labelChars {
223 b.WriteString(
224 textStyle.Render(string(c.currentValue)),
225 )
226 }
227
228 return b.String() + textStyle.Render(a.ellipsis.View())
229}
230
231func makeGradientRamp(length int) []color.Color {
232 t := theme.CurrentTheme()
233 startColor := theme.GetColor(t.Primary())
234 endColor := theme.GetColor(t.Secondary())
235 var (
236 c = make([]color.Color, length)
237 start, _ = colorful.Hex(startColor)
238 end, _ = colorful.Hex(endColor)
239 )
240 for i := range length {
241 step := start.BlendLuv(end, float64(i)/float64(length))
242 c[i] = lipgloss.Color(step.Hex())
243 }
244 return c
245}
246
247func reverse[T any](in []T) []T {
248 out := make([]T, len(in))
249 copy(out, in[:])
250 for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
251 out[i], out[j] = out[j], out[i]
252 }
253 return out
254}