Skip to content

Commit 94e165c

Browse files
Switch to using bubbletea to redraw terminal status message
1 parent 3c94b3a commit 94e165c

File tree

2 files changed

+91
-101
lines changed

2 files changed

+91
-101
lines changed

internal/tiger/cmd/service.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -815,8 +815,8 @@ func waitForServiceReady(client *api.ClientWithResponses, projectID, serviceID s
815815
defer ticker.Stop()
816816

817817
// Start the spinner
818-
spinner := newSpinner(ctx, output, "Service status: %s", util.DerefStr(initialStatus))
819-
defer spinner.stop()
818+
spinner := NewSpinner(output, "Service status: %s", util.DerefStr(initialStatus))
819+
defer spinner.Stop()
820820

821821
lastStatus := initialStatus
822822
for {
@@ -826,12 +826,12 @@ func waitForServiceReady(client *api.ClientWithResponses, projectID, serviceID s
826826
case <-ticker.C:
827827
resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID)
828828
if err != nil {
829-
spinner.update("Error checking service status: %v", err)
829+
spinner.Update("Error checking service status: %v", err)
830830
continue
831831
}
832832

833833
if resp.StatusCode() != 200 || resp.JSON200 == nil {
834-
spinner.update("Service not found or error checking status")
834+
spinner.Update("Service not found or error checking status")
835835
continue
836836
}
837837

@@ -841,13 +841,13 @@ func waitForServiceReady(client *api.ClientWithResponses, projectID, serviceID s
841841

842842
switch status {
843843
case "READY":
844-
spinner.stop()
844+
spinner.Stop()
845845
fmt.Fprintf(output, "🎉 Service is ready and running!\n")
846846
return service.Status, nil
847847
case "FAILED", "ERROR":
848848
return service.Status, fmt.Errorf("service creation failed with status: %s", status)
849849
default:
850-
spinner.update("Service status: %s", status)
850+
spinner.Update("Service status: %s", status)
851851
}
852852
}
853853
}
@@ -1020,8 +1020,8 @@ func waitForServiceDeletion(client *api.ClientWithResponses, projectID string, s
10201020
statusOutput := cmd.ErrOrStderr()
10211021

10221022
// Start the spinner
1023-
spinner := newSpinner(ctx, statusOutput, "Waiting for service '%s' to be deleted", serviceID)
1024-
defer spinner.stop()
1023+
spinner := NewSpinner(statusOutput, "Waiting for service '%s' to be deleted", serviceID)
1024+
defer spinner.Stop()
10251025

10261026
for {
10271027
select {
@@ -1040,7 +1040,7 @@ func waitForServiceDeletion(client *api.ClientWithResponses, projectID string, s
10401040

10411041
if resp.StatusCode() == 404 {
10421042
// Service is deleted
1043-
spinner.stop()
1043+
spinner.Stop()
10441044
fmt.Fprintf(statusOutput, "✅ Service '%s' has been successfully deleted.\n", serviceID)
10451045
return nil
10461046
}

internal/tiger/cmd/spinner.go

Lines changed: 82 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,126 @@
11
package cmd
22

33
import (
4-
"context"
54
"fmt"
65
"io"
76
"os"
8-
"strings"
9-
"sync"
107
"time"
118

9+
tea "github.com/charmbracelet/bubbletea"
1210
"golang.org/x/term"
1311
)
1412

1513
// spinnerFrames defines the animation frames for the spinner
1614
var spinnerFrames = []string{"⢎ ", "⠎⠁", "⠊⠑", "⠈⠱", " ⡱", "⢀⡰", "⢄⡠", "⢆⡀"}
1715

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
2923
}
3024

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{
3727
message: fmt.Sprintf(message, args...),
38-
done: make(chan struct{}),
3928
}
4029

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
4438
}
4539

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+
)
4845

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

52-
func (s *spinner) run() {
53-
defer close(s.done)
53+
return &Spinner{
54+
program: program,
55+
}
56+
}
5457

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+
}
5768

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 {
6071
return
6172
}
6273

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

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+
}
8381

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()))
8785
}
86+
return false
87+
}
8888

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
9492

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+
}
9798

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

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)
112110
}
111+
return m, nil
113112
}
114113

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)
128116
}
129117

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+
}
133121

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+
})
136126
}

0 commit comments

Comments
 (0)