diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 0e1842cdca97bbdf419b89ab01df1290d011bc32..63d365b2d5f3adf138a61a91db0b90f9edd1688d 100644 --- a/internal/tui/components/anim/anim.go +++ b/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 } diff --git a/internal/tui/components/anim/example/main.go b/internal/tui/components/anim/example/main.go index 88e37550daaf1f381ed651f55c9e215b404b63d9..78b7951f481a75dc6e56a65369866bb3e3160cc1 100644 --- a/internal/tui/components/anim/example/main.go +++ b/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) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index fe6147d73614ec6a1aaa52c6f675901d7eb3a4fc..db9261a34170e2a0b9c7a2c6d843490793b9d6db 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 } diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index afc65f57e70b70ffb2d80d62e220298dd964b088..fe61d44fb77f81d330447beecb9b1a7192a2a0c4 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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 }