Skip to content

Commit

Permalink
Support for multi-line spinner strings (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
Guillaume Bouvignies committed Dec 22, 2022
1 parent 329c376 commit 4a2bf41
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 2 deletions.
8 changes: 8 additions & 0 deletions _example/main.go
Expand Up @@ -3,6 +3,7 @@ package main

import (
"log"
"strings"
"time"

"github.com/briandowns/spinner"
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -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
)
5 changes: 4 additions & 1 deletion go.sum
Expand Up @@ -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=
40 changes: 39 additions & 1 deletion spinner.go
Expand Up @@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"io"
"math"
"os"
"runtime"
"strconv"
Expand All @@ -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
Expand Down Expand Up @@ -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 = ""
}

Expand Down Expand Up @@ -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)
}
}
55 changes: 55 additions & 0 deletions spinner_test.go
Expand Up @@ -20,6 +20,7 @@ import (
"io/ioutil"
"os"
"reflect"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -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
*/
Expand Down

0 comments on commit 4a2bf41

Please sign in to comment.