aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/cli/internal/ui
diff options
context:
space:
mode:
Diffstat (limited to 'cli/internal/ui')
-rw-r--r--cli/internal/ui/charset.go3
-rw-r--r--cli/internal/ui/colors.go54
-rw-r--r--cli/internal/ui/spinner.go80
-rw-r--r--cli/internal/ui/term/cursor.go73
-rw-r--r--cli/internal/ui/term/cursor_test.go43
-rw-r--r--cli/internal/ui/ui.go121
6 files changed, 374 insertions, 0 deletions
diff --git a/cli/internal/ui/charset.go b/cli/internal/ui/charset.go
new file mode 100644
index 0000000..0207c10
--- /dev/null
+++ b/cli/internal/ui/charset.go
@@ -0,0 +1,3 @@
+package ui
+
+var charset = []string{" ", "> ", ">> ", ">>>"}
diff --git a/cli/internal/ui/colors.go b/cli/internal/ui/colors.go
new file mode 100644
index 0000000..4b2eccd
--- /dev/null
+++ b/cli/internal/ui/colors.go
@@ -0,0 +1,54 @@
+package ui
+
+import (
+ "os"
+
+ "github.com/fatih/color"
+)
+
+type ColorMode int
+
+const (
+ ColorModeUndefined ColorMode = iota + 1
+ ColorModeSuppressed
+ ColorModeForced
+)
+
+func GetColorModeFromEnv() ColorMode {
+ // The FORCED_COLOR behavior and accepted values are taken from the supports-color NodeJS Package:
+ // The accepted values as documented are "0" to disable, and "1", "2", or "3" to force-enable color
+ // at the specified support level (1 = 16 colors, 2 = 256 colors, 3 = 16M colors).
+ // We don't currently use the level for anything specific, and just treat things as on and off.
+ //
+ // Note: while "false" and "true" aren't documented, the library coerces these values to 0 and 1
+ // respectively, so that behavior is reproduced here as well.
+ // https://www.npmjs.com/package/supports-color
+
+ switch forceColor := os.Getenv("FORCE_COLOR"); {
+ case forceColor == "false" || forceColor == "0":
+ return ColorModeSuppressed
+ case forceColor == "true" || forceColor == "1" || forceColor == "2" || forceColor == "3":
+ return ColorModeForced
+ default:
+ return ColorModeUndefined
+ }
+}
+
+func applyColorMode(colorMode ColorMode) ColorMode {
+ switch colorMode {
+ case ColorModeForced:
+ color.NoColor = false
+ case ColorModeSuppressed:
+ color.NoColor = true
+ case ColorModeUndefined:
+ default:
+ // color.NoColor already gets its default value based on
+ // isTTY and/or the presence of the NO_COLOR env variable.
+ }
+
+ if color.NoColor {
+ return ColorModeSuppressed
+ } else {
+ return ColorModeForced
+ }
+}
diff --git a/cli/internal/ui/spinner.go b/cli/internal/ui/spinner.go
new file mode 100644
index 0000000..6e47d2d
--- /dev/null
+++ b/cli/internal/ui/spinner.go
@@ -0,0 +1,80 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package ui
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "time"
+
+ "github.com/briandowns/spinner"
+)
+
+// startStopper is the interface to interact with the spinner.
+type startStopper interface {
+ Start()
+ Stop()
+}
+
+// Spinner represents an indicator that an asynchronous operation is taking place.
+//
+// For short operations, less than 4 seconds, display only the spinner with the Start and Stop methods.
+// For longer operations, display intermediate progress events using the Events method.
+type Spinner struct {
+ spin startStopper
+}
+
+// NewSpinner returns a spinner that outputs to w.
+func NewSpinner(w io.Writer) *Spinner {
+ interval := 125 * time.Millisecond
+ if os.Getenv("CI") == "true" {
+ interval = 30 * time.Second
+ }
+ s := spinner.New(charset, interval, spinner.WithHiddenCursor(true))
+ s.Writer = w
+ s.Color("faint")
+ return &Spinner{
+ spin: s,
+ }
+}
+
+// Start starts the spinner suffixed with a label.
+func (s *Spinner) Start(label string) {
+ s.suffix(fmt.Sprintf(" %s", label))
+ s.spin.Start()
+}
+
+// Stop stops the spinner and replaces it with a label.
+func (s *Spinner) Stop(label string) {
+ s.finalMSG(fmt.Sprint(label))
+ s.spin.Stop()
+}
+
+func (s *Spinner) lock() {
+ if spinner, ok := s.spin.(*spinner.Spinner); ok {
+ spinner.Lock()
+ }
+}
+
+func (s *Spinner) unlock() {
+ if spinner, ok := s.spin.(*spinner.Spinner); ok {
+ spinner.Unlock()
+ }
+}
+
+func (s *Spinner) suffix(label string) {
+ s.lock()
+ defer s.unlock()
+ if spinner, ok := s.spin.(*spinner.Spinner); ok {
+ spinner.Suffix = label
+ }
+}
+
+func (s *Spinner) finalMSG(label string) {
+ s.lock()
+ defer s.unlock()
+ if spinner, ok := s.spin.(*spinner.Spinner); ok {
+ spinner.FinalMSG = label
+ }
+}
diff --git a/cli/internal/ui/term/cursor.go b/cli/internal/ui/term/cursor.go
new file mode 100644
index 0000000..253f043
--- /dev/null
+++ b/cli/internal/ui/term/cursor.go
@@ -0,0 +1,73 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package cursor provides functionality to interact with the terminal cursor.
+package cursor
+
+import (
+ "io"
+ "os"
+
+ "github.com/AlecAivazis/survey/v2/terminal"
+)
+
+type cursor interface {
+ Up(n int) error
+ Down(n int) error
+ Hide() error
+ Show() error
+}
+
+// fakeFileWriter is a terminal.FileWriter.
+// If the underlying writer w does not implement Fd() then a dummy value is returned.
+type fakeFileWriter struct {
+ w io.Writer
+}
+
+// Write delegates to the internal writer.
+func (w *fakeFileWriter) Write(p []byte) (int, error) {
+ return w.w.Write(p)
+}
+
+// Fd is required to be implemented to satisfy the terminal.FileWriter interface.
+// If the underlying writer is a file, like os.Stdout, then invoke it. Otherwise, this method allows us to create
+// a Cursor that can write to any io.Writer like a bytes.Buffer by returning a dummy value.
+func (w *fakeFileWriter) Fd() uintptr {
+ if v, ok := w.w.(terminal.FileWriter); ok {
+ return v.Fd()
+ }
+ return 0
+}
+
+// Cursor represents the terminal's cursor.
+type Cursor struct {
+ c cursor
+}
+
+// New creates a new cursor that writes to stderr.
+func New() *Cursor {
+ return &Cursor{
+ c: &terminal.Cursor{
+ Out: os.Stderr,
+ },
+ }
+}
+
+// EraseLine erases a line from a FileWriter.
+func EraseLine(fw terminal.FileWriter) {
+ terminal.EraseLine(fw, terminal.ERASE_LINE_ALL)
+}
+
+// EraseLinesAbove erases a line and moves the cursor up from fw, repeated n times.
+func EraseLinesAbove(fw terminal.FileWriter, n int) {
+ c := Cursor{
+ c: &terminal.Cursor{
+ Out: fw,
+ },
+ }
+ for i := 0; i < n; i += 1 {
+ EraseLine(fw)
+ c.c.Up(1)
+ }
+ EraseLine(fw) // Erase the nth line as well.
+}
diff --git a/cli/internal/ui/term/cursor_test.go b/cli/internal/ui/term/cursor_test.go
new file mode 100644
index 0000000..270ebe8
--- /dev/null
+++ b/cli/internal/ui/term/cursor_test.go
@@ -0,0 +1,43 @@
+//go:build !windows
+// +build !windows
+
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package cursor
+
+import (
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/AlecAivazis/survey/v2/terminal"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEraseLine(t *testing.T) {
+ testCases := map[string]struct {
+ inWriter func(writer io.Writer) terminal.FileWriter
+ shouldErase bool
+ }{
+ "should erase a line if the writer is a file": {
+ inWriter: func(writer io.Writer) terminal.FileWriter {
+ return &fakeFileWriter{w: writer}
+ },
+ shouldErase: true,
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ // GIVEN
+ buf := new(strings.Builder)
+
+ // WHEN
+ EraseLine(tc.inWriter(buf))
+
+ // THEN
+ isErased := buf.String() != ""
+ require.Equal(t, tc.shouldErase, isErased)
+ })
+ }
+}
diff --git a/cli/internal/ui/ui.go b/cli/internal/ui/ui.go
new file mode 100644
index 0000000..9084c76
--- /dev/null
+++ b/cli/internal/ui/ui.go
@@ -0,0 +1,121 @@
+package ui
+
+import (
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/fatih/color"
+ "github.com/mattn/go-isatty"
+ "github.com/mitchellh/cli"
+ "github.com/vercel/turbo/cli/internal/ci"
+)
+
+const ansiEscapeStr = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
+
+// IsTTY is true when stdout appears to be a tty
+var IsTTY = isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
+
+// IsCI is true when we appear to be running in a non-interactive context.
+var IsCI = !IsTTY || ci.IsCi()
+var gray = color.New(color.Faint)
+var bold = color.New(color.Bold)
+var ERROR_PREFIX = color.New(color.Bold, color.FgRed, color.ReverseVideo).Sprint(" ERROR ")
+var WARNING_PREFIX = color.New(color.Bold, color.FgYellow, color.ReverseVideo).Sprint(" WARNING ")
+
+// InfoPrefix is a colored string for warning level log messages
+var InfoPrefix = color.New(color.Bold, color.FgWhite, color.ReverseVideo).Sprint(" INFO ")
+
+var ansiRegex = regexp.MustCompile(ansiEscapeStr)
+
+// Dim prints out dimmed text
+func Dim(str string) string {
+ return gray.Sprint(str)
+}
+
+func Bold(str string) string {
+ return bold.Sprint(str)
+}
+
+// Adapted from go-rainbow
+// Copyright (c) 2017 Raphael Amorim
+// Source: https://github.com/raphamorim/go-rainbow
+// SPDX-License-Identifier: MIT
+func rgb(i int) (int, int, int) {
+ var f = 0.275
+
+ return int(math.Sin(f*float64(i)+4*math.Pi/3)*127 + 128),
+ // int(math.Sin(f*float64(i)+2*math.Pi/3)*127 + 128),
+ int(45),
+ int(math.Sin(f*float64(i)+0)*127 + 128)
+}
+
+// Rainbow function returns a formated colorized string ready to print it to the shell/terminal
+//
+// Adapted from go-rainbow
+// Copyright (c) 2017 Raphael Amorim
+// Source: https://github.com/raphamorim/go-rainbow
+// SPDX-License-Identifier: MIT
+func Rainbow(text string) string {
+ var rainbowStr []string
+ for index, value := range text {
+ r, g, b := rgb(index)
+ str := fmt.Sprintf("\033[1m\033[38;2;%d;%d;%dm%c\033[0m\033[0;1m", r, g, b, value)
+ rainbowStr = append(rainbowStr, str)
+ }
+
+ return strings.Join(rainbowStr, "")
+}
+
+type stripAnsiWriter struct {
+ wrappedWriter io.Writer
+}
+
+func (into *stripAnsiWriter) Write(p []byte) (int, error) {
+ n, err := into.wrappedWriter.Write(ansiRegex.ReplaceAll(p, []byte{}))
+ if err != nil {
+ // The number of bytes returned here isn't directly related to the input bytes
+ // if ansi color codes were being stripped out, but we are counting on Stdout.Write
+ // not failing under typical operation as well.
+ return n, err
+ }
+
+ // Write must return a non-nil error if it returns n < len(p). Consequently, if the
+ // wrappedWrite.Write call succeeded we will return len(p) as the number of bytes
+ // written.
+ return len(p), nil
+}
+
+// Default returns the default colored ui
+func Default() *cli.ColoredUi {
+ return BuildColoredUi(ColorModeUndefined)
+}
+
+func BuildColoredUi(colorMode ColorMode) *cli.ColoredUi {
+ colorMode = applyColorMode(colorMode)
+
+ var outWriter, errWriter io.Writer
+
+ if colorMode == ColorModeSuppressed {
+ outWriter = &stripAnsiWriter{wrappedWriter: os.Stdout}
+ errWriter = &stripAnsiWriter{wrappedWriter: os.Stderr}
+ } else {
+ outWriter = os.Stdout
+ errWriter = os.Stderr
+ }
+
+ return &cli.ColoredUi{
+ Ui: &cli.BasicUi{
+ Reader: os.Stdin,
+ Writer: outWriter,
+ ErrorWriter: errWriter,
+ },
+ OutputColor: cli.UiColorNone,
+ InfoColor: cli.UiColorNone,
+ WarnColor: cli.UiColor{Code: int(color.FgYellow), Bold: false},
+ ErrorColor: cli.UiColorRed,
+ }
+}