spinner.go

  1package format
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"image/color"
  8	"os"
  9
 10	"charm.land/bubbles/v2/spinner"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/charmbracelet/crush/internal/tui/components/anim"
 14	"github.com/charmbracelet/x/ansi"
 15)
 16
 17// Spinner wraps the anim spinner for non-interactive mode (used for LLM
 18// generation).
 19type Spinner struct {
 20	done chan struct{}
 21	prog *tea.Program
 22}
 23
 24type model struct {
 25	cancel context.CancelFunc
 26	anim   *anim.Anim
 27}
 28
 29func (m model) Init() tea.Cmd  { return m.anim.Init() }
 30func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
 31
 32// Update implements tea.Model.
 33func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 34	switch msg := msg.(type) {
 35	case tea.KeyPressMsg:
 36		switch msg.String() {
 37		case "ctrl+c", "esc":
 38			m.cancel()
 39			return m, tea.Quit
 40		}
 41	}
 42	mm, cmd := m.anim.Update(msg)
 43	m.anim = mm.(*anim.Anim)
 44	return m, cmd
 45}
 46
 47// NewSpinner creates a new spinner with the given message.
 48func NewSpinner(ctx context.Context, cancel context.CancelFunc, animSettings anim.Settings) *Spinner {
 49	m := model{
 50		anim:   anim.New(animSettings),
 51		cancel: cancel,
 52	}
 53
 54	p := tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithContext(ctx))
 55
 56	return &Spinner{
 57		prog: p,
 58		done: make(chan struct{}, 1),
 59	}
 60}
 61
 62// Start begins the spinner animation.
 63func (s *Spinner) Start() {
 64	go func() {
 65		defer close(s.done)
 66		_, err := s.prog.Run()
 67		fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
 68		if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, tea.ErrInterrupted) {
 69			fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
 70		}
 71	}()
 72}
 73
 74// Stop ends the spinner animation.
 75func (s *Spinner) Stop() {
 76	s.prog.Quit()
 77	<-s.done
 78}
 79
 80// SimpleSpinner wraps a dot spinner for non-interactive CLI operations.
 81type SimpleSpinner struct {
 82	done chan struct{}
 83	prog *tea.Program
 84}
 85
 86type simpleModel struct {
 87	cancel  context.CancelFunc
 88	spinner spinner.Model
 89	label   string
 90}
 91
 92func (m simpleModel) Init() tea.Cmd  { return m.spinner.Tick }
 93func (m simpleModel) View() tea.View { return tea.NewView(m.spinner.View() + " " + m.label) }
 94
 95func (m simpleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 96	switch msg := msg.(type) {
 97	case tea.KeyPressMsg:
 98		switch msg.String() {
 99		case "ctrl+c", "esc":
100			m.cancel()
101			return m, tea.Quit
102		}
103	case spinner.TickMsg:
104		var cmd tea.Cmd
105		m.spinner, cmd = m.spinner.Update(msg)
106		return m, cmd
107	}
108	return m, nil
109}
110
111// NewSimpleSpinner creates a simple dot spinner for CLI operations.
112func NewSimpleSpinner(ctx context.Context, cancel context.CancelFunc, label string, c color.Color) *SimpleSpinner {
113	s := spinner.New(spinner.WithSpinner(spinner.Dot))
114	if c != nil {
115		s.Style = lipgloss.NewStyle().Foreground(c)
116	}
117	m := simpleModel{cancel: cancel, spinner: s, label: label}
118	p := tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithContext(ctx))
119	return &SimpleSpinner{prog: p, done: make(chan struct{}, 1)}
120}
121
122// Start begins the spinner animation.
123func (s *SimpleSpinner) Start() {
124	go func() {
125		defer close(s.done)
126		_, err := s.prog.Run()
127		fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
128		if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, tea.ErrInterrupted) {
129			fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
130		}
131	}()
132}
133
134// Stop ends the spinner animation.
135func (s *SimpleSpinner) Stop() {
136	s.prog.Quit()
137	<-s.done
138}