|
1 | 1 | package cmd |
2 | 2 |
|
3 | 3 | import ( |
4 | | - "context" |
5 | 4 | "fmt" |
6 | 5 | "io" |
7 | 6 | "os" |
8 | | - "strings" |
9 | | - "sync" |
10 | 7 | "time" |
11 | 8 |
|
| 9 | + tea "github.com/charmbracelet/bubbletea" |
12 | 10 | "golang.org/x/term" |
13 | 11 | ) |
14 | 12 |
|
15 | 13 | // spinnerFrames defines the animation frames for the spinner |
16 | 14 | var spinnerFrames = []string{"⢎ ", "⠎⠁", "⠊⠑", "⠈⠱", " ⡱", "⢀⡰", "⢄⡠", "⢆⡀"} |
17 | 15 |
|
18 | | -// spinner wraps output to show a nice spinner that updates in place |
19 | | -type spinner struct { |
20 | | - ctx context.Context |
21 | | - cancel context.CancelFunc |
22 | | - done chan struct{} |
23 | | - lock sync.Mutex |
24 | | - output io.Writer |
25 | | - tty bool |
26 | | - message string |
27 | | - frame int |
28 | | - lastLineLen int |
| 16 | +type Spinner struct { |
| 17 | + // Populated when output is a TTY |
| 18 | + program *tea.Program |
| 19 | + |
| 20 | + // Populated when output is not a TTY |
| 21 | + output io.Writer |
| 22 | + model *spinnerModel |
29 | 23 | } |
30 | 24 |
|
31 | | -func newSpinner(ctx context.Context, output io.Writer, message string, args ...any) *spinner { |
32 | | - ctx, cancel := context.WithCancel(ctx) |
33 | | - s := &spinner{ |
34 | | - ctx: ctx, |
35 | | - cancel: cancel, |
36 | | - output: output, |
| 25 | +func NewSpinner(output io.Writer, message string, args ...any) *Spinner { |
| 26 | + model := spinnerModel{ |
37 | 27 | message: fmt.Sprintf(message, args...), |
38 | | - done: make(chan struct{}), |
39 | 28 | } |
40 | 29 |
|
41 | | - // Check if output is a TTY |
42 | | - if f, ok := output.(*os.File); ok { |
43 | | - s.tty = term.IsTerminal(int(f.Fd())) |
| 30 | + // If output is not a TTY, print each message on a new line |
| 31 | + if !isTerminal(output) { |
| 32 | + s := &Spinner{ |
| 33 | + output: output, |
| 34 | + model: &model, |
| 35 | + } |
| 36 | + s.println() |
| 37 | + return s |
44 | 38 | } |
45 | 39 |
|
46 | | - // Start the spinner animation in a goroutine |
47 | | - go s.run() |
| 40 | + // If output is a TTY, use bubbletea to dynamically update the message |
| 41 | + program := tea.NewProgram( |
| 42 | + model, |
| 43 | + tea.WithOutput(output), |
| 44 | + ) |
48 | 45 |
|
49 | | - return s |
50 | | -} |
| 46 | + // Start the program in a goroutine |
| 47 | + go func() { |
| 48 | + if _, err := program.Run(); err != nil { |
| 49 | + fmt.Fprintf(output, "Error displaying output: %s\n", err) |
| 50 | + } |
| 51 | + }() |
51 | 52 |
|
52 | | -func (s *spinner) run() { |
53 | | - defer close(s.done) |
| 53 | + return &Spinner{ |
| 54 | + program: program, |
| 55 | + } |
| 56 | +} |
54 | 57 |
|
55 | | - // Initial render |
56 | | - s.render(true) |
| 58 | +func (s *Spinner) Update(message string, args ...any) { |
| 59 | + message = fmt.Sprintf(message, args...) |
| 60 | + if s.program != nil { |
| 61 | + s.program.Send(updateMsg(message)) |
| 62 | + } else if message != s.model.message { |
| 63 | + s.model.message = message |
| 64 | + s.model.incFrame() |
| 65 | + s.println() |
| 66 | + } |
| 67 | +} |
57 | 68 |
|
58 | | - // If not outputting to a terminal, do not constantly re-render the spinner |
59 | | - if !s.tty { |
| 69 | +func (s *Spinner) Stop() { |
| 70 | + if s.program == nil { |
60 | 71 | return |
61 | 72 | } |
62 | 73 |
|
63 | | - // Re-render the spinner every 100ms |
64 | | - ticker := time.NewTicker(100 * time.Millisecond) |
65 | | - defer ticker.Stop() |
66 | | - for { |
67 | | - select { |
68 | | - case <-s.ctx.Done(): |
69 | | - // Clear the line when finished |
70 | | - s.lock.Lock() |
71 | | - s.clearLine() |
72 | | - s.lock.Unlock() |
73 | | - return |
74 | | - case <-ticker.C: |
75 | | - s.render(true) |
76 | | - } |
77 | | - } |
| 74 | + s.program.Quit() |
| 75 | + s.program.Wait() |
78 | 76 | } |
79 | 77 |
|
80 | | -func (s *spinner) render(incFrame bool) { |
81 | | - s.lock.Lock() |
82 | | - defer s.lock.Unlock() |
| 78 | +func (s *Spinner) println() { |
| 79 | + fmt.Fprintln(s.output, s.model.View()) |
| 80 | +} |
83 | 81 |
|
84 | | - // Clear the previous line if outputting to a terminal |
85 | | - if s.tty { |
86 | | - s.clearLine() |
| 82 | +func isTerminal(w io.Writer) bool { |
| 83 | + if f, ok := w.(*os.File); ok { |
| 84 | + return term.IsTerminal(int(f.Fd())) |
87 | 85 | } |
| 86 | + return false |
| 87 | +} |
88 | 88 |
|
89 | | - // Build the spinner line |
90 | | - spinnerFrame := spinnerFrames[s.frame] |
91 | | - if incFrame { |
92 | | - s.frame = (s.frame + 1) % len(spinnerFrames) |
93 | | - } |
| 89 | +// Message types for the bubbletea model |
| 90 | +type tickMsg struct{} |
| 91 | +type updateMsg string |
94 | 92 |
|
95 | | - line := fmt.Sprintf("%s %s", spinnerFrame, s.message) |
96 | | - s.lastLineLen = len(line) |
| 93 | +// spinnerModel is the bubbletea model for the spinner |
| 94 | +type spinnerModel struct { |
| 95 | + message string |
| 96 | + frame int |
| 97 | +} |
97 | 98 |
|
98 | | - if s.tty { |
99 | | - // If outputting to a terminal, write without newline so it stays on the same line |
100 | | - fmt.Fprint(s.output, line) |
101 | | - } else { |
102 | | - // If not outputting to a terminal, write with a newline |
103 | | - fmt.Fprintln(s.output, line) |
104 | | - } |
| 99 | +func (m spinnerModel) Init() tea.Cmd { |
| 100 | + return m.tick() |
105 | 101 | } |
106 | 102 |
|
107 | | -func (s *spinner) clearLine() { |
108 | | - if s.lastLineLen > 0 { |
109 | | - fmt.Fprint(s.output, "\r") |
110 | | - fmt.Fprint(s.output, strings.Repeat(" ", s.lastLineLen)) |
111 | | - fmt.Fprint(s.output, "\r") |
| 103 | +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 104 | + switch msg := msg.(type) { |
| 105 | + case tickMsg: |
| 106 | + m.incFrame() |
| 107 | + return m, m.tick() |
| 108 | + case updateMsg: |
| 109 | + m.message = string(msg) |
112 | 110 | } |
| 111 | + return m, nil |
113 | 112 | } |
114 | 113 |
|
115 | | -func (s *spinner) update(message string, args ...any) { |
116 | | - s.lock.Lock() |
117 | | - newMessage := fmt.Sprintf(message, args...) |
118 | | - changed := s.message != newMessage |
119 | | - s.message = newMessage |
120 | | - s.lock.Unlock() |
121 | | - |
122 | | - // Immediately render the updated message, if it changed. Only increment |
123 | | - // the spinner frame if not outputting to a terminal (if outputting to a |
124 | | - // terminal, the spinner frame updates on a schedule via a time.Ticker). |
125 | | - if changed { |
126 | | - s.render(!s.tty) |
127 | | - } |
| 114 | +func (m spinnerModel) View() string { |
| 115 | + return fmt.Sprintf("%s %s", spinnerFrames[m.frame], m.message) |
128 | 116 | } |
129 | 117 |
|
130 | | -func (s *spinner) stop() { |
131 | | - // Cancel the context to stop the spinner |
132 | | - s.cancel() |
| 118 | +func (m *spinnerModel) incFrame() { |
| 119 | + m.frame = (m.frame + 1) % len(spinnerFrames) |
| 120 | +} |
133 | 121 |
|
134 | | - // Wait for spinner goroutine to finish rendering |
135 | | - <-s.done |
| 122 | +func (m *spinnerModel) tick() tea.Cmd { |
| 123 | + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { |
| 124 | + return tickMsg{} |
| 125 | + }) |
136 | 126 | } |
0 commit comments