Hide cursor and fix dots (#217)

* Hide cursor and fix dots spinner

* Allow restarting of spinner

* Don't spin on non TTY output

Signed-off-by: Daniel Weiße <dw@edgeless.systems>
This commit is contained in:
Daniel Weiße 2022-10-21 14:26:42 +02:00 committed by GitHub
parent 56981a709e
commit c82d5ccba9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 118 additions and 102 deletions

View File

@ -41,9 +41,9 @@ func NewCreateCmd() *cobra.Command {
func runCreate(cmd *cobra.Command, args []string) error { func runCreate(cmd *cobra.Command, args []string) error {
fileHandler := file.NewHandler(afero.NewOsFs()) fileHandler := file.NewHandler(afero.NewOsFs())
spinner, writer := newSpinner(cmd, cmd.OutOrStdout()) spinner := newSpinner(cmd.OutOrStdout())
defer spinner.Stop() defer spinner.Stop()
creator := cloudcmd.NewCreator(writer) creator := cloudcmd.NewCreator(spinner)
return create(cmd, creator, fileHandler, spinner) return create(cmd, creator, fileHandler, spinner)
} }

View File

@ -60,7 +60,7 @@ func runInitialize(cmd *cobra.Command, args []string) error {
return dialer.New(nil, validator.V(cmd), &net.Dialer{}) return dialer.New(nil, validator.V(cmd), &net.Dialer{})
} }
helmLoader := &helm.ChartLoader{} helmLoader := &helm.ChartLoader{}
spinner, _ := newSpinner(cmd, cmd.OutOrStdout()) spinner := newSpinner(cmd.OutOrStdout())
defer spinner.Stop() defer spinner.Stop()
ctx, cancel := context.WithTimeout(cmd.Context(), time.Hour) ctx, cancel := context.WithTimeout(cmd.Context(), time.Hour)

View File

@ -51,13 +51,14 @@ func newMiniUpCmd() *cobra.Command {
} }
func runUp(cmd *cobra.Command, args []string) error { func runUp(cmd *cobra.Command, args []string) error {
spinner, _ := newSpinner(cmd, cmd.OutOrStdout()) spinner := newSpinner(cmd.OutOrStdout())
defer spinner.Stop() 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 { if err := checkSystemRequirements(cmd.OutOrStdout()); err != nil {
return fmt.Errorf("system requirements not met: %w", err) return fmt.Errorf("system requirements not met: %w", err)
} }
@ -72,7 +73,7 @@ func up(cmd *cobra.Command, spinner spinnerInterf) error {
// create cluster // create cluster
spinner.Start("Creating cluster in QEMU ", false) 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() spinner.Stop()
if err != nil { if err != nil {
return fmt.Errorf("creating cluster: %w", err) 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) cmd.Printf("\tvirsh -c %s\n\n", connectURI)
// initialize cluster // 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 fmt.Errorf("initializing cluster: %w", err)
} }
return nil return nil
@ -222,7 +223,7 @@ func createMiniCluster(ctx context.Context, fileHandler file.Handler, creator cl
} }
// initializeMiniCluster initializes a QEMU cluster. // 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 // clean up cluster resources if initialization fails
defer func() { defer func() {
if retErr != nil { if retErr != nil {
@ -241,7 +242,7 @@ func initializeMiniCluster(cmd *cobra.Command, fileHandler file.Handler) (retErr
cmd.Flags().String("endpoint", "", "") cmd.Flags().String("endpoint", "", "")
cmd.Flags().Bool("conformance", false, "") 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 err
} }
return nil return nil

View File

@ -9,16 +9,23 @@ package cmd
import ( import (
"fmt" "fmt"
"io" "io"
"os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "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 ( var (
spinnerStates = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} spinnerStates = []string{"⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"}
dotsStates = []string{".", "..", "..."} dotsStates = []string{". ", ".. ", "..."}
) )
type spinnerInterf interface { type spinnerInterf interface {
@ -27,71 +34,77 @@ type spinnerInterf interface {
} }
type spinner struct { type spinner struct {
out *cobra.Command out io.Writer
delay time.Duration delay time.Duration
wg *sync.WaitGroup wg *sync.WaitGroup
stop int32 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) { func newSpinner(writer io.Writer) *spinner {
spinner := &spinner{ s := &spinner{
out: c, out: writer,
wg: &sync.WaitGroup{}, wg: &sync.WaitGroup{},
delay: 100 * time.Millisecond, delay: time.Millisecond * 100,
stop: 0,
} }
if writer != nil { s.spinFunc = spinTTY
interruptWriter := &interruptSpinWriter{ //
writer: writer, if !(writer == os.Stdout && tty.IsTerminal(os.Stdout.Fd())) {
spinner: spinner, s.spinFunc = spinNoTTY
}
return spinner, interruptWriter
} }
return spinner, nil return s
} }
// Start starts the spinner using the given text.
func (s *spinner) Start(text string, showDots bool) { func (s *spinner) Start(text string, showDots bool) {
s.wg.Add(1) s.wg.Add(1)
go func() {
defer s.wg.Done()
for i := 0; ; i = (i + 1) % len(spinnerStates) { go s.spinFunc(s.out, s.wg, &s.stop, s.delay, text, showDots)
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)
}()
} }
// Stop stops the spinner.
func (s *spinner) Stop() { func (s *spinner) Stop() {
atomic.StoreInt32(&s.stop, 1) atomic.StoreInt32(&s.stop, 1)
s.wg.Wait() s.wg.Wait()
} }
type interruptSpinWriter struct { // Write stops the spinner and writes the given bytes to the underlying writer.
spinner *spinner func (s *spinner) Write(p []byte) (n int, err error) {
writer io.Writer s.Stop()
return s.out.Write(p)
} }
func (w *interruptSpinWriter) Write(p []byte) (n int, err error) { func spinTTY(out io.Writer, wg *sync.WaitGroup, stop *int32, delay time.Duration, text string, showDots bool) {
w.spinner.Stop() defer wg.Done()
return w.writer.Write(p)
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{} type nopSpinner struct{}

View File

@ -10,7 +10,6 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@ -18,90 +17,91 @@ import (
) )
const ( const (
baseWait = 1 baseWait = 100
baseText = "Loading" baseText = "Loading"
) )
func TestSpinnerInitialState(t *testing.T) { func TestSpinnerInitialState(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
cmd := NewInitCmd() out := &bytes.Buffer{}
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) s.Start(baseText, true)
time.Sleep(baseWait * time.Second) time.Sleep(baseWait * time.Millisecond)
s.Stop() s.Stop()
assert.True(out.Len() > 0) assert.Greater(out.Len(), 0)
assert.True(errOut.Len() == 0)
outStr := out.String() 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) { func TestSpinnerFinalState(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
out := &bytes.Buffer{}
cmd := NewInitCmd() s := newSpinner(out)
var out bytes.Buffer s.delay = time.Millisecond * 10
cmd.SetOut(&out) s.spinFunc = spinTTY
var errOut bytes.Buffer
cmd.SetErr(&errOut)
s, _ := newSpinner(cmd, nil)
s.Start(baseText, true) s.Start(baseText, true)
time.Sleep(baseWait * time.Second) time.Sleep(baseWait * time.Millisecond)
s.Stop() s.Stop()
assert.True(out.Len() > 0) assert.Greater(out.Len(), 0)
assert.True(errOut.Len() == 0)
outStr := out.String() outStr := out.String()
assert.True(strings.HasSuffix(outStr, baseText+"... \n")) assert.True(strings.HasSuffix(outStr, baseText+"... \n"+showCursor))
} }
func TestSpinnerDisabledShowDotsFlag(t *testing.T) { func TestSpinnerDisabledShowDotsFlag(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
out := &bytes.Buffer{}
cmd := NewInitCmd() s := newSpinner(out)
var out bytes.Buffer s.delay = time.Millisecond * 10
cmd.SetOut(&out) s.spinFunc = spinTTY
var errOut bytes.Buffer
cmd.SetErr(&errOut)
s, _ := newSpinner(cmd, nil)
s.Start(baseText, false) s.Start(baseText, false)
time.Sleep(baseWait * time.Second) time.Sleep(baseWait * time.Millisecond)
s.Stop() s.Stop()
assert.True(out.Len() > 0) assert.True(out.Len() > 0)
assert.True(errOut.Len() == 0)
outStr := out.String() outStr := out.String()
assert.True(strings.HasPrefix(outStr, generateAllStatesAsString(baseText, false))) assert.True(strings.HasPrefix(outStr, hideCursor+generateAllStatesAsString(t, baseText, false)))
assert.True(strings.HasSuffix(outStr, baseText+" \n")) assert.True(strings.HasSuffix(outStr, baseText+" \n"+showCursor))
} }
func TestSpinnerInterruptWriter(t *testing.T) { func TestSpinnerInterruptWriter(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
cmd := NewInitCmd() out := &bytes.Buffer{}
var out bytes.Buffer
cmd.SetOut(&out)
var errOut bytes.Buffer
cmd.SetErr(&errOut)
s, interruptWriter := newSpinner(cmd, &out) s := newSpinner(out)
s.spinFunc = spinTTY
s.Start(baseText, false) s.Start(baseText, false)
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
_, err := interruptWriter.Write([]byte("test")) _, err := s.Write([]byte("test"))
assert.NoError(err) assert.NoError(err)
assert.Equal(int32(1), atomic.LoadInt32(&s.stop))
assert.True(strings.HasSuffix(out.String(), "test")) 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 var builder strings.Builder
for i := 0; i < len(spinnerStates); i++ { for i := 0; i < len(spinnerStates); i++ {

View File

@ -36,7 +36,7 @@ func NewTerminateCmd() *cobra.Command {
// runTerminate runs the terminate command. // runTerminate runs the terminate command.
func runTerminate(cmd *cobra.Command, args []string) error { func runTerminate(cmd *cobra.Command, args []string) error {
fileHandler := file.NewHandler(afero.NewOsFs()) fileHandler := file.NewHandler(afero.NewOsFs())
spinner, _ := newSpinner(cmd, cmd.OutOrStdout()) spinner := newSpinner(cmd.OutOrStdout())
defer spinner.Stop() defer spinner.Stop()
terminator := cloudcmd.NewTerminator() terminator := cloudcmd.NewTerminator()

2
go.mod
View File

@ -74,6 +74,7 @@ require (
github.com/icholy/replace v0.5.0 github.com/icholy/replace v0.5.0
github.com/manifoldco/promptui v0.9.0 github.com/manifoldco/promptui v0.9.0
github.com/martinjungblut/go-cryptsetup v0.0.0-20220520180014-fd0874fd07a6 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/microsoft/ApplicationInsights-Go v0.4.4
github.com/operator-framework/api v0.15.0 github.com/operator-framework/api v0.15.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
@ -223,7 +224,6 @@ require (
github.com/magiconair/properties v1.8.6 // indirect github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.12 // 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/mattn/go-runewidth v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect

View File

@ -164,6 +164,7 @@ require (
github.com/magiconair/properties v1.8.6 // indirect github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/manifoldco/promptui v0.9.0 // 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/mattn/go-runewidth v0.0.13 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect

View File

@ -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.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.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.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-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.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=