package tui
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// Animation primitives for the setup wizard.
//
// All of it is string arithmetic on a fixed frame budget: there is no
// goroutine, no clock beyond Bubble Tea's tick, and every animation is a pure
// function of an integer frame number. That is what makes the animations
// testable — a test asks for frame n and gets the same thing the terminal
// would draw — and what makes them skippable, since jumping to the last frame
// is just passing a bigger number.
// frameInterval is one animation frame. About 30fps: fast enough that a wipe
// reads as motion, slow enough that a terminal over ssh keeps up.
const frameInterval = 33 * time.Millisecond
// ease is the smoothstep curve, on [0,1]. Linear motion in a terminal reads as
// mechanical; easing in and out reads as something sliding to a stop.
func ease(t float64) float64 {
t = clamp01(t)
return t * t * (3 - 2*t)
}
// easeOut decelerates only. It is for things that arrive — a panel coming to
// rest — where an ease-in at the start reads as lag.
func easeOut(t float64) float64 {
t = clamp01(t)
return 1 - (1-t)*(1-t)
}
func clamp01(t float64) float64 {
switch {
case t < 0:
return 0
case t > 1:
return 1
}
return t
}
// progress turns a frame number into a 0..1 position over n frames. A zero or
// negative budget is already finished, which is how the reduced-motion path
// collapses every animation to its final frame without a special case at each
// call site.
func progress(frame, n int) float64 {
if n <= 0 {
return 1
}
return clamp01(float64(frame) / float64(n))
}
// blend mixes two adaptive colours, channel by channel, on both the light and
// the dark variant. It is what lets a letter ignite from faint to the accent
// rather than snapping between the two.
func blend(a, b lipgloss.AdaptiveColor, t float64) lipgloss.AdaptiveColor {
return lipgloss.AdaptiveColor{
Light: blendHex(a.Light, b.Light, t),
Dark: blendHex(a.Dark, b.Dark, t),
}
}
// blendHex interpolates two "#rrggbb" strings. Anything it cannot parse comes
// back as one of the endpoints rather than as a broken colour string, because
// a malformed colour is rendered by lipgloss as no colour at all — which would
// show up as a hole in the animation rather than as an error anyone could act
// on.
func blendHex(a, b string, t float64) string {
ar, ag, ab, aok := parseHex(a)
br, bg, bb, bok := parseHex(b)
if !aok || !bok {
if t < 0.5 {
return a
}
return b
}
t = clamp01(t)
lerp := func(x, y int) int { return int(math.Round(float64(x) + (float64(y)-float64(x))*t)) }
return fmt.Sprintf("#%02x%02x%02x", lerp(ar, br), lerp(ag, bg), lerp(ab, bb))
}
func parseHex(s string) (r, g, b int, ok bool) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v >> 16 & 0xff), int(v >> 8 & 0xff), int(v & 0xff), true
}
// triangle is a 0..1 triangle wave over period frames, for anything that
// breathes. Triangle rather than sine because the ends are what you notice — a
// sine lingers at both extremes and reads as a stutter at this frame rate.
// (The name pulse is taken by the spinner.)
func triangle(frame, period int) float64 {
if period <= 1 {
return 1
}
p := frame % period
half := period / 2
if p < half {
return float64(p) / float64(half)
}
return 1 - float64(p-half)/float64(period-half)
}
// slide pushes next in from the right while prev leaves to the left, as one
// strip of film moving across the frame.
//
// Both panels are cut with an ANSI-aware slicer, so a line's colours survive
// being cut in half. They are padded to a common height and width first: a
// ragged panel would otherwise shear, with each line starting its slide from
// wherever its own text happened to end.
func slide(prev, next string, width, height int, t float64) string {
width, height = maxInt(width, 1), maxInt(height, 1)
off := int(math.Round(ease(t) * float64(width)))
p := padPanel(prev, width, height)
n := padPanel(next, width, height)
out := make([]string, height)
for i := range out {
switch {
case off <= 0:
out[i] = p[i]
case off >= width:
out[i] = n[i]
default:
// The tail of the outgoing panel, then the head of the incoming
// one: together exactly one screen wide.
out[i] = ansi.Cut(p[i], off, width) + ansi.Cut(n[i], 0, off)
}
}
return strings.Join(out, "\n")
}
// padPanel squares a rendered panel off to width by height, so it can be cut
// at any column without the cut landing past the end of a short line.
func padPanel(s string, width, height int) []string {
lines := strings.Split(s, "\n")
if len(lines) > height {
lines = lines[:height]
}
out := make([]string, height)
for i := range out {
line := ""
if i < len(lines) {
line = lines[i]
}
if w := lipgloss.Width(line); w < width {
line += strings.Repeat(" ", width-w)
} else if w > width {
line = ansi.Truncate(line, width, "")
}
out[i] = line
}
return out
}
// reveal returns the first n display cells of s, for a typewriter. It counts
// cells rather than bytes so a multi-byte character is never half-drawn.
func reveal(s string, n int) string {
if n <= 0 {
return ""
}
if lipgloss.Width(s) <= n {
return s
}
return ansi.Cut(s, 0, n)
}
// sweep renders a bar filled to t, in the accent, with the leading cell
// brightened so the fill has a head rather than an edge.
func sweep(width int, t float64, fill lipgloss.AdaptiveColor) string {
width = maxInt(width, 1)
done := int(math.Round(clamp01(t) * float64(width)))
var b strings.Builder
for i := 0; i < width; i++ {
switch {
case i < done-1:
b.WriteString(lipgloss.NewStyle().Foreground(fill).Render("━"))
case i == done-1:
// The head is the accent lifted toward the text colour: a moving
// highlight, which is what makes the bar look like it is being
// drawn rather than growing.
b.WriteString(lipgloss.NewStyle().Foreground(blend(fill, colText, 0.45)).Bold(true).Render("━"))
default:
b.WriteString(styFaint.Render("─"))
}
}
return b.String()
}
// ignite renders text letter by letter, each one fading from `from` to `to` as
// a wave passes over it. The wave is a few cells wide, so the word lights up
// as a sweep rather than as a row of independent blinks.
func ignite(text string, from, to lipgloss.AdaptiveColor, t float64, bold bool) string {
runes := []rune(text)
if len(runes) == 0 {
return ""
}
// The head travels past the end of the word by the width of the wave, so
// the last letter finishes rather than stopping mid-fade.
const wave = 4.0
head := clamp01(t) * (float64(len(runes)) + wave)
var b strings.Builder
for i, r := range runes {
lit := clamp01((head - float64(i)) / wave)
st := lipgloss.NewStyle().Foreground(blend(from, to, lit))
if bold && lit > 0.5 {
st = st.Bold(true)
}
b.WriteString(st.Render(string(r)))
}
return b.String()
}