Merge branch 'main' into uv-crush

Ayman Bagabas created

Change summary

internal/tui/components/anim/anim.go              | 211 +++++++++++++---
internal/tui/components/anim/example/main.go      |  30 ++
internal/tui/components/chat/messages/messages.go |   8 
internal/tui/components/chat/messages/tool.go     |  16 +
4 files changed, 213 insertions(+), 52 deletions(-)

Detailed changes

internal/tui/components/anim/anim.go 🔗

@@ -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
 }

internal/tui/components/anim/example/main.go 🔗

@@ -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)

internal/tui/components/chat/messages/messages.go 🔗

@@ -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
 }

internal/tui/components/chat/messages/tool.go 🔗

@@ -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
 }