Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for multi-line spinner strings #146

Merged
merged 1 commit into from Dec 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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