@@ -2,7 +2,6 @@
package anim
import (
- "fmt"
"image/color"
"math/rand/v2"
"strings"
@@ -10,7 +9,6 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lucasb-eyer/go-colorful"
)
@@ -32,8 +30,19 @@ const (
maxBirthOffset = time.Second
// Number of frames to prerender for the animation. After this number
- // of frames, the animation will loop.
+ // of frames, the animation will loop. This only applies when color
+ // cycling is disabled.
prerenderedFrames = 10
+
+ // Default number of cycling chars.
+ defaultNumCyclingChars = 10
+)
+
+// Default colors for gradient.
+var (
+ defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
+ defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
+ defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
)
var (
@@ -52,6 +61,19 @@ func nextID() int {
// StepMsg is a message type used to trigger the next step in the animation.
type StepMsg struct{ id int }
+// Settings defines settings for the animation.
+type Settings struct {
+ Size int
+ Label string
+ LabelColor color.Color
+ GradColorA color.Color
+ GradColorB color.Color
+ CycleColors bool
+}
+
+// Default settings.
+const ()
+
// Anim is a Bubble for an animated spinner.
type Anim struct {
width int
@@ -60,7 +82,7 @@ type Anim struct {
labelWidth int
startTime time.Time
birthOffsets []time.Duration
- initialChars []string
+ initialFrames [][]string // frames for the initial characters
initialized bool
cyclingFrames [][]string // frames for the cycling characters
step int // current main frame step
@@ -70,27 +92,41 @@ type Anim struct {
}
// New creates a new Anim instance with the specified width and label.
-func New(numChars int, label string, t *styles.Theme) (a Anim) {
+func New(opts Settings) (a Anim) {
+ // Validate settings.
+ if opts.Size < 1 {
+ opts.Size = defaultNumCyclingChars
+ }
+ if colorIsUnset(opts.GradColorA) {
+ opts.GradColorA = defaultGradColorA
+ }
+ if colorIsUnset(opts.GradColorB) {
+ opts.GradColorB = defaultGradColorB
+ }
+ if colorIsUnset(opts.LabelColor) {
+ opts.LabelColor = defaultLabelColor
+ }
+
a.id = nextID()
a.startTime = time.Now()
- a.cyclingCharWidth = numChars
- a.labelWidth = lipgloss.Width(label)
+ a.cyclingCharWidth = opts.Size
+ a.labelWidth = lipgloss.Width(opts.Label)
// Total width of anim, in cells.
- a.width = numChars
- if label != "" {
- a.width += labelGapWidth + lipgloss.Width(label)
+ a.width = opts.Size
+ if opts.Label != "" {
+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
}
if a.labelWidth > 0 {
// Pre-render the label.
// XXX: We should really get the graphemes for the label, not the runes.
- labelRunes := []rune(label)
+ labelRunes := []rune(opts.Label)
a.label = make([]string, len(labelRunes))
for i := range a.label {
a.label[i] = lipgloss.NewStyle().
- Foreground(t.FgBase).
+ Foreground(opts.LabelColor).
Render(string(labelRunes[i]))
}
@@ -98,40 +134,69 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) {
a.ellipsisFrames = make([]string, len(ellipsisFrames))
for i, frame := range ellipsisFrames {
a.ellipsisFrames[i] = lipgloss.NewStyle().
- Foreground(t.FgBase).
+ Foreground(opts.LabelColor).
Render(frame)
}
}
// Pre-generate gradient.
- ramp := makeGradientRamp(a.width, t.Primary, t.Secondary)
+ var ramp []color.Color
+ numFrames := prerenderedFrames
+ if opts.CycleColors {
+ ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
+ numFrames = a.width * 2
+ } else {
+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
+ }
// Pre-render initial characters.
- a.initialChars = make([]string, a.width)
- for i := range a.initialChars {
- var c color.Color
- if i <= a.cyclingCharWidth {
- c = ramp[i]
- } else {
- c = t.FgBase
+ a.initialFrames = make([][]string, numFrames)
+ offset := 0
+ for i := range a.initialFrames {
+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
+ for j := range a.initialFrames[i] {
+ if j+offset >= len(ramp) {
+ continue // skip if we run out of colors
+ }
+
+ var c color.Color
+ if j <= a.cyclingCharWidth {
+ c = ramp[j+offset]
+ } else {
+ c = opts.LabelColor
+ }
+
+ // Also prerender the initial character with Lip Gloss to avoid
+ // processing in the render loop.
+ a.initialFrames[i][j] = lipgloss.NewStyle().
+ Foreground(c).
+ Render(string(initialChar))
+ }
+ if opts.CycleColors {
+ offset++
}
- a.initialChars[i] = lipgloss.NewStyle().
- Foreground(c).
- Render(string(initialChar))
}
// Prerender scrambled rune frames for the animation.
- a.cyclingFrames = make([][]string, prerenderedFrames)
+ a.cyclingFrames = make([][]string, numFrames)
+ offset = 0
for i := range a.cyclingFrames {
a.cyclingFrames[i] = make([]string, a.width)
for j := range a.cyclingFrames[i] {
- // NB: we also prerender the color with Lip Gloss here to avoid
- // processing in the render loop.
+ if j+offset >= len(ramp) {
+ continue // skip if we run out of colors
+ }
+
+ // Also prerender the color with Lip Gloss here to avoid processing
+ // in the render loop.
r := availableRunes[rand.IntN(len(availableRunes))]
a.cyclingFrames[i][j] = lipgloss.NewStyle().
- Foreground(ramp[j]).
+ Foreground(ramp[j+offset]).
Render(string(r))
}
+ if opts.CycleColors {
+ offset++
+ }
}
// Random assign a birth to each character for a stagged entrance effect.
@@ -143,6 +208,24 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) {
return a
}
+// Width returns the total width of the animation.
+func (a Anim) Width() (w int) {
+ w = a.width
+ if a.labelWidth > 0 {
+ w += labelGapWidth + a.labelWidth
+
+ var widestEllipsisFrame int
+ for _, f := range ellipsisFrames {
+ fw := lipgloss.Width(f)
+ if fw > widestEllipsisFrame {
+ widestEllipsisFrame = fw
+ }
+ }
+ w += widestEllipsisFrame
+ }
+ return w
+}
+
// Init starts the animation.
func (a Anim) Init() tea.Cmd {
return a.Step()
@@ -184,7 +267,7 @@ func (a Anim) View() string {
switch {
case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
// Birth offset not reached: render initial character.
- b.WriteString(a.initialChars[i])
+ b.WriteString(a.initialFrames[a.step][i])
case i < a.cyclingCharWidth:
// Render a cycling character.
b.WriteString(a.cyclingFrames[a.step][i])
@@ -212,22 +295,60 @@ func (a Anim) Step() tea.Cmd {
})
}
-func colorToHex(c color.Color) string {
- r, g, b, _ := c.RGBA()
- return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
+// makeGradientRamp() returns a slice of colors blended between the given keys.
+// Blending is done as Hcl to stay in gamut.
+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
+ if len(stops) < 2 {
+ return nil
+ }
+
+ points := make([]colorful.Color, len(stops))
+ for i, k := range stops {
+ points[i], _ = colorful.MakeColor(k)
+ }
+
+ numSegments := len(stops) - 1
+ if numSegments == 0 {
+ return nil
+ }
+ blended := make([]color.Color, 0, size)
+
+ // Calculate how many colors each segment should have.
+ segmentSizes := make([]int, numSegments)
+ baseSize := size / numSegments
+ remainder := size % numSegments
+
+ // Distribute the remainder across segments.
+ for i := range numSegments {
+ segmentSizes[i] = baseSize
+ if i < remainder {
+ segmentSizes[i]++
+ }
+ }
+
+ // Generate colors for each segment.
+ for i := range numSegments {
+ c1 := points[i]
+ c2 := points[i+1]
+ segmentSize := segmentSizes[i]
+
+ for j := range segmentSize {
+ if segmentSize == 0 {
+ continue
+ }
+ t := float64(j) / float64(segmentSize)
+ c := c1.BlendHcl(c2, t)
+ blended = append(blended, c)
+ }
+ }
+
+ return blended
}
-func makeGradientRamp(length int, from, to color.Color) []color.Color {
- startColor := colorToHex(from)
- endColor := colorToHex(to)
- var (
- c = make([]color.Color, length)
- start, _ = colorful.Hex(startColor)
- end, _ = colorful.Hex(endColor)
- )
- for i := range length {
- step := start.BlendLuv(end, float64(i)/float64(length))
- c[i] = lipgloss.Color(step.Hex())
- }
- return c
+func colorIsUnset(c color.Color) bool {
+ if c == nil {
+ return true
+ }
+ _, _, _, a := c.RGBA()
+ return a == 0
}
@@ -8,12 +8,14 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
anim "github.com/charmbracelet/crush/internal/tui/components/anim"
"github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/lipgloss/v2"
)
type model struct {
anim tea.Model
bgColor color.Color
quitting bool
+ w, h int
}
func (m model) Init() tea.Cmd {
@@ -22,6 +24,9 @@ func (m model) Init() tea.Cmd {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.w, m.h = msg.Width, msg.Height
+ return m, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
@@ -40,14 +45,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m model) View() tea.View {
- // XXX tea.View() needs a content setter.
+ if m.w == 0 || m.h == 0 {
+ return tea.NewView("")
+ }
+
v := tea.NewView("")
v.BackgroundColor = m.bgColor
+
if m.quitting {
return v
}
+
if a, ok := m.anim.(anim.Anim); ok {
- v = tea.NewView(a.View() + "\n")
+ l := lipgloss.NewLayer(a.View().String()).
+ Width(a.Width()).
+ X(m.w/2 - a.Width()/2).
+ Y(m.h / 2)
+
+ v = tea.NewView(lipgloss.NewCanvas(l))
v.BackgroundColor = m.bgColor
return v
}
@@ -58,8 +73,15 @@ func main() {
t := styles.CurrentTheme()
p := tea.NewProgram(model{
bgColor: t.BgBase,
- anim: anim.New(50, "Hello", t),
- })
+ anim: anim.New(anim.Settings{
+ Label: "Hello",
+ Size: 50,
+ LabelColor: t.FgBase,
+ GradColorA: t.Primary,
+ GradColorB: t.Secondary,
+ CycleColors: true,
+ }),
+ }, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Uh oh: %v\n", err)
@@ -49,9 +49,15 @@ var focusedMessageBorder = lipgloss.Border{
// NewMessageCmp creates a new message component with the given message and options
func NewMessageCmp(msg message.Message) MessageCmp {
+ t := styles.CurrentTheme()
m := &messageCmp{
message: msg,
- anim: anim.New(15, "", styles.CurrentTheme()),
+ anim: anim.New(anim.Settings{
+ Size: 15,
+ GradColorA: t.Primary,
+ GradColorB: t.Secondary,
+ CycleColors: true,
+ }),
}
return m
}
@@ -91,9 +91,21 @@ func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCal
opt(m)
}
t := styles.CurrentTheme()
- m.anim = anim.New(15, "Working", t)
+ m.anim = anim.New(anim.Settings{
+ Size: 15,
+ Label: "Working",
+ GradColorA: t.Primary,
+ GradColorB: t.Secondary,
+ LabelColor: t.FgBase,
+ CycleColors: true,
+ })
if m.isNested {
- m.anim = anim.New(10, "", t)
+ m.anim = anim.New(anim.Settings{
+ Size: 10,
+ GradColorA: t.Primary,
+ GradColorB: t.Secondary,
+ CycleColors: true,
+ })
}
return m
}