From 4ff6123091a5fa03e76befcfbb037b79a90a51c1 Mon Sep 17 00:00:00 2001 From: sha1n Date: Fri, 25 Sep 2020 00:18:53 +0300 Subject: [PATCH] fooling around with progress bars --- .github/dependabot.yml | 9 ++ .github/release-drafter.yml | 31 +++++ .github/workflows/go-report-card.yml | 14 +++ .github/workflows/go.yml | 33 ++++++ .github/workflows/release-drafter.yml | 20 ++++ .gitignore | 7 ++ Makefile | 124 ++++++++++++++++++++ README.md | 9 +- cursor.go | 40 +++++++ fake_terminal.go | 74 ++++++++++++ go.mod | 10 ++ go.sum | 27 +++++ internal/main.go | 134 ++++++++++++++++++++++ internal/main_test.go | 10 ++ progress_bar.go | 156 ++++++++++++++++++++++++++ progress_bar_test.go | 84 ++++++++++++++ spinner.go | 138 +++++++++++++++++++++++ spinner_test.go | 120 ++++++++++++++++++++ stdio.go | 27 +++++ terminal.go | 121 ++++++++++++++++++++ 20 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/go-report-card.yml create mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 Makefile create mode 100644 cursor.go create mode 100644 fake_terminal.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/main.go create mode 100644 internal/main_test.go create mode 100644 progress_bar.go create mode 100644 progress_bar_test.go create mode 100644 spinner.go create mode 100644 spinner_test.go create mode 100644 stdio.go create mode 100644 terminal.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2f1b232 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: +- package-ecosystem: gomod + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + assignees: + - sha1n diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..166cc26 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,31 @@ +name-template: 'v$RESOLVED_VERSION 🌈' +tag-template: 'v$RESOLVED_VERSION' +categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'enhancement' + - title: '🐛 Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - title: '🧰 Maintenance' + label: 'chore' +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&#@' +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/go-report-card.yml b/.github/workflows/go-report-card.yml new file mode 100644 index 0000000..c40bf05 --- /dev/null +++ b/.github/workflows/go-report-card.yml @@ -0,0 +1,14 @@ +name: Go report card + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Go report card + uses: creekorful/goreportcard-action@v1.0 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..f9b7b7c --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,33 @@ +name: Go + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.16 + + - name: Build + run: go build -v ./... + + - name: Test + run: | + go test -v -covermode=count -coverprofile=coverage.out ./... + + - name: Coverage + uses: jandelgado/gcov2lcov-action@v1.0.6 + - name: Coveralls + uses: coverallsapp/github-action@v1.1.2 + with: + github-token: ${{ secrets.github_token }} + path-to-lcov: coverage.lcov diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..027a5e8 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,20 @@ +name: Release Drafter + +on: + push: + branches: + - master + # pull_request event is required only for autolabeler + pull_request: + # Only following types are handled by the action, but one can default to all as well + types: [opened, reopened, synchronize] + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + # config-name: release-drafter.yml + # disable-autolabeler: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 66fd13c..3830a95 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,10 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +bin +build +vendor +.vscode +.DS_Store + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e06b539 --- /dev/null +++ b/Makefile @@ -0,0 +1,124 @@ +# Set VERSION to the latest version tag name. Assuming version tags are formatted 'v*' +VERSION := $(shell git describe --always --abbrev=0 --tags --match "v*" $(git rev-list --tags --max-count=1)) +BUILD := $(shell git rev-parse $(VERSION)) +PROJECTNAME := "termite" +# We pass that to the main module to generate the correct help text +PROGRAMNAME := $(PROJECTNAME) + +# Go related variables. +GOBASE := $(shell pwd) +GOPATH := $(GOBASE)/vendor:$(GOBASE) +GOBIN := $(GOBASE)/bin +GOBUILD := $(GOBASE)/build +GOFILES := $(shell find . -type f -name '*.go' -not -path './vendor/*') +GOOS_DARWIN := "darwin" +GOOS_LINUX := "linux" +GOOS_WINDOWS := "windows" +GOARCH_AMD64 := "amd64" +GOARCH_ARM64 := "arm64" +GOARCH_ARM := "arm" + +MODFLAGS=-mod=readonly + +# Use linker flags to provide version/build settings +LDFLAGS=-ldflags "-X=main.Version=$(VERSION) -X=main.Build=$(BUILD) -X=main.ProgramName=$(PROGRAMNAME)" + +# Redirect error output to a file, so we can show it in development mode. +STDERR := $(GOBUILD)/.$(PROJECTNAME)-stderr.txt + +# PID file will keep the process id of the server +PID := $(GOBUILD)/.$(PROJECTNAME).pid + +# Make is verbose in Linux. Make it silent. +MAKEFLAGS += --silent + +default: install lint format test compile + +ci-checks: lint format test + +install: go-get + +format: go-format + +lint: go-lint + +compile: + @[ -d $(GOBUILD) ] || mkdir -p $(GOBUILD) + @-touch $(STDERR) + @-rm $(STDERR) + @-$(MAKE) -s go-build #2> $(STDERR) + #@cat $(STDERR) | sed -e '1s/.*/\nError:\n/' | sed 's/make\[.*/ /' | sed "/^/s/^/ /" 1>&2 + + +test: install go-test + +cover: install go-cover + +clean: + @-rm $(GOBIN)/$(PROGRAMNAME)* 2> /dev/null + @-$(MAKE) go-clean + +go-lint: + @echo " > Linting source files..." + go vet $(MODFLAGS) -c=10 `go list $(MODFLAGS) ./...` + +go-format: + @echo " > Formating source files..." + gofmt -s -w $(GOFILES) + +go-build: go-get go-build-linux-amd64 go-build-linux-arm64 go-build-darwin-amd64 go-build-windows-amd64 go-build-windows-arm + +go-test: + go test $(MODFLAGS) `go list $(MODFLAGS) ./...` + +go-cover: + go test $(MODFLAGS) -coverprofile=$(GOBUILD)/.coverprof `go list $(MODFLAGS) ./...` + go tool cover -html=$(GOBUILD)/.coverprof -o $(GOBUILD)/coverage.html + @open $(GOBUILD)/coverage.html + +go-build-linux-amd64: + @echo " > Building linux amd64 binaries..." + @GOPATH=$(GOPATH) GOOS=$(GOOS_LINUX) GOARCH=$(GOARCH_AMD64) GOBIN=$(GOBIN) go build $(MODFLAGS) $(LDFLAGS) -o $(GOBIN)/$(PROGRAMNAME)-$(GOOS_LINUX)-$(GOARCH_AMD64) $(GOBASE)/demo + +go-build-linux-arm64: + @echo " > Building linux arm64 binaries..." + @GOPATH=$(GOPATH) GOOS=$(GOOS_LINUX) GOARCH=$(GOARCH_ARM64) GOBIN=$(GOBIN) go build $(MODFLAGS) $(LDFLAGS) -o $(GOBIN)/$(PROGRAMNAME)-$(GOOS_LINUX)-$(GOARCH_ARM64) $(GOBASE)/demo + +go-build-darwin-amd64: + @echo " > Building darwin binaries..." + @GOPATH=$(GOPATH) GOOS=$(GOOS_DARWIN) GOARCH=$(GOARCH_AMD64) GOBIN=$(GOBIN) go build $(MODFLAGS) $(LDFLAGS) -o $(GOBIN)/$(PROGRAMNAME)-$(GOOS_DARWIN)-$(GOARCH_AMD64) $(GOBASE)/demo + +go-build-windows-amd64: + @echo " > Building windows amd64 binaries..." + @GOPATH=$(GOPATH) GOOS=$(GOOS_WINDOWS) GOARCH=$(GOARCH_AMD64) GOBIN=$(GOBIN) go build $(MODFLAGS) $(LDFLAGS) -o $(GOBIN)/$(PROGRAMNAME)-$(GOOS_WINDOWS)-$(GOARCH_AMD64).exe $(GOBASE)/demo + +go-build-windows-arm: + @echo " > Building windows arm binaries..." + @GOPATH=$(GOPATH) GOOS=$(GOOS_WINDOWS) GOARCH=$(GOARCH_ARM) GOBIN=$(GOBIN) go build $(MODFLAGS) $(LDFLAGS) -o $(GOBIN)/$(PROGRAMNAME)-$(GOOS_WINDOWS)-$(GOARCH_ARM).exe $(GOBASE)/demo + +go-generate: + @echo " > Generating dependency files..." + @GOPATH=$(GOPATH) GOBIN=$(GOBIN) go generate $(generate) + +go-get: + @echo " > Checking if there is any missing dependencies..." + @GOPATH=$(GOPATH) GOBIN=$(GOBIN) go mod tidy + +go-install: + @GOPATH=$(GOPATH) GOBIN=$(GOBIN) go install $(GOFILES) + +go-clean: + @echo " > Cleaning build cache" + @GOPATH=$(GOPATH) GOBIN=$(GOBIN) go clean $(MODFLAGS) $(GOBASE) + @GOPATH=$(GOPATH) GOBIN=$(GOBIN) go clean -modcache + + + +.PHONY: help +all: help +help: Makefile + @echo + @echo " Choose a command run in "$(PROJECTNAME)":" + @echo + @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' + @echo \ No newline at end of file diff --git a/README.md b/README.md index 70cdd58..a0b9170 100644 --- a/README.md +++ b/README.md @@ -1 +1,8 @@ -# termite \ No newline at end of file +[![Go](https://github.com/sha1n/termite/actions/workflows/go.yml/badge.svg)](https://github.com/sha1n/termite/actions/workflows/go.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/sha1n/termite)](https://goreportcard.com/report/github.com/sha1n/termite) +[![Go report card](https://github.com/sha1n/termite/actions/workflows/go-report-card.yml/badge.svg)](https://github.com/sha1n/termite/actions/workflows/go-report-card.yml) +[![Release Drafter](https://github.com/sha1n/termite/actions/workflows/release-drafter.yml/badge.svg)](https://github.com/sha1n/termite/actions/workflows/release-drafter.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + + +# termite diff --git a/cursor.go b/cursor.go new file mode 100644 index 0000000..c69e320 --- /dev/null +++ b/cursor.go @@ -0,0 +1,40 @@ +package termite + +import ( + "fmt" +) + +// Cursor represents a terminal cursor +type Cursor interface { + Up(l int) + Down(l int) + Hide() + Show() +} + +type cursor struct { + t Terminal +} + +// NewCursor returns a new cursor for the specified terminal +func NewCursor(t Terminal) Cursor { + return &cursor{ + t: t, + } +} + +func (c *cursor) Up(lines int) { + c.t.Print(fmt.Sprintf("\033[%dA", lines)) +} + +func (c *cursor) Down(lines int) { + c.t.Print(fmt.Sprintf("\033[%dB", lines)) +} + +func (c *cursor) Hide() { + c.t.Print("\033[?25l") +} + +func (c *cursor) Show() { + c.t.Print("\033[?25h") +} diff --git a/fake_terminal.go b/fake_terminal.go new file mode 100644 index 0000000..ee3b515 --- /dev/null +++ b/fake_terminal.go @@ -0,0 +1,74 @@ +package termite + +import ( + "bytes" + "fmt" + "io" + "sync" +) + +type fakeTerm struct { + Out *bytes.Buffer + Err *bytes.Buffer + outLock *sync.Mutex + width int + height int +} + +// NewFakeTerminal ... +func NewFakeTerminal(width, height int) Terminal { + return &fakeTerm{ + Out: new(bytes.Buffer), + Err: new(bytes.Buffer), + outLock: &sync.Mutex{}, + width: width, + height: height, + } +} + +func (t *fakeTerm) Width() (width int) { + return t.width +} + +func (t *fakeTerm) Height() (height int) { + return t.height +} + +func (t *fakeTerm) StdOut() io.Writer { + return t.Out +} + +func (t *fakeTerm) StdErr() io.Writer { + return t.Err +} + +func (t *fakeTerm) Print(e interface{}) { + t.writeString(fmt.Sprintf("%v", e)) +} + +func (t *fakeTerm) Println(e interface{}) { + t.writeString(fmt.Sprintf("%v\r\n", e)) +} + +func (t *fakeTerm) EraseLine() { + t.writeString(TermControlEraseLine) +} + +func (t *fakeTerm) OverwriteLine(e interface{}) { + t.Print(fmt.Sprintf("%s%v", TermControlEraseLine, e)) +} + +func (t *fakeTerm) Clear() { + t.Out.Reset() +} + +func (t *fakeTerm) writeString(s string) (n int) { + t.outLock.Lock() + defer t.outLock.Unlock() + + if n, err := t.Out.WriteString(s); err == nil { + return n + } + + return 0 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1eeb91e --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/sha1n/termite + +go 1.15 + +require ( + github.com/fatih/color v1.12.0 + github.com/mattn/go-isatty v0.0.12 + github.com/stretchr/testify v1.7.0 + golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..80a7443 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc= +github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +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 h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/main.go b/internal/main.go new file mode 100644 index 0000000..0d9b1e1 --- /dev/null +++ b/internal/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/fatih/color" + "github.com/sha1n/termite" +) + +const taskDoneMarkUniChar = "\u2705" +const splash = ` + + ____ ____ ____ _ _ __ ____ ____ ____ ____ _ _ __ +(_ _)( __)( _ \( \/ )( )(_ _)( __) ( \( __)( \/ ) / \ + )( ) _) ) // \/ \ )( )( ) _) ) D ( ) _) / \/ \( O ) + (__) (____)(__\_)\_)(_/(__) (__) (____) (____/(____)\_)(_/ \__/ + +` + +func main() { + t := termite.NewTerminal(true) + t.Println(splash) + demo(t) +} + +func demo(t termite.Terminal) { + + c := termite.NewCursor(t) + c.Hide() + defer c.Show() + + demoSpinner(t) + demoCursor(t) + demoProgressBars(t) + demoConcurrentProgressBars(t) +} + +func printTitle(s string, t termite.Terminal) { + chars := len(s) + border := strings.Repeat("-", chars+2) + t.Println(border) + t.Println(fmt.Sprintf(" %s ", color.GreenString(strings.Title(s)))) + t.Println(border) + t.Println("") + +} + +func demoSpinner(t termite.Terminal) { + printTitle("Spinner progress indicator", t) + + spinner := termite.NewSpinner(t, 100) + if _, e := spinner.Start(); e == nil { + time.Sleep(time.Second * 2) + spinner.Stop(" - Done " + taskDoneMarkUniChar) + t.Println("") + } +} + +func demoCursor(t termite.Terminal) { + printTitle("Cursor back tracking and line rewrites", t) + + fmtTaskStatus := func(name, status string) string { + return fmt.Sprintf(" - Task %s - %s", name, status) + } + + cursor := termite.NewCursor(t) + t.Println(fmtTaskStatus("A", "pending...")) + t.Println(fmtTaskStatus("B", "pending...")) + t.Println(fmtTaskStatus("C", "pending...")) + + time.Sleep(time.Second * 1) + cursor.Up(3) + t.OverwriteLine(fmtTaskStatus("A", taskDoneMarkUniChar)) + cursor.Down(3) + + time.Sleep(time.Millisecond * 100) + cursor.Up(1) + t.OverwriteLine(fmtTaskStatus("C", taskDoneMarkUniChar)) + cursor.Down(1) + + time.Sleep(time.Millisecond * 100) + cursor.Up(2) + t.OverwriteLine(fmtTaskStatus("B", taskDoneMarkUniChar)) + cursor.Down(2) + + time.Sleep(time.Millisecond * 100) + + t.Println("") +} + +func demoProgressBars(t termite.Terminal) { + printTitle("Default progress bar", t) + + pb := termite.NewDefaultProgressBar(t, 20) + for pb.Tick() { + time.Sleep(time.Millisecond * 50) + } + + t.Println("") +} + +func demoConcurrentProgressBars(t termite.Terminal) { + printTitle("Concurrent tasks progress", t) + + b1 := termite.NewProgressBar(t, 1000, t.Width()*1/4, '\u258F', '\u2595', '\u2587') + b2 := termite.NewProgressBar(t, 1000, t.Width()*1/2, '\u258F', '\u2595', '\u2587') + b3 := termite.NewProgressBar(t, 1000, t.Width()*3/4, '\u258F', '\u2595', '\u2587') + b4 := termite.NewProgressBar(t, 1000, t.Width(), '\u258F', '\u2595', '\u2591') + + t1, _, _ := b1.Start() + t2, _, _ := b2.Start() + t3, _, _ := b3.Start() + t4, _, _ := b4.Start() + + cursor := termite.NewCursor(t) + cursor.Hide() + for i := 0; i < 1000; i++ { + t1() + cursor.Down(1) + t2() + cursor.Down(1) + t3() + cursor.Down(1) + t4() + time.Sleep(1 * time.Millisecond) + cursor.Up(3) + } + cursor.Down(3) + cursor.Show() + + t.Println("") +} diff --git a/internal/main_test.go b/internal/main_test.go new file mode 100644 index 0000000..04724f4 --- /dev/null +++ b/internal/main_test.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/sha1n/termite" + "testing" +) + +func TestSanity(t *testing.T) { + demo(termite.NewFakeTerminal(272, 72)) +} diff --git a/progress_bar.go b/progress_bar.go new file mode 100644 index 0000000..a3eccce --- /dev/null +++ b/progress_bar.go @@ -0,0 +1,156 @@ +package termite + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +// TickFn a tick handle +type TickFn = func() bool + +// ProgressBar a progress bar interface +type ProgressBar interface { + Tick() bool + IsDone() bool + Start() (TickFn, context.CancelFunc, error) +} + +type bar struct { + max int + ticks int + term Terminal + cursor Cursor + width int + leftBorder string + rightBorder string + fill string + active bool + mx *sync.RWMutex +} + +// NewProgressBar creates a new progress bar +// t - the terminal to use for io interactions +// maxTicks - how many ticks are to be considered 100% of the progress +// width - bar width in characters +// leftBorder - left border character +// rightBorder - right border character +// fill - fill character +func NewProgressBar(t Terminal, maxTicks int, width int, leftBorder rune, rightBorder rune, fill rune) ProgressBar { + return &bar{ + max: maxTicks, + ticks: 0, + term: t, + cursor: NewCursor(t), + width: min(width, t.Width()-7), // 7 = 2 borders, 3 digits, % sign + 1 padding char + leftBorder: string(leftBorder), + rightBorder: string(rightBorder), + fill: string(fill), + mx: &sync.RWMutex{}, + } +} + +// NewDefaultProgressBar creates a progress bar with default settings +func NewDefaultProgressBar(t Terminal, maxTicks int) ProgressBar { + return NewProgressBar( + t, maxTicks, t.Width()/2, '\u258F', '\u2595', '\u2587', + ) +} + +// IsDone returns whether or not this progress bar has reached 100% +func (b *bar) IsDone() bool { + return b.ticks >= b.max +} + +// Tick increments the progress by one tick. Does not imply visual change. +func (b *bar) Tick() bool { + if b.IsDone() { + return false + } + + // return to overwrite the previous progress bar only if it exists + if b.ticks > 0 { + b.cursor.Up(1) + } + b.ticks++ + + totalChars := b.width + percent := float32(b.ticks) / float32(b.max) + charsToFill := int(percent * float32(totalChars)) + spaceChars := totalChars - charsToFill + + b.term.OverwriteLine( + fmt.Sprintf( + "%s%s%s%s %d%%%s", + b.leftBorder, strings.Repeat(b.fill, charsToFill), + strings.Repeat(" ", spaceChars), + b.rightBorder, + int(percent*100), + // this ensures neater end of line when the program ends + // with a progress bar and no line feed is entered. + TermControlCRLF, + ), + ) + + return spaceChars > 0 +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// 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) { + 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 + + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + + events := make(chan bool) + var done bool + waitStart := &sync.WaitGroup{} + waitStart.Add(1) + + tick = func() bool { + if ctx.Err() != nil { + return false + } + + if !done { + events <- true + done = !<-events + } + return !done + } + + go func() { + waitStart.Done() + for { + select { + case <-ctx.Done(): + for b.Tick() { + } + return + + case <-events: + events <- b.Tick() + } + } + }() + + waitStart.Wait() + + return tick, cancel, err +} diff --git a/progress_bar_test.go b/progress_bar_test.go new file mode 100644 index 0000000..e60d8c8 --- /dev/null +++ b/progress_bar_test.go @@ -0,0 +1,84 @@ +package termite + +import ( + "math/rand" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + terminalWidth = 100 + terminalHeight = 100 +) + +var fakeTerminal = NewFakeTerminal(terminalWidth, terminalHeight) + +func TestFullWidthProgressBar(t *testing.T) { + testProgressBarWith(t, terminalWidth, terminalWidth) +} + +func TestOversizedProgressBar(t *testing.T) { + testProgressBarWith(t, terminalWidth*2, terminalWidth/2+rand.Intn(terminalWidth*2)) +} + +func TestTickAnAlreadyDoneProgressBar(t *testing.T) { + bar := NewDefaultProgressBar(fakeTerminal, 2) + + assert.True(t, bar.Tick()) + assert.False(t, bar.Tick()) + assert.False(t, bar.Tick()) + assert.True(t, bar.IsDone()) +} + +func TestStart(t *testing.T) { + bar := NewDefaultProgressBar(fakeTerminal, 2) + + tick, cancel, err := bar.Start() + + assert.NoError(t, err) + assert.NotNil(t, tick) + assert.NotNil(t, cancel) + + assert.True(t, tick()) + assert.False(t, tick()) +} + +func TestStartWithAlreadyStartedBar(t *testing.T) { + bar := NewDefaultProgressBar(fakeTerminal, 2) + + _, _, err := bar.Start() + assert.NoError(t, err) + + _, _, err = bar.Start() + assert.Error(t, err) +} + +func TestStartCancel(t *testing.T) { + bar := NewDefaultProgressBar(fakeTerminal, 2) + + tick, cancel, err := bar.Start() + + assert.NoError(t, err) + assert.NotNil(t, tick) + assert.NotNil(t, cancel) + + assert.True(t, tick()) + cancel() + assert.False(t, tick()) +} + +func testProgressBarWith(t *testing.T, width, maxTicks int) { + bar := NewProgressBar(fakeTerminal, maxTicks, width, '|', '-', '|') + + var count = 0 + for { + if !bar.Tick() { + break + } + count++ + } + + assert.True(t, bar.IsDone()) + assert.Equal(t, maxTicks-1, count) +} diff --git a/spinner.go b/spinner.go new file mode 100644 index 0000000..21b8772 --- /dev/null +++ b/spinner.go @@ -0,0 +1,138 @@ +package termite + +import ( + "container/ring" + "context" + "errors" + "fmt" + "sync" + "time" +) + +var defaultSpinnerCharacters = []string{ + "\u259B", "\u2599", "\u259F", "\u259C", +} + +// Spinner a spinning progress indicator +type Spinner interface { + Start() (context.CancelFunc, error) + Stop(string) error +} + +type spinner struct { + terminal Terminal + cursor Cursor + interval time.Duration + mx *sync.RWMutex + active bool + stopC chan bool +} + +// NewSpinner creates a new Spinner with the specified update interval +func NewSpinner(t Terminal, interval int32) Spinner { + return &spinner{ + terminal: t, + interval: time.Duration(interval), + mx: &sync.RWMutex{}, + active: false, + stopC: make(chan bool), + } +} + +// NewDefaultSpinner creates a new Spinner with a default update interval +func NewDefaultSpinner(t Terminal) Spinner { + return NewSpinner(t, 500) +} + +// Start starts the spinner in the background and returns a cancellation handle and an error in case the spinner is already running. +func (s *spinner) Start() (cancel context.CancelFunc, err error) { + s.mx.Lock() + defer s.mx.Unlock() + + if s.active { + return nil, errors.New("spinner already active") + } + + s.active = true + context, cancel := context.WithCancel(context.Background()) + waitStart := &sync.WaitGroup{} + waitStart.Add(1) + + go func() { + var spinring = createSpinnerRing() + timer := time.NewTicker(time.Millisecond * s.interval) + + waitStart.Done() + + defer func() { + s.mx.Lock() + defer s.mx.Unlock() + + s.active = false + }() + + for { + select { + case <-context.Done(): + timer.Stop() + s.printExitMessage("Cancelled...") + return + + case <-s.stopC: + timer.Stop() + return + + case <-timer.C: + spinring = spinring.Next() + s.terminal.OverwriteLine(fmt.Sprintf("%s", spinring.Value)) + } + } + }() + + waitStart.Wait() + + return cancel, err +} + +func (s *spinner) isActive() bool { + s.mx.Lock() + defer s.mx.Unlock() + + return s.active +} + +// Stop stops the spinner and displays the specified message +func (s *spinner) Stop(message string) (err error) { + s.mx.Lock() + defer s.mx.Unlock() + + if !s.active { + err = errors.New("spinner not active") + } else { + s.stopC <- true + s.active = false + s.printExitMessage(message) + } + + return err +} + +func (s *spinner) printExitMessage(message string) { + s.terminal.EraseLine() + s.terminal.Println(message) +} + +func createSpinnerRing() *ring.Ring { + r := ring.New(4) + + r.Value = defaultSpinnerCharacters[0] + r = r.Next() + r.Value = defaultSpinnerCharacters[1] + r = r.Next() + r.Value = defaultSpinnerCharacters[2] + r = r.Next() + r.Value = defaultSpinnerCharacters[3] + r = r.Next() + + return r +} diff --git a/spinner_test.go b/spinner_test.go new file mode 100644 index 0000000..7215810 --- /dev/null +++ b/spinner_test.go @@ -0,0 +1,120 @@ +package termite + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSpinnerCharSequence(t *testing.T) { + fakeTerminal := NewFakeTerminal(80, 80) + + spinner := NewSpinner(fakeTerminal, 1) + cancel, err := spinner.Start() + defer cancel() + + assert.NoError(t, err) + assert.NotNil(t, cancel) + + assertSpinnerCharSequence(t, fakeTerminal) +} + +func TestSpinnerCancellation(t *testing.T) { + fakeTerminal := NewFakeTerminal(80, 80) + + spin := NewSpinner(fakeTerminal, 1) + cancel, _ := spin.Start() + + assertSpinnerCharSequence(t, fakeTerminal) + + cancel() + assertStoppedEventually(t, fakeTerminal, spin.(*spinner)) +} + +func TestSpinnerStartAlreadyRunning(t *testing.T) { + fakeTerminal := NewFakeTerminal(80, 80) + + spin := NewSpinner(fakeTerminal, 1) + cancel, _ := spin.Start() + defer cancel() + + _, err := spin.Start() + assert.Error(t, err) +} + +func TestSpinnerStopAlreadyStopped(t *testing.T) { + fakeTerminal := NewFakeTerminal(80, 80) + + spin := NewSpinner(fakeTerminal, 1) + spin.Start() + err := spin.Stop("") + assert.NoError(t, err) + + assert.Error(t, spin.Stop(""), "expected error") +} + +func assertStoppedEventually(t *testing.T, fakeTerminal Terminal, spinner *spinner) { + termOutput := (fakeTerminal.(*fakeTerm)).Out + startTime := time.Now() + + for spinner.isActive() { + // guard against infinite loop + if time.Now().After(startTime.Add(spinner.interval + time.Second)) { + break + } + } + + termOutput.Reset() // clear the buffer + assert.False(t, spinner.isActive()) + + time.Sleep(spinner.interval * 10) + assert.Error(t, termOutput.UnreadByte()) +} + +func assertSpinnerCharSequence(t *testing.T, fakeTerminal Terminal) { + termOutput := (fakeTerminal.(*fakeTerm)).Out + readChars := make([]string, 4) + readCharsCount := 0 + + readSequence := func() string { + startTime := time.Now() + for { + s, _ := termOutput.ReadString('\n') + if s == "" { + continue + } + strippedString := strings.TrimSpace(s) + strippedString = strings.Trim(strippedString, TermControlEraseLine) + + // guard again infinite loop + if time.Now().After(startTime.Add(time.Second * 30)) { + return "" + } + + return strippedString + } + } + + // find the first character in the spinner sequence, so we can validate order properly + for { + strippedString := readSequence() + if strippedString != "" && strippedString == defaultSpinnerCharacters[0] { + readChars[0] = strippedString + break + } + // guard against infinite loop caused by bugs + readCharsCount++ + if readCharsCount > 8 { + assert.Fail(t, "something went wrong...") + } + } + + readChars[1] = readSequence() + readChars[2] = readSequence() + readChars[3] = readSequence() + + assert.Equal(t, defaultSpinnerCharacters, readChars) + +} diff --git a/stdio.go b/stdio.go new file mode 100644 index 0000000..d8dd1e1 --- /dev/null +++ b/stdio.go @@ -0,0 +1,27 @@ +package termite + +import ( + "github.com/mattn/go-isatty" + "io" + "os" +) + +// StdoutWriter to be used as standard out +var StdoutWriter io.Writer + +// StderrWriter to be used as standard err +var StderrWriter io.Writer + +// StdinReader to be used as standard in +var StdinReader io.Reader + +// Tty whether or not we have a terminal +var Tty bool + +func init() { + StdoutWriter = os.Stdout + StderrWriter = os.Stderr + StdinReader = os.Stdin + + Tty = isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) +} diff --git a/terminal.go b/terminal.go new file mode 100644 index 0000000..5bf5570 --- /dev/null +++ b/terminal.go @@ -0,0 +1,121 @@ +package termite + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "sync" + + "golang.org/x/crypto/ssh/terminal" +) + +// TermControlEraseLine clears the current line and positions the cursor at the beginning +const TermControlEraseLine = "\r\033[K" + +// TermControlClearScreen emulates the bash/sh clear command +const TermControlClearScreen = "\033[H\033[2J" + +// TermControlCRLF line feed +const TermControlCRLF = "\r\n" + +// Terminal privides terminal related APIs +type Terminal interface { + StdOut() io.Writer + StdErr() io.Writer + + Width() int + Height() int + + Print(e interface{}) + Println(e interface{}) + OverwriteLine(e interface{}) + EraseLine() + Clear() +} + +type term struct { + Out *bufio.Writer + Err *bufio.Writer + autoFlush bool + outLock *sync.Mutex +} + +// NewTerminal creates a instance of Terminal +func NewTerminal(autoFlush bool) Terminal { + return &term{ + Out: bufio.NewWriter(StdoutWriter), + Err: bufio.NewWriter(StderrWriter), + autoFlush: autoFlush, + outLock: &sync.Mutex{}, + } +} + +func (t *term) Width() (width int) { + if !Tty { + return 0 + } + + if width, _, err := terminal.GetSize(int(os.Stdin.Fd())); err == nil { + return width + } + + // FIXME: we probably need to check whether we have a terminal and handle that earlier. + panic(errors.New("can't get terminal width")) +} + +func (t *term) Height() (height int) { + if !Tty { + return 0 + } + + if _, height, err := terminal.GetSize(int(os.Stdin.Fd())); err == nil { + return height + } + + // FIXME: we probably need to check whether we have a terminal and handle that earlier. + panic(errors.New("can't get terminal height")) +} + +func (t *term) StdOut() io.Writer { + return t.Out +} + +func (t *term) StdErr() io.Writer { + return t.Err +} + +func (t *term) Print(e interface{}) { + t.writeString(fmt.Sprintf("%v", e)) +} + +func (t *term) Println(e interface{}) { + t.writeString(fmt.Sprintf("%v%s", e, TermControlCRLF)) +} + +func (t *term) EraseLine() { + t.writeString(TermControlEraseLine) +} + +func (t *term) OverwriteLine(e interface{}) { + t.Print(fmt.Sprintf("%s%v", TermControlEraseLine, e)) +} + +func (t *term) Clear() { + t.writeString(TermControlClearScreen) +} + +func (t *term) writeString(s string) (n int) { + t.outLock.Lock() + defer t.outLock.Unlock() + + if t.autoFlush { + defer t.Out.Flush() + } + + if n, err := t.Out.WriteString(s); err == nil { + return n + } + return 0 +}