From c82d5ccba9f10ace4140c8e79166910103a68d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Wei=C3=9Fe?= <66256922+daniel-weisse@users.noreply.github.com> Date: Fri, 21 Oct 2022 14:26:42 +0200 Subject: [PATCH] Hide cursor and fix dots (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Hide cursor and fix dots spinner * Allow restarting of spinner * Don't spin on non TTY output Signed-off-by: Daniel Weiße --- cli/internal/cmd/create.go | 4 +- cli/internal/cmd/init.go | 2 +- cli/internal/cmd/miniup.go | 15 +++-- cli/internal/cmd/spinner.go | 107 +++++++++++++++++-------------- cli/internal/cmd/spinner_test.go | 86 ++++++++++++------------- cli/internal/cmd/terminate.go | 2 +- go.mod | 2 +- hack/go.mod | 1 + hack/go.sum | 1 + 9 files changed, 118 insertions(+), 102 deletions(-) diff --git a/cli/internal/cmd/create.go b/cli/internal/cmd/create.go index 9ab2b2d35..2d4dec0db 100644 --- a/cli/internal/cmd/create.go +++ b/cli/internal/cmd/create.go @@ -41,9 +41,9 @@ func NewCreateCmd() *cobra.Command { func runCreate(cmd *cobra.Command, args []string) error { fileHandler := file.NewHandler(afero.NewOsFs()) - spinner, writer := newSpinner(cmd, cmd.OutOrStdout()) + spinner := newSpinner(cmd.OutOrStdout()) defer spinner.Stop() - creator := cloudcmd.NewCreator(writer) + creator := cloudcmd.NewCreator(spinner) return create(cmd, creator, fileHandler, spinner) } diff --git a/cli/internal/cmd/init.go b/cli/internal/cmd/init.go index 1bbf2c973..90463e759 100644 --- a/cli/internal/cmd/init.go +++ b/cli/internal/cmd/init.go @@ -60,7 +60,7 @@ func runInitialize(cmd *cobra.Command, args []string) error { return dialer.New(nil, validator.V(cmd), &net.Dialer{}) } helmLoader := &helm.ChartLoader{} - spinner, _ := newSpinner(cmd, cmd.OutOrStdout()) + spinner := newSpinner(cmd.OutOrStdout()) defer spinner.Stop() ctx, cancel := context.WithTimeout(cmd.Context(), time.Hour) diff --git a/cli/internal/cmd/miniup.go b/cli/internal/cmd/miniup.go index 03863dc22..9fbe54326 100644 --- a/cli/internal/cmd/miniup.go +++ b/cli/internal/cmd/miniup.go @@ -51,13 +51,14 @@ func newMiniUpCmd() *cobra.Command { } func runUp(cmd *cobra.Command, args []string) error { - spinner, _ := newSpinner(cmd, cmd.OutOrStdout()) + spinner := newSpinner(cmd.OutOrStdout()) defer spinner.Stop() + creator := cloudcmd.NewCreator(spinner) - return up(cmd, spinner) + return up(cmd, creator, spinner) } -func up(cmd *cobra.Command, spinner spinnerInterf) error { +func up(cmd *cobra.Command, creator cloudCreator, spinner spinnerInterf) error { if err := checkSystemRequirements(cmd.OutOrStdout()); err != nil { return fmt.Errorf("system requirements not met: %w", err) } @@ -72,7 +73,7 @@ func up(cmd *cobra.Command, spinner spinnerInterf) error { // create cluster spinner.Start("Creating cluster in QEMU ", false) - err = createMiniCluster(cmd.Context(), fileHandler, cloudcmd.NewCreator(cmd.OutOrStdout()), config) + err = createMiniCluster(cmd.Context(), fileHandler, creator, config) spinner.Stop() if err != nil { return fmt.Errorf("creating cluster: %w", err) @@ -86,7 +87,7 @@ func up(cmd *cobra.Command, spinner spinnerInterf) error { cmd.Printf("\tvirsh -c %s\n\n", connectURI) // initialize cluster - if err := initializeMiniCluster(cmd, fileHandler); err != nil { + if err := initializeMiniCluster(cmd, fileHandler, spinner); err != nil { return fmt.Errorf("initializing cluster: %w", err) } return nil @@ -222,7 +223,7 @@ func createMiniCluster(ctx context.Context, fileHandler file.Handler, creator cl } // initializeMiniCluster initializes a QEMU cluster. -func initializeMiniCluster(cmd *cobra.Command, fileHandler file.Handler) (retErr error) { +func initializeMiniCluster(cmd *cobra.Command, fileHandler file.Handler, spinner spinnerInterf) (retErr error) { // clean up cluster resources if initialization fails defer func() { if retErr != nil { @@ -241,7 +242,7 @@ func initializeMiniCluster(cmd *cobra.Command, fileHandler file.Handler) (retErr cmd.Flags().String("endpoint", "", "") cmd.Flags().Bool("conformance", false, "") - if err := initialize(cmd, newDialer, fileHandler, helmLoader, license.NewClient(), nopSpinner{}); err != nil { + if err := initialize(cmd, newDialer, fileHandler, helmLoader, license.NewClient(), spinner); err != nil { return err } return nil diff --git a/cli/internal/cmd/spinner.go b/cli/internal/cmd/spinner.go index e64556f27..6c7eba714 100644 --- a/cli/internal/cmd/spinner.go +++ b/cli/internal/cmd/spinner.go @@ -9,16 +9,23 @@ package cmd import ( "fmt" "io" + "os" "sync" "sync/atomic" "time" - "github.com/spf13/cobra" + tty "github.com/mattn/go-isatty" +) + +const ( + // hideCursor and showCursor are ANSI escape sequences to hide and show the cursor. + hideCursor = "\033[?25l" + showCursor = "\033[?25h" ) var ( - spinnerStates = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} - dotsStates = []string{".", "..", "..."} + spinnerStates = []string{"⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"} + dotsStates = []string{". ", ".. ", "..."} ) type spinnerInterf interface { @@ -27,71 +34,77 @@ type spinnerInterf interface { } type spinner struct { - out *cobra.Command - delay time.Duration - wg *sync.WaitGroup - stop int32 + out io.Writer + delay time.Duration + wg *sync.WaitGroup + stop int32 + spinFunc func(out io.Writer, wg *sync.WaitGroup, stop *int32, delay time.Duration, text string, showDots bool) } -func newSpinner(c *cobra.Command, writer io.Writer) (*spinner, *interruptSpinWriter) { - spinner := &spinner{ - out: c, +func newSpinner(writer io.Writer) *spinner { + s := &spinner{ + out: writer, wg: &sync.WaitGroup{}, - delay: 100 * time.Millisecond, - stop: 0, + delay: time.Millisecond * 100, } - if writer != nil { - interruptWriter := &interruptSpinWriter{ - writer: writer, - spinner: spinner, - } - return spinner, interruptWriter + s.spinFunc = spinTTY + // + if !(writer == os.Stdout && tty.IsTerminal(os.Stdout.Fd())) { + s.spinFunc = spinNoTTY } - return spinner, nil + return s } +// Start starts the spinner using the given text. func (s *spinner) Start(text string, showDots bool) { s.wg.Add(1) - go func() { - defer s.wg.Done() - for i := 0; ; i = (i + 1) % len(spinnerStates) { - if atomic.LoadInt32(&s.stop) != 0 { - break - } - dotsState := "" - if showDots { - dotsState = dotsStates[i%len(dotsStates)] - } - state := fmt.Sprintf("\r%s %s%s", spinnerStates[i], text, dotsState) - s.out.Print(state) - time.Sleep(s.delay) - } - - dotsState := "" - if showDots { - dotsState = dotsStates[len(dotsStates)-1] - } - finalState := fmt.Sprintf("\r%s%s ", text, dotsState) - s.out.Println(finalState) - }() + go s.spinFunc(s.out, s.wg, &s.stop, s.delay, text, showDots) } +// Stop stops the spinner. func (s *spinner) Stop() { atomic.StoreInt32(&s.stop, 1) s.wg.Wait() } -type interruptSpinWriter struct { - spinner *spinner - writer io.Writer +// Write stops the spinner and writes the given bytes to the underlying writer. +func (s *spinner) Write(p []byte) (n int, err error) { + s.Stop() + return s.out.Write(p) } -func (w *interruptSpinWriter) Write(p []byte) (n int, err error) { - w.spinner.Stop() - return w.writer.Write(p) +func spinTTY(out io.Writer, wg *sync.WaitGroup, stop *int32, delay time.Duration, text string, showDots bool) { + defer wg.Done() + + fmt.Fprint(out, hideCursor) + + for i := 0; ; i = (i + 1) % len(spinnerStates) { + if atomic.LoadInt32(stop) != 0 { + break + } + dotsState := "" + if showDots { + dotsState = dotsStates[i%len(dotsStates)] + } + state := fmt.Sprintf("\r%s %s%s", spinnerStates[i], text, dotsState) + fmt.Fprint(out, state) + time.Sleep(delay) + } + dotsState := "" + if showDots { + dotsState = dotsStates[len(dotsStates)-1] + } + finalState := fmt.Sprintf("\r%s%s ", text, dotsState) + fmt.Fprintln(out, finalState) + fmt.Fprint(out, showCursor) +} + +func spinNoTTY(out io.Writer, wg *sync.WaitGroup, _ *int32, _ time.Duration, text string, _ bool) { + defer wg.Done() + fmt.Fprintln(out, text+"...") } type nopSpinner struct{} diff --git a/cli/internal/cmd/spinner_test.go b/cli/internal/cmd/spinner_test.go index 712983599..b35c85051 100644 --- a/cli/internal/cmd/spinner_test.go +++ b/cli/internal/cmd/spinner_test.go @@ -10,7 +10,6 @@ import ( "bytes" "fmt" "strings" - "sync/atomic" "testing" "time" @@ -18,90 +17,91 @@ import ( ) const ( - baseWait = 1 + baseWait = 100 baseText = "Loading" ) func TestSpinnerInitialState(t *testing.T) { assert := assert.New(t) - cmd := NewInitCmd() - var out bytes.Buffer - cmd.SetOut(&out) - var errOut bytes.Buffer - cmd.SetErr(&errOut) + out := &bytes.Buffer{} - s, _ := newSpinner(cmd, nil) + s := newSpinner(out) + s.delay = time.Millisecond * 10 + s.spinFunc = spinTTY s.Start(baseText, true) - time.Sleep(baseWait * time.Second) + time.Sleep(baseWait * time.Millisecond) s.Stop() - assert.True(out.Len() > 0) - assert.True(errOut.Len() == 0) + assert.Greater(out.Len(), 0) outStr := out.String() - assert.True(strings.HasPrefix(outStr, generateAllStatesAsString(baseText, true))) + assert.True(strings.HasPrefix(outStr, hideCursor+generateAllStatesAsString(t, baseText, true))) } func TestSpinnerFinalState(t *testing.T) { assert := assert.New(t) + out := &bytes.Buffer{} - cmd := NewInitCmd() - var out bytes.Buffer - cmd.SetOut(&out) - var errOut bytes.Buffer - cmd.SetErr(&errOut) - - s, _ := newSpinner(cmd, nil) + s := newSpinner(out) + s.delay = time.Millisecond * 10 + s.spinFunc = spinTTY s.Start(baseText, true) - time.Sleep(baseWait * time.Second) + time.Sleep(baseWait * time.Millisecond) s.Stop() - assert.True(out.Len() > 0) - assert.True(errOut.Len() == 0) + assert.Greater(out.Len(), 0) outStr := out.String() - assert.True(strings.HasSuffix(outStr, baseText+"... \n")) + assert.True(strings.HasSuffix(outStr, baseText+"... \n"+showCursor)) } func TestSpinnerDisabledShowDotsFlag(t *testing.T) { assert := assert.New(t) + out := &bytes.Buffer{} - cmd := NewInitCmd() - var out bytes.Buffer - cmd.SetOut(&out) - var errOut bytes.Buffer - cmd.SetErr(&errOut) - - s, _ := newSpinner(cmd, nil) + s := newSpinner(out) + s.delay = time.Millisecond * 10 + s.spinFunc = spinTTY s.Start(baseText, false) - time.Sleep(baseWait * time.Second) + time.Sleep(baseWait * time.Millisecond) s.Stop() assert.True(out.Len() > 0) - assert.True(errOut.Len() == 0) outStr := out.String() - assert.True(strings.HasPrefix(outStr, generateAllStatesAsString(baseText, false))) - assert.True(strings.HasSuffix(outStr, baseText+" \n")) + assert.True(strings.HasPrefix(outStr, hideCursor+generateAllStatesAsString(t, baseText, false))) + assert.True(strings.HasSuffix(outStr, baseText+" \n"+showCursor)) } func TestSpinnerInterruptWriter(t *testing.T) { assert := assert.New(t) - cmd := NewInitCmd() - var out bytes.Buffer - cmd.SetOut(&out) - var errOut bytes.Buffer - cmd.SetErr(&errOut) + out := &bytes.Buffer{} - s, interruptWriter := newSpinner(cmd, &out) + s := newSpinner(out) + s.spinFunc = spinTTY s.Start(baseText, false) time.Sleep(200 * time.Millisecond) - _, err := interruptWriter.Write([]byte("test")) + _, err := s.Write([]byte("test")) assert.NoError(err) - assert.Equal(int32(1), atomic.LoadInt32(&s.stop)) assert.True(strings.HasSuffix(out.String(), "test")) } -func generateAllStatesAsString(text string, showDots bool) string { +func TestSpinNoTTY(t *testing.T) { + assert := assert.New(t) + + out := &bytes.Buffer{} + + s := newSpinner(out) + s.spinFunc = spinNoTTY + s.Start(baseText, true) + time.Sleep(baseWait * time.Millisecond) + s.Stop() + assert.Greater(out.Len(), 0) + assert.Equal(baseText+"...\n", out.String()) +} + +func generateAllStatesAsString(t *testing.T, text string, showDots bool) string { + t.Helper() + var builder strings.Builder for i := 0; i < len(spinnerStates); i++ { diff --git a/cli/internal/cmd/terminate.go b/cli/internal/cmd/terminate.go index dc2cb3d3f..4913e7e38 100644 --- a/cli/internal/cmd/terminate.go +++ b/cli/internal/cmd/terminate.go @@ -36,7 +36,7 @@ func NewTerminateCmd() *cobra.Command { // runTerminate runs the terminate command. func runTerminate(cmd *cobra.Command, args []string) error { fileHandler := file.NewHandler(afero.NewOsFs()) - spinner, _ := newSpinner(cmd, cmd.OutOrStdout()) + spinner := newSpinner(cmd.OutOrStdout()) defer spinner.Stop() terminator := cloudcmd.NewTerminator() diff --git a/go.mod b/go.mod index 9f9e7b248..320d4a2c4 100644 --- a/go.mod +++ b/go.mod @@ -74,6 +74,7 @@ require ( github.com/icholy/replace v0.5.0 github.com/manifoldco/promptui v0.9.0 github.com/martinjungblut/go-cryptsetup v0.0.0-20220520180014-fd0874fd07a6 + github.com/mattn/go-isatty v0.0.14 github.com/microsoft/ApplicationInsights-Go v0.4.4 github.com/operator-framework/api v0.15.0 github.com/pkg/errors v0.9.1 @@ -223,7 +224,6 @@ require ( github.com/magiconair/properties v1.8.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect diff --git a/hack/go.mod b/hack/go.mod index 945b70cb4..db2d3de1d 100644 --- a/hack/go.mod +++ b/hack/go.mod @@ -164,6 +164,7 @@ require ( github.com/magiconair/properties v1.8.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/manifoldco/promptui v0.9.0 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/hack/go.sum b/hack/go.sum index 40de5a251..163e747ac 100644 --- a/hack/go.sum +++ b/hack/go.sum @@ -965,6 +965,7 @@ github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2y github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=