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/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}