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