diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 5ab75676775acfba7dbe606d01bd049c39dcf013..0e1842cdca97bbdf419b89ab01df1290d011bc32 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,220 @@ 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)) +// 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) + } + + 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])) } - // 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 - } - 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 + // 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) + } } - // Initial characters that cycle forever. - c.cyclingChars = make([]cyclingChar, n) + // Pre-generate gradient. + ramp := makeGradientRamp(a.width, t.Primary, t.Secondary) - for i := range n { - c.cyclingChars[i] = cyclingChar{ - finalValue: -1, // cycle forever - initialDelay: makeInitialDelay(), + // 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(c). + Render(string(initialChar)) + } + + // 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)) } } - // 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 - } + // 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 } - 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 && a.labelWidth > 0 { + // 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 animation. -func (a anim) View() string { - 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) +// View renders the current state of the animation. +func (a Anim) View() string { + 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]) } } - - // 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 && a.labelWidth > 0 { + b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed]) } return 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..88e37550daaf1f381ed651f55c9e215b404b63d9 --- /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.BackgroundColor = m.bgColor + if m.quitting { + return v + } + if a, ok := m.anim.(anim.Anim); ok { + v = tea.NewView(a.View() + "\n") + v.BackgroundColor = 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) + } +} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 89f8a23c87e438b6c30041c2b53017e389be6b31..fe6147d73614ec6a1aaa52c6f675901d7eb3a4fc 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 88508c66762225c1b82db8bc208144d70e1c77a5..afc65f57e70b70ffb2d80d62e220298dd964b088 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/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index cb7c0b649fd626df090b9ca62580cb53ed030458..d28dfceb3d6eff4a4f57b248df5160d82412c705 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -75,7 +75,7 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c } func (m *sidebarCmp) Init() tea.Cmd { - m.logo = m.logoBlock() + m.logo = m.logoBlock(false) m.cwd = cwd() return nil } @@ -228,9 +228,9 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg { func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) { - m.logo = m.logoBlock() + m.logo = m.logoBlock(true) } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) { - m.logo = m.logoBlock() + m.logo = m.logoBlock(false) } m.width = width @@ -242,9 +242,9 @@ func (m *sidebarCmp) GetSize() (int, int) { return m.width, m.height } -func (m *sidebarCmp) logoBlock() string { +func (m *sidebarCmp) logoBlock(compact bool) string { t := styles.CurrentTheme() - return logo.Render(version.Version, true, logo.Opts{ + return logo.Render(version.Version, compact, logo.Opts{ FieldColor: t.Primary, TitleColorA: t.Secondary, TitleColorB: t.Primary, diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index f3a4a672305e655754b0c45d82ffaa003d10e425..2bd8aaed34b636d92b488af961635973be9576ae 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -65,15 +65,16 @@ func (s *splashCmp) View() string { return content } -func (m *splashCmp) logoBlock() string { +func (s *splashCmp) logoBlock() string { t := styles.CurrentTheme() + const padding = 2 return logo.Render(version.Version, false, logo.Opts{ FieldColor: t.Primary, TitleColorA: t.Secondary, TitleColorB: t.Primary, CharmColor: t.Secondary, VersionColor: t.Primary, - Width: m.width - 2, // -2 for padding + Width: s.width - padding, }) } diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 8d0f93d425f4167c75188ec45a49d12e3078a351..cb74287aacbb54baebbe26656b0f7f4eff62beb4 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)) {