From 9d02f43c6fe4720046eed644dbbed1bf26182c33 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Fri, 22 Jun 2018 16:27:23 -0400 Subject: [PATCH] Self-review. --- widgets/sparkline/sparkline.go | 43 ++++++++++----------- widgets/sparkline/sparkline_test.go | 60 ++++++++++++++++++++++------- widgets/sparkline/sparks.go | 33 +++++++++++----- 3 files changed, 92 insertions(+), 44 deletions(-) diff --git a/widgets/sparkline/sparkline.go b/widgets/sparkline/sparkline.go index 9c3615d..c38369a 100644 --- a/widgets/sparkline/sparkline.go +++ b/widgets/sparkline/sparkline.go @@ -17,7 +17,7 @@ import ( // 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 displayed value or has a statically set maximum. +// the largest visible value. // // Implements widgetapi.Widget. This object is thread-safe. type SparkLine struct { @@ -61,40 +61,32 @@ func (sl *SparkLine) Draw(cvs *canvas.Canvas) error { blocks := toBlocks(v, max, ar.Dy()) curY := ar.Max.Y - 1 for i := 0; i < blocks.full; i++ { - cells, err := cvs.SetCell( + if _, err := cvs.SetCell( image.Point{curX, curY}, - sparks[len(sparks)-1], + sparks[len(sparks)-1], // Last spark represents full cell. cell.FgColor(sl.opts.color), - ) - if err != nil { + ); err != nil { return err } - if cells != 1 { - panic(fmt.Sprintf("set an unexpected number of cells %d while filling a full block, expected one", cells)) - } curY-- } if blocks.partSpark != 0 { - cells, err := cvs.SetCell( + if _, err := cvs.SetCell( image.Point{curX, curY}, blocks.partSpark, cell.FgColor(sl.opts.color), - ) - if err != nil { + ); err != nil { return err } - - if cells != 1 { - panic(fmt.Sprintf("set an unexpected number of cells %d while filling a partial block, expected one", cells)) - } } 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...), @@ -111,10 +103,15 @@ func (sl *SparkLine) Draw(cvs *canvas.Canvas) error { // integers. // The last added data point will be the one displayed all the way on the right // of the SparkLine. -func (sl *SparkLine) Add(data ...int) error { +// 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) @@ -124,11 +121,12 @@ func (sl *SparkLine) Add(data ...int) error { return nil } -// Clear removes all the data points in the sparkline, effectively returning to +// 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 } @@ -145,8 +143,9 @@ func (*SparkLine) Mouse(m *terminalapi.Mouse) error { // 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 @@ -165,10 +164,10 @@ func (sl *SparkLine) area(cvs *canvas.Canvas) image.Rectangle { ) } -// minSize returns the minimum canvas size for the sparkline based on the options. +// minSize returns the minimum canvas size for the SparkLine based on the options. func (sl *SparkLine) minSize() image.Point { - // At least one data point. - const minWidth = 1 + const minWidth = 1 // At least one data point. + var minHeight int if sl.opts.height > 0 { minHeight = sl.opts.height @@ -190,7 +189,7 @@ func (sl *SparkLine) Options() widgetapi.Options { min := sl.minSize() var max image.Point if sl.opts.height > 0 { - max = min + max = min // Fix the height to the one specified. } return widgetapi.Options{ diff --git a/widgets/sparkline/sparkline_test.go b/widgets/sparkline/sparkline_test.go index 8212e79..1e9c480 100644 --- a/widgets/sparkline/sparkline_test.go +++ b/widgets/sparkline/sparkline_test.go @@ -39,7 +39,7 @@ func TestSparkLine(t *testing.T) { desc: "fails on negative data points", sparkLine: New(), update: func(sl *SparkLine) error { - return sl.Add(0, 3, -1, 2) + return sl.Add([]int{0, 3, -1, 2}) }, canvas: image.Rect(0, 0, 1, 1), want: func(size image.Point) *faketerm.Terminal { @@ -51,7 +51,7 @@ func TestSparkLine(t *testing.T) { desc: "single height sparkline", sparkLine: New(), update: func(sl *SparkLine) error { - return sl.Add(0, 1, 2, 3, 4, 5, 6, 7, 8) + 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 { @@ -65,13 +65,28 @@ func TestSparkLine(t *testing.T) { 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(0, 1, 2, 3, 4, 5, 6, 7, 8) + 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 { @@ -85,11 +100,30 @@ func TestSparkLine(t *testing.T) { 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(7, 8) + return sl.Add([]int{7, 8}) }, canvas: image.Rect(0, 0, 9, 1), want: func(size image.Point) *faketerm.Terminal { @@ -110,7 +144,7 @@ func TestSparkLine(t *testing.T) { Label("Hello"), ), update: func(sl *SparkLine) error { - return sl.Add(0, 1, 2, 3, 8, 3, 2, 1, 1) + 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 { @@ -132,7 +166,7 @@ func TestSparkLine(t *testing.T) { Label("Hello world"), ), update: func(sl *SparkLine) error { - return sl.Add(8) + return sl.Add([]int{8}) }, canvas: image.Rect(0, 0, 9, 2), want: func(size image.Point) *faketerm.Terminal { @@ -152,7 +186,7 @@ func TestSparkLine(t *testing.T) { desc: "stretches up to the height of the container", sparkLine: New(), update: func(sl *SparkLine) error { - return sl.Add(0, 100, 50, 85) + return sl.Add([]int{0, 100, 50, 85}) }, canvas: image.Rect(0, 0, 4, 4), want: func(size image.Point) *faketerm.Terminal { @@ -188,7 +222,7 @@ func TestSparkLine(t *testing.T) { Label("zoo"), ), update: func(sl *SparkLine) error { - return sl.Add(0, 90, 30, 85) + return sl.Add([]int{0, 90, 30, 85}) }, canvas: image.Rect(0, 0, 4, 4), want: func(size image.Point) *faketerm.Terminal { @@ -222,7 +256,7 @@ func TestSparkLine(t *testing.T) { Height(2), ), update: func(sl *SparkLine) error { - return sl.Add(0, 100, 50, 85) + return sl.Add([]int{0, 100, 50, 85}) }, canvas: image.Rect(0, 0, 4, 4), want: func(size image.Point) *faketerm.Terminal { @@ -250,7 +284,7 @@ func TestSparkLine(t *testing.T) { Height(2), ), update: func(sl *SparkLine) error { - return sl.Add(0, 100, 50, 0) + return sl.Add([]int{0, 100, 50, 0}) }, canvas: image.Rect(0, 0, 4, 4), want: func(size image.Point) *faketerm.Terminal { @@ -281,7 +315,7 @@ func TestSparkLine(t *testing.T) { ), ), update: func(sl *SparkLine) error { - return sl.Add(0, 1) + return sl.Add([]int{0, 1}) }, canvas: image.Rect(0, 0, 9, 2), want: func(size image.Point) *faketerm.Terminal { @@ -304,7 +338,7 @@ func TestSparkLine(t *testing.T) { desc: "displays only data points that fit the width", sparkLine: New(), update: func(sl *SparkLine) error { - return sl.Add(0, 1, 2, 3, 4, 5, 6, 7, 8) + 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 { @@ -323,7 +357,7 @@ func TestSparkLine(t *testing.T) { desc: "data points not visible don't affect the determined max data point", sparkLine: New(), update: func(sl *SparkLine) error { - return sl.Add(10, 4, 8) + return sl.Add([]int{10, 4, 8}) }, canvas: image.Rect(0, 0, 2, 1), want: func(size image.Point) *faketerm.Terminal { diff --git a/widgets/sparkline/sparks.go b/widgets/sparkline/sparks.go index 1acdcba..272bb6b 100644 --- a/widgets/sparkline/sparks.go +++ b/widgets/sparkline/sparks.go @@ -1,14 +1,16 @@ package sparkline -import "math" - // 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. -// Note that the last character representing fully populated cell isn't ever -// used. If we need to fill the cell fully, we use a space character with background -// color set. This ensures we have no gaps between cells. var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} // visibleMax determines the maximum visible data point given the canvas width. @@ -32,13 +34,14 @@ func visibleMax(data []int, width int) ([]int, int) { return data, max } -// blocks represents blocks that display one value on a SparkLine. +// 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 part blocks should be displayed. + // topmost block. Equals to zero if no partial block should be displayed. partSpark rune } @@ -50,7 +53,7 @@ func toBlocks(value, max, vertCells int) blocks { return blocks{} } - // How many of the smallesr spark elements fit into a cell. + // 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, @@ -72,7 +75,8 @@ func toBlocks(value, max, vertCells int) blocks { } // round returns the nearest integer, rounding half away from zero. -// Copied from Go 1.10, package math for backwards compatibility with go 1.8. +// 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 { @@ -80,3 +84,14 @@ func round(x float64) float64 { } 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)) + } + } +}