From 3734d9fe94561a1fb8b648144525cae40fc01f9d Mon Sep 17 00:00:00 2001 From: JediB0T Date: Sun, 20 May 2018 14:30:57 -0700 Subject: [PATCH] progress: Task(s) progress tracker (#39) --- README.md | 49 ++++++- bench_test.go | 26 ++++ cmd/demo-progress/README.md | 24 ++++ cmd/demo-progress/demo.go | 102 ++++++++++++++ cmd/profile-progress/profile.go | 60 ++++++++ list/README.md | 3 +- profile.sh | 2 +- progress/README.md | 39 ++++++ progress/progress.go | 203 ++++++++++++++++++++++++++++ progress/progress_test.go | 168 +++++++++++++++++++++++ progress/render.go | 164 ++++++++++++++++++++++ progress/render_test.go | 173 ++++++++++++++++++++++++ progress/style.go | 157 +++++++++++++++++++++ progress/tracker.go | 233 ++++++++++++++++++++++++++++++++ progress/tracker_test.go | 159 ++++++++++++++++++++++ progress/writer.go | 35 +++++ util/cursor.go | 39 ++++++ util/cursor_test.go | 23 ++++ 18 files changed, 1650 insertions(+), 9 deletions(-) create mode 100644 cmd/demo-progress/README.md create mode 100644 cmd/demo-progress/demo.go create mode 100644 cmd/profile-progress/profile.go create mode 100644 progress/README.md create mode 100644 progress/progress.go create mode 100644 progress/progress_test.go create mode 100644 progress/render.go create mode 100644 progress/render_test.go create mode 100644 progress/style.go create mode 100644 progress/tracker.go create mode 100644 progress/tracker_test.go create mode 100644 progress/writer.go create mode 100644 util/cursor.go create mode 100644 util/cursor_test.go diff --git a/README.md b/README.md index eaee3c6..e1cb2ba 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ Pretty-print tables into ASCII/Unicode strings. +-----+------------+-----------+--------+-----------------------------+ ``` -A demonstration of all the capabilities can be found here: [cmd/demo-table](cmd/demo-table) +A demonstration of all the capabilities can be found here: +[cmd/demo-table](cmd/demo-table) ## List @@ -75,7 +76,40 @@ Pretty-print lists with multiple levels/indents into ASCII/Unicode strings. ■ The Gunslinger ``` -A demonstration of all the capabilities can be found here: [cmd/demo-list](cmd/demo-list) +A demonstration of all the capabilities can be found here: +[cmd/demo-list](cmd/demo-list) + +# Progress + +Track the Progress of one or more Tasks (like downloading multiple files in +parallel). + + - Track one or more Tasks at the same time + - Dynamically add one or more Task Trackers while `Render()` is in progress + - Choose to have the Writer auto-stop the Render when no more Trackers are + in queue, or manually stop using `Stop()` + - Redirect output to an io.Writer object (like os.StdOut) + - Completely customizable styles + - Many ready-to-use styles: [progress/style.go](progress/style.go) + - Colorize various parts of the Tracker using `StyleColors` + - Customize how Trackers get rendered using `StyleOptions` + +Sample Progress Tracking: +``` +Calculating Total # 1 ... done! [3.25K in 100ms] +Calculating Total # 2 ... done! [6.50K in 100ms] +Downloading File # 3 ... done! [9.75KB in 100ms] +Transferring Amount # 4 ... done! [$26.00K in 200ms] +Transferring Amount # 5 ... done! [£32.50K in 201ms] +Downloading File # 6 ... done! [58.50KB in 300ms] +Calculating Total # 7 ... done! [91.00K in 400ms] +Transferring Amount # 8 ... 60.9% (●●●●●●●●●●●●●●◌◌◌◌◌◌◌◌◌) [$78.00K in 399.071ms] +Downloading File # 9 ... 32.1% (●●●●●●●○◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌) [58.50KB in 298.947ms] +Transferring Amount # 10 ... 13.0% (●●○◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌) [£32.50K in 198.84ms] +``` + +A demonstration of all the capabilities can be found here: +[cmd/demo-progress](cmd/demo-progress) ## Text @@ -97,9 +131,10 @@ The unit-tests for each of the above show how these are to be used. Partial output of `make bench`: ``` -BenchmarkList_Render-8 1000000 1836 ns/op 808 B/op 22 allocs/op -BenchmarkTable_Render-8 100000 20736 ns/op 5426 B/op 191 allocs/op -BenchmarkTable_RenderCSV-8 300000 4394 ns/op 2336 B/op 45 allocs/op -BenchmarkTable_RenderHTML-8 200000 6563 ns/op 3793 B/op 44 allocs/op -BenchmarkTable_RenderMarkdown-8 300000 4666 ns/op 2272 B/op 43 allocs/op +BenchmarkList_Render-8 1000000 1848 ns/op 808 B/op 22 allocs/op +BenchmarkProgress_Render-8 2 800904500 ns/op 8832 B/op 373 allocs/op +BenchmarkTable_Render-8 100000 21025 ns/op 5538 B/op 188 allocs/op +BenchmarkTable_RenderCSV-8 300000 4507 ns/op 2464 B/op 45 allocs/op +BenchmarkTable_RenderHTML-8 200000 6471 ns/op 3921 B/op 44 allocs/op +BenchmarkTable_RenderMarkdown-8 300000 4720 ns/op 2400 B/op 43 allocs/op ``` diff --git a/bench_test.go b/bench_test.go index 630bd23..5becb33 100644 --- a/bench_test.go +++ b/bench_test.go @@ -4,8 +4,11 @@ import ( "testing" "github.com/jedib0t/go-pretty/list" + "github.com/jedib0t/go-pretty/progress" "github.com/jedib0t/go-pretty/table" "github.com/jedib0t/go-pretty/text" + "io/ioutil" + "time" ) var ( @@ -21,6 +24,9 @@ var ( {20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"}, {300, "Tyrion", "Lannister", 5000}, } + tracker1 = progress.Tracker{Message: "Calculation Total # 1", Total: 1000, Units: progress.UnitsDefault} + tracker2 = progress.Tracker{Message: "Downloading File # 2", Total: 1000, Units: progress.UnitsBytes} + tracker3 = progress.Tracker{Message: "Transferring Amount # 3", Total: 1000, Units: progress.UnitsCurrencyDollar} ) func generateBenchmarkTable() table.Writer { @@ -45,6 +51,26 @@ func BenchmarkList_Render(b *testing.B) { } } +func BenchmarkProgress_Render(b *testing.B) { + trackSomething := func(pw progress.Writer, tracker *progress.Tracker) { + tracker.Reset() + pw.AppendTracker(tracker) + time.Sleep(time.Millisecond * 500) + tracker.Increment(tracker.Total) + } + + for i := 0; i < b.N; i++ { + pw := progress.NewWriter() + pw.SetAutoStop(true) + pw.SetOutputWriter(ioutil.Discard) + go trackSomething(pw, &tracker1) + go trackSomething(pw, &tracker2) + go trackSomething(pw, &tracker3) + time.Sleep(time.Millisecond * 50) + pw.Render() + } +} + func BenchmarkTable_Render(b *testing.B) { for i := 0; i < b.N; i++ { generateBenchmarkTable().Render() diff --git a/cmd/demo-progress/README.md b/cmd/demo-progress/README.md new file mode 100644 index 0000000..2b7ea3c --- /dev/null +++ b/cmd/demo-progress/README.md @@ -0,0 +1,24 @@ +Output of `go run cmd/demo-list/demo.go`: + +``` +Tracking Progress of 13 trackers ... + +Calculating Total # 1 ... done! [250 in 101ms] +Calculating Total # 2 ... done! [2.00K in 101ms] +Downloading File # 3 ... done! [6.75KB in 101ms] +Transferring Amount # 4 ... done! [$16.00K in 200ms] +Transferring Amount # 5 ... done! [£31.25K in 201ms] +Downloading File # 6 ... done! [54.00KB in 300ms] +Calculating Total # 7 ... done! [85.75K in 400ms] +Transferring Amount # 8 ... done! [$128.00K in 500ms] +Downloading File # 9 ... done! [182.25KB in 700ms] +Transferring Amount # 10 ... done! [£250.00K in 801ms] +Calculating Total # 11 ... done! [332.75K in 1s] +Transferring Amount # 12 ... done! [$432.00K in 1.2s] +Calculating Total # 13 ... done! [549.25K in 1.301s] + +All done! +``` + +Real-time playback of the output @ asciinema.org: +[![asciicast](https://asciinema.org/a/KcPw8aoBSsYCBOj60wluhu5z3.png)](https://asciinema.org/a/KcPw8aoBSsYCBOj60wluhu5z3) diff --git a/cmd/demo-progress/demo.go b/cmd/demo-progress/demo.go new file mode 100644 index 0000000..008e3b6 --- /dev/null +++ b/cmd/demo-progress/demo.go @@ -0,0 +1,102 @@ +package main + +import ( + "flag" + "fmt" + "time" + + "github.com/jedib0t/go-pretty/progress" +) + +var ( + autoStop = flag.Bool("auto-stop", false, "Auto-stop rendering?") + numTrackers = flag.Int64("num-trackers", 13, "Number of Trackers") +) + +func trackSomething(pw progress.Writer, idx int64) { + total := idx * idx * idx * 250 + incrementPerCycle := idx * (*numTrackers) * 250 + + var units progress.Units + switch { + case idx%5 == 0: + units = progress.UnitsCurrencyPound + case idx%4 == 0: + units = progress.UnitsCurrencyDollar + case idx%3 == 0: + units = progress.UnitsBytes + default: + units = progress.UnitsDefault + } + + var message string + switch units { + case progress.UnitsBytes: + message = fmt.Sprintf("Downloading File #%3d", idx) + case progress.UnitsCurrencyDollar, progress.UnitsCurrencyEuro, progress.UnitsCurrencyPound: + message = fmt.Sprintf("Transferring Amount #%3d", idx) + default: + message = fmt.Sprintf("Calculating Total #%3d", idx) + } + tracker := progress.Tracker{Message: message, Total: total, Units: units} + + pw.AppendTracker(&tracker) + + c := time.Tick(time.Millisecond * 100) + for !tracker.IsDone() { + select { + case <-c: + tracker.Increment(incrementPerCycle) + } + } +} + +func main() { + flag.Parse() + fmt.Printf("Tracking Progress of %d trackers ...\n\n", *numTrackers) + + // instantiate a Progress Writer and set up the options + pw := progress.NewWriter() + pw.SetAutoStop(*autoStop) + pw.SetTrackerLength(25) + pw.ShowTime(true) + pw.ShowTracker(true) + pw.ShowValue(true) + pw.SetSortBy(progress.SortByPercentDsc) + pw.SetStyle(progress.StyleCircle) + pw.SetTrackerPosition(progress.PositionRight) + pw.SetUpdateFrequency(time.Millisecond * 100) + pw.Style().Colors = progress.StyleColorsExample + pw.Style().Options.PercentFormat = "%4.1f%%" + + // call Render() in async mode; yes we don't have any trackers at the moment + go pw.Render() + + // add a bunch of trackers with random parameters to demo most of the + // features available; do this in async too like a client might do (for ex. + // when downloading a bunch of files in parallel) + for idx := int64(1); idx <= *numTrackers; idx++ { + go trackSomething(pw, idx) + + // in auto-stop mode, the Render logic terminates the moment it detects + // zero active trackers; but in a manual-stop mode, it keeps waiting and + // is a good chance to demo trackers being added dynamically while other + // trackers are active or done + if !*autoStop { + time.Sleep(time.Millisecond * 100) + } + } + + // wait for one or more trackers to become active (just blind-wait for a + // second) and then keep watching until Rendering is in progress + time.Sleep(time.Second) + for pw.IsRenderInProgress() { + // for manual-stop mode, stop when there are no more active trackers + if !*autoStop && pw.LengthActive() == 0 { + pw.Stop() + } + time.Sleep(time.Millisecond * 100) + } + + fmt.Println("\nAll done!") +} diff --git a/cmd/profile-progress/profile.go b/cmd/profile-progress/profile.go new file mode 100644 index 0000000..723e5c4 --- /dev/null +++ b/cmd/profile-progress/profile.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "strconv" + "time" + + "github.com/jedib0t/go-pretty/progress" + "github.com/pkg/profile" +) + +var ( + tracker1 = progress.Tracker{Message: "Calculation Total # 1", Total: 1000, Units: progress.UnitsDefault} + tracker2 = progress.Tracker{Message: "Downloading File # 2", Total: 1000, Units: progress.UnitsBytes} + tracker3 = progress.Tracker{Message: "Transferring Amount # 3", Total: 1000, Units: progress.UnitsCurrencyDollar} + profilers = []func(*profile.Profile){ + profile.CPUProfile, + profile.MemProfileRate(512), + } +) + +func profileRender(profiler func(profile2 *profile.Profile), n int) { + defer profile.Start(profiler, profile.ProfilePath("./")).Stop() + + trackSomething := func(pw progress.Writer, tracker *progress.Tracker) { + tracker.Reset() + pw.AppendTracker(tracker) + time.Sleep(time.Millisecond * 500) + tracker.Increment(tracker.Total) + } + + for i := 0; i < n; i++ { + pw := progress.NewWriter() + pw.SetAutoStop(true) + pw.SetOutputWriter(ioutil.Discard) + go trackSomething(pw, &tracker1) + go trackSomething(pw, &tracker2) + go trackSomething(pw, &tracker3) + time.Sleep(time.Millisecond * 50) + pw.Render() + } +} + +func main() { + numRenders := 5 + if len(os.Args) > 1 { + var err error + numRenders, err = strconv.Atoi(os.Args[2]) + if err != nil { + fmt.Printf("Invalid Argument: '%s'\n", os.Args[2]) + os.Exit(1) + } + } + + for _, profiler := range profilers { + profileRender(profiler, numRenders) + } +} diff --git a/list/README.md b/list/README.md index 615e2b4..f2c7d4d 100644 --- a/list/README.md +++ b/list/README.md @@ -26,4 +26,5 @@ Pretty-print lists with multiple levels/indents into ASCII/Unicode strings. ■ The Gunslinger ``` -A demonstration of all the capabilities can be found here: [../cmd/demo-list](../cmd/demo-list) +A demonstration of all the capabilities can be found here: +[../cmd/demo-list](../cmd/demo-list) diff --git a/profile.sh b/profile.sh index 175e2bb..a184f54 100755 --- a/profile.sh +++ b/profile.sh @@ -4,7 +4,7 @@ rm -fr profile # profile each supported package -for what in "list" "table" +for what in "list" "progress" "table" do echo "Profiling ${what} ..." mkdir -p profile/${what} diff --git a/progress/README.md b/progress/README.md new file mode 100644 index 0000000..78deaa5 --- /dev/null +++ b/progress/README.md @@ -0,0 +1,39 @@ +# Progress +[![GoDoc](https://godoc.org/github.com/jedib0t/go-pretty/progress?status.svg)](https://godoc.org/github.com/jedib0t/go-pretty/progress) + +Track the Progress of one or more Tasks (like downloading multiple files in +parallel). + + - Track one or more Tasks at the same time + - Dynamically add one or more Task Trackers while `Render()` is in progress + - Choose to have the Writer auto-stop the Render when no more Trackers are + in queue, or manually stop using `Stop()` + - Redirect output to an io.Writer object (like os.StdOut) + - Completely customizable styles + - Many ready-to-use styles: [style.go](style.go) + - Colorize various parts of the Tracker using `StyleColors` + - Customize how Trackers get rendered using `StyleOptions` + +Sample Progress Tracking: +``` +Calculating Total # 1 ... done! [3.25K in 100ms] +Calculating Total # 2 ... done! [6.50K in 100ms] +Downloading File # 3 ... done! [9.75KB in 100ms] +Transferring Amount # 4 ... done! [$26.00K in 200ms] +Transferring Amount # 5 ... done! [£32.50K in 201ms] +Downloading File # 6 ... done! [58.50KB in 300ms] +Calculating Total # 7 ... done! [91.00K in 400ms] +Transferring Amount # 8 ... 60.9% (●●●●●●●●●●●●●●◌◌◌◌◌◌◌◌◌) [$78.00K in 399.071ms] +Downloading File # 9 ... 32.1% (●●●●●●●○◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌) [58.50KB in 298.947ms] +Transferring Amount # 10 ... 13.0% (●●○◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌◌) [£32.50K in 198.84ms] +``` + +Real-time playback of the output @ asciinema.org: +[![asciicast](https://asciinema.org/a/KcPw8aoBSsYCBOj60wluhu5z3.png)](https://asciinema.org/a/KcPw8aoBSsYCBOj60wluhu5z3) + +A demonstration of all the capabilities can be found here: +[../cmd/demo-progress](../cmd/demo-progress) + +# TODO + + - Optimize CPU and Memory Usage diff --git a/progress/progress.go b/progress/progress.go new file mode 100644 index 0000000..0028c78 --- /dev/null +++ b/progress/progress.go @@ -0,0 +1,203 @@ +package progress + +import ( + "fmt" + "io" + "math" + "os" + "sync" + "time" + "unicode/utf8" +) + +var ( + // DefaultLengthTracker defines a sane value for a Tracker's length. + DefaultLengthTracker = 20 + + // DefaultUpdateFrequency defines a sane value for the frequency with which + // all the Tracker's get updated on the screen. + DefaultUpdateFrequency = time.Millisecond * 250 +) + +// Progress helps track progress for one or more tasks. +type Progress struct { + autoStop bool + done chan bool + lengthTracker int + lengthProgress int + outputWriter io.Writer + hideTime bool + hideTracker bool + hideValue bool + hidePercentage bool + renderInProgress bool + sortBy SortBy + style *Style + trackerPosition Position + trackersActive []*Tracker + trackersDone []*Tracker + trackersInQueue []*Tracker + trackersInQueueMutex sync.Mutex + updateFrequency time.Duration +} + +// Position defines the position of the Tracker with respect to the Tracker's +// Message. +type Position int + +const ( + // PositionLeft will make the Tracker be displayed first before the Message. + PositionLeft Position = iota + + // PositionRight will make the Tracker be displayed after the Message. + PositionRight +) + +// AppendTracker appends a single Tracker for tracking. The Tracker gets added +// to a queue, which gets picked up by the Render logic in the next rendering +// cycle. +func (p *Progress) AppendTracker(tracker *Tracker) { + p.trackersInQueueMutex.Lock() + if tracker.Total <= 0 { + tracker.Total = math.MaxInt64 + } + tracker.start() + p.trackersInQueue = append(p.trackersInQueue, tracker) + p.trackersInQueueMutex.Unlock() +} + +// AppendTrackers appends one or more Trackers for tracking. +func (p *Progress) AppendTrackers(trackers []*Tracker) { + for _, tracker := range trackers { + p.AppendTracker(tracker) + } +} + +// IsRenderInProgress returns true if a call to Render() was made, and is still +// in progress and has not ended yet. +func (p *Progress) IsRenderInProgress() bool { + return p.renderInProgress +} + +// Length returns the number of Trackers tracked overall. +func (p *Progress) Length() int { + return len(p.trackersInQueue) + len(p.trackersActive) + len(p.trackersDone) +} + +// LengthActive returns the number of Trackers actively tracked (not done yet). +func (p *Progress) LengthActive() int { + return len(p.trackersInQueue) + len(p.trackersActive) +} + +// SetAutoStop toggles the auto-stop functionality. Auto-stop set to true would +// mean that the Render() function will automatically stop once all currently +// active Trackers reach their final states. When set to false, the client code +// will have to call Progress.Stop() to stop the Render() logic. Default: false. +func (p *Progress) SetAutoStop(autoStop bool) { + p.autoStop = autoStop +} + +// SetOutputWriter redirects the output of Render to an io.writer object like +// os.Stdout or os.Stderr or a file. Warning: redirecting the output to a file +// may not work well as the Render() logic moves the cursor around a lot. +func (p *Progress) SetOutputWriter(writer io.Writer) { + p.outputWriter = writer +} + +// SetSortBy defines the sorting mechanism to use to sort the Active Trackers +// before rendering the. Default: no-sorting == sort-by-insertion-order. +func (p *Progress) SetSortBy(sortBy SortBy) { + p.sortBy = sortBy +} + +// SetStyle sets the Style to use for rendering. +func (p *Progress) SetStyle(style Style) { + p.style = &style +} + +// SetTrackerLength sets the text-length of all the Trackers. +func (p *Progress) SetTrackerLength(length int) { + p.lengthTracker = length +} + +// SetTrackerPosition sets the position of the tracker with respect to the +// Tracker message text. +func (p *Progress) SetTrackerPosition(position Position) { + p.trackerPosition = position +} + +// SetUpdateFrequency sets the update frequency while rendering the trackers. +// the lower the value, the more number of times the Trackers get refreshed. A +// sane value would be 250ms. +func (p *Progress) SetUpdateFrequency(frequency time.Duration) { + p.updateFrequency = frequency +} + +// ShowPercentage toggles showing the Percent complete for each Tracker. +func (p *Progress) ShowPercentage(show bool) { + p.hidePercentage = !show +} + +// ShowTime toggles showing the Time taken by each Tracker. +func (p *Progress) ShowTime(show bool) { + p.hideTime = !show +} + +// ShowTracker toggles showing the Tracker (the progress bar). +func (p *Progress) ShowTracker(show bool) { + p.hideTracker = !show +} + +// ShowValue toggles showing the actual Value of the Tracker. +func (p *Progress) ShowValue(show bool) { + p.hideValue = !show +} + +// Stop stops the Render() logic that is in progress. +func (p *Progress) Stop() { + if p.renderInProgress { + p.done <- true + } +} + +// Style returns the current Style. +func (p *Progress) Style() *Style { + if p.style == nil { + tempStyle := StyleDefault + p.style = &tempStyle + } + return p.style +} + +func (p *Progress) initForRender() { + // pick a default style + p.Style() + + // reset the signals + p.done = make(chan bool, 1) + + // pick default lengths if no valid ones set + if p.lengthTracker <= 0 { + p.lengthTracker = DefaultLengthTracker + } + + // calculate length of the actual progress bar by discount the left/right + // border/box chars + p.lengthProgress = p.lengthTracker - + utf8.RuneCountInString(p.style.Chars.BoxLeft) - + utf8.RuneCountInString(p.style.Chars.BoxRight) + + // if not output write has been set, output to STDOUT + if p.outputWriter == nil { + p.outputWriter = os.Stdout + } + + // pick a sane update frequency if none set + if p.updateFrequency <= 0 { + p.updateFrequency = DefaultUpdateFrequency + } +} + +func (p *Progress) write(a ...interface{}) { + p.outputWriter.Write([]byte(fmt.Sprint(a...))) +} diff --git a/progress/progress_test.go b/progress/progress_test.go new file mode 100644 index 0000000..21af42c --- /dev/null +++ b/progress/progress_test.go @@ -0,0 +1,168 @@ +package progress + +import ( + "math" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestProgress_AppendTracker(t *testing.T) { + p := Progress{} + assert.Equal(t, 0, len(p.trackersInQueue)) + + tracker := &Tracker{} + assert.Equal(t, int64(0), tracker.Total) + + p.AppendTracker(tracker) + assert.Equal(t, 1, len(p.trackersInQueue)) + assert.Equal(t, int64(math.MaxInt64), tracker.Total) +} + +func TestProgress_AppendTrackers(t *testing.T) { + p := Progress{} + assert.Equal(t, 0, len(p.trackersInQueue)) + + p.AppendTrackers([]*Tracker{{}, {}}) + assert.Equal(t, 2, len(p.trackersInQueue)) +} + +func TestProgress_IsRenderInProgress(t *testing.T) { + p := Progress{} + assert.False(t, p.IsRenderInProgress()) + + p.renderInProgress = true + assert.True(t, p.IsRenderInProgress()) +} + +func TestProgress_Length(t *testing.T) { + p := Progress{} + assert.Equal(t, 0, p.Length()) + + p.AppendTracker(&Tracker{}) + assert.Equal(t, 1, p.Length()) +} + +func TestProgress_LengthActive(t *testing.T) { + p := Progress{} + assert.Equal(t, 0, p.Length()) + assert.Equal(t, 0, p.LengthActive()) + + p.AppendTracker(&Tracker{}) + assert.Equal(t, 1, p.Length()) + assert.Equal(t, 1, p.LengthActive()) +} + +func TestProgress_SetAutoStop(t *testing.T) { + p := Progress{} + assert.False(t, p.autoStop) + + p.SetAutoStop(true) + assert.True(t, p.autoStop) +} + +func TestProgress_SetOutputWriter(t *testing.T) { + p := Progress{} + assert.Nil(t, p.outputWriter) + + p.SetOutputWriter(os.Stdout) + assert.Equal(t, os.Stdout, p.outputWriter) +} + +func TestProgress_SetSortBy(t *testing.T) { + p := Progress{} + assert.Zero(t, p.sortBy) + + p.SetSortBy(SortByMessage) + assert.Equal(t, SortByMessage, p.sortBy) +} + +func TestProgress_SetStyle(t *testing.T) { + p := Progress{} + assert.Nil(t, p.style) + + p.SetStyle(StyleCircle) + assert.Equal(t, StyleCircle.Name, p.Style().Name) +} + +func TestProgress_SetTrackerLength(t *testing.T) { + p := Progress{} + assert.Equal(t, 0, p.lengthTracker) + + p.initForRender() + assert.Equal(t, DefaultLengthTracker, p.lengthTracker) + + p.SetTrackerLength(80) + assert.Equal(t, 80, p.lengthTracker) +} + +func TestProgress_SetTrackerPosition(t *testing.T) { + p := Progress{} + assert.Equal(t, PositionLeft, p.trackerPosition) + + p.SetTrackerPosition(PositionRight) + assert.Equal(t, PositionRight, p.trackerPosition) +} + +func TestProgress_SetUpdateFrequency(t *testing.T) { + p := Progress{} + assert.Equal(t, time.Duration(0), p.updateFrequency) + + p.initForRender() + assert.Equal(t, DefaultUpdateFrequency, p.updateFrequency) + + p.SetUpdateFrequency(time.Duration(time.Second)) + assert.Equal(t, time.Duration(time.Second), p.updateFrequency) +} + +func TestProgress_ShowPercentage(t *testing.T) { + p := Progress{} + assert.False(t, p.hidePercentage) + + p.ShowPercentage(false) + assert.True(t, p.hidePercentage) +} + +func TestProgress_ShowTime(t *testing.T) { + p := Progress{} + assert.False(t, p.hideTime) + + p.ShowTime(false) + assert.True(t, p.hideTime) +} + +func TestProgress_ShowTracker(t *testing.T) { + p := Progress{} + assert.False(t, p.hideTracker) + + p.ShowTracker(false) + assert.True(t, p.hideTracker) +} + +func TestProgress_ShowValue(t *testing.T) { + p := Progress{} + assert.False(t, p.hideValue) + + p.ShowValue(false) + assert.True(t, p.hideValue) +} + +func TestProgress_Stop(t *testing.T) { + doneChannel := make(chan bool, 1) + + p := Progress{} + p.done = doneChannel + p.renderInProgress = true + p.Stop() + assert.True(t, <-doneChannel) +} + +func TestProgress_Style(t *testing.T) { + p := Progress{} + assert.Nil(t, p.style) + + assert.NotNil(t, p.Style()) + assert.Equal(t, StyleDefault.Name, p.Style().Name) +} diff --git a/progress/render.go b/progress/render.go new file mode 100644 index 0000000..f50eb88 --- /dev/null +++ b/progress/render.go @@ -0,0 +1,164 @@ +package progress + +import ( + "math" + "strings" + "time" + + "github.com/jedib0t/go-pretty/util" +) + +// Render renders the Progress tracker and handles all existing trackers and +// those that are added dynamically while render is in progress. +func (p *Progress) Render() { + if !p.renderInProgress { + p.initForRender() + + c := time.Tick(p.updateFrequency) + for p.renderInProgress = true; p.renderInProgress; { + select { + case <-c: + if len(p.trackersInQueue) > 0 || len(p.trackersActive) > 0 { + p.renderTrackers() + } + case <-p.done: + p.renderInProgress = false + } + } + } +} + +func (p *Progress) renderTrackers() { + // move up N times based on the number of active trackers + if len(p.trackersActive) > 0 { + p.write(util.CursorUp.Sprintn(len(p.trackersActive))) + } + + // move trackers waiting in queue to the active list + if len(p.trackersInQueue) > 0 { + p.trackersInQueueMutex.Lock() + p.trackersActive = append(p.trackersActive, p.trackersInQueue...) + p.trackersInQueue = []*Tracker{} + p.trackersInQueueMutex.Unlock() + } + + // render the finished trackers and move them to the "done" list + for idx, tracker := range p.trackersActive { + if tracker.IsDone() { + p.renderTracker(tracker) + if idx < len(p.trackersActive) { + p.trackersActive = append(p.trackersActive[:idx], p.trackersActive[idx+1:]...) + } + p.trackersDone = append(p.trackersDone, tracker) + } + } + + // sort and render the active trackers + p.sortBy.Sort(p.trackersActive) + for _, tracker := range p.trackersActive { + p.renderTracker(tracker) + } + + // stop if auto stop is enabled and there are no more active trackers + if p.autoStop && len(p.trackersInQueue) == 0 && len(p.trackersActive) == 0 { + p.done <- true + } +} + +func (p *Progress) renderTracker(t *Tracker) { + p.write(util.EraseLine.Sprint()) + + pDotValue := float64(t.Total) / float64(p.lengthProgress) + pFinishedDots := float64(t.value) / pDotValue + pFinishedLen := int(math.Ceil(pFinishedDots)) + pUnfinishedLen := p.lengthProgress - pFinishedLen + + var pFinished, pInProgress, pUnfinished string + if pFinishedLen > 0 { + pFinished = strings.Repeat(p.style.Chars.Finished, pFinishedLen-1) + } + if pUnfinishedLen > 0 { + pUnfinished = strings.Repeat(p.style.Chars.Unfinished, pUnfinishedLen) + } + + pFinishedDecimals := pFinishedDots - float64(int(pFinishedDots)) + if pFinishedDecimals > 0.75 { + pInProgress = p.style.Chars.Finished75 + } else if pFinishedDecimals > 0.50 { + pInProgress = p.style.Chars.Finished50 + } else if pFinishedDecimals > 0.25 { + pInProgress = p.style.Chars.Finished25 + } else { + pInProgress = p.style.Chars.Unfinished + } + + if t.IsDone() { + p.renderTrackerDone(t) + } else { + p.renderTrackerProgress(t, p.style.Colors.Tracker.Sprintf("%s%s%s%s%s", + p.style.Chars.BoxLeft, pFinished, pInProgress, pUnfinished, p.style.Chars.BoxRight, + )) + } +} + +func (p *Progress) renderTrackerDone(t *Tracker) { + p.write(p.style.Colors.Message.Sprint(t.Message)) + p.write(" " + p.style.Options.MessageTrackerSeparator + " ") + p.write(p.style.Colors.Done.Sprint(p.style.Options.DoneString)) + p.renderTrackerValueAndTime(t) + p.write("\n") +} + +func (p *Progress) renderTrackerProgress(t *Tracker, trackerStr string) { + if p.trackerPosition == PositionRight { + p.write(p.style.Colors.Message.Sprint(t.Message)) + p.write(" " + p.style.Options.MessageTrackerSeparator + " ") + p.renderTrackerPercentage(t) + if !p.hideTracker { + p.write(" " + p.style.Colors.Tracker.Sprint(trackerStr)) + } + p.renderTrackerValueAndTime(t) + p.write("\n") + } else { + p.renderTrackerPercentage(t) + if !p.hideTracker { + p.write(" " + p.style.Colors.Tracker.Sprint(trackerStr)) + } + p.renderTrackerValueAndTime(t) + p.write(" " + p.style.Options.MessageTrackerSeparator + " ") + p.write(p.style.Colors.Message.Sprint(t.Message)) + p.write("\n") + } +} + +func (p *Progress) renderTrackerPercentage(t *Tracker) { + if !p.hidePercentage { + p.write(p.style.Colors.Percent.Sprintf(p.style.Options.PercentFormat, t.PercentDone())) + } +} + +func (p *Progress) renderTrackerValueAndTime(t *Tracker) { + if !p.hideValue || !p.hideTime { + var out strings.Builder + out.WriteString(" [") + if !p.hideValue { + out.WriteString(p.style.Colors.Value.Sprint(t.Units.Sprint(t.value))) + } + if !p.hideValue && !p.hideTime { + out.WriteString(" ") + } + if !p.hideTime { + out.WriteString("in ") + if t.IsDone() { + out.WriteString(p.style.Colors.Time.Sprint( + t.timeStop.Sub(t.timeStart).Round(p.style.Options.TimeDonePrecision))) + } else { + out.WriteString(p.style.Colors.Time.Sprint( + time.Since(t.timeStart).Round(p.style.Options.TimeInProgressPrecision))) + } + } + out.WriteString("]") + + p.write(p.style.Colors.Stats.Sprint(out.String())) + } +} diff --git a/progress/render_test.go b/progress/render_test.go new file mode 100644 index 0000000..d40f1c7 --- /dev/null +++ b/progress/render_test.go @@ -0,0 +1,173 @@ +package progress + +import ( + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type outputWriter struct { + Text strings.Builder +} + +func (rc *outputWriter) Write(p []byte) (n int, err error) { + return rc.Text.Write(p) +} + +func (rc *outputWriter) String() string { + return rc.Text.String() +} + +func generateWriter() Writer { + pw := NewWriter() + pw.SetAutoStop(false) + pw.SetTrackerLength(25) + pw.ShowPercentage(true) + pw.ShowTime(true) + pw.ShowTracker(true) + pw.ShowValue(true) + pw.SetSortBy(SortByNone) + pw.SetStyle(StyleDefault) + pw.SetTrackerPosition(PositionRight) + pw.SetUpdateFrequency(time.Millisecond * 100) + pw.Style().Colors = StyleColors{} + pw.Style().Options = StyleOptions{ + DoneString: "done!", + MessageTrackerSeparator: "...", + PercentFormat: "%5.2f%%", + TimeDonePrecision: time.Millisecond, + TimeInProgressPrecision: time.Microsecond, + } + return pw +} + +func trackSomething(pw Writer, tracker *Tracker) { + pw.AppendTracker(tracker) + + incrementPerCycle := tracker.Total / 3 + + c := time.Tick(time.Millisecond * 100) + for !tracker.IsDone() { + select { + case <-c: + if tracker.value+incrementPerCycle > tracker.Total { + tracker.Increment(tracker.Total - tracker.value) + } else { + tracker.Increment(incrementPerCycle) + } + } + } +} + +func renderAndWait(pw Writer, autoStop bool) { + go pw.Render() + time.Sleep(time.Millisecond * 100) + for pw.IsRenderInProgress() { + if pw.LengthActive() == 0 { + break + } + time.Sleep(time.Millisecond * 100) + } + if !autoStop { + pw.Stop() + } +} + +func TestProgress_RenderNothing(t *testing.T) { + renderOutput := outputWriter{} + + pw := generateWriter() + pw.SetOutputWriter(&renderOutput) + + go pw.Render() + time.Sleep(time.Second) + pw.Stop() + time.Sleep(time.Second) + + assert.Empty(t, renderOutput.String()) +} + +func TestProgress_RenderSomeTrackers_OnLeftSide(t *testing.T) { + renderOutput := outputWriter{} + + pw := generateWriter() + pw.SetOutputWriter(&renderOutput) + pw.SetTrackerPosition(PositionLeft) + go trackSomething(pw, &Tracker{Message: "Calculation Total # 1", Total: 1000, Units: UnitsDefault}) + go trackSomething(pw, &Tracker{Message: "Downloading File # 2", Total: 1000, Units: UnitsBytes}) + go trackSomething(pw, &Tracker{Message: "Transferring Amount # 3", Total: 1000, Units: UnitsCurrencyDollar}) + renderAndWait(pw, false) + + expectedOutPatterns := []*regexp.Regexp{ + regexp.MustCompile(`\x1b\[K\d+\.\d+% \[[#\.]{23}\] \[\d+ in [\d\.ms]+\] ... Calculation Total # 1`), + regexp.MustCompile(`\x1b\[K\d+\.\d+% \[[#\.]{23}\] \[\d+B in [\d\.ms]+\] ... Downloading File # 2`), + regexp.MustCompile(`\x1b\[K\d+\.\d+% \[[#\.]{23}\] \[\$\d+ in [\d\.ms]+\] ... Transferring Amount # 3`), + regexp.MustCompile(`\x1b\[KCalculation Total # 1 \.\.\. done! \[\d+\.\d+K in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KDownloading File # 2 \.\.\. done! \[\d+\.\d+KB in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KTransferring Amount # 3 \.\.\. done! \[\$\d+\.\d+K in [\d\.ms]+\]`), + } + out := renderOutput.String() + for _, expectedOutPattern := range expectedOutPatterns { + if !expectedOutPattern.MatchString(out) { + assert.Fail(t, "Failed to find a pattern in the Output.", expectedOutPattern.String()) + } + } +} + +func TestProgress_RenderSomeTrackers_OnRightSide(t *testing.T) { + renderOutput := outputWriter{} + + pw := generateWriter() + pw.SetOutputWriter(&renderOutput) + pw.SetTrackerPosition(PositionRight) + go trackSomething(pw, &Tracker{Message: "Calculation Total # 1", Total: 1000, Units: UnitsDefault}) + go trackSomething(pw, &Tracker{Message: "Downloading File # 2", Total: 1000, Units: UnitsBytes}) + go trackSomething(pw, &Tracker{Message: "Transferring Amount # 3", Total: 1000, Units: UnitsCurrencyDollar}) + renderAndWait(pw, false) + + expectedOutPatterns := []*regexp.Regexp{ + regexp.MustCompile(`\x1b\[KCalculation Total # 1 ... \d+\.\d+% \[[#\.]{23}\] \[\d+ in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KDownloading File # 2 ... \d+\.\d+% \[[#\.]{23}\] \[\d+B in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KTransferring Amount # 3 ... \d+\.\d+% \[[#\.]{23}\] \[\$\d+ in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KCalculation Total # 1 \.\.\. done! \[\d+\.\d+K in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KDownloading File # 2 \.\.\. done! \[\d+\.\d+KB in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KTransferring Amount # 3 \.\.\. done! \[\$\d+\.\d+K in [\d\.ms]+\]`), + } + out := renderOutput.String() + for _, expectedOutPattern := range expectedOutPatterns { + if !expectedOutPattern.MatchString(out) { + assert.Fail(t, "Failed to find a pattern in the Output.", expectedOutPattern.String()) + } + } +} + +func TestProgress_RenderSomeTrackers_OnRightSideWithAutoStop(t *testing.T) { + renderOutput := outputWriter{} + + pw := generateWriter() + pw.SetAutoStop(true) + pw.SetOutputWriter(&renderOutput) + pw.SetTrackerPosition(PositionRight) + go trackSomething(pw, &Tracker{Message: "Calculation Total # 1", Total: 1000, Units: UnitsDefault}) + go trackSomething(pw, &Tracker{Message: "Downloading File # 2", Total: 1000, Units: UnitsBytes}) + go trackSomething(pw, &Tracker{Message: "Transferring Amount # 3", Total: 1000, Units: UnitsCurrencyDollar}) + renderAndWait(pw, true) + + expectedOutPatterns := []*regexp.Regexp{ + regexp.MustCompile(`\x1b\[KCalculation Total # 1 ... \d+\.\d+% \[[#\.]{23}\] \[\d+ in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KDownloading File # 2 ... \d+\.\d+% \[[#\.]{23}\] \[\d+B in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KTransferring Amount # 3 ... \d+\.\d+% \[[#\.]{23}\] \[\$\d+ in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KCalculation Total # 1 \.\.\. done! \[\d+\.\d+K in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KDownloading File # 2 \.\.\. done! \[\d+\.\d+KB in [\d\.ms]+\]`), + regexp.MustCompile(`\x1b\[KTransferring Amount # 3 \.\.\. done! \[\$\d+\.\d+K in [\d\.ms]+\]`), + } + out := renderOutput.String() + for _, expectedOutPattern := range expectedOutPatterns { + if !expectedOutPattern.MatchString(out) { + assert.Fail(t, "Failed to find a pattern in the Output.", expectedOutPattern.String()) + } + } +} diff --git a/progress/style.go b/progress/style.go new file mode 100644 index 0000000..d583206 --- /dev/null +++ b/progress/style.go @@ -0,0 +1,157 @@ +package progress + +import ( + "time" + + "github.com/jedib0t/go-pretty/text" +) + +// Style declares how to render the Progress/Trackers. +type Style struct { + Name string + Chars StyleChars + Colors StyleColors + Options StyleOptions +} + +var ( + // StyleDefault uses ASCII text to render the Trackers. + StyleDefault = Style{ + Name: "StyleDefault", + Chars: StyleCharsDefault, + Colors: StyleColorsDefault, + Options: StyleOptionsDefault, + } + + // StyleBlocks uses UNICODE Block Drawing characters to render the Trackers. + StyleBlocks = Style{ + Name: "StyleBlocks", + Chars: StyleCharsBlocks, + Colors: StyleColorsDefault, + Options: StyleOptionsDefault, + } + + // StyleCircle uses UNICODE Circle runes to render the Trackers. + StyleCircle = Style{ + Name: "StyleCircle", + Chars: StyleCharsCircle, + Colors: StyleColorsDefault, + Options: StyleOptionsDefault, + } + + // StyleRhombus uses UNICODE Rhombus runes to render the Trackers. + StyleRhombus = Style{ + Name: "StyleRhombus", + Chars: StyleCharsRhombus, + Colors: StyleColorsDefault, + Options: StyleOptionsDefault, + } +) + +// StyleChars defines the characters/strings to use for rendering the Tracker. +type StyleChars struct { + BoxLeft string + BoxRight string + Finished string + Finished25 string + Finished50 string + Finished75 string + Unfinished string +} + +var ( + // StyleCharsDefault uses simple ASCII characters. + StyleCharsDefault = StyleChars{ + BoxLeft: "[", + BoxRight: "]", + Finished: "#", + Finished25: ".", + Finished50: ".", + Finished75: ".", + Unfinished: ".", + } + + // StyleCharsBlocks uses UNICODE Block Drawing characters. + StyleCharsBlocks = StyleChars{ + BoxLeft: "║", + BoxRight: "║", + Finished: "█", + Finished25: "░", + Finished50: "▒", + Finished75: "▓", + Unfinished: "░", + } + + // StyleCharsCircle uses UNICODE Circle characters. + StyleCharsCircle = StyleChars{ + BoxLeft: "(", + BoxRight: ")", + Finished: "●", + Finished25: "○", + Finished50: "○", + Finished75: "○", + Unfinished: "◌", + } + + // StyleCharsRhombus uses UNICODE Rhombus characters. + StyleCharsRhombus = StyleChars{ + BoxLeft: "<", + BoxRight: ">", + Finished: "◆", + Finished25: "◈", + Finished50: "◈", + Finished75: "◈", + Unfinished: "◇", + } +) + +// StyleColors defines what colors to use for various parts of the Progress and +// Tracker texts. +type StyleColors struct { + Done text.Colors + Message text.Colors + Percent text.Colors + Stats text.Colors + Time text.Colors + Tracker text.Colors + Value text.Colors +} + +var ( + // StyleColorsDefault defines sane color choices - None. + StyleColorsDefault = StyleColors{} + + // StyleColorsExample defines a few choice color options. Use this is just as + // an example to customize the Tracker/text colors. + StyleColorsExample = StyleColors{ + Done: text.Colors{text.FgWhite, text.BgBlack}, + Message: text.Colors{text.FgWhite, text.BgBlack}, + Percent: text.Colors{text.FgHiRed, text.BgBlack}, + Stats: text.Colors{text.FgHiBlack, text.BgBlack}, + Time: text.Colors{text.FgGreen, text.BgBlack}, + Tracker: text.Colors{text.FgYellow, text.BgBlack}, + Value: text.Colors{text.FgCyan, text.BgBlack}, + } +) + +// StyleOptions defines misc. options to control how the Tracker or its parts +// gets rendered. +type StyleOptions struct { + DoneString string + MessageTrackerSeparator string + PercentFormat string + TimeDonePrecision time.Duration + TimeInProgressPrecision time.Duration +} + +var ( + // StyleOptionsDefault defines sane defaults for the Options. Use this as an + // example to customize the Tracker rendering. + StyleOptionsDefault = StyleOptions{ + DoneString: "done!", + MessageTrackerSeparator: "...", + PercentFormat: "%5.2f%%", + TimeDonePrecision: time.Millisecond, + TimeInProgressPrecision: time.Microsecond, + } +) diff --git a/progress/tracker.go b/progress/tracker.go new file mode 100644 index 0000000..22d2714 --- /dev/null +++ b/progress/tracker.go @@ -0,0 +1,233 @@ +package progress + +import ( + "fmt" + "sort" + "time" +) + +// Tracker helps track the progress of a single task. The way to use it is to +// instantiate a Tracker with a valid Message, a valid (expected) Total, and +// Units values. This should then be fed to the Progress Writer with the +// Writer.AppendTracker() method. When the task that is being done has progress, +// increment the value using the Tracker.Increment(value) method. +type Tracker struct { + // Message should contain a short description of the "task" + Message string + // Total should be set to the (expected) Total/Final value to be reached + Total int64 + // Units defines the type of the "value" being tracked + Units Units + + done bool + timeStart time.Time + timeStop time.Time + value int64 +} + +// Increment updates the current value of the task being tracked. +func (t *Tracker) Increment(value int64) { + if !t.done { + t.value += value + if t.Total > 0 && t.value >= t.Total { + t.stop() + } + } +} + +// IsDone returns true if the tracker is done (value has reached the expected +// Total set during initialization). +func (t *Tracker) IsDone() bool { + return t.done +} + +// MarkAsDone forces completion of the tracker by updating the current value as +// the expected Total value. +func (t *Tracker) MarkAsDone() { + t.Total = t.value + t.stop() +} + +// PercentDone returns the currently completed percentage value. +func (t *Tracker) PercentDone() float64 { + return float64(t.value) * 100.0 / float64(t.Total) +} + +// Reset resets the tracker to its initial state. +func (t *Tracker) Reset() { + t.done = false + t.timeStart = time.Time{} + t.timeStop = time.Time{} + t.value = 0 +} + +func (t *Tracker) start() { + t.done = false + t.timeStart = time.Now() +} + +func (t *Tracker) stop() { + t.done = true + t.timeStop = time.Now() + if t.value > t.Total { + t.Total = t.value + } +} + +// Units defines the "type" of the value being tracked by the Tracker. +type Units int + +const ( + // UnitsDefault doesn't define any units. The value will be treated as any + // other number. + UnitsDefault Units = iota + + // UnitsBytes defines the value as a storage unit. Values will be converted + // and printed in one of these forms: B, KB, MB, GB, TB, PB + UnitsBytes + + // UnitsCurrencyDollar defines the value as a Dollar amount. Values will be + // converted and printed in one of these forms: $x.yz, $x.yzK, $x.yzM, + // $x.yzB, $x.yzT + UnitsCurrencyDollar + + // UnitsCurrencyEuro defines the value as a Euro amount. Values will be + // converted and printed in one of these forms: ₠x.yz, ₠x.yzK, ₠x.yzM, + // ₠x.yzB, ₠x.yzT + UnitsCurrencyEuro + + // UnitsCurrencyPound defines the value as a Pound amount. Values will be + // converted and printed in one of these forms: £x.yz, £x.yzK, £x.yzM, + // £x.yzB, £x.yzT + UnitsCurrencyPound +) + +// Sprint prints the value as defined by the Units. +func (tu Units) Sprint(value int64) string { + switch tu { + case UnitsBytes: + return tu.sprintBytes(value) + case UnitsCurrencyDollar: + return "$" + tu.sprintAll(value) + case UnitsCurrencyEuro: + return "₠" + tu.sprintAll(value) + case UnitsCurrencyPound: + return "£" + tu.sprintAll(value) + default: + return tu.sprintAll(value) + } +} + +func (tu Units) sprintAll(value int64) string { + if value < 1000 { + return fmt.Sprintf("%d", value) + } else if value < 1000000 { + return fmt.Sprintf("%.2fK", float64(value)/1000.0) + } else if value < 1000000000 { + return fmt.Sprintf("%.2fM", float64(value)/1000000.0) + } else if value < 1000000000000 { + return fmt.Sprintf("%.2fB", float64(value)/1000000000.0) + } else if value < 1000000000000000 { + return fmt.Sprintf("%.2fT", float64(value)/1000000000000.0) + } else { + return fmt.Sprintf("%.2fQ", float64(value)/1000000000000000.0) + } +} + +func (tu Units) sprintBytes(value int64) string { + if value < 1000 { + return fmt.Sprintf("%dB", value) + } else if value < 1000000 { + return fmt.Sprintf("%.2fKB", float64(value)/1000.0) + } else if value < 1000000000 { + return fmt.Sprintf("%.2fMB", float64(value)/1000000.0) + } else if value < 1000000000000 { + return fmt.Sprintf("%.2fGB", float64(value)/1000000000.0) + } else if value < 1000000000000000 { + return fmt.Sprintf("%.2fTB", float64(value)/1000000000000.0) + } else { + return fmt.Sprintf("%.2fPB", float64(value)/1000000000000000.0) + } +} + +// SortBy helps sort a list of Trackers by various means. +type SortBy int + +const ( + // SortByNone doesn't do any sorting == sort by insertion order. + SortByNone SortBy = iota + + // SortByMessage sorts by the Message alphabetically in ascending order. + SortByMessage + + // SortByMessageDsc sorts by the Message alphabetically in descending order. + SortByMessageDsc + + // SortByPercent sorts by the Percentage complete in ascending order. + SortByPercent + + // SortByPercentDsc sorts by the Percentage complete in descending order. + SortByPercentDsc + + // SortByValue sorts by the Value in ascending order. + SortByValue + + // SortByValueDsc sorts by the Value in descending order. + SortByValueDsc +) + +// Sort applies the sorting method defined by SortBy. +func (ts SortBy) Sort(trackers []*Tracker) { + switch ts { + case SortByMessage: + sort.Sort(sortByMessage(trackers)) + case SortByMessageDsc: + sort.Sort(sortByMessageDsc(trackers)) + case SortByPercent: + sort.Sort(sortByPercent(trackers)) + case SortByPercentDsc: + sort.Sort(sortByPercentDsc(trackers)) + case SortByValue: + sort.Sort(sortByValue(trackers)) + case SortByValueDsc: + sort.Sort(sortByValueDsc(trackers)) + default: + // no sort + } +} + +type sortByMessage []*Tracker + +func (ta sortByMessage) Len() int { return len(ta) } +func (ta sortByMessage) Swap(i, j int) { ta[i], ta[j] = ta[j], ta[i] } +func (ta sortByMessage) Less(i, j int) bool { return ta[i].Message < ta[j].Message } + +type sortByMessageDsc []*Tracker + +func (ta sortByMessageDsc) Len() int { return len(ta) } +func (ta sortByMessageDsc) Swap(i, j int) { ta[i], ta[j] = ta[j], ta[i] } +func (ta sortByMessageDsc) Less(i, j int) bool { return ta[i].Message > ta[j].Message } + +type sortByPercent []*Tracker + +func (ta sortByPercent) Len() int { return len(ta) } +func (ta sortByPercent) Swap(i, j int) { ta[i], ta[j] = ta[j], ta[i] } +func (ta sortByPercent) Less(i, j int) bool { return ta[i].PercentDone() < ta[j].PercentDone() } + +type sortByPercentDsc []*Tracker + +func (ta sortByPercentDsc) Len() int { return len(ta) } +func (ta sortByPercentDsc) Swap(i, j int) { ta[i], ta[j] = ta[j], ta[i] } +func (ta sortByPercentDsc) Less(i, j int) bool { return ta[i].PercentDone() > ta[j].PercentDone() } + +type sortByValue []*Tracker + +func (ta sortByValue) Len() int { return len(ta) } +func (ta sortByValue) Swap(i, j int) { ta[i], ta[j] = ta[j], ta[i] } +func (ta sortByValue) Less(i, j int) bool { return ta[i].value < ta[j].value } + +type sortByValueDsc []*Tracker + +func (ta sortByValueDsc) Len() int { return len(ta) } +func (ta sortByValueDsc) Swap(i, j int) { ta[i], ta[j] = ta[j], ta[i] } +func (ta sortByValueDsc) Less(i, j int) bool { return ta[i].value > ta[j].value } diff --git a/progress/tracker_test.go b/progress/tracker_test.go new file mode 100644 index 0000000..b644327 --- /dev/null +++ b/progress/tracker_test.go @@ -0,0 +1,159 @@ +package progress + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "time" +) + +func TestTracker_Increment(t *testing.T) { + tracker := Tracker{Total: 100} + assert.Equal(t, int64(0), tracker.value) + assert.Equal(t, int64(100), tracker.Total) + + tracker.Increment(10) + assert.Equal(t, int64(10), tracker.value) + assert.Equal(t, int64(100), tracker.Total) + + tracker.Increment(100) + assert.Equal(t, int64(110), tracker.value) + assert.Equal(t, int64(110), tracker.Total) + assert.False(t, tracker.timeStop.IsZero()) + assert.True(t, tracker.IsDone()) +} + +func TestTracker_IsDone(t *testing.T) { + tracker := Tracker{Total: 10} + assert.False(t, tracker.IsDone()) + + tracker.Increment(10) + assert.True(t, tracker.IsDone()) +} + +func TestTracker_MarkAsDone(t *testing.T) { + tracker := Tracker{} + assert.False(t, tracker.IsDone()) + assert.True(t, tracker.timeStop.IsZero()) + + tracker.MarkAsDone() + assert.True(t, tracker.IsDone()) + assert.False(t, tracker.timeStop.IsZero()) +} + +func TestTracker_PercentDone(t *testing.T) { + tracker := Tracker{Total: 100} + assert.Equal(t, 0.00, tracker.PercentDone()) + + for idx := 1; idx <= 100; idx++ { + tracker.Increment(1) + assert.Equal(t, float64(idx), tracker.PercentDone()) + } +} + +func TestTracker_Reset(t *testing.T) { + tracker := Tracker{Total: 100} + assert.False(t, tracker.done) + assert.Equal(t, time.Time{}, tracker.timeStart) + assert.Equal(t, time.Time{}, tracker.timeStop) + assert.Equal(t, int64(0), tracker.value) + + tracker.start() + tracker.Increment(tracker.Total) + tracker.stop() + assert.True(t, tracker.done) + assert.NotEqual(t, time.Time{}, tracker.timeStart) + assert.NotEqual(t, time.Time{}, tracker.timeStop) + assert.Equal(t, tracker.Total, tracker.value) + + tracker.Reset() + assert.False(t, tracker.done) + assert.Equal(t, time.Time{}, tracker.timeStart) + assert.Equal(t, time.Time{}, tracker.timeStop) + assert.Equal(t, int64(0), tracker.value) +} + +func TestUnits_Sprint(t *testing.T) { + assert.Equal(t, "1", UnitsDefault.Sprint(1)) + assert.Equal(t, "1.50K", UnitsDefault.Sprint(1500)) + assert.Equal(t, "1.50M", UnitsDefault.Sprint(1500000)) + assert.Equal(t, "1.50B", UnitsDefault.Sprint(1500000000)) + assert.Equal(t, "1.50T", UnitsDefault.Sprint(1500000000000)) + assert.Equal(t, "1.50Q", UnitsDefault.Sprint(1500000000000000)) + assert.Equal(t, "1500.00Q", UnitsDefault.Sprint(1500000000000000000)) + + assert.Equal(t, "1B", UnitsBytes.Sprint(1)) + assert.Equal(t, "1.50KB", UnitsBytes.Sprint(1500)) + assert.Equal(t, "1.50MB", UnitsBytes.Sprint(1500000)) + assert.Equal(t, "1.50GB", UnitsBytes.Sprint(1500000000)) + assert.Equal(t, "1.50TB", UnitsBytes.Sprint(1500000000000)) + assert.Equal(t, "1.50PB", UnitsBytes.Sprint(1500000000000000)) + assert.Equal(t, "1500.00PB", UnitsBytes.Sprint(1500000000000000000)) + + assert.Equal(t, "$1", UnitsCurrencyDollar.Sprint(1)) + assert.Equal(t, "$1.50K", UnitsCurrencyDollar.Sprint(1500)) + assert.Equal(t, "$1.50M", UnitsCurrencyDollar.Sprint(1500000)) + assert.Equal(t, "$1.50B", UnitsCurrencyDollar.Sprint(1500000000)) + assert.Equal(t, "$1.50T", UnitsCurrencyDollar.Sprint(1500000000000)) + assert.Equal(t, "$1.50Q", UnitsCurrencyDollar.Sprint(1500000000000000)) + assert.Equal(t, "$1500.00Q", UnitsCurrencyDollar.Sprint(1500000000000000000)) + + assert.Equal(t, "₠1", UnitsCurrencyEuro.Sprint(1)) + assert.Equal(t, "₠1.50K", UnitsCurrencyEuro.Sprint(1500)) + assert.Equal(t, "₠1.50M", UnitsCurrencyEuro.Sprint(1500000)) + assert.Equal(t, "₠1.50B", UnitsCurrencyEuro.Sprint(1500000000)) + assert.Equal(t, "₠1.50T", UnitsCurrencyEuro.Sprint(1500000000000)) + assert.Equal(t, "₠1.50Q", UnitsCurrencyEuro.Sprint(1500000000000000)) + assert.Equal(t, "₠1500.00Q", UnitsCurrencyEuro.Sprint(1500000000000000000)) + + assert.Equal(t, "£1", UnitsCurrencyPound.Sprint(1)) + assert.Equal(t, "£1.50K", UnitsCurrencyPound.Sprint(1500)) + assert.Equal(t, "£1.50M", UnitsCurrencyPound.Sprint(1500000)) + assert.Equal(t, "£1.50B", UnitsCurrencyPound.Sprint(1500000000)) + assert.Equal(t, "£1.50T", UnitsCurrencyPound.Sprint(1500000000000)) + assert.Equal(t, "£1.50Q", UnitsCurrencyPound.Sprint(1500000000000000)) + assert.Equal(t, "£1500.00Q", UnitsCurrencyPound.Sprint(1500000000000000000)) +} + +func TestSortBy(t *testing.T) { + trackers := []*Tracker{ + {Message: "Downloading File # 2", Total: 1000, value: 300}, + {Message: "Downloading File # 1", Total: 1000, value: 100}, + {Message: "Downloading File # 3", Total: 1000, value: 500}, + } + + SortByNone.Sort(trackers) + assert.Equal(t, "Downloading File # 2", trackers[0].Message) + assert.Equal(t, "Downloading File # 1", trackers[1].Message) + assert.Equal(t, "Downloading File # 3", trackers[2].Message) + + SortByMessage.Sort(trackers) + assert.Equal(t, "Downloading File # 1", trackers[0].Message) + assert.Equal(t, "Downloading File # 2", trackers[1].Message) + assert.Equal(t, "Downloading File # 3", trackers[2].Message) + + SortByMessageDsc.Sort(trackers) + assert.Equal(t, "Downloading File # 3", trackers[0].Message) + assert.Equal(t, "Downloading File # 2", trackers[1].Message) + assert.Equal(t, "Downloading File # 1", trackers[2].Message) + + SortByPercent.Sort(trackers) + assert.Equal(t, "Downloading File # 1", trackers[0].Message) + assert.Equal(t, "Downloading File # 2", trackers[1].Message) + assert.Equal(t, "Downloading File # 3", trackers[2].Message) + + SortByPercentDsc.Sort(trackers) + assert.Equal(t, "Downloading File # 3", trackers[0].Message) + assert.Equal(t, "Downloading File # 2", trackers[1].Message) + assert.Equal(t, "Downloading File # 1", trackers[2].Message) + + SortByValue.Sort(trackers) + assert.Equal(t, "Downloading File # 1", trackers[0].Message) + assert.Equal(t, "Downloading File # 2", trackers[1].Message) + assert.Equal(t, "Downloading File # 3", trackers[2].Message) + + SortByValueDsc.Sort(trackers) + assert.Equal(t, "Downloading File # 3", trackers[0].Message) + assert.Equal(t, "Downloading File # 2", trackers[1].Message) + assert.Equal(t, "Downloading File # 1", trackers[2].Message) +} diff --git a/progress/writer.go b/progress/writer.go new file mode 100644 index 0000000..e02987e --- /dev/null +++ b/progress/writer.go @@ -0,0 +1,35 @@ +package progress + +import ( + "io" + "time" +) + +// Writer declares the interfaces that can be used to setup and render a +// Progress tracker with one or more trackers. +type Writer interface { + AppendTracker(tracker *Tracker) + AppendTrackers(trackers []*Tracker) + IsRenderInProgress() bool + Length() int + LengthActive() int + SetAutoStop(autoStop bool) + SetOutputWriter(output io.Writer) + SetSortBy(sortBy SortBy) + SetStyle(style Style) + SetTrackerLength(length int) + SetTrackerPosition(position Position) + ShowPercentage(show bool) + ShowTime(show bool) + ShowTracker(show bool) + ShowValue(show bool) + SetUpdateFrequency(frequency time.Duration) + Stop() + Style() *Style + Render() +} + +// NewWriter initializes and returns a Writer. +func NewWriter() Writer { + return &Progress{} +} diff --git a/util/cursor.go b/util/cursor.go new file mode 100644 index 0000000..ea4b78c --- /dev/null +++ b/util/cursor.go @@ -0,0 +1,39 @@ +package util + +import ( + "fmt" +) + +// Cursor helps move the cursor on the console in multiple directions. +type Cursor rune + +const ( + // CursorDown helps move the Cursor Down X lines + CursorDown Cursor = 'B' + + // CursorLeft helps move the Cursor Left X characters + CursorLeft Cursor = 'D' + + // CursorRight helps move the Cursor Right X characters + CursorRight Cursor = 'C' + + // CursorUp helps move the Cursor Up X lines + CursorUp Cursor = 'A' + + // EraseLine helps erase all characters to the Right of the Cursor in the + // current line + EraseLine Cursor = 'K' +) + +// Sprint prints the Escape Sequence to move the Cursor once. +func (c Cursor) Sprint() string { + return fmt.Sprintf("%s%c", EscapeStart, c) +} + +// Sprintn prints the Escape Sequence to move the Cursor "n" times. +func (c Cursor) Sprintn(n int) string { + if c == EraseLine { + return c.Sprint() + } + return fmt.Sprintf("%s%d%c", EscapeStart, n, c) +} diff --git a/util/cursor_test.go b/util/cursor_test.go new file mode 100644 index 0000000..e7099d3 --- /dev/null +++ b/util/cursor_test.go @@ -0,0 +1,23 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCursor_Sprint(t *testing.T) { + assert.Equal(t, "\x1b[B", CursorDown.Sprint()) + assert.Equal(t, "\x1b[D", CursorLeft.Sprint()) + assert.Equal(t, "\x1b[C", CursorRight.Sprint()) + assert.Equal(t, "\x1b[A", CursorUp.Sprint()) + assert.Equal(t, "\x1b[K", EraseLine.Sprint()) +} + +func TestCursor_Sprintn(t *testing.T) { + assert.Equal(t, "\x1b[5B", CursorDown.Sprintn(5)) + assert.Equal(t, "\x1b[5D", CursorLeft.Sprintn(5)) + assert.Equal(t, "\x1b[5C", CursorRight.Sprintn(5)) + assert.Equal(t, "\x1b[5A", CursorUp.Sprintn(5)) + assert.Equal(t, "\x1b[K", EraseLine.Sprintn(5)) +}