diff --git a/README.md b/README.md index d8ec1d7a..8e1d0be3 100644 --- a/README.md +++ b/README.md @@ -6,82 +6,80 @@ # termdash -This project implements a terminal based dashboard. The feature set is inspired -by the [gizak/termui](http://github.com/gizak/termui) project, which in turn -was inspired by a javascript based -[yaronn/blessed-contrib](http://github.com/yaronn/blessed-contrib). Why the -rewrite you ask? +[termdashdemo](termdashdemo/termdashdemo.go) -1. The above mentioned [gizak/termui](http://github.com/gizak/termui) is - abandoned and isn't maintained anymore. -1. The project doesn't follow the design goals outlined below. +This project implements a cross-platform customizable terminal based dashboard. +The feature set is inspired by the +[gizak/termui](http://github.com/gizak/termui) project, which in turn was +inspired by a javascript based +[yaronn/blessed-contrib](http://github.com/yaronn/blessed-contrib). -# Design goals +This rewrite focuses on code readability, maintainability and testability, see +the [design goals](doc/design_goals.md). It aims to achieve the following +[requirements](doc/requirements.md). See the [high-level design](doc/hld.md) +for more details. -This effort is focused on good software design and maintainability. By good -design I mean: +# Current feature set -1. Write readable, well documented code. -1. Only beautiful, simple APIs, no exposed concurrency, channels, internals, etc. -1. Follow [Effective Go](http://golang.org/doc/effective_go.html). -1. Provide an infrastructure that allows development of individual dashboard - components in separation. -1. The infrastructure must enforce consistency in how the dashboard components - are implemented. -1. Focus on maintainability, the infrastructure and dashboard components must - have good test coverage, the repository must have CI/CD enabled. +- Full support for terminal window resizing throughout the infrastructure. +- Customizable layout, widget placement, borders, colors, etc. +- Focusable containers and widgets. +- Processing of keyboard and mouse events. +- Periodic and event driven screen redraw. +- A library of widgets, see below. +- UTF-8 for all text elements. +- Drawing primitives (Go functions) for widget development with character and + sub-character resolution. -On top of that - let's have fun, learn something and become better developers -together. +# Installation -# Requirements +To install this library, run the following: -1. Native support of the UTF-8 encoding. -1. Simple container management to position the widgets and set their size. -1. Mouse and keyboard input. -1. Cross-platform terminal based output. -1. Unit testing framework for simple and readable tests of dashboard elements. -1. Tooling to streamline addition of new widgets. -1. Apache-2.0 licence for the project. +``` +go get -u github.com/mum4k/termdash -# High-Level design +``` -See the [design document](doc/hld.md). +# Usage -# Contributing +The usage of most of these elements is demonstrated in +[termdashdemo.go](termdashdemo/termdashdemo.go). To execute the demo: -If you are willing to contribute, improve the infrastructure or develop a -widget, first of all Thank You! Your help is appreciated. -Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines related -to the Google's CLA, and code review requirements. +``` +go run github.com/mum4k/termdash/termdashdemo/termdashdemo.go +``` -As stated above the primary goal of this project is to develop readable, well -designed code, the functionality and efficiency come second. This is achieved -through detailed code reviews, design discussions and following of the [design -guidelines](doc/design_guidelines.md). Please familiarize yourself with these -before contributing. +# Documentation -## Contributing widgets +Code documentation can be viewed in +[godoc](https://godoc.org/github.com/mum4k/termdash). -If you're developing a new widget, please see the [widget -development](doc/widget_development.md) section. +Project documentation is available in the [doc](doc/) directory. ## Implemented Widgets ### The Gauge Displays the progress of an operation. Run the -[gaugedemo](widgets/gauge/demo/gaugedemo.go). +[gaugedemo](widgets/gauge/gaugedemo/gaugedemo.go). + +```go +go run github.com/mum4k/termdash/widgets/gauge/gaugedemo/gaugedemo.go +``` -[gaugedemo](widgets/gauge/demo/gaugedemo.go) +[gaugedemo](widgets/gauge/gaugedemo/gaugedemo.go) ### The Text Displays text content, supports trimming and scrolling of content. Run the -[textdemo](widgets/text/demo/textdemo.go). +[textdemo](widgets/text/textdemo/textdemo.go). -[textdemo](widgets/gauge/demo/gaugedemo.go) +```go +go run github.com/mum4k/termdash/widgets/text/textdemo/textdemo.go +``` + +[textdemo](widgets/text/textdemo/textdemo.go) ### The SparkLine @@ -89,6 +87,10 @@ Draws a graph showing a series of values as vertical bars. The bars can have sub-cell height. Run the [sparklinedemo](widgets/sparkline/sparklinedemo/sparklinedemo.go). +```go +go run github.com/mum4k/termdash/widgets/sparkline/sparklinedemo/sparklinedemo.go +``` + [sparklinedemo](widgets/sparkline/sparklinedemo/sparklinedemo.go) ### The BarChart @@ -96,15 +98,40 @@ sub-cell height. Run the Displays multiple bars showing relative ratios of values. Run the [barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go). +```go +go run github.com/mum4k/termdash/widgets/barchart/barchartdemo/barchartdemo.go +``` + [barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go) ### The LineChart -Displays series of values as line charts. Run the +Displays series of values on a line chart. Run the [linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go). +```go +go run github.com/mum4k/termdash/widgets/linechart/linechartdemo/linechartdemo.go +``` + [linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go) +# Contributing + +If you are willing to contribute, improve the infrastructure or develop a +widget, first of all Thank You! Your help is appreciated. + +Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines related +to the Google's CLA, and code review requirements. + +As stated above the primary goal of this project is to develop readable, well +designed code, the functionality and efficiency come second. This is achieved +through detailed code reviews, design discussions and following of the [design +guidelines](doc/design_guidelines.md). Please familiarize yourself with these +before contributing. + +If you're developing a new widget, please see the [widget +development](doc/widget_development.md) section. + ## Disclaimer diff --git a/doc/design_goals.md b/doc/design_goals.md new file mode 100644 index 00000000..14b343cf --- /dev/null +++ b/doc/design_goals.md @@ -0,0 +1,14 @@ +# Design goals + +This effort is focused on good software design and maintainability. By good +design I mean: + +1. Write readable, well documented code. +1. Only beautiful, simple APIs, no exposed concurrency, channels, internals, etc. +1. Follow [Effective Go](http://golang.org/doc/effective_go.html). +1. Provide an infrastructure that allows development of individual dashboard + components in separation. +1. The infrastructure must enforce consistency in how the dashboard components + are implemented. +1. Focus on maintainability, the infrastructure and dashboard components must + have good test coverage, the repository must have CI/CD enabled. diff --git a/doc/requirements.md b/doc/requirements.md new file mode 100644 index 00000000..badc0173 --- /dev/null +++ b/doc/requirements.md @@ -0,0 +1,9 @@ +# Requirements + +1. Native support of the UTF-8 encoding. +1. Simple container management to position the widgets and set their size. +1. Mouse and keyboard input. +1. Cross-platform terminal based output. +1. Unit testing framework for simple and readable tests of dashboard elements. +1. Tooling to streamline addition of new widgets. +1. Apache-2.0 licence for the project. diff --git a/images/termdashdemo.gif b/images/termdashdemo.gif new file mode 100644 index 00000000..e81b90d6 Binary files /dev/null and b/images/termdashdemo.gif differ diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go new file mode 100644 index 00000000..8d25d458 --- /dev/null +++ b/termdashdemo/termdashdemo.go @@ -0,0 +1,385 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Binary termdashdemo demonstrates the functionality of termdash and its various widgets. +// Exist when 'q' is pressed. +package main + +import ( + "context" + "fmt" + "math" + "math/rand" + "time" + + "github.com/mum4k/termdash" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/terminal/termbox" + "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/widgets/barchart" + "github.com/mum4k/termdash/widgets/gauge" + "github.com/mum4k/termdash/widgets/linechart" + "github.com/mum4k/termdash/widgets/sparkline" + "github.com/mum4k/termdash/widgets/text" +) + +// redrawInterval is how often termdash redraws the screen. +const redrawInterval = 250 * time.Millisecond + +// layout prepares the screen layout by creating the container and placing +// widgets. +func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, error) { + spGreen, spRed := newSparkLines(ctx) + textAndSpark := []container.Option{ + container.SplitHorizontal( + container.Top( + container.Border(draw.LineStyleLight), + container.BorderTitle("Termdash demo, press Q to quit"), + container.BorderColor(cell.ColorNumber(39)), + container.PlaceWidget(newTextTime(ctx)), + ), + container.Bottom( + container.SplitVertical( + container.Left( + container.Border(draw.LineStyleLight), + container.BorderTitle("A rolling text"), + container.PlaceWidget(newRollText(ctx)), + ), + container.Right( + container.Border(draw.LineStyleLight), + container.BorderTitle("A SparkLine group"), + container.SplitHorizontal( + container.Top(container.PlaceWidget(spGreen)), + container.Bottom(container.PlaceWidget(spRed)), + ), + ), + ), + ), + container.SplitPercent(30), + ), + } + + gaugeAndHeartbeat := []container.Option{ + container.SplitHorizontal( + container.Top( + container.Border(draw.LineStyleLight), + container.BorderTitle("A Gauge"), + container.BorderColor(cell.ColorNumber(39)), + container.PlaceWidget(newGauge(ctx)), + ), + container.Bottom( + container.Border(draw.LineStyleLight), + container.BorderTitle("A LineChart"), + container.PlaceWidget(newHeartbeat(ctx)), + ), + container.SplitPercent(20), + ), + } + + leftSide := []container.Option{ + container.SplitHorizontal( + container.Top(textAndSpark...), + container.Bottom(gaugeAndHeartbeat...), + container.SplitPercent(50), + ), + } + + staticText, err := newStaticText() + if err != nil { + return nil, err + } + + rightSide := []container.Option{ + container.SplitHorizontal( + container.Top( + container.Border(draw.LineStyleLight), + container.BorderTitle("BarChart"), + container.PlaceWidget(newBarChart(ctx)), + container.BorderTitleAlignRight(), + ), + container.Bottom( + container.SplitHorizontal( + container.Top( + container.PlaceWidget(staticText), + ), + container.Bottom( + container.Border(draw.LineStyleLight), + container.BorderTitle("Multiple series"), + container.BorderTitleAlignRight(), + container.PlaceWidget(newSines(ctx)), + ), + container.SplitPercent(30), + ), + ), + container.SplitPercent(30), + ), + } + + c, err := container.New( + t, + container.SplitVertical( + container.Left(leftSide...), + container.Right(rightSide...), + container.SplitPercent(70), + ), + ) + if err != nil { + return nil, err + } + return c, nil +} + +func main() { + t, err := termbox.New(termbox.ColorMode(terminalapi.ColorMode256)) + if err != nil { + panic(err) + } + defer t.Close() + + ctx, cancel := context.WithCancel(context.Background()) + c, err := layout(ctx, t) + if err != nil { + panic(err) + } + + quitter := func(k *terminalapi.Keyboard) { + if k.Key == 'q' || k.Key == 'Q' { + cancel() + } + } + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(redrawInterval)); err != nil { + panic(err) + } +} + +// periodic executes the provided closure periodically every interval. +// Exits when the context expires. +func periodic(ctx context.Context, interval time.Duration, fn func() error) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := fn(); err != nil { + panic(err) + } + case <-ctx.Done(): + return + } + } +} + +// newTextTime creates a new Text widget that displays the current time. +func newTextTime(ctx context.Context) *text.Text { + t := text.New() + + go periodic(ctx, 1*time.Second, func() error { + t.Reset() + txt := time.Now().UTC().Format(time.UnixDate) + if err := t.Write(fmt.Sprintf("\n%s", txt), text.WriteCellOpts(cell.FgColor(cell.ColorMagenta))); err != nil { + return err + } + return nil + }) + return t +} + +// newRollText creates a new Text widget that displays rolling text. +func newRollText(ctx context.Context) *text.Text { + t := text.New(text.RollContent()) + + i := 0 + go periodic(ctx, 1*time.Second, func() error { + if err := t.Write(fmt.Sprintf("Writing line %d.\n", i), text.WriteCellOpts(cell.FgColor(cell.ColorNumber(142)))); err != nil { + return err + } + i++ + return nil + }) + return t +} + +// newSparkLines creates two new sparklines displaying random values. +func newSparkLines(ctx context.Context) (*sparkline.SparkLine, *sparkline.SparkLine) { + spGreen := sparkline.New( + sparkline.Label("Green SparkLine", cell.FgColor(cell.ColorBlue)), + sparkline.Color(cell.ColorGreen), + ) + + const max = 100 + go periodic(ctx, 250*time.Millisecond, func() error { + v := int(rand.Int31n(max + 1)) + if err := spGreen.Add([]int{v}); err != nil { + return err + } + return nil + }) + + spRed := sparkline.New( + sparkline.Label("Red SparkLine", cell.FgColor(cell.ColorBlue)), + sparkline.Color(cell.ColorRed), + ) + go periodic(ctx, 500*time.Millisecond, func() error { + v := int(rand.Int31n(max + 1)) + if err := spRed.Add([]int{v}); err != nil { + return err + } + return nil + }) + return spGreen, spRed + +} + +// newGauge creates a demo Gauge widget. +func newGauge(ctx context.Context) *gauge.Gauge { + g := gauge.New() + + const start = 35 + progress := start + + go periodic(ctx, 2*time.Second, func() error { + if err := g.Percent(progress); err != nil { + return err + } + progress++ + if progress > 100 { + progress = start + } + return nil + }) + return g +} + +// newHeartbeat returns a line chart that displays a heartbeat-like progression. +func newHeartbeat(ctx context.Context) *linechart.LineChart { + var inputs []float64 + for i := 0; i < 100; i++ { + v := math.Pow(math.Sin(float64(i)), 63) * math.Sin(float64(i)+1.5) * 8 + inputs = append(inputs, v) + } + + lc := linechart.New( + linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), + linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), + linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), + ) + step := 0 + go periodic(ctx, redrawInterval/3, func() error { + step = (step + 1) % len(inputs) + if err := lc.Series("heartbeat", rotate(inputs, step), + linechart.SeriesCellOpts(cell.FgColor(cell.ColorNumber(87))), + linechart.SeriesXLabels(map[int]string{ + 0: "zero", + }), + ); err != nil { + return err + } + return nil + }) + return lc +} + +// newBarChart returns a BarcChart that displays random values on multiple bars. +func newBarChart(ctx context.Context) *barchart.BarChart { + bc := barchart.New( + barchart.BarColors([]cell.Color{ + cell.ColorNumber(33), + cell.ColorNumber(39), + cell.ColorNumber(45), + cell.ColorNumber(51), + cell.ColorNumber(81), + cell.ColorNumber(87), + }), + barchart.ValueColors([]cell.Color{ + cell.ColorBlack, + cell.ColorBlack, + cell.ColorBlack, + cell.ColorBlack, + cell.ColorBlack, + cell.ColorBlack, + }), + barchart.ShowValues(), + ) + + const ( + bars = 6 + max = 100 + ) + values := make([]int, bars) + go periodic(ctx, 1*time.Second, func() error { + for i := range values { + values[i] = int(rand.Int31n(max + 1)) + } + + if err := bc.Values(values, max); err != nil { + return err + } + return nil + }) + return bc +} + +// newStaticText returns a new text widget with static content. +func newStaticText() (*text.Text, error) { + t := text.New( + text.WrapAtRunes(), + ) + if err := t.Write("\n\n\nA text widget without a border.", text.WriteCellOpts(cell.FgColor(cell.ColorNumber(201)))); err != nil { + return nil, err + } + return t, nil +} + +// newSines returns a line chart that displays multiple sine series. +func newSines(ctx context.Context) *linechart.LineChart { + var inputs []float64 + for i := 0; i < 200; i++ { + v := math.Sin(float64(i) / 100 * math.Pi) + inputs = append(inputs, v) + } + + lc := linechart.New( + linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), + linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), + linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), + ) + step1 := 0 + go periodic(ctx, redrawInterval/3, func() error { + step1 = (step1 + 1) % len(inputs) + if err := lc.Series("first", rotate(inputs, step1), + linechart.SeriesCellOpts(cell.FgColor(cell.ColorBlue)), + ); err != nil { + return err + } + + step2 := (step1 + 100) % len(inputs) + if err := lc.Series("second", rotate(inputs, step2), linechart.SeriesCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { + return err + } + + return nil + }) + return lc +} + +// rotate returns a new slice with inputs rotated by step. +// I.e. for a step of one: +// inputs[0] -> inputs[len(inputs)-1] +// inputs[1] -> inputs[0] +// And so on. +func rotate(inputs []float64, step int) []float64 { + return append(inputs[step:], inputs[:step]...) +} diff --git a/widgets/gauge/demo/gaugedemo.go b/widgets/gauge/gaugedemo/gaugedemo.go similarity index 100% rename from widgets/gauge/demo/gaugedemo.go rename to widgets/gauge/gaugedemo/gaugedemo.go diff --git a/widgets/linechart/linechartdemo/linechartdemo.go b/widgets/linechart/linechartdemo/linechartdemo.go index 9f06a531..063b894f 100644 --- a/widgets/linechart/linechartdemo/linechartdemo.go +++ b/widgets/linechart/linechartdemo/linechartdemo.go @@ -80,7 +80,7 @@ func main() { } defer t.Close() - const redrawInterval = 25 * time.Millisecond + const redrawInterval = 250 * time.Millisecond ctx, cancel := context.WithCancel(context.Background()) lc := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), diff --git a/widgets/text/demo/textdemo.go b/widgets/text/textdemo/textdemo.go similarity index 100% rename from widgets/text/demo/textdemo.go rename to widgets/text/textdemo/textdemo.go