From 4a2bf41d37afd57914b766c011f3fa9a22303102 Mon Sep 17 00:00:00 2001 From: Guillaume Bouvignies Date: Thu, 22 Dec 2022 17:07:43 +0100 Subject: [PATCH] Support for multi-line spinner strings (#146) --- _example/main.go | 8 +++++++ go.mod | 1 + go.sum | 5 ++++- spinner.go | 40 ++++++++++++++++++++++++++++++++++- spinner_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/_example/main.go b/_example/main.go index a9688f8..50781e4 100644 --- a/_example/main.go +++ b/_example/main.go @@ -3,6 +3,7 @@ package main import ( "log" + "strings" "time" "github.com/briandowns/spinner" @@ -22,6 +23,13 @@ func main() { s.Suffix = " :appended text" // Append text after the spinner time.Sleep(4 * time.Second) + s.Suffix = " :appended " + strings.Repeat("very long text ", 20) // Append very long text + time.Sleep(4 * time.Second) + + s.Suffix = " :appended multi \nline\nsuffix\ntext" // Append multi line text + time.Sleep(4 * time.Second) + + s.Suffix = " :appended text" // Append text after the spinner s.Prefix = "Colors: " if err := s.Color("yellow"); err != nil { diff --git a/go.mod b/go.mod index 2da0084..5d2ea4a 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,5 @@ require ( github.com/fatih/color v1.7.0 github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 + golang.org/x/term v0.1.0 ) diff --git a/go.sum b/go.sum index a0d4725..9074037 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/spinner.go b/spinner.go index b8abe6a..ae5f238 100644 --- a/spinner.go +++ b/spinner.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "io" + "math" "os" "runtime" "strconv" @@ -29,6 +30,7 @@ import ( "github.com/fatih/color" "github.com/mattn/go-isatty" + "golang.org/x/term" ) // errInvalidColor is returned when attempting to set an invalid color @@ -439,13 +441,23 @@ func (s *Spinner) erase() { return } + numberOfLinesToErase := computeNumberOfLinesNeededToPrintString(s.lastOutputPlain) + // Taken from https://en.wikipedia.org/wiki/ANSI_escape_code: // \r - Carriage return - Moves the cursor to column zero // \033[K - Erases part of the line. If n is 0 (or missing), clear from // cursor to the end of the line. If n is 1, clear from cursor to beginning // of the line. If n is 2, clear entire line. Cursor position does not // change. - fmt.Fprintf(s.Writer, "\r\033[K") + // \033[F - Go to the beginning of previous line + eraseCodeString := strings.Builder{} + // current position is at the end of the last printed line. Start by erasing current line + eraseCodeString.WriteString("\r\033[K") // start by erasing current line + for i := 1; i < numberOfLinesToErase; i++ { + // For each additional lines, go up one line and erase it. + eraseCodeString.WriteString("\033[F\033[K") + } + fmt.Fprintf(s.Writer, eraseCodeString.String()) s.lastOutputPlain = "" } @@ -473,3 +485,29 @@ func GenerateNumberSequence(length int) []string { func isRunningInTerminal() bool { return isatty.IsTerminal(os.Stdout.Fd()) } + +func computeNumberOfLinesNeededToPrintString(linePrinted string) int { + terminalWidth := math.MaxInt // assume infinity by default to keep behaviour consistent with what we had before + if term.IsTerminal(0) { + if width, _, err := term.GetSize(0); err == nil { + terminalWidth = width + } + } + return computeNumberOfLinesNeededToPrintStringInternal(linePrinted, terminalWidth) +} + +func computeNumberOfLinesNeededToPrintStringInternal(linePrinted string, maxLineWidth int) int { + if linePrinted == "" { + // empty string will necessarily take one line + return 1 + } + idxOfNewline := strings.Index(linePrinted, "\n") + if idxOfNewline < 0 { + // we use utf8.RunCountInString() in place of len() because the string contains "complex" unicode chars that + // might be represented by multiple individual bytes (typically spinner char) + return int(math.Ceil(float64(utf8.RuneCountInString(linePrinted)) / float64(maxLineWidth))) + } else { + return computeNumberOfLinesNeededToPrintStringInternal(linePrinted[:idxOfNewline], maxLineWidth) + + computeNumberOfLinesNeededToPrintStringInternal(linePrinted[idxOfNewline+1:], maxLineWidth) + } +} diff --git a/spinner_test.go b/spinner_test.go index 3d66848..076110b 100644 --- a/spinner_test.go +++ b/spinner_test.go @@ -20,6 +20,7 @@ import ( "io/ioutil" "os" "reflect" + "strings" "sync" "testing" "time" @@ -280,6 +281,60 @@ func TestWithWriter(t *testing.T) { _ = s } +func TestComputeNumberOfLinesNeededToPrintStringInternal_SingleLine(t *testing.T) { + line := "Hello world" + result := computeNumberOfLinesNeededToPrintStringInternal(line, 50) + expectedResult := 1 + if result != expectedResult { + t.Errorf("Line '%s' shoud be printed on '%d' line, got '%d'", line, expectedResult, result) + } +} + +func TestComputeNumberOfLinesNeededToPrintStringInternal_MultiLine(t *testing.T) { + line := "Hello\n world" + result := computeNumberOfLinesNeededToPrintStringInternal(line, 50) + expectedResult := 2 + if result != expectedResult { + t.Errorf("Line '%s' shoud be printed on '%d' lines, got '%d'", line, expectedResult, result) + } +} + +func TestComputeNumberOfLinesPrinted_LongString(t *testing.T) { + line := "Hello world! I am a super long string that will be printed in 2 lines" + result := computeNumberOfLinesNeededToPrintStringInternal(line, 50) + expectedResult := 2 + if result != expectedResult { + t.Errorf("Line '%s' shoud be printed on '%d' lines, got '%d'", line, expectedResult, result) + } +} + +func TestComputeNumberOfLinesNeededToPrintStringInternal_LongStringWithNewlines(t *testing.T) { + line := "Hello world!\nI am a super long string that will be printed in 2 lines.\nAnother new line" + result := computeNumberOfLinesNeededToPrintStringInternal(line, 50) + expectedResult := 4 + if result != expectedResult { + t.Errorf("Line '%s' shoud be printed on '%d' lines, got '%d'", line, expectedResult, result) + } +} + +func TestComputeNumberOfLinesNeededToPrintStringInternal_NewlineCharAtTheEnd(t *testing.T) { + line := "Hello world!\n" + result := computeNumberOfLinesNeededToPrintStringInternal(line, 50) + expectedResult := 2 + if result != expectedResult { + t.Errorf("Line '%s' shoud be printed on '%d' lines, got '%d'", line, expectedResult, result) + } +} + +func TestComputeNumberOfLinesNeededToPrintStringInternal_StringExactlyTheSizeOfTheScreen(t *testing.T) { + line := strings.Repeat("a", 50) + result := computeNumberOfLinesNeededToPrintStringInternal(line, 50) + expectedResult := 1 + if result != expectedResult { + t.Errorf("Line '%s' shoud be printed on '%d' lines, got '%d'", line, expectedResult, result) + } +} + /* Benchmarks */