From 5df4167d15df40865411b900348bbea0299fd00b Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 2 Jul 2025 12:18:48 -0400 Subject: [PATCH 1/4] chore: overhaul spinner --- internal/tui/components/anim/anim.go | 414 ++++++++----------- internal/tui/components/anim/example/main.go | 68 +++ 2 files changed, 230 insertions(+), 252 deletions(-) create mode 100644 internal/tui/components/anim/example/main.go diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 8d40bbfe03e775de779414d60c94ba3e6f80635f..9fa8e6f724435541839b29cdfe2cb47f6d2ca35a 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -1,3 +1,4 @@ +// Package anim provides an animated spinner. package anim import ( @@ -5,302 +6,211 @@ import ( "image/color" "math/rand/v2" "strings" + "sync/atomic" "time" - "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" - "github.com/google/uuid" "github.com/lucasb-eyer/go-colorful" ) const ( - charCyclingFPS = time.Second / 22 - colorCycleFPS = time.Second / 5 - maxCyclingChars = 60 + fps = 20 + initialChar = '.' + labelGap = " " + labelGapWidth = 1 + + // Periods of ellipsis animation speed in steps. + // + // If the FPS is 20 (50 milliseconds) this means that the ellipsis will + // change every 8 frames (400 milliseconds). + ellipsisAnimSpeed = 8 + + // The maximum amount of time that can pass before a character appears. + // This is used to create a staggered entrance effect. + maxBirthOffset = time.Second + + // Number of frames to prerender for the animation. After this number + // of frames, the animation will loop. + prerenderedFrames = 10 ) var ( - charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_") - charRunePool = make([]rune, 1000) // Pre-generated pool of random characters - poolIndex = 0 + availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_") + ellipsisFrames = []string{".", "..", "...", ""} ) -func init() { - // Pre-populate the character pool to avoid runtime random generation. - for i := range charRunePool { - charRunePool[i] = charRunes[rand.IntN(len(charRunes))] - } -} - -type charState int - -const ( - charInitialState charState = iota - charCyclingState - charEndOfLifeState -) - -// cyclingChar is a single animated character. -type cyclingChar struct { - finalValue rune // if < 0 cycle forever - currentValue rune - initialDelay time.Duration - lifetime time.Duration -} - -func (c cyclingChar) randomRune() rune { - // Use pre-generated pool instead of runtime random generation. - poolIndex = (poolIndex + 1) % len(charRunePool) - return charRunePool[poolIndex] -} - -func (c cyclingChar) state(start time.Time) charState { - now := time.Now() - if now.Before(start.Add(c.initialDelay)) { - return charInitialState - } - if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) { - return charEndOfLifeState - } - return charCyclingState -} - -type StepCharsMsg struct { - id string -} - -func stepChars(id string) tea.Cmd { - return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg { - return StepCharsMsg{id} - }) -} - -type ColorCycleMsg struct { - id string -} - -func cycleColors(id string) tea.Cmd { - return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg { - return ColorCycleMsg{id} - }) -} - -type Animation interface { - util.Model - ID() string -} - -// anim is the model that manages the animation that displays while the -// output is being generated. -type anim struct { - start time.Time - cyclingChars []cyclingChar - labelChars []cyclingChar - ramp []lipgloss.Style - label []rune - ellipsis spinner.Model - ellipsisStarted bool - id string -} - -type animOption func(*anim) - -func WithId(id string) animOption { - return func(a *anim) { - a.id = id - } -} - -func New(cyclingCharsSize uint, label string, opts ...animOption) Animation { - // #nosec G115 - n := min(int(cyclingCharsSize), maxCyclingChars) - - gap := " " - if n == 0 { - gap = "" - } - - id := uuid.New() - c := anim{ - start: time.Now(), - label: []rune(gap + label), - ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)), - id: id.String(), - } - - for _, opt := range opts { - opt(&c) - } - - // If we're in truecolor mode (and there are enough cycling characters) - // color the cycling characters with a gradient ramp. - const minRampSize = 3 - if n >= minRampSize { - // Optimized: single capacity allocation for color cycling. - c.ramp = make([]lipgloss.Style, 0, n*2) - ramp := makeGradientRamp(n) - for _, color := range ramp { - c.ramp = append(c.ramp, lipgloss.NewStyle().Foreground(color)) - } - // Create reversed copy for seamless color cycling. - reversed := make([]lipgloss.Style, len(c.ramp)) - for i, style := range c.ramp { - reversed[len(c.ramp)-1-i] = style +// Internal ID management. Used during animating to ensure that frame messages +// are received only by spinner components that sent them. +var lastID int64 + +func nextID() int { + return int(atomic.AddInt64(&lastID, 1)) +} + +// StepMsg is a message type used to trigger the next step in the animation. +type StepMsg struct{ id int } + +// Anim is a Bubble for an animated spinner. +type Anim struct { + width int + cyclingCharWidth int + label []string + labelWidth int + startTime time.Time + birthOffsets []time.Duration + initialChars []string + initialized bool + cyclingFrames [][]string // frames for the cycling characters + step int // current main frame step + ellipsisStep int // current ellipsis frame step + ellipsisFrames []string // ellipsis animation frames + id int +} + +// New creates a new Anim instance with the specified width and label. +func New(numChars int, label string, t *styles.Theme) (a Anim) { + a.id = nextID() + + a.startTime = time.Now() + a.cyclingCharWidth = numChars + a.labelWidth = lipgloss.Width(label) + + // Total width of anim, in cells. + a.width = numChars + if label != "" { + a.width += labelGapWidth + lipgloss.Width(label) + } + + // Pre-render the label. + // XXX: We should really get the graphemes for the label, not the runes. + labelRunes := []rune(label) + a.label = make([]string, len(labelRunes)) + for i := range a.label { + a.label[i] = lipgloss.NewStyle(). + Foreground(t.FgBase). + Render(string(labelRunes[i])) + } + + // Pre-generate gradient. + ramp := makeGradientRamp(a.width, t.Primary, t.Secondary) + + // Pre-render initial characters. + a.initialChars = make([]string, a.width) + for i := range a.initialChars { + a.initialChars[i] = lipgloss.NewStyle(). + Foreground(ramp[i]). + Render(string(initialChar)) + } + + // Pre-render the ellipsis frames. + a.ellipsisFrames = make([]string, len(ellipsisFrames)) + for i, frame := range ellipsisFrames { + a.ellipsisFrames[i] = lipgloss.NewStyle(). + Foreground(t.FgBase). + Render(frame) + } + + // Prerender scrambled rune frames for the animation. + a.cyclingFrames = make([][]string, prerenderedFrames) + 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. + r := availableRunes[rand.IntN(len(availableRunes))] + a.cyclingFrames[i][j] = lipgloss.NewStyle(). + Foreground(ramp[j]). + Render(string(r)) } - c.ramp = append(c.ramp, reversed...) - } - - makeDelay := func(a int32, b time.Duration) time.Duration { - return time.Duration(rand.Int32N(a)) * (time.Millisecond * b) //nolint:gosec } - makeInitialDelay := func() time.Duration { - return makeDelay(8, 60) //nolint:mnd + // Random assign a birth to each character for a stagged entrance effect. + a.birthOffsets = make([]time.Duration, a.width) + for i := range a.birthOffsets { + a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond } - // Initial characters that cycle forever. - c.cyclingChars = make([]cyclingChar, n) - - for i := range n { - c.cyclingChars[i] = cyclingChar{ - finalValue: -1, // cycle forever - initialDelay: makeInitialDelay(), - } - } - - // Label text that only cycles for a little while. - c.labelChars = make([]cyclingChar, len(c.label)) - - for i, r := range c.label { - c.labelChars[i] = cyclingChar{ - finalValue: r, - initialDelay: makeInitialDelay(), - lifetime: makeDelay(5, 180), //nolint:mnd - } - } - - return c + return a } -// Init initializes the animation. -func (a anim) Init() tea.Cmd { - return tea.Batch(stepChars(a.id), cycleColors(a.id)) +// Init starts the animation. +func (a Anim) Init() tea.Cmd { + return a.Step() } -// Update handles messages. -func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd +// Update processes animation steps (or not). +func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case StepCharsMsg: + case StepMsg: if msg.id != a.id { + // Reject messages that are not for this instance. return a, nil } - a.updateChars(&a.cyclingChars) - a.updateChars(&a.labelChars) - if !a.ellipsisStarted { - var eol int - for _, c := range a.labelChars { - if c.state(a.start) == charEndOfLifeState { - eol++ - } - } - if eol == len(a.label) { - // If our entire label has reached end of life, start the - // ellipsis "spinner" after a short pause. - a.ellipsisStarted = true - cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd - return a.ellipsis.Tick() - }) - } + a.step++ + if a.step >= len(a.cyclingFrames) { + a.step = 0 } - return a, tea.Batch(stepChars(a.id), cmd) - case ColorCycleMsg: - if msg.id != a.id { - return a, nil - } - const minColorCycleSize = 2 - if len(a.ramp) < minColorCycleSize { - return a, nil + if a.initialized { + // Manage the ellipsis animation. + a.ellipsisStep++ + if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) { + a.ellipsisStep = 0 + } + } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset { + a.initialized = true } - a.ramp = append(a.ramp[1:], a.ramp[0]) - return a, cycleColors(a.id) - case spinner.TickMsg: - var cmd tea.Cmd - a.ellipsis, cmd = a.ellipsis.Update(msg) - return a, cmd + return a, a.Step() default: return a, nil } } -func (a anim) ID() string { - return a.id -} - -func (a *anim) updateChars(chars *[]cyclingChar) { - charSlice := *chars // dereference to avoid repeated pointer access - for i, c := range charSlice { - switch c.state(a.start) { - case charInitialState: - charSlice[i].currentValue = '.' - case charCyclingState: - charSlice[i].currentValue = c.randomRune() - case charEndOfLifeState: - charSlice[i].currentValue = c.finalValue +// View renders the current state of the animation. +func (a Anim) View() tea.View { + var b strings.Builder + for i := range a.width { + switch { + case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]: + // Birth offset not reached: render initial character. + b.WriteString(a.initialChars[i]) + case i < a.cyclingCharWidth: + // Render a cycling character. + b.WriteString(a.cyclingFrames[a.step][i]) + case i == a.cyclingCharWidth: + // Render label gap. + b.WriteString(labelGap) + case i > a.cyclingCharWidth: + // Label. + b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth]) } } -} - -// View renders the animation. -func (a anim) View() tea.View { - var ( - t = styles.CurrentTheme() - b strings.Builder - ) - - // Optimized capacity calculation to reduce allocations. - const ( - bytesPerChar = 15 // Reduced estimate for ANSI styling - bufferSize = 30 // Reduced safety margin - ) - estimatedCap := len(a.cyclingChars)*bytesPerChar + len(a.labelChars)*bytesPerChar + bufferSize - b.Grow(estimatedCap) - - // Render cycling characters with gradient (if available). - for i, c := range a.cyclingChars { - if len(a.ramp) > i { - b.WriteString(a.ramp[i].Render(string(c.currentValue))) - } else { - b.WriteRune(c.currentValue) - } - } - - // Render label characters and ellipsis. - if len(a.labelChars) > 1 { - textStyle := t.S().Text - for _, c := range a.labelChars { - b.WriteString(textStyle.Render(string(c.currentValue))) - } - b.WriteString(textStyle.Render(a.ellipsis.View())) + // Render animated ellipsis at the end of the label if all characters + // have been initialized. + if a.initialized { + b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed]) } - return tea.NewView(b.String()) } -func GetColor(c color.Color) string { - rgba := color.RGBAModel.Convert(c).(color.RGBA) - return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) +// Step is a command that triggers the next step in the animation. +func (a Anim) Step() tea.Cmd { + return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { + return StepMsg{id: a.id} + }) +} + +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)) } -func makeGradientRamp(length int) []color.Color { - t := styles.CurrentTheme() - startColor := GetColor(t.Primary) - endColor := GetColor(t.Secondary) +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) diff --git a/internal/tui/components/anim/example/main.go b/internal/tui/components/anim/example/main.go new file mode 100644 index 0000000000000000000000000000000000000000..d15f3c98fa3b9a3213efef3ca1fb02247f35243b --- /dev/null +++ b/internal/tui/components/anim/example/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "image/color" + "os" + + tea "github.com/charmbracelet/bubbletea/v2" + anim "github.com/charmbracelet/crush/internal/tui/components/anim" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +type model struct { + anim tea.Model + bgColor color.Color + quitting bool +} + +func (m model) Init() tea.Cmd { + return m.anim.Init() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + default: + return m, nil + } + case anim.StepMsg: + var cmd tea.Cmd + m.anim, cmd = m.anim.Update(msg) + return m, cmd + default: + return m, nil + } +} + +func (m model) View() tea.View { + // XXX tea.View() needs a content setter. + v := tea.NewView("") + v.SetBackgroundColor(m.bgColor) + if m.quitting { + return v + } + if a, ok := m.anim.(anim.Anim); ok { + v = tea.NewView(a.View().String() + "\n") + v.SetBackgroundColor(m.bgColor) + return v + } + return v +} + +func main() { + t := styles.CurrentTheme() + p := tea.NewProgram(model{ + bgColor: t.BgBase, + anim: anim.New(50, "Hello", t), + }) + + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Uh oh: %v\n", err) + os.Exit(1) + } +} From b1df347f4341795742c27cf194a6d3ca59bcfff1 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 2 Jul 2025 12:19:42 -0400 Subject: [PATCH 2/4] chore: integrate overhauled spinner --- internal/tui/components/chat/messages/messages.go | 5 ++--- internal/tui/components/chat/messages/tool.go | 8 ++++---- internal/tui/components/core/list/list.go | 3 +-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 98d8b2979a90f46fa5901bc77d1e8b4a5105f04d..dc50bc58e71bf47cc7f38bb8f028be52b13a06b2 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" @@ -52,7 +51,7 @@ var focusedMessageBorder = lipgloss.Border{ func NewMessageCmp(msg message.Message) MessageCmp { m := &messageCmp{ message: msg, - anim: anim.New(15, ""), + anim: anim.New(15, "", styles.CurrentTheme()), } return m } @@ -71,7 +70,7 @@ func (m *messageCmp) Init() tea.Cmd { // Manages animation updates for spinning messages and stops animation when appropriate. func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg: + case anim.StepMsg: m.spinning = m.shouldSpin() if m.spinning { u, cmd := m.anim.Update(msg) diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 5a70acfe297e8f9716e229cb013160a6662a5970..2602768553f4d0480eb956bdd20db29c2842852f 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -3,7 +3,6 @@ package messages import ( "fmt" - "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/anim" @@ -91,9 +90,10 @@ func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCal for _, opt := range opts { opt(m) } - m.anim = anim.New(15, "Working") + t := styles.CurrentTheme() + m.anim = anim.New(15, "Working", t) if m.isNested { - m.anim = anim.New(10, "") + m.anim = anim.New(10, "", t) } return m } @@ -112,7 +112,7 @@ func (m *toolCallCmp) Init() tea.Cmd { // Manages animation updates for pending tool calls. func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg: + case anim.StepMsg: var cmds []tea.Cmd for i, nested := range m.nestedToolCalls { if nested.Spinning() { diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 3ad3f68dffd46960bcfb5f969991f25218494a2c..f0887ee8aed5df7d3fcb34fe282cf916fad5920a 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -7,7 +7,6 @@ import ( "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/components/anim" @@ -271,7 +270,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m.handleKeyPress(msg) - case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg: + case anim.StepMsg: return m.handleAnimationMsg(msg) } if m.selectionState.isValidIndex(len(m.filteredItems)) { From e9f79b011e4075bb76fabfe8113d151eaedd01c7 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 2 Jul 2025 14:32:00 -0400 Subject: [PATCH 3/4] fix: don't render periods of ellipsis when there's no label --- internal/tui/components/anim/anim.go | 38 +++++++++++++++------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 9fa8e6f724435541839b29cdfe2cb47f6d2ca35a..712fe1555b4b0d5a891f8412acbeede5e68c1e40 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -83,14 +83,24 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) { a.width += labelGapWidth + lipgloss.Width(label) } - // Pre-render the label. - // XXX: We should really get the graphemes for the label, not the runes. - labelRunes := []rune(label) - a.label = make([]string, len(labelRunes)) - for i := range a.label { - a.label[i] = lipgloss.NewStyle(). - Foreground(t.FgBase). - Render(string(labelRunes[i])) + if a.labelWidth > 0 { + // Pre-render the label. + // XXX: We should really get the graphemes for the label, not the runes. + labelRunes := []rune(label) + a.label = make([]string, len(labelRunes)) + for i := range a.label { + a.label[i] = lipgloss.NewStyle(). + Foreground(t.FgBase). + Render(string(labelRunes[i])) + } + + // Pre-render the ellipsis frames which come after the label. + a.ellipsisFrames = make([]string, len(ellipsisFrames)) + for i, frame := range ellipsisFrames { + a.ellipsisFrames[i] = lipgloss.NewStyle(). + Foreground(t.FgBase). + Render(frame) + } } // Pre-generate gradient. @@ -104,14 +114,6 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) { Render(string(initialChar)) } - // Pre-render the ellipsis frames. - a.ellipsisFrames = make([]string, len(ellipsisFrames)) - for i, frame := range ellipsisFrames { - a.ellipsisFrames[i] = lipgloss.NewStyle(). - Foreground(t.FgBase). - Render(frame) - } - // Prerender scrambled rune frames for the animation. a.cyclingFrames = make([][]string, prerenderedFrames) for i := range a.cyclingFrames { @@ -154,7 +156,7 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.step = 0 } - if a.initialized { + if a.initialized && a.labelWidth > 0 { // Manage the ellipsis animation. a.ellipsisStep++ if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) { @@ -190,7 +192,7 @@ func (a Anim) View() tea.View { } // Render animated ellipsis at the end of the label if all characters // have been initialized. - if a.initialized { + if a.initialized && a.labelWidth > 0 { b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed]) } return tea.NewView(b.String()) From 35fd928f9f268cce1bd43aef32d2cf6a5cb5a066 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 2 Jul 2025 14:32:57 -0400 Subject: [PATCH 4/4] chore: match initialchar colors in label position to label colors --- internal/tui/components/anim/anim.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 712fe1555b4b0d5a891f8412acbeede5e68c1e40..73a34111ec285a6b2c6c82ff6ea2af3fe019646a 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -109,8 +109,14 @@ func New(numChars int, label string, t *styles.Theme) (a Anim) { // 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.initialChars[i] = lipgloss.NewStyle(). - Foreground(ramp[i]). + Foreground(c). Render(string(initialChar)) }