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).
-[
](widgets/gauge/demo/gaugedemo.go)
+[
](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).
+
+[
](widgets/sparkline/sparklinedemo/sparklinedemo.go)
+
+### The BarChart
+
+Displays multiple bars showing relative ratios of values. Run the
+[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go).
+
+[
](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
+}