anim.go

  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}