anim.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. // Package anim provides an animated spinner.
  2. package anim
  3. import (
  4. "fmt"
  5. "image/color"
  6. "math/rand/v2"
  7. "strings"
  8. "sync/atomic"
  9. "time"
  10. "github.com/zeebo/xxh3"
  11. tea "github.com/charmbracelet/bubbletea/v2"
  12. "github.com/charmbracelet/lipgloss/v2"
  13. "github.com/lucasb-eyer/go-colorful"
  14. "github.com/charmbracelet/crush/internal/csync"
  15. "github.com/charmbracelet/crush/internal/tui/util"
  16. )
  17. const (
  18. fps = 20
  19. initialChar = '.'
  20. labelGap = " "
  21. labelGapWidth = 1
  22. // Periods of ellipsis animation speed in steps.
  23. //
  24. // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
  25. // change every 8 frames (400 milliseconds).
  26. ellipsisAnimSpeed = 8
  27. // The maximum amount of time that can pass before a character appears.
  28. // This is used to create a staggered entrance effect.
  29. maxBirthOffset = time.Second
  30. // Number of frames to prerender for the animation. After this number
  31. // of frames, the animation will loop. This only applies when color
  32. // cycling is disabled.
  33. prerenderedFrames = 10
  34. // Default number of cycling chars.
  35. defaultNumCyclingChars = 10
  36. )
  37. // Default colors for gradient.
  38. var (
  39. defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
  40. defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
  41. defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
  42. )
  43. var (
  44. availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
  45. ellipsisFrames = []string{".", "..", "...", ""}
  46. )
  47. // Internal ID management. Used during animating to ensure that frame messages
  48. // are received only by spinner components that sent them.
  49. var lastID int64
  50. func nextID() int {
  51. return int(atomic.AddInt64(&lastID, 1))
  52. }
  53. // Cache for expensive animation calculations
  54. type animCache struct {
  55. initialFrames [][]string
  56. cyclingFrames [][]string
  57. width int
  58. labelWidth int
  59. label []string
  60. ellipsisFrames []string
  61. }
  62. var animCacheMap = csync.NewMap[string, *animCache]()
  63. // settingsHash creates a hash key for the settings to use for caching
  64. func settingsHash(opts Settings) string {
  65. h := xxh3.New()
  66. fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
  67. opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
  68. return fmt.Sprintf("%x", h.Sum(nil))
  69. }
  70. // StepMsg is a message type used to trigger the next step in the animation.
  71. type StepMsg struct{ id int }
  72. // Settings defines settings for the animation.
  73. type Settings struct {
  74. Size int
  75. Label string
  76. LabelColor color.Color
  77. GradColorA color.Color
  78. GradColorB color.Color
  79. CycleColors bool
  80. }
  81. // Default settings.
  82. const ()
  83. // Anim is a Bubble for an animated spinner.
  84. type Anim struct {
  85. width int
  86. cyclingCharWidth int
  87. label *csync.Slice[string]
  88. labelWidth int
  89. labelColor color.Color
  90. startTime time.Time
  91. birthOffsets []time.Duration
  92. initialFrames [][]string // frames for the initial characters
  93. initialized atomic.Bool
  94. cyclingFrames [][]string // frames for the cycling characters
  95. step atomic.Int64 // current main frame step
  96. ellipsisStep atomic.Int64 // current ellipsis frame step
  97. ellipsisFrames *csync.Slice[string] // ellipsis animation frames
  98. id int
  99. }
  100. // New creates a new Anim instance with the specified width and label.
  101. func New(opts Settings) *Anim {
  102. a := &Anim{}
  103. // Validate settings.
  104. if opts.Size < 1 {
  105. opts.Size = defaultNumCyclingChars
  106. }
  107. if colorIsUnset(opts.GradColorA) {
  108. opts.GradColorA = defaultGradColorA
  109. }
  110. if colorIsUnset(opts.GradColorB) {
  111. opts.GradColorB = defaultGradColorB
  112. }
  113. if colorIsUnset(opts.LabelColor) {
  114. opts.LabelColor = defaultLabelColor
  115. }
  116. a.id = nextID()
  117. a.startTime = time.Now()
  118. a.cyclingCharWidth = opts.Size
  119. a.labelColor = opts.LabelColor
  120. // Check cache first
  121. cacheKey := settingsHash(opts)
  122. cached, exists := animCacheMap.Get(cacheKey)
  123. if exists {
  124. // Use cached values
  125. a.width = cached.width
  126. a.labelWidth = cached.labelWidth
  127. a.label = csync.NewSliceFrom(cached.label)
  128. a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
  129. a.initialFrames = cached.initialFrames
  130. a.cyclingFrames = cached.cyclingFrames
  131. } else {
  132. // Generate new values and cache them
  133. a.labelWidth = lipgloss.Width(opts.Label)
  134. // Total width of anim, in cells.
  135. a.width = opts.Size
  136. if opts.Label != "" {
  137. a.width += labelGapWidth + lipgloss.Width(opts.Label)
  138. }
  139. // Render the label
  140. a.renderLabel(opts.Label)
  141. // Pre-generate gradient.
  142. var ramp []color.Color
  143. numFrames := prerenderedFrames
  144. if opts.CycleColors {
  145. ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
  146. numFrames = a.width * 2
  147. } else {
  148. ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
  149. }
  150. // Pre-render initial characters.
  151. a.initialFrames = make([][]string, numFrames)
  152. offset := 0
  153. for i := range a.initialFrames {
  154. a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
  155. for j := range a.initialFrames[i] {
  156. if j+offset >= len(ramp) {
  157. continue // skip if we run out of colors
  158. }
  159. var c color.Color
  160. if j <= a.cyclingCharWidth {
  161. c = ramp[j+offset]
  162. } else {
  163. c = opts.LabelColor
  164. }
  165. // Also prerender the initial character with Lip Gloss to avoid
  166. // processing in the render loop.
  167. a.initialFrames[i][j] = lipgloss.NewStyle().
  168. Foreground(c).
  169. Render(string(initialChar))
  170. }
  171. if opts.CycleColors {
  172. offset++
  173. }
  174. }
  175. // Prerender scrambled rune frames for the animation.
  176. a.cyclingFrames = make([][]string, numFrames)
  177. offset = 0
  178. for i := range a.cyclingFrames {
  179. a.cyclingFrames[i] = make([]string, a.width)
  180. for j := range a.cyclingFrames[i] {
  181. if j+offset >= len(ramp) {
  182. continue // skip if we run out of colors
  183. }
  184. // Also prerender the color with Lip Gloss here to avoid processing
  185. // in the render loop.
  186. r := availableRunes[rand.IntN(len(availableRunes))]
  187. a.cyclingFrames[i][j] = lipgloss.NewStyle().
  188. Foreground(ramp[j+offset]).
  189. Render(string(r))
  190. }
  191. if opts.CycleColors {
  192. offset++
  193. }
  194. }
  195. // Cache the results
  196. labelSlice := make([]string, a.label.Len())
  197. for i, v := range a.label.Seq2() {
  198. labelSlice[i] = v
  199. }
  200. ellipsisSlice := make([]string, a.ellipsisFrames.Len())
  201. for i, v := range a.ellipsisFrames.Seq2() {
  202. ellipsisSlice[i] = v
  203. }
  204. cached = &animCache{
  205. initialFrames: a.initialFrames,
  206. cyclingFrames: a.cyclingFrames,
  207. width: a.width,
  208. labelWidth: a.labelWidth,
  209. label: labelSlice,
  210. ellipsisFrames: ellipsisSlice,
  211. }
  212. animCacheMap.Set(cacheKey, cached)
  213. }
  214. // Random assign a birth to each character for a stagged entrance effect.
  215. a.birthOffsets = make([]time.Duration, a.width)
  216. for i := range a.birthOffsets {
  217. a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
  218. }
  219. return a
  220. }
  221. // SetLabel updates the label text and re-renders it.
  222. func (a *Anim) SetLabel(newLabel string) {
  223. a.labelWidth = lipgloss.Width(newLabel)
  224. // Update total width
  225. a.width = a.cyclingCharWidth
  226. if newLabel != "" {
  227. a.width += labelGapWidth + a.labelWidth
  228. }
  229. // Re-render the label
  230. a.renderLabel(newLabel)
  231. }
  232. // renderLabel renders the label with the current label color.
  233. func (a *Anim) renderLabel(label string) {
  234. if a.labelWidth > 0 {
  235. // Pre-render the label.
  236. labelRunes := []rune(label)
  237. a.label = csync.NewSlice[string]()
  238. for i := range labelRunes {
  239. rendered := lipgloss.NewStyle().
  240. Foreground(a.labelColor).
  241. Render(string(labelRunes[i]))
  242. a.label.Append(rendered)
  243. }
  244. // Pre-render the ellipsis frames which come after the label.
  245. a.ellipsisFrames = csync.NewSlice[string]()
  246. for _, frame := range ellipsisFrames {
  247. rendered := lipgloss.NewStyle().
  248. Foreground(a.labelColor).
  249. Render(frame)
  250. a.ellipsisFrames.Append(rendered)
  251. }
  252. } else {
  253. a.label = csync.NewSlice[string]()
  254. a.ellipsisFrames = csync.NewSlice[string]()
  255. }
  256. }
  257. // Width returns the total width of the animation.
  258. func (a *Anim) Width() (w int) {
  259. w = a.width
  260. if a.labelWidth > 0 {
  261. w += labelGapWidth + a.labelWidth
  262. var widestEllipsisFrame int
  263. for _, f := range ellipsisFrames {
  264. fw := lipgloss.Width(f)
  265. if fw > widestEllipsisFrame {
  266. widestEllipsisFrame = fw
  267. }
  268. }
  269. w += widestEllipsisFrame
  270. }
  271. return w
  272. }
  273. // Init starts the animation.
  274. func (a *Anim) Init() tea.Cmd {
  275. return a.Step()
  276. }
  277. // Update processes animation steps (or not).
  278. func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
  279. switch msg := msg.(type) {
  280. case StepMsg:
  281. if msg.id != a.id {
  282. // Reject messages that are not for this instance.
  283. return a, nil
  284. }
  285. step := a.step.Add(1)
  286. if int(step) >= len(a.cyclingFrames) {
  287. a.step.Store(0)
  288. }
  289. if a.initialized.Load() && a.labelWidth > 0 {
  290. // Manage the ellipsis animation.
  291. ellipsisStep := a.ellipsisStep.Add(1)
  292. if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
  293. a.ellipsisStep.Store(0)
  294. }
  295. } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
  296. a.initialized.Store(true)
  297. }
  298. return a, a.Step()
  299. default:
  300. return a, nil
  301. }
  302. }
  303. // View renders the current state of the animation.
  304. func (a *Anim) View() string {
  305. var b strings.Builder
  306. step := int(a.step.Load())
  307. for i := range a.width {
  308. switch {
  309. case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
  310. // Birth offset not reached: render initial character.
  311. b.WriteString(a.initialFrames[step][i])
  312. case i < a.cyclingCharWidth:
  313. // Render a cycling character.
  314. b.WriteString(a.cyclingFrames[step][i])
  315. case i == a.cyclingCharWidth:
  316. // Render label gap.
  317. b.WriteString(labelGap)
  318. case i > a.cyclingCharWidth:
  319. // Label.
  320. if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
  321. b.WriteString(labelChar)
  322. }
  323. }
  324. }
  325. // Render animated ellipsis at the end of the label if all characters
  326. // have been initialized.
  327. if a.initialized.Load() && a.labelWidth > 0 {
  328. ellipsisStep := int(a.ellipsisStep.Load())
  329. if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
  330. b.WriteString(ellipsisFrame)
  331. }
  332. }
  333. return b.String()
  334. }
  335. // Step is a command that triggers the next step in the animation.
  336. func (a *Anim) Step() tea.Cmd {
  337. return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
  338. return StepMsg{id: a.id}
  339. })
  340. }
  341. // makeGradientRamp() returns a slice of colors blended between the given keys.
  342. // Blending is done as Hcl to stay in gamut.
  343. func makeGradientRamp(size int, stops ...color.Color) []color.Color {
  344. if len(stops) < 2 {
  345. return nil
  346. }
  347. points := make([]colorful.Color, len(stops))
  348. for i, k := range stops {
  349. points[i], _ = colorful.MakeColor(k)
  350. }
  351. numSegments := len(stops) - 1
  352. if numSegments == 0 {
  353. return nil
  354. }
  355. blended := make([]color.Color, 0, size)
  356. // Calculate how many colors each segment should have.
  357. segmentSizes := make([]int, numSegments)
  358. baseSize := size / numSegments
  359. remainder := size % numSegments
  360. // Distribute the remainder across segments.
  361. for i := range numSegments {
  362. segmentSizes[i] = baseSize
  363. if i < remainder {
  364. segmentSizes[i]++
  365. }
  366. }
  367. // Generate colors for each segment.
  368. for i := range numSegments {
  369. c1 := points[i]
  370. c2 := points[i+1]
  371. segmentSize := segmentSizes[i]
  372. for j := range segmentSize {
  373. if segmentSize == 0 {
  374. continue
  375. }
  376. t := float64(j) / float64(segmentSize)
  377. c := c1.BlendHcl(c2, t)
  378. blended = append(blended, c)
  379. }
  380. }
  381. return blended
  382. }
  383. func colorIsUnset(c color.Color) bool {
  384. if c == nil {
  385. return true
  386. }
  387. _, _, _, a := c.RGBA()
  388. return a == 0
  389. }