mirror of
https://github.com/mum4k/termdash.git
synced 2025-05-01 22:17:51 +08:00
Merge branch '10-braille-canvas-copyto' into 20-braille-canvas
This commit is contained in:
commit
7c240faf4f
18
README.md
18
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
|
Displays text content, supports trimming and scrolling of content. Run the
|
||||||
[textdemo](widgets/text/demo/textdemo.go).
|
[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
|
## Disclaimer
|
||||||
|
|
||||||
|
BIN
images/barchartdemo.gif
Normal file
BIN
images/barchartdemo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 160 KiB |
BIN
images/sparklinedemo.gif
Normal file
BIN
images/sparklinedemo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 292 KiB |
@ -95,9 +95,12 @@ func MouseSubscriber(f func(*terminalapi.Mouse)) Option {
|
|||||||
// Blocks until the context expires.
|
// Blocks until the context expires.
|
||||||
func Run(ctx context.Context, t terminalapi.Terminal, c *container.Container, opts ...Option) error {
|
func Run(ctx context.Context, t terminalapi.Terminal, c *container.Container, opts ...Option) error {
|
||||||
td := newTermdash(t, c, opts...)
|
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.
|
// Controller controls a termdash instance.
|
||||||
|
76
widgets/sparkline/options.go
Normal file
76
widgets/sparkline/options.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
223
widgets/sparkline/sparkline.go
Normal file
223
widgets/sparkline/sparkline.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
493
widgets/sparkline/sparkline_test.go
Normal file
493
widgets/sparkline/sparkline_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
116
widgets/sparkline/sparklinedemo/sparklinedemo.go
Normal file
116
widgets/sparkline/sparklinedemo/sparklinedemo.go
Normal 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
111
widgets/sparkline/sparks.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
270
widgets/sparkline/sparks_test.go
Normal file
270
widgets/sparkline/sparks_test.go
Normal 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
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user