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() }