Skip to content

Commit

Permalink
feat: create a simple spinner for non terminal interactions (#4538)
Browse files Browse the repository at this point in the history
* create a simple spinner for non terminal interactions

* remove unused method

* add changelog

* fix lint

* Update ignite/pkg/cliui/clispinner/clispinner.go
  • Loading branch information
Pantani authored Feb 26, 2025
1 parent ec7fd51 commit a3c4c51
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 88 deletions.
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- [#4477](https://github.com/ignite/cli/pull/4477) IBC v10 support
- [#4166](https://github.com/ignite/cli/issues/4166) Migrate buf config files to v2
- [#4494](https://github.com/ignite/cli/pull/4494) Automatic migrate the buf configs to v2
- [#4538](https://github.com/ignite/cli/pull/4538) Create a simple spinner for non-terminal interactions

### Changes

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ require (
github.com/golangci/golangci-lint v1.64.5
github.com/google/go-github/v48 v48.2.0
github.com/google/go-querystring v1.1.0
github.com/gookit/color v1.5.4
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.6.3
github.com/iancoleman/strcase v0.3.0
Expand Down Expand Up @@ -448,6 +449,7 @@ require (
github.com/vbatts/tar-split v0.11.6 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,8 @@ github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqE
github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s=
github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=
Expand Down Expand Up @@ -1740,6 +1742,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU=
github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
Expand Down
10 changes: 0 additions & 10 deletions ignite/cmd/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,6 @@ func linkPluginCmd(rootCmd *cobra.Command, p *plugin.Plugin, pluginCmd *plugin.C
}
cmd.AddCommand(newCmd)

// NOTE(tb) we could probably simplify by removing this condition and call the
// plugin even if the invoked command isn't runnable. If we do so, the plugin
// will be responsible for outputing the standard cobra output, which implies
// it must use cobra too. This is how cli-plugin-network works, but to make
// it for all, we need to change the `plugin scaffold` output (so it outputs
// something similar than the cli-plugin-network) and update the docs.
if len(pluginCmd.Commands) == 0 {
// pluginCmd has no sub commands, so it's runnable
newCmd.RunE = func(cmd *cobra.Command, args []string) error {
Expand All @@ -362,10 +356,6 @@ func linkPluginCmd(rootCmd *cobra.Command, p *plugin.Plugin, pluginCmd *plugin.C
execCmd.ImportFlags(cmd)
err = p.Interface.Execute(ctx, execCmd, api)

// NOTE(tb): This pause gives enough time for go-plugin to sync the
// output from stdout/stderr of the plugin. Without that pause, this
// output can be discarded and not printed in the user console.
time.Sleep(100 * time.Millisecond)
return err
})
}
Expand Down
113 changes: 37 additions & 76 deletions ignite/pkg/cliui/clispinner/clispinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,32 @@ package clispinner

import (
"io"
"time"
"os"

"github.com/briandowns/spinner"
"golang.org/x/term"
)

// DefaultText defines the default spinner text.
const DefaultText = "Initializing..."

var (
refreshRate = time.Millisecond * 200
charset = spinner.CharSets[4]
spinnerColor = "blue"
)

type Spinner struct {
sp *spinner.Spinner
}

type (
Spinner interface {
SetText(text string) Spinner
SetPrefix(text string) Spinner
SetCharset(charset []string) Spinner
SetColor(color string) Spinner
Start() Spinner
Stop() Spinner
IsActive() bool
Writer() io.Writer
}

Option func(*Options)

Options struct {
writer io.Writer
text string
writer io.Writer
text string
charset []string
}
)

Expand All @@ -43,76 +45,35 @@ func WithText(text string) Option {
}
}

// WithCharset configures the spinner charset.
func WithCharset(charset []string) Option {
return func(options *Options) {
options.charset = charset
}
}

// New creates a new spinner.
func New(options ...Option) *Spinner {
func New(options ...Option) Spinner {
o := Options{}
for _, apply := range options {
apply(&o)
}

text := o.text
if text == "" {
text = DefaultText
}

spOptions := []spinner.Option{
spinner.WithColor(spinnerColor),
spinner.WithSuffix(" " + text),
if isRunningInTerminal(o.writer) {
return newTermSpinner(o)
}
return newSimpleSpinner(o)
}

if o.writer != nil {
spOptions = append(spOptions, spinner.WithWriter(o.writer))
// isRunningInTerminal check if the writer file descriptor is a terminal.
//
//nolint
func isRunningInTerminal(w io.Writer) bool {
if w == nil {
return term.IsTerminal(int(os.Stdout.Fd()))
}

return &Spinner{
sp: spinner.New(charset, refreshRate, spOptions...),
if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
}

// SetText sets the text for spinner.
func (s *Spinner) SetText(text string) *Spinner {
s.sp.Lock()
s.sp.Suffix = " " + text
s.sp.Unlock()
return s
}

// SetPrefix sets the prefix for spinner.
func (s *Spinner) SetPrefix(text string) *Spinner {
s.sp.Lock()
s.sp.Prefix = text + " "
s.sp.Unlock()
return s
}

// SetCharset sets the prefix for spinner.
func (s *Spinner) SetCharset(charset []string) *Spinner {
s.sp.UpdateCharSet(charset)
return s
}

// SetColor sets the prefix for spinner.
func (s *Spinner) SetColor(color string) *Spinner {
_ = s.sp.Color(color)
return s
}

// Start starts spinning.
func (s *Spinner) Start() *Spinner {
s.sp.Start()
return s
}

// Stop stops spinning.
func (s *Spinner) Stop() *Spinner {
s.sp.Stop()
s.sp.Prefix = ""
_ = s.sp.Color(spinnerColor)
s.sp.UpdateCharSet(charset)
s.sp.Stop()
return s
}

func (s *Spinner) IsActive() bool {
return s.sp.Active()
return false
}
156 changes: 156 additions & 0 deletions ignite/pkg/cliui/clispinner/simple.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package clispinner

import (
"fmt"
"io"
"os"
"sync"
"time"

"github.com/briandowns/spinner"
"github.com/gookit/color"
)

var (
simpleCharset = spinner.CharSets[4]
simpleRefreshRate = time.Millisecond * 300
simpleColor = color.Blue
)

type SimpleSpinner struct {
mu sync.Mutex
writer io.Writer
charset []string
text string
prefix string
color string
active bool
stopChan chan struct{}
}

// newSimpleSpinner creates a new simple spinner.
func newSimpleSpinner(o Options) *SimpleSpinner {
text := o.text
if text == "" {
text = DefaultText
}

charset := o.charset
if len(charset) == 0 {
charset = simpleCharset
}

writer := o.writer
if writer == nil {
writer = os.Stdout
}

return &SimpleSpinner{
charset: charset,
text: text,
writer: writer,
}
}

// SetText sets the text for the spinner.
func (s *SimpleSpinner) SetText(text string) Spinner {
s.mu.Lock()
s.text = text
s.mu.Unlock()
return s
}

// SetPrefix sets the prefix for the spinner.
func (s *SimpleSpinner) SetPrefix(prefix string) Spinner {
s.mu.Lock()
s.prefix = prefix
s.mu.Unlock()
return s
}

// SetCharset sets the charset for the spinner.
func (s *SimpleSpinner) SetCharset(charset []string) Spinner {
s.mu.Lock()
s.charset = charset
s.mu.Unlock()
return s
}

// SetColor sets the color for the spinner (if color functionality is added).
func (s *SimpleSpinner) SetColor(color string) Spinner {
s.mu.Lock()
s.color = color
s.mu.Unlock()
return s
}

// Start begins the spinner animation.
func (s *SimpleSpinner) Start() Spinner {
s.mu.Lock()
if s.active {
s.mu.Unlock()
return s // Do nothing if already active
}
s.active = true
s.stopChan = make(chan struct{})

// Extract spinner data safely within the mutex
prefix := s.prefix
text := s.text
writer := s.writer
charset := s.charset
s.mu.Unlock()

// Start the animation loop in a separate goroutine
go func() {
ticker := time.NewTicker(simpleRefreshRate)
defer ticker.Stop()

index := 0
for {
select {
case <-s.stopChan: // Stop the spinner
_, _ = fmt.Fprintf(writer, "\r\033[K") // Clear the spinner's line
return
case <-ticker.C: // Update the spinner on each tick
s.mu.Lock()
frame := charset[index]
str := fmt.Sprintf("\r%s%s %s", prefix, simpleColor.Sprint(frame), text)
_, _ = fmt.Fprint(writer, str) // Update the spinner in the same line
index++
if index >= len(charset) {
index = 0
}
s.mu.Unlock()
}
}
}()
return s
}

// Stop ends the spinner animation.
func (s *SimpleSpinner) Stop() Spinner {
s.mu.Lock()
if !s.active {
s.mu.Unlock()
return s // Do nothing if already inactive
}
close(s.stopChan)
s.active = false
s.stopChan = nil
fmt.Print("\r") // Clear spinner line on stop
s.mu.Unlock()
return s
}

// IsActive returns whether the spinner is currently active.
func (s *SimpleSpinner) IsActive() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.active
}

// Writer returns the spinner writer.
func (s *SimpleSpinner) Writer() io.Writer {
return s.writer
}
Loading

0 comments on commit a3c4c51

Please sign in to comment.