diff --git a/internal/ui/spinner/example/main.go b/internal/ui/spinner/example/main.go new file mode 100644 index 0000000000000000000000000000000000000000..66f6ac1c218de0ffe11c88090801a737f8088897 --- /dev/null +++ b/internal/ui/spinner/example/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + + "github.com/charmbracelet/crush/internal/ui/spinner" +) + +// Model is the Bubble Tea model for the example program. +type Model struct { + spinner spinner.Spinner + quitting bool +} + +// Init initializes the model. It satisfies tea.Model. +func (m Model) Init() tea.Cmd { + return m.spinner.Step() +} + +// Update updates the model per on incoming messages. It satisfies tea.Model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd +} + +// View renders the model to a string. It satisfies tea.Model. +func (m Model) View() tea.View { + if m.quitting { + return tea.NewView("") + } + return tea.NewView(m.spinner.View()) +} + +func main() { + if _, err := tea.NewProgram(Model{ + spinner: spinner.NewSpinner(), + }).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 new file mode 100644 index 0000000000000000000000000000000000000000..6c67745753db78aec345583ed2110464bb5b55fc --- /dev/null +++ b/internal/ui/spinner/spinner.go @@ -0,0 +1,95 @@ +// Package spinner implements a spinner used to indicate processing is occurring. +package spinner + +import ( + "strings" + "sync/atomic" + "time" + + tea "charm.land/bubbletea/v2" +) + +const ( + fps = 24 + emptyChar = '░' +) + +var blocks = []rune{ + '▁', + '▂', + '▃', + '▄', + '▅', + '▆', + '▇', + '█', +} + +// 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)) +} + +type StepMsg struct{ ID int } + +type Config struct { + Width int +} + +func DefaultConfig() Config { + return Config{Width: 12} +} + +type Spinner struct { + Config Config + id int + index int +} + +func NewSpinner() Spinner { + return Spinner{ + id: nextID(), + Config: DefaultConfig(), + } +} + +func (s Spinner) Init() tea.Cmd { + return nil +} + +func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) { + if _, ok := msg.(StepMsg); ok { + if msg.(StepMsg).ID != s.id { + // Reject events from other spinners. + return s, nil + } + s.index++ + if s.index > s.Config.Width-1 { + s.index = 0 + } + return s, s.Step() + } + return s, nil +} + +func (s Spinner) Step() tea.Cmd { + return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { + return StepMsg{ID: s.id} + }) +} + +func (s Spinner) View() string { + var b strings.Builder + for i := range s.Config.Width { + if i == s.index { + b.WriteRune(blocks[len(blocks)-1]) + continue + } + b.WriteRune(emptyChar) + } + + return b.String() +}