1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Merge branch '10-braille-canvas-copyto' into 20-braille-canvas

This commit is contained in:
Jakub Sobon 2018-07-02 23:29:58 -04:00
commit 7c240faf4f
10 changed files with 1311 additions and 3 deletions

View File

@ -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).
[<img src="./images/textdemo.gif" alt="gaugedemo" type="image/gif">](widgets/gauge/demo/gaugedemo.go)
[<img src="./images/textdemo.gif" alt="textdemo" type="image/gif">](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).
[<img src="./images/sparklinedemo.gif" alt="sparklinedemo" type="image/gif">](widgets/sparkline/sparklinedemo/sparklinedemo.go)
### The BarChart
Displays multiple bars showing relative ratios of values. Run the
[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go).
[<img src="./images/barchartdemo.gif" alt="barchartdemo" type="image/gif">](widgets/barchart/barchartdemo/barchartdemo.go)
## Disclaimer

BIN
images/barchartdemo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
images/sparklinedemo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

View File

@ -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.

View File

@ -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
})
}

View File

@ -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,
}
}

View File

@ -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)
}
})
}
}

View File

@ -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)
}
}

111
widgets/sparkline/sparks.go Normal file
View File

@ -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))
}
}
}

View File

@ -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
}