Skip to content

Commit

Permalink
added progress message support (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
sha1n authored Jun 12, 2021
1 parent b3556db commit 0ccb7a0
Show file tree
Hide file tree
Showing 7 changed files with 438 additions and 38 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.15
require (
github.com/fatih/color v1.12.0
github.com/mattn/go-isatty v0.0.13
github.com/sha1n/clib v0.0.7
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
)
294 changes: 292 additions & 2 deletions go.sum

Large diffs are not rendered by default.

16 changes: 11 additions & 5 deletions internal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func demoMatrix(ctx *demoContext) {
rand.Shuffle(len(indexes), func(i, j int) { indexes[i], indexes[j] = indexes[j], indexes[i] })
for _, i := range indexes {
update(i, status)
tick()
tick("")
}
}

Expand Down Expand Up @@ -183,13 +183,15 @@ func demoConcurrentProgressBars(ctx *demoContext) {
printTitle("Concurrent tasks progress", ctx)

cursor := termite.NewCursor(termite.StdoutWriter)
ticks := 20
ticks := 200
progressTickerWith := func(width int, formatter termite.ProgressBarFormatter) (func(), context.CancelFunc) {
bar := termite.NewProgressBar(termite.StdoutWriter, ticks, width, ctx.termWidth, formatter)
tick, cancel, _ := bar.Start()
actualTicks := 0

return func() {
tick()
actualTicks++
tick(fmt.Sprintf("Running %d out of %d :", actualTicks, ticks))
cursor.Down(1)
}, cancel
}
Expand All @@ -199,7 +201,7 @@ func demoConcurrentProgressBars(ctx *demoContext) {

termWidth := ctx.termWidth
termite.AllocateNewLines(4) // allocate 4 lines
tick1, cancel1 = progressTickerWith(termWidth*1/8, &customProgressBarFormatter{Fill: '\u258C', formatBorderFn: color.WhiteString, formatFillFn: color.HiCyanString})
tick1, cancel1 = progressTickerWith(termWidth*3/16, &customProgressBarFormatter{Fill: '\u258C', formatBorderFn: color.WhiteString, formatFillFn: color.HiCyanString})
tick2, cancel2 = progressTickerWith(termWidth*1/4, &customProgressBarFormatter{Fill: '\u2592', formatBorderFn: color.YellowString, formatFillFn: color.BlueString})
tick3, cancel3 = progressTickerWith(termWidth*3/8, &customProgressBarFormatter{Fill: '\u2591', formatBorderFn: color.GreenString, formatFillFn: color.RedString})
tick4, cancel4 = progressTickerWith(termWidth*1/2, &customProgressBarFormatter{Fill: '\u2587', formatBorderFn: color.RedString, formatFillFn: color.GreenString})
Expand All @@ -217,7 +219,7 @@ func demoConcurrentProgressBars(ctx *demoContext) {
tick4()
}

for i := 0; i < 20; i++ {
for i := 0; i < ticks; i++ {
tick()
time.Sleep(time.Millisecond * 10)
cursor.Up(4)
Expand Down Expand Up @@ -276,3 +278,7 @@ func (f *customProgressBarFormatter) FormatFill() string {
func (f *customProgressBarFormatter) FormatBlank() string {
return f.formatFillFn(fmt.Sprintf("%c", f.Fill))
}

func (f *customProgressBarFormatter) MessageAreaWidth() int {
return 25
}
93 changes: 66 additions & 27 deletions progress_bar.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const (

// DefaultProgressBarBlank default progress bar fill character
DefaultProgressBarBlank = '\u2591'

percentAreaSpace = 7
)

// DefaultProgressBarFormatter returns a new instance of the default ProgressBarFormatter
Expand All @@ -33,6 +35,17 @@ func DefaultProgressBarFormatter() *SimpleProgressBarFormatter {
}
}

// DefaultProgressBarFormatterWidth returns a default formatter with custom message area width.
func DefaultProgressBarFormatterWidth(width int) *SimpleProgressBarFormatter {
return &SimpleProgressBarFormatter{
LeftBorderChar: DefaultProgressBarLeftBorder,
RightBorderChar: DefaultProgressBarRightBorder,
FillChar: DefaultProgressBarFill,
BlankChar: DefaultProgressBarBlank,
MessageWidth: width,
}
}

// ProgressBarFormatter a formatter to control the style of a ProgressBar.
type ProgressBarFormatter interface {
// FormatLeftBorder returns a string that contains one visible character and optionally
Expand All @@ -50,6 +63,9 @@ type ProgressBarFormatter interface {
// FormatBlank returns a string that contains one visible character and optionally
// additional styling charatcers such as color codes, background and other effects.
FormatBlank() string

// MessageAreaWidth return the number of character used for the message area.
MessageAreaWidth() int
}

// SimpleProgressBarFormatter a simple ProgressBarFormatter implementation which is based on constructor values.
Expand All @@ -58,6 +74,7 @@ type SimpleProgressBarFormatter struct {
RightBorderChar rune
FillChar rune
BlankChar rune
MessageWidth int
}

// FormatLeftBorder returns the left border char
Expand All @@ -80,24 +97,36 @@ func (f *SimpleProgressBarFormatter) FormatBlank() string {
return fmt.Sprintf("%c", f.BlankChar)
}

// TickFn a tick handle
type TickFn = func() bool
// MessageAreaWidth returns zero
func (f *SimpleProgressBarFormatter) MessageAreaWidth() int {
return f.MessageWidth
}

// TickMessageFn a tick handle
type TickMessageFn = func(string) bool

// ProgressBar a progress bar interface
type ProgressBar interface {
Tick() bool
TickMessage(message string) bool
IsDone() bool
Start() (TickFn, context.CancelFunc, error)
Start() (TickMessageFn, context.CancelFunc, error)
}

type bar struct {
maxTicks int
ticks int
writer io.Writer
width int
formatter ProgressBarFormatter
active bool
mx *sync.RWMutex
maxTicks int
ticks int
writer io.Writer
width int
formatter ProgressBarFormatter
renderStringFormat string
active bool
mx *sync.RWMutex
}

type progressEvent struct {
ok bool
msg string
}

// NewProgressBar creates a new progress bar
Expand All @@ -107,13 +136,15 @@ type bar struct {
// width - bar width in characters
// formatter - a formatter for this progress bar
func NewProgressBar(writer io.Writer, maxTicks int, terminalWidth int, width int, formatter ProgressBarFormatter) ProgressBar {
renderFormat := fmt.Sprintf("%%s%%%ds %%s%%s%%s%%s %%d%%%%", formatter.MessageAreaWidth())
return &bar{
maxTicks: maxTicks,
ticks: 0,
writer: writer,
width: max(0, min(width, terminalWidth-7)), // 7 = 2 borders, 3 digits, % sign + 1 padding char
formatter: formatter,
mx: &sync.RWMutex{},
maxTicks: maxTicks,
ticks: 0,
writer: writer,
width: max(0, min(width, terminalWidth-percentAreaSpace-formatter.MessageAreaWidth())),
formatter: formatter,
renderStringFormat: renderFormat,
mx: &sync.RWMutex{},
}
}

Expand All @@ -131,43 +162,49 @@ func (b *bar) IsDone() bool {

// Tick increments the progress by one tick. Does not imply visual change.
func (b *bar) Tick() bool {
return b.TickMessage("")
}

// TickTickMessage increments the progress by one tick. Does not imply visual change.
func (b *bar) TickMessage(message string) bool {
if b.IsDone() {
return false
}

b.ticks++

return b.render()
return b.render(message)
}

// Start starts the progress bar in the background and returns a tick handle, a cancellation handle and an error in case
// this bar has already been started.
func (b *bar) Start() (tick TickFn, cancel context.CancelFunc, err error) {
func (b *bar) Start() (tick TickMessageFn, cancel context.CancelFunc, err error) {
defer b.mx.Unlock()
b.mx.Lock()

if b.active {
return nil, nil, errors.New("Progress bar already running in the background")
}
b.active = true
b.render()
b.render("")

var ctx context.Context
ctx, cancel = context.WithCancel(context.Background())

events := make(chan bool)
events := make(chan progressEvent)
var done bool
waitStart := &sync.WaitGroup{}
waitStart.Add(1)

tick = func() bool {
tick = func(msg string) bool {
if ctx.Err() != nil {
return false
}

if !done {
events <- true
done = !<-events
events <- progressEvent{ok: true, msg: msg}
maybeDoneEvent := <-events
done = !maybeDoneEvent.ok
}
return !done
}
Expand All @@ -179,8 +216,9 @@ func (b *bar) Start() (tick TickFn, cancel context.CancelFunc, err error) {
case <-ctx.Done():
return

case <-events:
events <- b.Tick()
case evt := <-events:
evt.ok = b.TickMessage(evt.msg)
events <- evt
}
}
}()
Expand All @@ -190,7 +228,7 @@ func (b *bar) Start() (tick TickFn, cancel context.CancelFunc, err error) {
return tick, cancel, err
}

func (b *bar) render() bool {
func (b *bar) render(message string) bool {
totalChars := b.width
percent := float32(b.ticks) / float32(b.maxTicks)
charsToFill := int(percent * float32(totalChars))
Expand All @@ -199,8 +237,9 @@ func (b *bar) render() bool {
_, _ = io.WriteString(
b.writer,
fmt.Sprintf(
"%s%s%s%s%s %d%%\r",
b.renderStringFormat,
TermControlEraseLine,
TruncateString(message, b.formatter.MessageAreaWidth()),
b.formatter.FormatLeftBorder(),
strings.Repeat(b.formatter.FormatFill(), charsToFill),
strings.Repeat(b.formatter.FormatBlank(), spaceChars),
Expand Down
29 changes: 25 additions & 4 deletions progress_bar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"math/rand"
"testing"

"github.com/sha1n/clib/pkg/test"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -44,8 +45,8 @@ func TestStart(t *testing.T) {
assert.NotNil(t, tick)
assert.NotNil(t, cancel)

assert.True(t, tick())
assert.False(t, tick())
assert.True(t, tick(test.RandomString()))
assert.False(t, tick(test.RandomString()))
}

func TestStartWithAlreadyStartedBar(t *testing.T) {
Expand All @@ -69,9 +70,29 @@ func TestStartCancel(t *testing.T) {
assert.NotNil(t, tick)
assert.NotNil(t, cancel)

assert.True(t, tick())
assert.True(t, tick(test.RandomString()))
cancel()
assert.False(t, tick())
assert.False(t, tick(test.RandomString()))
}

func TestTickMessageNotDisplayedIfWidthIsZero(t *testing.T) {
emulatedStdout := new(bytes.Buffer)
bar := NewDefaultProgressBar(emulatedStdout, fakeTerminalWidth, 2)

aRandomMessage := test.RandomString()

assert.True(t, bar.TickMessage(aRandomMessage))
assert.NotContains(t, emulatedStdout.String(), aRandomMessage)
}

func TestTickMessage(t *testing.T) {
emulatedStdout := new(bytes.Buffer)

aRandomMessage := test.RandomString()
bar := NewProgressBar(emulatedStdout, 2, 100, 100, DefaultProgressBarFormatterWidth(len(aRandomMessage)))

assert.True(t, bar.TickMessage(aRandomMessage))
assert.Contains(t, emulatedStdout.String(), aRandomMessage)
}

func testProgressBarWith(t *testing.T, termWidth, width, maxTicks int) {
Expand Down
17 changes: 17 additions & 0 deletions strings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package termite

import (
"fmt"
)

// TruncateString returns a string that is at most maxLen long.
// If s is longer than maxLen, it is trimmed to (maxLen - 2) and three dots are appended.
func TruncateString(s string, maxLen int) string {
if len(s) > maxLen {
if maxLen > 1 {
return fmt.Sprintf("%s..", s[:maxLen-2])
}
return ""
}
return s
}
26 changes: 26 additions & 0 deletions strings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package termite

import "testing"

func TestTruncateString(t *testing.T) {
type args struct {
s string
maxLen int
}
tests := []struct {
name string
args args
want string
}{
{name: "below threshold", args: args{s: "hello world!", maxLen: 20}, want: "hello world!"},
{name: "above threshold", args: args{s: "hello world!", maxLen: 6}, want: "hell.."},
{name: "zero length", args: args{s: "hello", maxLen: 0}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateString(tt.args.s, tt.args.maxLen); got != tt.want {
t.Errorf("TruncateString() = %v, want %v", got, tt.want)
}
})
}
}

0 comments on commit 0ccb7a0

Please sign in to comment.