diff --git a/cmd/demo-progress/demo.go b/cmd/demo-progress/demo.go index 0bc6b1d..f141e89 100644 --- a/cmd/demo-progress/demo.go +++ b/cmd/demo-progress/demo.go @@ -118,11 +118,11 @@ func main() { // instantiate a Progress Writer and set up the options pw := progress.NewWriter() pw.SetAutoStop(*flagAutoStop) - pw.SetTrackerLength(25) - pw.SetMessageWidth(24) + pw.SetMessageLength(24) pw.SetNumTrackersExpected(*flagNumTrackers) pw.SetSortBy(progress.SortByPercentDsc) pw.SetStyle(progress.StyleDefault) + pw.SetTrackerLength(25) pw.SetTrackerPosition(progress.PositionRight) pw.SetUpdateFrequency(time.Millisecond * 100) pw.Style().Colors = progress.StyleColorsExample diff --git a/go.mod b/go.mod index 1b19eb9..ee96473 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,6 @@ require ( github.com/mattn/go-runewidth v0.0.13 github.com/pkg/profile v1.6.0 github.com/stretchr/testify v1.7.4 - golang.org/x/sys v0.1.0 + golang.org/x/sys v0.16.0 + golang.org/x/term v0.16.0 ) diff --git a/go.sum b/go.sum index 697cf25..3929bc8 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,10 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM= github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/progress/progress.go b/progress/progress.go index 26960c9..ed0ee26 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -1,14 +1,15 @@ package progress import ( + "context" "fmt" "io" "os" "sync" "time" - "unicode/utf8" "github.com/jedib0t/go-pretty/v6/text" + "golang.org/x/term" ) var ( @@ -23,13 +24,12 @@ var ( // Progress helps track progress for one or more tasks. type Progress struct { autoStop bool - done chan bool + lengthMessage int lengthProgress int lengthProgressOverall int lengthTracker int logsToRender []string logsToRenderMutex sync.RWMutex - messageWidth int numTrackersExpected int64 outputWriter io.Writer overallTracker *Tracker @@ -37,10 +37,14 @@ type Progress struct { pinnedMessages []string pinnedMessageMutex sync.RWMutex pinnedMessageNumLines int + renderContext context.Context + renderContextCancel context.CancelFunc renderInProgress bool renderInProgressMutex sync.RWMutex sortBy SortBy style *Style + terminalWidth int + terminalWidthOverride int trackerPosition Position trackersActive []*Tracker trackersActiveMutex sync.RWMutex @@ -168,11 +172,19 @@ func (p *Progress) SetAutoStop(autoStop bool) { p.autoStop = autoStop } -// SetMessageWidth sets the (printed) length of the tracker message. Any message -// longer the specified width will be snipped abruptly. Any message shorter than +// SetMessageLength sets the (printed) length of the tracker message. Any +// message longer the specified length will be snipped. Any message shorter than // the specified width will be padded with spaces. +func (p *Progress) SetMessageLength(length int) { + p.lengthMessage = length +} + +// SetMessageWidth sets the (printed) length of the tracker message. Any message +// longer the specified width will be snipped. Any message shorter than the +// specified width will be padded with spaces. +// Deprecated: in favor of SetMessageLength(length) func (p *Progress) SetMessageWidth(width int) { - p.messageWidth = width + p.lengthMessage = width } // SetNumTrackersExpected sets the expected number of trackers to be tracked. @@ -209,6 +221,12 @@ func (p *Progress) SetStyle(style Style) { p.style = &style } +// SetTerminalWidth sets up a sticky terminal width and prevents the Progress +// Writer from polling for the real width during render. +func (p *Progress) SetTerminalWidth(width int) { + p.terminalWidthOverride = width +} + // SetTrackerLength sets the text-length of all the Trackers. func (p *Progress) SetTrackerLength(length int) { p.lengthTracker = length @@ -266,7 +284,7 @@ func (p *Progress) ShowValue(show bool) { // Stop stops the Render() logic that is in progress. func (p *Progress) Stop() { if p.IsRenderInProgress() { - p.done <- true + p.renderContextCancel() } } @@ -279,6 +297,13 @@ func (p *Progress) Style() *Style { return p.style } +func (p *Progress) getTerminalWidth() int { + if p.terminalWidthOverride > 0 { + return p.terminalWidthOverride + } + return p.terminalWidth +} + func (p *Progress) initForRender() { // pick a default style p.Style() @@ -287,7 +312,7 @@ func (p *Progress) initForRender() { } // reset the signals - p.done = make(chan bool, 1) + p.renderContext, p.renderContextCancel = context.WithCancel(context.Background()) // pick default lengths if no valid ones set if p.lengthTracker <= 0 { @@ -297,13 +322,15 @@ func (p *Progress) initForRender() { // calculate length of the actual progress bar by discounting the left/right // border/box chars p.lengthProgress = p.lengthTracker - - utf8.RuneCountInString(p.style.Chars.BoxLeft) - - utf8.RuneCountInString(p.style.Chars.BoxRight) - p.lengthProgressOverall = p.messageWidth + + text.RuneWidthWithoutEscSequences(p.style.Chars.BoxLeft) - + text.RuneWidthWithoutEscSequences(p.style.Chars.BoxRight) + p.lengthProgressOverall = p.lengthMessage + text.RuneWidthWithoutEscSequences(p.style.Options.Separator) + p.lengthProgress + 1 if p.style.Visibility.Percentage { - p.lengthProgressOverall += text.RuneWidthWithoutEscSequences(fmt.Sprintf(p.style.Options.PercentFormat, 0.0)) + p.lengthProgressOverall += text.RuneWidthWithoutEscSequences( + fmt.Sprintf(p.style.Options.PercentFormat, 0.0), + ) } // if not output write has been set, output to STDOUT @@ -315,6 +342,29 @@ func (p *Progress) initForRender() { if p.updateFrequency <= 0 { p.updateFrequency = DefaultUpdateFrequency } + + // get the current terminal size for preventing roll-overs, and do this in a + // background loop until end of render + go p.watchTerminalSize() // needs p.updateFrequency +} + +func (p *Progress) updateTerminalSize() { + p.terminalWidth, _, _ = term.GetSize(int(os.Stdout.Fd())) +} + +func (p *Progress) watchTerminalSize() { + // once + p.updateTerminalSize() + // until end of time + ticker := time.NewTicker(time.Second / 10) + for { + select { + case <-ticker.C: + p.updateTerminalSize() + case <-p.renderContext.Done(): + return + } + } } // renderHint has hints for the Render*() logic diff --git a/progress/progress_test.go b/progress/progress_test.go index e5f94de..a43891f 100644 --- a/progress/progress_test.go +++ b/progress/progress_test.go @@ -1,6 +1,7 @@ package progress import ( + "context" "math" "os" "testing" @@ -143,6 +144,16 @@ func TestProgress_SetStyle(t *testing.T) { assert.Equal(t, StyleCircle.Name, p.Style().Name) } +func TestProgress_SetMessageLength(t *testing.T) { + p := Progress{} + assert.Equal(t, 0, p.lengthMessage) + + p.SetMessageLength(80) + assert.Equal(t, 80, p.lengthMessage) + p.SetMessageWidth(81) + assert.Equal(t, 81, p.lengthMessage) +} + func TestProgress_SetTrackerLength(t *testing.T) { p := Progress{} assert.Equal(t, 0, p.lengthTracker) @@ -222,13 +233,11 @@ func TestProgress_ShowValue(t *testing.T) { } func TestProgress_Stop(t *testing.T) { - doneChannel := make(chan bool, 1) - p := Progress{} - p.done = doneChannel + p.renderContext, p.renderContextCancel = context.WithCancel(context.Background()) p.renderInProgress = true p.Stop() - assert.True(t, <-doneChannel) + assert.NotNil(t, <-p.renderContext.Done()) } func TestProgress_Style(t *testing.T) { diff --git a/progress/render.go b/progress/render.go index fe77501..7ee4f15 100644 --- a/progress/render.go +++ b/progress/render.go @@ -22,7 +22,7 @@ func (p *Progress) Render() { select { case <-ticker.C: lastRenderLength = p.renderTrackers(lastRenderLength) - case <-p.done: + case <-p.renderContext.Done(): // always render the current state before finishing render in // case it hasn't been shown yet p.renderTrackers(lastRenderLength) @@ -180,7 +180,11 @@ func (p *Progress) renderPinnedMessages(out *strings.Builder) { numLines := len(p.pinnedMessages) for _, msg := range p.pinnedMessages { msg = strings.TrimSpace(msg) - out.WriteString(p.style.Colors.Pinned.Sprint(msg)) + msg = p.style.Colors.Pinned.Sprint(msg) + if width := p.getTerminalWidth(); width > 0 { + msg = text.Trim(msg, width) + } + out.WriteString(msg) out.WriteRune('\n') numLines += strings.Count(msg, "\n") @@ -192,28 +196,37 @@ func (p *Progress) renderTracker(out *strings.Builder, t *Tracker, hint renderHi message := t.message() message = strings.ReplaceAll(message, "\t", " ") message = strings.ReplaceAll(message, "\r", "") - if p.messageWidth > 0 { + if p.lengthMessage > 0 { messageLen := text.RuneWidthWithoutEscSequences(message) - if messageLen < p.messageWidth { - message = text.Pad(message, p.messageWidth, ' ') + if messageLen < p.lengthMessage { + message = text.Pad(message, p.lengthMessage, ' ') } else { - message = text.Snip(message, p.messageWidth, p.style.Options.SnipIndicator) + message = text.Snip(message, p.lengthMessage, p.style.Options.SnipIndicator) } } + tOut := &strings.Builder{} + tOut.Grow(p.lengthProgressOverall) if hint.isOverallTracker { if !t.IsDone() { hint := renderHint{hideValue: true, isOverallTracker: true} - p.renderTrackerProgress(out, t, message, p.generateTrackerStr(t, p.lengthProgressOverall, hint), hint) + p.renderTrackerProgress(tOut, t, message, p.generateTrackerStr(t, p.lengthProgressOverall, hint), hint) } } else { if t.IsDone() { - p.renderTrackerDone(out, t, message) + p.renderTrackerDone(tOut, t, message) } else { hint := renderHint{hideTime: !p.style.Visibility.Time, hideValue: !p.style.Visibility.Value} - p.renderTrackerProgress(out, t, message, p.generateTrackerStr(t, p.lengthProgress, hint), hint) + p.renderTrackerProgress(tOut, t, message, p.generateTrackerStr(t, p.lengthProgress, hint), hint) } } + + outStr := tOut.String() + if width := p.getTerminalWidth(); width > 0 { + outStr = text.Trim(outStr, width) + } + out.WriteString(outStr) + out.WriteRune('\n') } func (p *Progress) renderTrackerDone(out *strings.Builder, t *Tracker, message string) { @@ -225,7 +238,6 @@ func (p *Progress) renderTrackerDone(out *strings.Builder, t *Tracker, message s out.WriteString(p.style.Colors.Error.Sprint(p.style.Options.ErrorString)) } p.renderTrackerStats(out, t, renderHint{hideTime: !p.style.Visibility.Time, hideValue: !p.style.Visibility.Value}) - out.WriteRune('\n') } func (p *Progress) renderTrackerMessage(out *strings.Builder, t *Tracker, message string) { @@ -252,7 +264,6 @@ func (p *Progress) renderTrackerProgress(out *strings.Builder, t *Tracker, messa if hint.isOverallTracker { out.WriteString(p.style.Colors.Tracker.Sprint(trackerStr)) p.renderTrackerStats(out, t, hint) - out.WriteRune('\n') } else if p.trackerPosition == PositionRight { p.renderTrackerMessage(out, t, message) out.WriteString(p.style.Colors.Message.Sprint(p.style.Options.Separator)) @@ -261,7 +272,6 @@ func (p *Progress) renderTrackerProgress(out *strings.Builder, t *Tracker, messa out.WriteString(p.style.Colors.Tracker.Sprint(" " + trackerStr)) } p.renderTrackerStats(out, t, hint) - out.WriteRune('\n') } else { p.renderTrackerPercentage(out, t) if p.style.Visibility.Tracker { @@ -270,7 +280,6 @@ func (p *Progress) renderTrackerProgress(out *strings.Builder, t *Tracker, messa p.renderTrackerStats(out, t, hint) out.WriteString(p.style.Colors.Message.Sprint(p.style.Options.Separator)) p.renderTrackerMessage(out, t, message) - out.WriteRune('\n') } } @@ -301,7 +310,7 @@ func (p *Progress) renderTrackers(lastRenderLength int) int { // stop if auto stop is enabled and there are no more active trackers if p.autoStop && p.LengthActive() == 0 { - p.done <- true + p.renderContextCancel() } return out.Len() diff --git a/progress/render_test.go b/progress/render_test.go index df0adc8..c815e5f 100644 --- a/progress/render_test.go +++ b/progress/render_test.go @@ -544,7 +544,7 @@ func TestProgress_RenderSomeTrackers_WithLineWidth1(t *testing.T) { renderOutput := outputWriter{} pw := generateWriter() - pw.SetMessageWidth(5) + pw.SetMessageLength(5) pw.SetOutputWriter(&renderOutput) pw.SetTrackerPosition(PositionRight) go trackSomething(pw, &Tracker{Message: "Calculating Total # 1\r", Total: 1000, Units: UnitsDefault}) @@ -573,7 +573,7 @@ func TestProgress_RenderSomeTrackers_WithLineWidth2(t *testing.T) { renderOutput := outputWriter{} pw := generateWriter() - pw.SetMessageWidth(50) + pw.SetMessageLength(50) pw.SetOutputWriter(&renderOutput) pw.SetTrackerPosition(PositionRight) go trackSomething(pw, &Tracker{Message: "Calculating Total # 1\r", Total: 1000, Units: UnitsDefault}) @@ -598,6 +598,33 @@ func TestProgress_RenderSomeTrackers_WithLineWidth2(t *testing.T) { showOutputOnFailure(t, out) } +func TestProgress_RenderSomeTrackers_WithTerminalWidth(t *testing.T) { + renderOutput := outputWriter{} + + pw := generateWriter() + pw.SetMessageLength(5) + pw.SetOutputWriter(&renderOutput) + pw.SetTerminalWidth(10) + pw.SetTrackerPosition(PositionRight) + go trackSomething(pw, &Tracker{Message: "Calculating Total # 1\r", Total: 1000, Units: UnitsDefault}) + go trackSomething(pw, &Tracker{Message: "Downloading File\t# 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(`Calc~ \.\.\. \n`), + regexp.MustCompile(`Down~ \.\.\. \n`), + regexp.MustCompile(`Tran~ \.\.\. \n`), + } + out := renderOutput.String() + for _, expectedOutPattern := range expectedOutPatterns { + if !expectedOutPattern.MatchString(out) { + assert.Fail(t, "Failed to find a pattern in the Output.", expectedOutPattern.String()) + } + } + showOutputOnFailure(t, out) +} + func TestProgress_RenderSomeTrackers_WithOverallTracker(t *testing.T) { renderOutput := outputWriter{} @@ -812,28 +839,26 @@ func TestProgress_RenderSomeTrackers_WithOverallTracker_WithSpeedOverall_Without showOutputOnFailure(t, out) } -func TestProgress_RenderSomeTrackers_WithPinMessage_OneLine(t *testing.T) { +func TestProgress_RenderSomeTrackers_WithPinnedMessages_OneLine(t *testing.T) { renderOutput := outputWriter{} pw := generateWriter() - pw.SetMessageWidth(5) + pw.SetMessageLength(5) pw.SetOutputWriter(&renderOutput) + pw.SetTerminalWidth(10) pw.SetTrackerPosition(PositionRight) pw.Style().Visibility.Pinned = true - pw.SetPinnedMessages("PIN") + pw.SetPinnedMessages("PINNED MESSAGE #1") go trackSomething(pw, &Tracker{Message: "Calculating Total # 1\r", Total: 1000, Units: UnitsDefault}) go trackSomething(pw, &Tracker{Message: "Downloading File\t# 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(`PIN`), - regexp.MustCompile(`Calc~ \.\.\. \d+\.\d+% \[[#.]{23}] \[\d+ in [\d.]+ms]`), - regexp.MustCompile(`Down~ \.\.\. \d+\.\d+% \[[#.]{23}] \[\d+B in [\d.]+ms]`), - regexp.MustCompile(`Tran~ \.\.\. \d+\.\d+% \[[#.]{23}] \[\$\d+ in [\d.]+ms]`), - regexp.MustCompile(`Calc~ \.\.\. done! \[\d+\.\d+K in [\d.]+ms]`), - regexp.MustCompile(`Down~ \.\.\. done! \[\d+\.\d+KB in [\d.]+ms]`), - regexp.MustCompile(`Tran~ \.\.\. done! \[\$\d+\.\d+K in [\d.]+ms]`), + regexp.MustCompile(`PINNED MES\n`), + regexp.MustCompile(`Calc~ \.\.\. \n`), + regexp.MustCompile(`Down~ \.\.\. \n`), + regexp.MustCompile(`Tran~ \.\.\. \n`), } out := renderOutput.String() for _, expectedOutPattern := range expectedOutPatterns { @@ -844,11 +869,11 @@ func TestProgress_RenderSomeTrackers_WithPinMessage_OneLine(t *testing.T) { showOutputOnFailure(t, out) } -func TestProgress_RenderSomeTrackers_WithPinMessage_MultiLines(t *testing.T) { +func TestProgress_RenderSomeTrackers_WithPinnedMessages_MultiLines(t *testing.T) { renderOutput := outputWriter{} pw := generateWriter() - pw.SetMessageWidth(5) + pw.SetMessageLength(5) pw.SetOutputWriter(&renderOutput) pw.SetTrackerPosition(PositionRight) pw.Style().Visibility.Pinned = true diff --git a/progress/writer.go b/progress/writer.go index 0e6d7bc..3368854 100644 --- a/progress/writer.go +++ b/progress/writer.go @@ -17,14 +17,22 @@ type Writer interface { LengthInQueue() int Log(msg string, a ...interface{}) SetAutoStop(autoStop bool) - SetMessageWidth(width int) + SetMessageLength(length int) SetNumTrackersExpected(numTrackers int) SetOutputWriter(output io.Writer) SetPinnedMessages(messages ...string) SetSortBy(sortBy SortBy) SetStyle(style Style) + SetTerminalWidth(width int) SetTrackerLength(length int) SetTrackerPosition(position Position) + SetUpdateFrequency(frequency time.Duration) + Stop() + Style() *Style + Render() + + // Deprecated: in favor of SetMessageLength(length) + SetMessageWidth(width int) // Deprecated: in favor of Style().Visibility.ETA ShowETA(show bool) // Deprecated: in favor of Style().Visibility.TrackerOverall @@ -37,10 +45,6 @@ type Writer interface { ShowTracker(show bool) // Deprecated: in favor of Style().Visibility.Value ShowValue(show bool) - SetUpdateFrequency(frequency time.Duration) - Stop() - Style() *Style - Render() } // NewWriter initializes and returns a Writer.