feat: new calm-energy spinner

Christian Rocha created

Change summary

internal/ui/spinner/example/main.go | 13 +---
internal/ui/spinner/spinner.go      | 87 ++++++++++++++++++++++--------
2 files changed, 66 insertions(+), 34 deletions(-)

Detailed changes

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)

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()
 }