diff --git a/README.md b/README.md index baecf7d..2b10f51 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,23 @@ Displays the progress of an operation. Run the Displays text content, supports trimming and scrolling of content. Run the [textdemo](widgets/text/demo/textdemo.go). -[gaugedemo](widgets/gauge/demo/gaugedemo.go) +[textdemo](widgets/gauge/demo/gaugedemo.go) + +### The SparkLine + +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). + +[sparklinedemo](widgets/sparkline/sparklinedemo/sparklinedemo.go) + +### The BarChart + +Displays multiple bars showing relative ratios of values. Run the +[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go). + +[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go) + ## Disclaimer diff --git a/images/barchartdemo.gif b/images/barchartdemo.gif new file mode 100644 index 0000000..14ecaaa Binary files /dev/null and b/images/barchartdemo.gif differ diff --git a/images/sparklinedemo.gif b/images/sparklinedemo.gif new file mode 100644 index 0000000..4bec7a5 Binary files /dev/null and b/images/sparklinedemo.gif differ diff --git a/termdash.go b/termdash.go index 98eb710..fa02238 100644 --- a/termdash.go +++ b/termdash.go @@ -95,9 +95,12 @@ func MouseSubscriber(f func(*terminalapi.Mouse)) Option { // Blocks until the context expires. func Run(ctx context.Context, t terminalapi.Terminal, c *container.Container, opts ...Option) error { td := newTermdash(t, c, opts...) - defer td.stop() - return td.start(ctx) + err := td.start(ctx) + // Only return the status (error or nil) after the termdash event + // processing goroutine actually exits. + td.stop() + return err } // Controller controls a termdash instance. diff --git a/widgets/sparkline/options.go b/widgets/sparkline/options.go new file mode 100644 index 0000000..a8db45c --- /dev/null +++ b/widgets/sparkline/options.go @@ -0,0 +1,76 @@ +// Copyright 2018 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. + +package sparkline + +// options.go contains configurable options for SparkLine. + +import "github.com/mum4k/termdash/cell" + +// Option is used to provide options. +type Option interface { + // set sets the provided option. + set(*options) +} + +// option implements Option. +type option func(*options) + +// set implements Option.set. +func (o option) set(opts *options) { + o(opts) +} + +// options holds the provided options. +type options struct { + label string + labelCellOpts []cell.Option + height int + color cell.Color +} + +// newOptions returns options with the default values set. +func newOptions() *options { + return &options{ + color: DefaultColor, + } +} + +// Label adds a label above the SparkLine. +func Label(text string, cOpts ...cell.Option) Option { + return option(func(opts *options) { + opts.label = text + opts.labelCellOpts = cOpts + }) +} + +// Height sets a fixed height for the SparkLine. +// If not provided, the SparkLine takes all the available vertical space in the +// container. +func Height(h int) Option { + return option(func(opts *options) { + opts.height = h + }) +} + +// DefaultColor is the default value for the Color option. +const DefaultColor = cell.ColorGreen + +// Color sets the color of the SparkLine. +// Defaults to DefaultColor if not set. +func Color(c cell.Color) Option { + return option(func(opts *options) { + opts.color = c + }) +} diff --git a/widgets/sparkline/sparkline.go b/widgets/sparkline/sparkline.go new file mode 100644 index 0000000..7c8ab94 --- /dev/null +++ b/widgets/sparkline/sparkline.go @@ -0,0 +1,223 @@ +// Copyright 2018 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. + +// Package sparkline is a widget that draws a graph showing a series of values as vertical bars. +package sparkline + +import ( + "errors" + "fmt" + "image" + "sync" + + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/widgetapi" +) + +// SparkLine draws a graph showing a series of values as vertical bars. +// +// Bars can have sub-cell height. The graphs scale adjusts dynamically based on +// the largest visible value. +// +// Implements widgetapi.Widget. This object is thread-safe. +type SparkLine struct { + // data are the data points the SparkLine displays. + data []int + + // mu protects the SparkLine. + mu sync.Mutex + + // opts are the provided options. + opts *options +} + +// New returns a new SparkLine. +func New(opts ...Option) *SparkLine { + opt := newOptions() + for _, o := range opts { + o.set(opt) + } + return &SparkLine{ + opts: opt, + } +} + +// Draw draws the SparkLine widget onto the canvas. +// Implements widgetapi.Widget.Draw. +func (sl *SparkLine) Draw(cvs *canvas.Canvas) error { + sl.mu.Lock() + defer sl.mu.Unlock() + + ar := sl.area(cvs) + visible, max := visibleMax(sl.data, ar.Dx()) + var curX int + if len(visible) < ar.Dx() { + curX = ar.Max.X - len(visible) + } else { + curX = ar.Min.X + } + + for _, v := range visible { + blocks := toBlocks(v, max, ar.Dy()) + curY := ar.Max.Y - 1 + for i := 0; i < blocks.full; i++ { + if _, err := cvs.SetCell( + image.Point{curX, curY}, + sparks[len(sparks)-1], // Last spark represents full cell. + cell.FgColor(sl.opts.color), + ); err != nil { + return err + } + + curY-- + } + + if blocks.partSpark != 0 { + if _, err := cvs.SetCell( + image.Point{curX, curY}, + blocks.partSpark, + cell.FgColor(sl.opts.color), + ); err != nil { + return err + } + } + + curX++ + } + + if sl.opts.label != "" { + // Label is placed immediately above the SparkLine. + lStart := image.Point{ar.Min.X, ar.Min.Y - 1} + if err := draw.Text(cvs, sl.opts.label, lStart, + draw.TextCellOpts(sl.opts.labelCellOpts...), + draw.TextOverrunMode(draw.OverrunModeThreeDot), + ); err != nil { + return err + } + } + return nil +} + +// Add adds data points to the SparkLine. +// Each data point is represented by one bar on the SparkLine. Zero value data +// points are valid and are represented by an empty space on the SparkLine +// (i.e. a missing bar). +// +// At least one data point must be provided. All data points must be positive +// integers. +// +// The last added data point will be the one displayed all the way on the right +// of the SparkLine. If there are more data points than we can fit bars to the +// width of the SparkLine, only the last n data points that fit will be +// visible. +// +// Provided options override values set when New() was called. +func (sl *SparkLine) Add(data []int, opts ...Option) error { + sl.mu.Lock() + defer sl.mu.Unlock() + + for _, opt := range opts { + opt.set(sl.opts) + } + + for i, d := range data { + if d < 0 { + return fmt.Errorf("data point[%d]: %v must be a positive integer", i, d) + } + } + sl.data = append(sl.data, data...) + return nil +} + +// Clear removes all the data points in the SparkLine, effectively returning to +// an empty graph. +func (sl *SparkLine) Clear() { + sl.mu.Lock() + defer sl.mu.Unlock() + + sl.data = nil +} + +// Keyboard input isn't supported on the SparkLine widget. +func (*SparkLine) Keyboard(k *terminalapi.Keyboard) error { + return errors.New("the SparkLine widget doesn't support keyboard events") +} + +// Mouse input isn't supported on the SparkLine widget. +func (*SparkLine) Mouse(m *terminalapi.Mouse) error { + return errors.New("the SparkLine widget doesn't support mouse events") +} + +// area returns the area of the canvas available to the SparkLine. +func (sl *SparkLine) area(cvs *canvas.Canvas) image.Rectangle { + cvsAr := cvs.Area() + maxY := cvsAr.Max.Y + + // Height is determined based on options (fixed height / label). + var minY int + if sl.opts.height > 0 { + minY = maxY - sl.opts.height + } else { + minY = cvsAr.Min.Y + + if sl.opts.label != "" { + minY++ // Reserve one line for the label. + } + } + return image.Rect( + cvsAr.Min.X, + minY, + cvsAr.Max.X, + maxY, + ) +} + +// minSize returns the minimum canvas size for the SparkLine based on the options. +func (sl *SparkLine) minSize() image.Point { + const minWidth = 1 // At least one data point. + + var minHeight int + if sl.opts.height > 0 { + minHeight = sl.opts.height + } else { + minHeight = 1 // At least one line of characters. + } + + if sl.opts.label != "" { + minHeight++ // One line for the text label. + } + return image.Point{minWidth, minHeight} +} + +// Options implements widgetapi.Widget.Options. +func (sl *SparkLine) Options() widgetapi.Options { + sl.mu.Lock() + defer sl.mu.Unlock() + + min := sl.minSize() + var max image.Point + if sl.opts.height > 0 { + max = min // Fix the height to the one specified. + } + + return widgetapi.Options{ + MinimumSize: min, + MaximumSize: max, + WantKeyboard: false, + WantMouse: false, + } +} diff --git a/widgets/sparkline/sparkline_test.go b/widgets/sparkline/sparkline_test.go new file mode 100644 index 0000000..47df87e --- /dev/null +++ b/widgets/sparkline/sparkline_test.go @@ -0,0 +1,493 @@ +// Copyright 2018 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. + +package sparkline + +import ( + "image" + "testing" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/canvas/testcanvas" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/draw/testdraw" + "github.com/mum4k/termdash/terminal/faketerm" + "github.com/mum4k/termdash/widgetapi" +) + +func TestSparkLine(t *testing.T) { + tests := []struct { + desc string + sparkLine *SparkLine + update func(*SparkLine) error // update gets called before drawing of the widget. + canvas image.Rectangle + want func(size image.Point) *faketerm.Terminal + wantUpdateErr bool // whether to expect an error on a call to the update function + wantDrawErr bool + }{ + { + desc: "draws empty for no data points", + sparkLine: New(), + update: func(sl *SparkLine) error { + return nil + }, + canvas: image.Rect(0, 0, 1, 1), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + }, + { + desc: "fails on negative data points", + sparkLine: New(), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 3, -1, 2}) + }, + canvas: image.Rect(0, 0, 1, 1), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + wantUpdateErr: true, + }, + { + desc: "single height sparkline", + sparkLine: New(), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}) + }, + canvas: image.Rect(0, 0, 9, 1), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "▁▂▃▄▅▆▇█", image.Point{1, 0}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "sparkline can be cleared", + sparkLine: New(), + update: func(sl *SparkLine) error { + if err := sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}); err != nil { + return err + } + sl.Clear() + return nil + }, + canvas: image.Rect(0, 0, 9, 1), + want: func(size image.Point) *faketerm.Terminal { + return faketerm.MustNew(size) + }, + }, + { + desc: "sets sparkline color", + sparkLine: New( + Color(cell.ColorMagenta), + ), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}) + }, + canvas: image.Rect(0, 0, 9, 1), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "▁▂▃▄▅▆▇█", image.Point{1, 0}, draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + )) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "sets sparkline color on a call to Add", + sparkLine: New(), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}, Color(cell.ColorMagenta)) + }, + canvas: image.Rect(0, 0, 9, 1), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "▁▂▃▄▅▆▇█", image.Point{1, 0}, draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + )) + testcanvas.MustApply(c, ft) + return ft + }, + }, + + { + desc: "draws data points from the right", + sparkLine: New(), + update: func(sl *SparkLine) error { + return sl.Add([]int{7, 8}) + }, + canvas: image.Rect(0, 0, 9, 1), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "▇█", image.Point{7, 0}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "single height sparkline with label", + sparkLine: New( + Label("Hello"), + ), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 1, 2, 3, 8, 3, 2, 1, 1}) + }, + canvas: image.Rect(0, 0, 9, 2), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "Hello", image.Point{0, 0}) + testdraw.MustText(c, "▁▂▃█▃▂▁▁", image.Point{1, 1}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "too long label is trimmed", + sparkLine: New( + Label("Hello world"), + ), + update: func(sl *SparkLine) error { + return sl.Add([]int{8}) + }, + canvas: image.Rect(0, 0, 9, 2), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "Hello wo…", image.Point{0, 0}) + testdraw.MustText(c, "█", image.Point{8, 1}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "stretches up to the height of the container", + sparkLine: New(), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 100, 50, 85}) + }, + canvas: image.Rect(0, 0, 4, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "█", image.Point{1, 0}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "▃", image.Point{3, 0}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "█", image.Point{1, 1}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "█", image.Point{3, 1}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "███", image.Point{1, 2}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "███", image.Point{1, 3}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "stretches up to the height of the container with label", + sparkLine: New( + Label("zoo"), + ), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 90, 30, 85}) + }, + canvas: image.Rect(0, 0, 4, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "zoo", image.Point{0, 0}) + testdraw.MustText(c, "█", image.Point{1, 1}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "▇", image.Point{3, 1}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "█", image.Point{1, 2}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "█", image.Point{3, 2}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "███", image.Point{1, 3}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "respects fixed height", + sparkLine: New( + Height(2), + ), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 100, 50, 85}) + }, + canvas: image.Rect(0, 0, 4, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "█", image.Point{1, 2}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "▆", image.Point{3, 2}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "███", image.Point{1, 3}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "respects fixed height with label", + sparkLine: New( + Label("zoo"), + Height(2), + ), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 100, 50, 0}) + }, + canvas: image.Rect(0, 0, 4, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "zoo", image.Point{0, 1}, draw.TextCellOpts( + cell.FgColor(cell.ColorDefault), + )) + testdraw.MustText(c, "█", image.Point{1, 2}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + testdraw.MustText(c, "██", image.Point{1, 3}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "sets label color", + sparkLine: New( + Label( + "Hello", + cell.FgColor(cell.ColorBlue), + cell.BgColor(cell.ColorYellow), + ), + ), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 1}) + }, + canvas: image.Rect(0, 0, 9, 2), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "Hello", image.Point{0, 0}, draw.TextCellOpts( + cell.FgColor(cell.ColorBlue), + cell.BgColor(cell.ColorYellow), + )) + testdraw.MustText(c, "█", image.Point{8, 1}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "displays only data points that fit the width", + sparkLine: New(), + update: func(sl *SparkLine) error { + return sl.Add([]int{0, 1, 2, 3, 4, 5, 6, 7, 8}) + }, + canvas: image.Rect(0, 0, 3, 1), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "▆▇█", image.Point{0, 0}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "data points not visible don't affect the determined max data point", + sparkLine: New(), + update: func(sl *SparkLine) error { + return sl.Add([]int{10, 4, 8}) + }, + canvas: image.Rect(0, 0, 2, 1), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + testdraw.MustText(c, "▄█", image.Point{0, 0}, draw.TextCellOpts( + cell.FgColor(DefaultColor), + )) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + + err = tc.update(tc.sparkLine) + if (err != nil) != tc.wantUpdateErr { + t.Errorf("update => unexpected error: %v, wantUpdateErr: %v", err, tc.wantUpdateErr) + + } + if err != nil { + return + } + + err = tc.sparkLine.Draw(c) + if (err != nil) != tc.wantDrawErr { + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) + } + if err != nil { + return + } + + got, err := faketerm.New(c.Size()) + if err != nil { + t.Fatalf("faketerm.New => unexpected error: %v", err) + } + + if err := c.Apply(got); err != nil { + t.Fatalf("Apply => unexpected error: %v", err) + } + + if diff := faketerm.Diff(tc.want(c.Size()), got); diff != "" { + t.Errorf("Draw => %v", diff) + } + }) + } +} + +func TestOptions(t *testing.T) { + tests := []struct { + desc string + sparkLine *SparkLine + want widgetapi.Options + }{ + { + desc: "no label and no fixed height", + sparkLine: New(), + want: widgetapi.Options{ + MinimumSize: image.Point{1, 1}, + WantKeyboard: false, + WantMouse: false, + }, + }, + { + desc: "label and no fixed height", + sparkLine: New( + Label("foo"), + ), + want: widgetapi.Options{ + MinimumSize: image.Point{1, 2}, + WantKeyboard: false, + WantMouse: false, + }, + }, + { + desc: "no label and fixed height", + sparkLine: New( + Height(3), + ), + want: widgetapi.Options{ + MinimumSize: image.Point{1, 3}, + MaximumSize: image.Point{1, 3}, + WantKeyboard: false, + WantMouse: false, + }, + }, + { + desc: "label and fixed height", + sparkLine: New( + Label("foo"), + Height(3), + ), + want: widgetapi.Options{ + MinimumSize: image.Point{1, 4}, + MaximumSize: image.Point{1, 4}, + WantKeyboard: false, + WantMouse: false, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := tc.sparkLine.Options() + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) + } + + }) + } +} diff --git a/widgets/sparkline/sparklinedemo/sparklinedemo.go b/widgets/sparkline/sparklinedemo/sparklinedemo.go new file mode 100644 index 0000000..be5d1e2 --- /dev/null +++ b/widgets/sparkline/sparklinedemo/sparklinedemo.go @@ -0,0 +1,116 @@ +// Copyright 2018 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 sparklinedemo displays a couple of SparkLine widgets. +// Exist when 'q' is pressed. +package main + +import ( + "context" + "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/sparkline" +) + +// playSparkLine continuously adds values to the SparkLine, once every delay. +// Exits when the context expires. +func playSparkLine(ctx context.Context, sl *sparkline.SparkLine, delay time.Duration) { + const max = 100 + + ticker := time.NewTicker(delay) + defer ticker.Stop() + for { + select { + case <-ticker.C: + v := int(rand.Int31n(max + 1)) + if err := sl.Add([]int{v}); err != nil { + panic(err) + } + + case <-ctx.Done(): + return + } + } +} + +func main() { + t, err := termbox.New() + if err != nil { + panic(err) + } + defer t.Close() + + ctx, cancel := context.WithCancel(context.Background()) + green := sparkline.New( + sparkline.Label("Green SparkLine", cell.FgColor(cell.ColorBlue)), + sparkline.Color(cell.ColorGreen), + ) + go playSparkLine(ctx, green, 250*time.Millisecond) + red := sparkline.New( + sparkline.Label("Red SparkLine", cell.FgColor(cell.ColorBlue)), + sparkline.Color(cell.ColorRed), + ) + go playSparkLine(ctx, red, 500*time.Millisecond) + yellow := sparkline.New( + sparkline.Label("Yellow SparkLine", cell.FgColor(cell.ColorGreen)), + sparkline.Color(cell.ColorYellow), + ) + go playSparkLine(ctx, yellow, 1*time.Second) + + c := container.New( + t, + container.Border(draw.LineStyleLight), + container.BorderTitle("PRESS Q TO QUIT"), + container.SplitVertical( + container.Left( + container.SplitHorizontal( + container.Top(), + container.Bottom( + container.Border(draw.LineStyleLight), + container.BorderTitle("SparkLine group"), + container.SplitHorizontal( + container.Top( + container.PlaceWidget(green), + ), + container.Bottom( + container.PlaceWidget(red), + ), + ), + ), + ), + ), + container.Right( + container.Border(draw.LineStyleLight), + container.PlaceWidget(yellow), + ), + ), + ) + + quitter := func(k *terminalapi.Keyboard) { + if k.Key == 'q' || k.Key == 'Q' { + cancel() + } + } + + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter)); err != nil { + panic(err) + } +} diff --git a/widgets/sparkline/sparks.go b/widgets/sparkline/sparks.go new file mode 100644 index 0000000..78dc016 --- /dev/null +++ b/widgets/sparkline/sparks.go @@ -0,0 +1,111 @@ +// Copyright 2018 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. + +package sparkline + +// sparks.go contains code that determines which characters should be used to +// represent a value on the SparkLine. + +import ( + "fmt" + "math" + + runewidth "github.com/mattn/go-runewidth" +) + +// sparks are the characters used to draw the SparkLine. +var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +// visibleMax determines the maximum visible data point given the canvas width. +// Returns a slice that contains only visible data points and the maximum value +// among them. +func visibleMax(data []int, width int) ([]int, int) { + if width <= 0 || len(data) == 0 { + return nil, 0 + } + + if width < len(data) { + data = data[len(data)-width:] + } + + var max int + for _, v := range data { + if v > max { + max = v + } + } + return data, max +} + +// blocks represents the building blocks that display one value on a SparkLine. +// I.e. one vertical bar. +type blocks struct { + // full is the number of fully populated blocks. + full int + + // partSpark is the spark character from sparks that should be used in the + // topmost block. Equals to zero if no partial block should be displayed. + partSpark rune +} + +// toBlocks determines the number of full and partial vertical blocks required +// to represent the provided value given the specified max visible value and +// number of vertical cells available to the SparkLine. +func toBlocks(value, max, vertCells int) blocks { + if value <= 0 || max <= 0 || vertCells <= 0 { + return blocks{} + } + + // How many of the smallest spark elements fit into a cell. + cellSparks := len(sparks) + + // Scale is how much of the max does one smallest spark element represent, + // given the vertical cells that will be used to represent the value. + scale := float64(cellSparks) * float64(vertCells) / float64(max) + + // How many smallest spark elements are needed to represent the value. + elements := int(round(float64(value) * scale)) + + b := blocks{ + full: elements / cellSparks, + } + + part := elements % cellSparks + if part > 0 { + b.partSpark = sparks[part-1] + } + return b +} + +// round returns the nearest integer, rounding half away from zero. +// Copied from the math package of Go 1.10 for backwards compatibility with Go +// 1.8 where the math.Round function doesn't exist yet. +func round(x float64) float64 { + t := math.Trunc(x) + if math.Abs(x-t) >= 0.5 { + return t + math.Copysign(1, x) + } + return t +} + +// init ensures that all spark characters are half-width runes. +// The SparkLine widget assumes that each value can be represented in a column +// that has a width of one cell. +func init() { + for i, s := range sparks { + if got := runewidth.RuneWidth(s); got > 1 { + panic(fmt.Sprintf("all sparks must be half-width runes (width of one), spark[%d] has width %d", i, got)) + } + } +} diff --git a/widgets/sparkline/sparks_test.go b/widgets/sparkline/sparks_test.go new file mode 100644 index 0000000..c6fe0e2 --- /dev/null +++ b/widgets/sparkline/sparks_test.go @@ -0,0 +1,270 @@ +// Copyright 2018 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. + +package sparkline + +import ( + "testing" + + "github.com/kylelemons/godebug/pretty" +) + +func TestVisibleMax(t *testing.T) { + tests := []struct { + desc string + data []int + width int + wantData []int + wantMax int + }{ + { + desc: "zero for no data", + width: 3, + wantData: nil, + wantMax: 0, + }, + { + desc: "zero for zero width", + data: []int{0, 1}, + width: 0, + wantData: nil, + wantMax: 0, + }, + { + desc: "zero for negative width", + data: []int{0, 1}, + width: -1, + wantData: nil, + wantMax: 0, + }, + { + desc: "all values are zero", + data: []int{0, 0, 0}, + width: 3, + wantData: []int{0, 0, 0}, + wantMax: 0, + }, + { + desc: "all values are visible", + data: []int{8, 0, 1}, + width: 3, + wantData: []int{8, 0, 1}, + wantMax: 8, + }, + { + desc: "width greater than number of values", + data: []int{8, 0, 1}, + width: 10, + wantData: []int{8, 0, 1}, + wantMax: 8, + }, + { + desc: "only some values are visible", + data: []int{8, 2, 1}, + width: 2, + wantData: []int{2, 1}, + wantMax: 2, + }, + { + desc: "only one value is visible", + data: []int{8, 2, 1}, + width: 1, + wantData: []int{1}, + wantMax: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + gotData, gotMax := visibleMax(tc.data, tc.width) + if diff := pretty.Compare(tc.wantData, gotData); diff != "" { + t.Errorf("visibleMax => unexpected visible data, diff (-want, +got):\n%s", diff) + } + if gotMax != tc.wantMax { + t.Errorf("visibleMax => gotMax %v, wantMax %v", gotMax, tc.wantMax) + } + }) + } +} + +func TestToBlocks(t *testing.T) { + tests := []struct { + desc string + value int + max int + vertCells int + want blocks + }{ + { + desc: "zero value has no blocks", + value: 0, + max: 10, + vertCells: 2, + want: blocks{}, + }, + { + desc: "negative value has no blocks", + value: -1, + max: 10, + vertCells: 2, + want: blocks{}, + }, + { + desc: "zero max has no blocks", + value: 10, + max: 0, + vertCells: 2, + want: blocks{}, + }, + { + desc: "negative max has no blocks", + value: 10, + max: -1, + vertCells: 2, + want: blocks{}, + }, + { + desc: "zero vertCells has no blocks", + value: 10, + max: 10, + vertCells: 0, + want: blocks{}, + }, + { + desc: "negative vertCells has no blocks", + value: 10, + max: 10, + vertCells: -1, + want: blocks{}, + }, + { + desc: "single line, zero value", + value: 0, + max: 8, + vertCells: 1, + want: blocks{}, + }, + { + desc: "single line, value is 1/8", + value: 1, + max: 8, + vertCells: 1, + want: blocks{full: 0, partSpark: sparks[0]}, + }, + { + desc: "single line, value is 2/8", + value: 2, + max: 8, + vertCells: 1, + want: blocks{full: 0, partSpark: sparks[1]}, + }, + { + desc: "single line, value is 3/8", + value: 3, + max: 8, + vertCells: 1, + want: blocks{full: 0, partSpark: sparks[2]}, + }, + { + desc: "single line, value is 4/8", + value: 4, + max: 8, + vertCells: 1, + want: blocks{full: 0, partSpark: sparks[3]}, + }, + { + desc: "single line, value is 5/8", + value: 5, + max: 8, + vertCells: 1, + want: blocks{full: 0, partSpark: sparks[4]}, + }, + { + desc: "single line, value is 6/8", + value: 6, + max: 8, + vertCells: 1, + want: blocks{full: 0, partSpark: sparks[5]}, + }, + { + desc: "single line, value is 7/8", + value: 7, + max: 8, + vertCells: 1, + want: blocks{full: 0, partSpark: sparks[6]}, + }, + { + desc: "single line, value is 8/8", + value: 8, + max: 8, + vertCells: 1, + want: blocks{full: 1, partSpark: 0}, + }, + { + desc: "multi line, zero value", + value: 0, + max: 24, + vertCells: 3, + want: blocks{}, + }, + { + desc: "multi line, lowest block is partial", + value: 2, + max: 24, + vertCells: 3, + want: blocks{full: 0, partSpark: sparks[1]}, + }, + { + desc: "multi line, two full blocks, no partial block", + value: 16, + max: 24, + vertCells: 3, + want: blocks{full: 2, partSpark: 0}, + }, + { + desc: "multi line, topmost block is partial", + value: 20, + max: 24, + vertCells: 3, + want: blocks{full: 2, partSpark: sparks[3]}, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := toBlocks(tc.value, tc.max, tc.vertCells) + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("toBlocks => unexpected diff (-want, +got):\n%s", diff) + if got.full != tc.want.full { + t.Errorf("toBlocks => unexpected diff, blocks.full got %d, want %d", got.full, tc.want.full) + } + if got.partSpark != tc.want.partSpark { + t.Errorf("toBlocks => unexpected diff, blocks.partSpark got '%c' (sparks[%d])), want '%c' (sparks[%d])", + got.partSpark, findRune(got.partSpark, sparks), tc.want.partSpark, findRune(tc.want.partSpark, sparks)) + } + } + }) + } +} + +// findRune finds the rune in the slice and returns its index. +// Returns -1 if the rune isn't in the slice. +func findRune(target rune, runes []rune) int { + for i, r := range runes { + if r == target { + return i + } + } + return -1 +}