From 958074efe9ae406065f63ff5a6db275e6cf396bc Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 29 Jan 2026 12:40:17 -0500 Subject: [PATCH] feat: new calm-energy spinner --- internal/ui/spinner/example/main.go | 13 ++--- internal/ui/spinner/spinner.go | 87 ++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 34 deletions(-) diff --git a/internal/ui/spinner/example/main.go b/internal/ui/spinner/example/main.go index 5fd9dce6e7f3ea6297ae025315ac27460e839a54..3c9163d4cfd27204083fa66683d44631489f7c8f 100644 --- a/internal/ui/spinner/example/main.go +++ b/internal/ui/spinner/example/main.go @@ -1,5 +1,7 @@ package main +// This example is used for spinner tuning. + import ( "fmt" "os" @@ -17,7 +19,7 @@ type Model struct { // Init initializes the model. It satisfies tea.Model. func (m Model) Init() tea.Cmd { - return m.spinner.Step() + return m.spinner.Start() } // Update updates the model per on incoming messages. It satisfies tea.Model. @@ -45,15 +47,8 @@ func (m Model) View() tea.View { } func main() { - f, err := tea.LogToFile("spinner.log", "spinner") - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating log file: %v\n", err) - os.Exit(1) - } - defer f.Close() - if _, err := tea.NewProgram(Model{ - spinner: spinner.NewSpinner(), + spinner: spinner.NewSpinner("Romanticizing"), }).Run(); err != nil { fmt.Fprintf(os.Stderr, "Error running program: %v\n", err) os.Exit(1) diff --git a/internal/ui/spinner/spinner.go b/internal/ui/spinner/spinner.go index 17e8c362b5d2d08235004638275047836357454f..807a808ebafab3514a478879e42233347a0c0b2e 100644 --- a/internal/ui/spinner/spinner.go +++ b/internal/ui/spinner/spinner.go @@ -13,11 +13,15 @@ import ( ) const ( - fps = 24 - decay = 12 - pauseSteps = 48 - lowChar = "•" - highChar = "│" + fps = 24 + decay = 12 + pauseSteps = 48 + lowChar = "·" + highChar = "│" + ellipsisChar = "." + maxEllipsisDots = 3 + ellipsisFPS = 8 + ellipsisPause = 2 // frames to pause at max dots ) // Internal ID management. Used during animating to ensure that frame messages @@ -32,12 +36,14 @@ type Config struct { Width int EmptyColor color.Color Blend []color.Color + LabelColor color.Color } // DefaultConfig returns the default spinner configuration. func DefaultConfig() Config { return Config{ - Width: 16, + Width: 14, + LabelColor: charmtone.Smoke, EmptyColor: charmtone.Charcoal, Blend: []color.Color{ charmtone.Charcoal, @@ -47,24 +53,31 @@ func DefaultConfig() Config { } } +// StepMsg is a message sent to spinners to indicate it's time to update their +// state. type StepMsg struct { ID int tag int } +// Spinner is a spinner Bubble. type Spinner struct { - Config Config - id int - tag int - index int - pause int - cells []int - maxAt []int // frame when cell reached max height - emptyChar string - blendStyles []lipgloss.Style + Label string + Config Config + id int + tag int + ellipsisStep int + index int + pause int + cells []int + maxAt []int // frame when cell reached max height + emptyChar string + blendStyles []lipgloss.Style + labelEllipsisDot string } -func NewSpinner() Spinner { +// NewSpinner creates a new Spinner with the given label. +func NewSpinner(label string) Spinner { c := DefaultConfig() blend := lipgloss.Blend1D(c.Width, c.Blend...) blendStyles := make([]lipgloss.Style, len(blend)) @@ -73,21 +86,27 @@ func NewSpinner() Spinner { blendStyles[i] = lipgloss.NewStyle().Foreground(s) } + labelStyle := lipgloss.NewStyle().Foreground(c.LabelColor) + return Spinner{ - Config: c, - id: nextID(), - index: -1, - cells: make([]int, c.Width), - maxAt: make([]int, c.Width), - emptyChar: lipgloss.NewStyle().Foreground(c.EmptyColor).Render(string(lowChar)), - blendStyles: blendStyles, + Label: labelStyle.Render(label), + labelEllipsisDot: labelStyle.Render(ellipsisChar), + Config: c, + id: nextID(), + index: -1, + cells: make([]int, c.Width), + maxAt: make([]int, c.Width), + emptyChar: lipgloss.NewStyle().Foreground(c.EmptyColor).Render(string(lowChar)), + blendStyles: blendStyles, } } +// Init initializes the spinner. It satisfies tea.Model. func (s Spinner) Init() tea.Cmd { return nil } +// Update updates the spinner per incoming messages. It satisfies tea.Model. func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { if _, ok := msg.(StepMsg); ok { if msg.(StepMsg).ID != s.id { @@ -95,6 +114,11 @@ func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { return s, nil } + s.ellipsisStep++ + if s.ellipsisStep > ellipsisFPS*(maxEllipsisDots+ellipsisPause) { + s.ellipsisStep = 0 + } + if s.pause > 0 { s.pause-- } else { @@ -119,17 +143,19 @@ func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { } s.tag++ - return s, s.Step() + return s, s.Start() } return s, nil } -func (s Spinner) Step() tea.Cmd { +// Start starts the spinner animation. +func (s Spinner) Start() tea.Cmd { return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { return StepMsg{ID: s.id} }) } +// View renders the spinner to a string. It satisfies tea.Model. func (s Spinner) View() string { if s.Config.Width == 0 { return "" @@ -144,5 +170,16 @@ func (s Spinner) View() string { b.WriteString(s.blendStyles[s.cells[i]-1].Render(highChar)) } + if s.Label != "" { + b.WriteString(" ") + b.WriteString(s.Label) + + // Draw ellipsis. + dots := min(s.ellipsisStep/ellipsisFPS, maxEllipsisDots) + for range dots { + b.WriteString(s.labelEllipsisDot) + } + } + return b.String() }