github.com/aacfactory/fns@v1.2.86-0.20240310083819-80d667fc0a17/cmd/generates/spinner/spin.go (about)

     1  /*
     2   * Copyright 2023 Wang Min Xiang
     3   *
     4   * Licensed under the Apache License, Version 2.0 (the "License");
     5   * you may not use this file except in compliance with the License.
     6   * You may obtain a copy of the License at
     7   *
     8   * 	http://www.apache.org/licenses/LICENSE-2.0
     9   *
    10   * Unless required by applicable law or agreed to in writing, software
    11   * distributed under the License is distributed on an "AS IS" BASIS,
    12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13   * See the License for the specific language governing permissions and
    14   * limitations under the License.
    15   *
    16   */
    17  
    18  package spinner
    19  
    20  import (
    21  	"errors"
    22  	"fmt"
    23  	"io"
    24  	"math"
    25  	"os"
    26  	"runtime"
    27  	"strconv"
    28  	"strings"
    29  	"sync"
    30  	"time"
    31  	"unicode/utf8"
    32  
    33  	"github.com/fatih/color"
    34  	"github.com/mattn/go-isatty"
    35  	"golang.org/x/term"
    36  )
    37  
    38  // errInvalidColor is returned when attempting to set an invalid color
    39  var errInvalidColor = errors.New("invalid color")
    40  
    41  // validColors holds an array of the only colors allowed
    42  var validColors = map[string]bool{
    43  	// default colors for backwards compatibility
    44  	"black":   true,
    45  	"red":     true,
    46  	"green":   true,
    47  	"yellow":  true,
    48  	"blue":    true,
    49  	"magenta": true,
    50  	"cyan":    true,
    51  	"white":   true,
    52  
    53  	// attributes
    54  	"reset":        true,
    55  	"bold":         true,
    56  	"faint":        true,
    57  	"italic":       true,
    58  	"underline":    true,
    59  	"blinkslow":    true,
    60  	"blinkrapid":   true,
    61  	"reversevideo": true,
    62  	"concealed":    true,
    63  	"crossedout":   true,
    64  
    65  	// foreground text
    66  	"fgBlack":   true,
    67  	"fgRed":     true,
    68  	"fgGreen":   true,
    69  	"fgYellow":  true,
    70  	"fgBlue":    true,
    71  	"fgMagenta": true,
    72  	"fgCyan":    true,
    73  	"fgWhite":   true,
    74  
    75  	// foreground Hi-Intensity text
    76  	"fgHiBlack":   true,
    77  	"fgHiRed":     true,
    78  	"fgHiGreen":   true,
    79  	"fgHiYellow":  true,
    80  	"fgHiBlue":    true,
    81  	"fgHiMagenta": true,
    82  	"fgHiCyan":    true,
    83  	"fgHiWhite":   true,
    84  
    85  	// background text
    86  	"bgBlack":   true,
    87  	"bgRed":     true,
    88  	"bgGreen":   true,
    89  	"bgYellow":  true,
    90  	"bgBlue":    true,
    91  	"bgMagenta": true,
    92  	"bgCyan":    true,
    93  	"bgWhite":   true,
    94  
    95  	// background Hi-Intensity text
    96  	"bgHiBlack":   true,
    97  	"bgHiRed":     true,
    98  	"bgHiGreen":   true,
    99  	"bgHiYellow":  true,
   100  	"bgHiBlue":    true,
   101  	"bgHiMagenta": true,
   102  	"bgHiCyan":    true,
   103  	"bgHiWhite":   true,
   104  }
   105  
   106  var isWindows = runtime.GOOS == "windows"
   107  var isWindowsTerminalOnWindows = len(os.Getenv("WT_SESSION")) > 0 && isWindows
   108  
   109  var colorAttributeMap = map[string]color.Attribute{
   110  	"black":        color.FgBlack,
   111  	"red":          color.FgRed,
   112  	"green":        color.FgGreen,
   113  	"yellow":       color.FgYellow,
   114  	"blue":         color.FgBlue,
   115  	"magenta":      color.FgMagenta,
   116  	"cyan":         color.FgCyan,
   117  	"white":        color.FgWhite,
   118  	"reset":        color.Reset,
   119  	"bold":         color.Bold,
   120  	"faint":        color.Faint,
   121  	"italic":       color.Italic,
   122  	"underline":    color.Underline,
   123  	"blinkslow":    color.BlinkSlow,
   124  	"blinkrapid":   color.BlinkRapid,
   125  	"reversevideo": color.ReverseVideo,
   126  	"concealed":    color.Concealed,
   127  	"crossedout":   color.CrossedOut,
   128  	"fgBlack":      color.FgBlack,
   129  	"fgRed":        color.FgRed,
   130  	"fgGreen":      color.FgGreen,
   131  	"fgYellow":     color.FgYellow,
   132  	"fgBlue":       color.FgBlue,
   133  	"fgMagenta":    color.FgMagenta,
   134  	"fgCyan":       color.FgCyan,
   135  	"fgWhite":      color.FgWhite,
   136  	"fgHiBlack":    color.FgHiBlack,
   137  	"fgHiRed":      color.FgHiRed,
   138  	"fgHiGreen":    color.FgHiGreen,
   139  	"fgHiYellow":   color.FgHiYellow,
   140  	"fgHiBlue":     color.FgHiBlue,
   141  	"fgHiMagenta":  color.FgHiMagenta,
   142  	"fgHiCyan":     color.FgHiCyan,
   143  	"fgHiWhite":    color.FgHiWhite,
   144  	"bgBlack":      color.BgBlack,
   145  	"bgRed":        color.BgRed,
   146  	"bgGreen":      color.BgGreen,
   147  	"bgYellow":     color.BgYellow,
   148  	"bgBlue":       color.BgBlue,
   149  	"bgMagenta":    color.BgMagenta,
   150  	"bgCyan":       color.BgCyan,
   151  	"bgWhite":      color.BgWhite,
   152  	"bgHiBlack":    color.BgHiBlack,
   153  	"bgHiRed":      color.BgHiRed,
   154  	"bgHiGreen":    color.BgHiGreen,
   155  	"bgHiYellow":   color.BgHiYellow,
   156  	"bgHiBlue":     color.BgHiBlue,
   157  	"bgHiMagenta":  color.BgHiMagenta,
   158  	"bgHiCyan":     color.BgHiCyan,
   159  	"bgHiWhite":    color.BgHiWhite,
   160  }
   161  
   162  func validColor(c string) bool {
   163  	return validColors[c]
   164  }
   165  
   166  type Spinner struct {
   167  	mu              *sync.RWMutex
   168  	Delay           time.Duration
   169  	chars           []string
   170  	Prefix          string
   171  	Suffix          string
   172  	FinalMSG        string
   173  	lastOutputPlain string
   174  	LastOutput      string
   175  	color           func(a ...interface{}) string
   176  	Writer          io.Writer
   177  	WriterFile      *os.File
   178  	active          bool
   179  	enabled         bool
   180  	stopChan        chan struct{}
   181  	HideCursor      bool
   182  	PreUpdate       func(s *Spinner)
   183  	PostUpdate      func(s *Spinner)
   184  }
   185  
   186  func New(cs []string, d time.Duration, options ...Option) *Spinner {
   187  	s := &Spinner{
   188  		Delay:      d,
   189  		chars:      cs,
   190  		color:      color.New(color.FgWhite).SprintFunc(),
   191  		mu:         &sync.RWMutex{},
   192  		Writer:     color.Output,
   193  		WriterFile: os.Stdout,
   194  		stopChan:   make(chan struct{}, 1),
   195  		active:     false,
   196  		enabled:    true,
   197  		HideCursor: true,
   198  	}
   199  
   200  	for _, option := range options {
   201  		option(s)
   202  	}
   203  
   204  	return s
   205  }
   206  
   207  type Option func(*Spinner)
   208  
   209  type Options struct {
   210  	Color      string
   211  	Suffix     string
   212  	FinalMSG   string
   213  	HideCursor bool
   214  }
   215  
   216  func WithColor(color string) Option {
   217  	return func(s *Spinner) {
   218  		s.Color(color)
   219  	}
   220  }
   221  
   222  func WithSuffix(suffix string) Option {
   223  	return func(s *Spinner) {
   224  		s.Suffix = suffix
   225  	}
   226  }
   227  
   228  func WithFinalMSG(finalMsg string) Option {
   229  	return func(s *Spinner) {
   230  		s.FinalMSG = finalMsg
   231  	}
   232  }
   233  
   234  func WithHiddenCursor(hideCursor bool) Option {
   235  	return func(s *Spinner) {
   236  		s.HideCursor = hideCursor
   237  	}
   238  }
   239  
   240  func WithWriter(w io.Writer) Option {
   241  	return func(s *Spinner) {
   242  		s.mu.Lock()
   243  		s.Writer = w
   244  		s.WriterFile = os.Stdout
   245  		s.mu.Unlock()
   246  	}
   247  }
   248  
   249  func (s *Spinner) Active() bool {
   250  	return s.active
   251  }
   252  
   253  func (s *Spinner) Enabled() bool {
   254  	return s.enabled
   255  }
   256  
   257  func (s *Spinner) Enable() {
   258  	s.enabled = true
   259  	s.Restart()
   260  }
   261  
   262  func (s *Spinner) Disable() {
   263  	s.enabled = false
   264  	s.Stop()
   265  }
   266  
   267  func (s *Spinner) Start() {
   268  	s.mu.Lock()
   269  	if s.active || !s.enabled || !isRunningInTerminal(s) {
   270  		s.mu.Unlock()
   271  		return
   272  	}
   273  	if s.HideCursor && !isWindowsTerminalOnWindows {
   274  		fmt.Fprint(s.Writer, "\033[?25l")
   275  	}
   276  	if isWindows && !isWindowsTerminalOnWindows {
   277  		color.NoColor = true
   278  	}
   279  
   280  	s.active = true
   281  	s.mu.Unlock()
   282  
   283  	go func() {
   284  		for {
   285  			for i := 0; i < len(s.chars); i++ {
   286  				select {
   287  				case <-s.stopChan:
   288  					return
   289  				default:
   290  					s.mu.Lock()
   291  					if !s.active {
   292  						s.mu.Unlock()
   293  						return
   294  					}
   295  					if !isWindowsTerminalOnWindows {
   296  						s.erase()
   297  					}
   298  
   299  					if s.PreUpdate != nil {
   300  						s.PreUpdate(s)
   301  					}
   302  
   303  					var outColor string
   304  					if isWindows {
   305  						if s.Writer == os.Stderr {
   306  							outColor = fmt.Sprintf("\r%s%s%s", s.Prefix, s.chars[i], s.Suffix)
   307  						} else {
   308  							outColor = fmt.Sprintf("\r%s%s%s", s.Prefix, s.color(s.chars[i]), s.Suffix)
   309  						}
   310  					} else {
   311  						outColor = fmt.Sprintf("\r%s%s%s", s.Prefix, s.color(s.chars[i]), s.Suffix)
   312  					}
   313  					outPlain := fmt.Sprintf("\r%s%s%s", s.Prefix, s.chars[i], s.Suffix)
   314  					fmt.Fprint(s.Writer, outColor)
   315  					s.lastOutputPlain = outPlain
   316  					s.LastOutput = outColor
   317  					delay := s.Delay
   318  
   319  					if s.PostUpdate != nil {
   320  						s.PostUpdate(s)
   321  					}
   322  
   323  					s.mu.Unlock()
   324  					time.Sleep(delay)
   325  				}
   326  			}
   327  		}
   328  	}()
   329  }
   330  
   331  func (s *Spinner) Stop() {
   332  	s.mu.Lock()
   333  	defer s.mu.Unlock()
   334  	if s.active {
   335  		s.active = false
   336  		if s.HideCursor && !isWindowsTerminalOnWindows {
   337  			// makes the cursor visible
   338  			fmt.Fprint(s.Writer, "\033[?25h")
   339  		}
   340  		s.erase()
   341  		if s.FinalMSG != "" {
   342  			if isWindowsTerminalOnWindows {
   343  				fmt.Fprint(s.Writer, "\r", s.FinalMSG)
   344  			} else {
   345  				fmt.Fprint(s.Writer, s.FinalMSG)
   346  			}
   347  		}
   348  		s.stopChan <- struct{}{}
   349  	}
   350  }
   351  
   352  func (s *Spinner) Restart() {
   353  	s.Stop()
   354  	s.Start()
   355  }
   356  
   357  func (s *Spinner) Reverse() {
   358  	s.mu.Lock()
   359  	for i, j := 0, len(s.chars)-1; i < j; i, j = i+1, j-1 {
   360  		s.chars[i], s.chars[j] = s.chars[j], s.chars[i]
   361  	}
   362  	s.mu.Unlock()
   363  }
   364  
   365  func (s *Spinner) Color(colors ...string) error {
   366  	colorAttributes := make([]color.Attribute, len(colors))
   367  
   368  	for index, c := range colors {
   369  		if !validColor(c) {
   370  			return errInvalidColor
   371  		}
   372  		colorAttributes[index] = colorAttributeMap[c]
   373  	}
   374  
   375  	s.mu.Lock()
   376  	s.color = color.New(colorAttributes...).SprintFunc()
   377  	s.mu.Unlock()
   378  	return nil
   379  }
   380  
   381  func (s *Spinner) UpdateSpeed(d time.Duration) {
   382  	s.mu.Lock()
   383  	s.Delay = d
   384  	s.mu.Unlock()
   385  }
   386  
   387  func (s *Spinner) UpdateCharSet(cs []string) {
   388  	s.mu.Lock()
   389  	s.chars = cs
   390  	s.mu.Unlock()
   391  }
   392  
   393  func (s *Spinner) erase() {
   394  	n := utf8.RuneCountInString(s.lastOutputPlain)
   395  	if runtime.GOOS == "windows" && !isWindowsTerminalOnWindows {
   396  		clearString := "\r" + strings.Repeat(" ", n) + "\r"
   397  		fmt.Fprint(s.Writer, clearString)
   398  		s.lastOutputPlain = ""
   399  		return
   400  	}
   401  
   402  	numberOfLinesToErase := computeNumberOfLinesNeededToPrintString(s.lastOutputPlain)
   403  
   404  	eraseCodeString := strings.Builder{}
   405  	eraseCodeString.WriteString("\r\033[K") // start by erasing current line
   406  	for i := 1; i < numberOfLinesToErase; i++ {
   407  		eraseCodeString.WriteString("\033[F\033[K")
   408  	}
   409  	fmt.Fprintf(s.Writer, eraseCodeString.String())
   410  	s.lastOutputPlain = ""
   411  }
   412  
   413  func (s *Spinner) Lock() {
   414  	s.mu.Lock()
   415  }
   416  
   417  func (s *Spinner) Unlock() {
   418  	s.mu.Unlock()
   419  }
   420  
   421  func GenerateNumberSequence(length int) []string {
   422  	numSeq := make([]string, length)
   423  	for i := 0; i < length; i++ {
   424  		numSeq[i] = strconv.Itoa(i)
   425  	}
   426  	return numSeq
   427  }
   428  
   429  func isRunningInTerminal(s *Spinner) bool {
   430  	return isatty.IsTerminal(s.WriterFile.Fd())
   431  }
   432  
   433  func computeNumberOfLinesNeededToPrintString(linePrinted string) int {
   434  	terminalWidth := math.MaxInt
   435  	if term.IsTerminal(0) {
   436  		if width, _, err := term.GetSize(0); err == nil {
   437  			terminalWidth = width
   438  		}
   439  	}
   440  	return computeNumberOfLinesNeededToPrintStringInternal(linePrinted, terminalWidth)
   441  }
   442  
   443  func isAnsiMarker(r rune) bool {
   444  	return r == '\x1b'
   445  }
   446  
   447  func isAnsiTerminator(r rune) bool {
   448  	return (r >= 0x40 && r <= 0x5a) || (r == 0x5e) || (r >= 0x60 && r <= 0x7e)
   449  }
   450  
   451  func computeLineWidth(line string) int {
   452  	width := 0
   453  	ansi := false
   454  
   455  	for _, r := range []rune(line) {
   456  		if ansi || isAnsiMarker(r) {
   457  			ansi = !isAnsiTerminator(r)
   458  		} else {
   459  			width += utf8.RuneLen(r)
   460  		}
   461  	}
   462  
   463  	return width
   464  }
   465  
   466  func computeNumberOfLinesNeededToPrintStringInternal(linePrinted string, maxLineWidth int) int {
   467  	lineCount := 0
   468  	for _, line := range strings.Split(linePrinted, "\n") {
   469  		lineCount += 1
   470  
   471  		lineWidth := computeLineWidth(line)
   472  		if lineWidth > maxLineWidth {
   473  			lineCount += int(float64(lineWidth) / float64(maxLineWidth))
   474  		}
   475  	}
   476  
   477  	return lineCount
   478  }