Vectrum
Sign In
230 lines · 6.9 KB · Go
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()
}