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

Merge branch 'devel' into text-input

This commit is contained in:
Jakub Sobon 2019-04-18 23:57:35 -04:00
commit afe70553e5
8 changed files with 303 additions and 26 deletions

View File

@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
executed. executed.
- The SegmentDisplay widget now has a method that returns the observed character - The SegmentDisplay widget now has a method that returns the observed character
capacity the last time Draw was called. capacity the last time Draw was called.
- The grid.Builder API now allows users to specify options for intermediate
containers, i.e. containers that don't have widgets, but represent rows and
columns.
- Line chart widget now allows `math.NaN` values to represent "no value" (values
that will not be rendered) in the values slice.
#### Breaking API changes #### Breaking API changes
@ -119,7 +124,6 @@ identifiers shouldn't be used externally.
- The Text widget now has a Write option that atomically replaces the entire - The Text widget now has a Write option that atomically replaces the entire
text content. text content.
#### Improvements to the infrastructure #### Improvements to the infrastructure
- A function that draws text vertically. - A function that draws text vertically.
@ -248,7 +252,7 @@ identifiers shouldn't be used externally.
- The Gauge widget. - The Gauge widget.
- The Text widget. - The Text widget.
[Unreleased]: https://github.com/mum4k/termdash/compare/v0.8.0...devel [unreleased]: https://github.com/mum4k/termdash/compare/v0.8.0...devel
[0.8.0]: https://github.com/mum4k/termdash/compare/v0.7.2...v0.8.0 [0.8.0]: https://github.com/mum4k/termdash/compare/v0.7.2...v0.8.0
[0.7.2]: https://github.com/mum4k/termdash/compare/v0.7.1...v0.7.2 [0.7.2]: https://github.com/mum4k/termdash/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/mum4k/termdash/compare/v0.7.0...v0.7.1 [0.7.1]: https://github.com/mum4k/termdash/compare/v0.7.0...v0.7.1

View File

@ -109,18 +109,19 @@ func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.O
switch e := elem.(type) { switch e := elem.(type) {
case *row: case *row:
if len(elems) > 0 { if len(elems) > 0 {
perc := innerPerc(e.heightPerc, parentHeightPerc) perc := innerPerc(e.heightPerc, parentHeightPerc)
childHeightPerc := parentHeightPerc - e.heightPerc childHeightPerc := parentHeightPerc - e.heightPerc
return []container.Option{ return []container.Option{
container.SplitHorizontal( container.SplitHorizontal(
container.Top(build(e.subElem, 100, parentWidthPerc)...), container.Top(append(e.cOpts, build(e.subElem, 100, parentWidthPerc)...)...),
container.Bottom(build(elems, childHeightPerc, parentWidthPerc)...), container.Bottom(build(elems, childHeightPerc, parentWidthPerc)...),
container.SplitPercent(perc), container.SplitPercent(perc),
), ),
} }
} }
return build(e.subElem, 100, parentWidthPerc) return append(e.cOpts, build(e.subElem, 100, parentWidthPerc)...)
case *col: case *col:
if len(elems) > 0 { if len(elems) > 0 {
@ -128,13 +129,13 @@ func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.O
childWidthPerc := parentWidthPerc - e.widthPerc childWidthPerc := parentWidthPerc - e.widthPerc
return []container.Option{ return []container.Option{
container.SplitVertical( container.SplitVertical(
container.Left(build(e.subElem, parentHeightPerc, 100)...), container.Left(append(e.cOpts, build(e.subElem, parentHeightPerc, 100)...)...),
container.Right(build(elems, parentHeightPerc, childWidthPerc)...), container.Right(build(elems, parentHeightPerc, childWidthPerc)...),
container.SplitPercent(perc), container.SplitPercent(perc),
), ),
} }
} }
return build(e.subElem, parentHeightPerc, 100) return append(e.cOpts, build(e.subElem, parentHeightPerc, 100)...)
case *widget: case *widget:
opts := e.cOpts opts := e.cOpts
@ -186,6 +187,9 @@ type row struct {
// subElem are the sub Rows or Columns or a single widget. // subElem are the sub Rows or Columns or a single widget.
subElem []Element subElem []Element
// cOpts are the options for the row's container.
cOpts []container.Option
} }
// isElement implements Element.isElement. // isElement implements Element.isElement.
@ -204,6 +208,9 @@ type col struct {
// subElem are the sub Rows or Columns or a single widget. // subElem are the sub Rows or Columns or a single widget.
subElem []Element subElem []Element
// cOpts are the options for the column's container.
cOpts []container.Option
} }
// isElement implements Element.isElement. // isElement implements Element.isElement.
@ -244,6 +251,16 @@ func RowHeightPerc(heightPerc int, subElements ...Element) Element {
} }
} }
// RowHeightPercWithOpts is like RowHeightPerc, but also allows to apply
// additional options to the container that represents the row.
func RowHeightPercWithOpts(heightPerc int, cOpts []container.Option, subElements ...Element) Element {
return &row{
heightPerc: heightPerc,
subElem: subElements,
cOpts: cOpts,
}
}
// ColWidthPerc creates a column of the specified width. // ColWidthPerc creates a column of the specified width.
// The width is supplied as width percentage of the parent element. // The width is supplied as width percentage of the parent element.
// The sum of all widths at the same level cannot be larger than 100%. If it // The sum of all widths at the same level cannot be larger than 100%. If it
@ -257,6 +274,16 @@ func ColWidthPerc(widthPerc int, subElements ...Element) Element {
} }
} }
// ColWidthPercWithOpts is like ColWidthPerc, but also allows to apply
// additional options to the container that represents the column.
func ColWidthPercWithOpts(widthPerc int, cOpts []container.Option, subElements ...Element) Element {
return &col{
widthPerc: widthPerc,
subElem: subElements,
cOpts: cOpts,
}
}
// Widget adds a widget into the Row or Column. // Widget adds a widget into the Row or Column.
// The options will be applied to the container that directly holds this // The options will be applied to the container that directly holds this
// widget. // widget.

View File

@ -389,6 +389,45 @@ func TestBuilder(t *testing.T) {
return ft return ft
}, },
}, },
{
desc: "two equal rows with options",
termSize: image.Point{10, 10},
builder: func() *Builder {
b := New()
b.Add(RowHeightPercWithOpts(
50,
[]container.Option{
container.Border(linestyle.Double),
},
Widget(mirror()),
))
b.Add(RowHeightPercWithOpts(
50,
[]container.Option{
container.Border(linestyle.Double),
},
Widget(mirror()),
))
return b
}(),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
top, bot := mustHSplit(ft.Area(), 50)
topCvs := testcanvas.MustNew(top)
botCvs := testcanvas.MustNew(bot)
testdraw.MustBorder(topCvs, topCvs.Area(), draw.BorderLineStyle(linestyle.Double))
testdraw.MustBorder(botCvs, botCvs.Area(), draw.BorderLineStyle(linestyle.Double))
testcanvas.MustApply(topCvs, ft)
testcanvas.MustApply(botCvs, ft)
topWidget := testcanvas.MustNew(area.ExcludeBorder(top))
botWidget := testcanvas.MustNew(area.ExcludeBorder(bot))
fakewidget.MustDraw(ft, topWidget, &widgetapi.Meta{}, widgetapi.Options{})
fakewidget.MustDraw(ft, botWidget, &widgetapi.Meta{}, widgetapi.Options{})
return ft
},
},
{ {
desc: "two unequal rows", desc: "two unequal rows",
termSize: image.Point{10, 10}, termSize: image.Point{10, 10},
@ -406,6 +445,45 @@ func TestBuilder(t *testing.T) {
return ft return ft
}, },
}, },
{
desc: "two equal columns with options",
termSize: image.Point{20, 10},
builder: func() *Builder {
b := New()
b.Add(ColWidthPercWithOpts(
50,
[]container.Option{
container.Border(linestyle.Double),
},
Widget(mirror()),
))
b.Add(ColWidthPercWithOpts(
50,
[]container.Option{
container.Border(linestyle.Double),
},
Widget(mirror()),
))
return b
}(),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
left, right := mustVSplit(ft.Area(), 50)
leftCvs := testcanvas.MustNew(left)
rightCvs := testcanvas.MustNew(right)
testdraw.MustBorder(leftCvs, leftCvs.Area(), draw.BorderLineStyle(linestyle.Double))
testdraw.MustBorder(rightCvs, rightCvs.Area(), draw.BorderLineStyle(linestyle.Double))
testcanvas.MustApply(leftCvs, ft)
testcanvas.MustApply(rightCvs, ft)
leftWidget := testcanvas.MustNew(area.ExcludeBorder(left))
rightWidget := testcanvas.MustNew(area.ExcludeBorder(right))
fakewidget.MustDraw(ft, leftWidget, &widgetapi.Meta{}, widgetapi.Options{})
fakewidget.MustDraw(ft, rightWidget, &widgetapi.Meta{}, widgetapi.Options{})
return ft
},
},
{ {
desc: "two equal columns", desc: "two equal columns",
termSize: image.Point{20, 10}, termSize: image.Point{20, 10},

View File

@ -109,14 +109,21 @@ func Round(x float64) float64 {
// MinMax returns the smallest and the largest value among the provided values. // MinMax returns the smallest and the largest value among the provided values.
// Returns (0, 0) if there are no values. // Returns (0, 0) if there are no values.
// Ignores NaN values. Allowing NaN values could lead to a corner case where all
// values can be NaN, in this case the function will return NaN as min and max.
func MinMax(values []float64) (min, max float64) { func MinMax(values []float64) (min, max float64) {
if len(values) == 0 { if len(values) == 0 {
return 0, 0 return 0, 0
} }
min = math.MaxFloat64 min = math.MaxFloat64
max = -1 * math.MaxFloat64 max = -1 * math.MaxFloat64
allNaN := true
for _, v := range values { for _, v := range values {
if math.IsNaN(v) {
continue
}
allNaN = false
if v < min { if v < min {
min = v min = v
} }
@ -124,6 +131,11 @@ func MinMax(values []float64) (min, max float64) {
max = v max = v
} }
} }
if allNaN {
return math.NaN(), math.NaN()
}
return min, max return min, max
} }

View File

@ -215,13 +215,28 @@ func TestMinMax(t *testing.T) {
wantMin: -11.3, wantMin: -11.3,
wantMax: 22.5, wantMax: 22.5,
}, },
{
desc: "min and max among negative, positive, zero and NaN values",
values: []float64{1.1, 0, 1.3, math.NaN(), -11.3, 22.5},
wantMin: -11.3,
wantMax: 22.5,
},
{
desc: "all NaN values",
values: []float64{math.NaN(), math.NaN(), math.NaN(), math.NaN()},
wantMin: math.NaN(),
wantMax: math.NaN(),
},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) { t.Run(tc.desc, func(t *testing.T) {
gotMin, gotMax := MinMax(tc.values) gotMin, gotMax := MinMax(tc.values)
if gotMin != tc.wantMin || gotMax != tc.wantMax { if diff := pretty.Compare(tc.wantMin, gotMin); diff != "" {
t.Errorf("MinMax => (%v, %v), want (%v, %v)", gotMin, gotMax, tc.wantMin, tc.wantMax) t.Errorf("MinMax => unexpected min, diff (-want, +got):\n %s", diff)
}
if diff := pretty.Compare(tc.wantMax, gotMax); diff != "" {
t.Errorf("MinMax => unexpected max, diff (-want, +got):\n %s", diff)
} }
}) })
} }

View File

@ -166,6 +166,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
container.BorderTitle("A rolling text"), container.BorderTitle("A rolling text"),
), ),
), ),
grid.ColWidthPerc(50,
grid.RowHeightPerc(50, grid.RowHeightPerc(50,
grid.Widget(w.spGreen, grid.Widget(w.spGreen,
container.Border(linestyle.Light), container.Border(linestyle.Light),
@ -179,6 +180,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
), ),
), ),
), ),
),
grid.RowHeightPerc(7, grid.RowHeightPerc(7,
grid.Widget(w.gauge, grid.Widget(w.gauge,
container.Border(linestyle.Light), container.Border(linestyle.Light),

View File

@ -19,6 +19,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"image" "image"
"math"
"sort" "sort"
"sync" "sync"
@ -56,7 +57,7 @@ func newSeriesValues(values []float64) *seriesValues {
v := make([]float64, len(values)) v := make([]float64, len(values))
copy(v, values) copy(v, values)
min, max := numbers.MinMax(v) min, max := minMax(v)
return &seriesValues{ return &seriesValues{
values: v, values: v,
min: min, min: min,
@ -175,8 +176,9 @@ func (lc *LineChart) yMinMax() (float64, float64) {
maximums = append(maximums, lc.opts.yAxisCustomScale.max) maximums = append(maximums, lc.opts.yAxisCustomScale.max)
} }
min, _ := numbers.MinMax(minimums) min, _ := minMax(minimums)
_, max := numbers.MinMax(maximums) _, max := minMax(maximums)
return min, max return min, max
} }
@ -196,6 +198,8 @@ func (lc *LineChart) ValueCapacity() int {
// Series sets the values that should be displayed as the line chart with the // Series sets the values that should be displayed as the line chart with the
// provided label. // provided label.
// The values that should not be displayed on the line chart should be represented
// as math.NaN values on the values slice.
// Subsequent calls with the same label replace any previously provided values. // Subsequent calls with the same label replace any previously provided values.
func (lc *LineChart) Series(label string, values []float64, opts ...SeriesOption) error { func (lc *LineChart) Series(label string, values []float64, opts ...SeriesOption) error {
if label == "" { if label == "" {
@ -364,6 +368,7 @@ func (lc *LineChart) graphAr(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDe
// drawSeries draws the graph representing the stored series. // drawSeries draws the graph representing the stored series.
// Returns XDetails that might be adjusted to not start at zero value if some // Returns XDetails that might be adjusted to not start at zero value if some
// of the series didn't fit the graphs and XAxisUnscaled was provided. // of the series didn't fit the graphs and XAxisUnscaled was provided.
// If the series has NaN values they will be ignored and not draw on the graph.
func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*axes.XDetails, error) { func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*axes.XDetails, error) {
graphAr := lc.graphAr(cvs, xd, yd) graphAr := lc.graphAr(cvs, xd, yd)
bc, err := braille.New(graphAr) bc, err := braille.New(graphAr)
@ -406,7 +411,14 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.
var prev float64 var prev float64
for i := 1; i < len(sv.values); i++ { for i := 1; i < len(sv.values); i++ {
v := sv.values[i]
prev = sv.values[i-1] prev = sv.values[i-1]
// Skip the values that are missing.
if math.IsNaN(v) || math.IsNaN(prev) {
continue
}
if i < int(xdZoomed.Scale.Min.Value)+1 || i > int(xdZoomed.Scale.Max.Value) { if i < int(xdZoomed.Scale.Min.Value)+1 || i > int(xdZoomed.Scale.Max.Value) {
// Don't draw lines for values that aren't supposed to be visible. // Don't draw lines for values that aren't supposed to be visible.
// These are either values outside of the current zoom or // These are either values outside of the current zoom or
@ -429,7 +441,7 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.
if err != nil { if err != nil {
return nil, fmt.Errorf("failure for series %v[%d] on scale %v, yd.Scale.ValueToPixel(%v) => %v", name, i-1, yd.Scale, prev, err) return nil, fmt.Errorf("failure for series %v[%d] on scale %v, yd.Scale.ValueToPixel(%v) => %v", name, i-1, yd.Scale, prev, err)
} }
v := sv.values[i]
endY, err := yd.Scale.ValueToPixel(v) endY, err := yd.Scale.ValueToPixel(v)
if err != nil { if err != nil {
return nil, fmt.Errorf("failure for series %v[%d] on scale %v, yd.Scale.ValueToPixel(%v) => %v", name, i, yd.Scale, v, err) return nil, fmt.Errorf("failure for series %v[%d] on scale %v, yd.Scale.ValueToPixel(%v) => %v", name, i, yd.Scale, v, err)
@ -519,3 +531,17 @@ func (lc *LineChart) maxXValue() int {
} }
return maxLen - 1 return maxLen - 1
} }
// minMax is a wrapper around numbers.MinMax that controls
// the output if the values are NaN and sets defaults if it's
// the case.
func minMax(values []float64) (x, y float64) {
min, max := numbers.MinMax(values)
if math.IsNaN(min) {
min = 0
}
if math.IsNaN(max) {
max = 0
}
return min, max
}

View File

@ -1523,6 +1523,119 @@ func TestLineChartDraws(t *testing.T) {
testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0})
testbraille.MustCopyTo(bc, c) testbraille.MustCopyTo(bc, c)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "all NaN values",
canvas: image.Rect(0, 0, 20, 10),
writes: func(lc *LineChart) error {
return lc.Series("first", []float64{math.NaN(), math.NaN(), math.NaN(), math.NaN(), math.NaN()})
},
wantCapacity: 36,
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
// Y and X axis.
lines := []draw.HVLine{
{Start: image.Point{1, 0}, End: image.Point{1, 8}},
{Start: image.Point{1, 8}, End: image.Point{19, 8}},
}
testdraw.MustHVLines(c, lines)
// Value labels.
testdraw.MustText(c, "0", image.Point{0, 7})
testdraw.MustText(c, "0", image.Point{2, 9})
testdraw.MustText(c, "1", image.Point{6, 9})
testdraw.MustText(c, "2", image.Point{10, 9})
testdraw.MustText(c, "3", image.Point{14, 9})
testdraw.MustText(c, "4", image.Point{18, 9})
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "first and last NaN values",
canvas: image.Rect(0, 0, 28, 10),
writes: func(lc *LineChart) error {
return lc.Series("first", []float64{math.NaN(), math.NaN(), 100, 150, math.NaN()})
},
wantCapacity: 44,
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
// Y and X axis.
lines := []draw.HVLine{
{Start: image.Point{5, 0}, End: image.Point{5, 8}},
{Start: image.Point{5, 8}, End: image.Point{27, 8}},
}
testdraw.MustHVLines(c, lines)
// Value labels.
testdraw.MustText(c, "0", image.Point{4, 7})
testdraw.MustText(c, "77.44", image.Point{0, 3})
testdraw.MustText(c, "0", image.Point{6, 9})
testdraw.MustText(c, "1", image.Point{11, 9})
testdraw.MustText(c, "2", image.Point{16, 9})
testdraw.MustText(c, "3", image.Point{22, 9})
testdraw.MustText(c, "4", image.Point{27, 9})
graphAr := image.Rect(6, 0, 25, 8)
bc := testbraille.MustNew(graphAr)
testdraw.MustBrailleLine(bc, image.Point{21, 10}, image.Point{32, 0})
testbraille.MustCopyTo(bc, c)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "more values than capacity, X rescales with NaN values ignored",
canvas: image.Rect(0, 0, 11, 10),
writes: func(lc *LineChart) error {
return lc.Series("first", []float64{0, 1, 2, 3, 4, 5, 6, 7, math.NaN(), math.NaN(), math.NaN(), math.NaN(), 12, 13, 14, 15, 16, 17, 18, 19})
},
wantCapacity: 12,
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
// Y and X axis.
lines := []draw.HVLine{
{Start: image.Point{4, 0}, End: image.Point{4, 8}},
{Start: image.Point{4, 8}, End: image.Point{10, 8}},
}
testdraw.MustHVLines(c, lines)
// Value labels.
testdraw.MustText(c, "0", image.Point{3, 7})
testdraw.MustText(c, "9.92", image.Point{0, 3})
testdraw.MustText(c, "0", image.Point{5, 9})
testdraw.MustText(c, "14", image.Point{9, 9})
// Braille line.
graphAr := image.Rect(5, 0, 11, 8)
bc := testbraille.MustNew(graphAr)
testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{1, 29})
testdraw.MustBrailleLine(bc, image.Point{1, 29}, image.Point{1, 28})
testdraw.MustBrailleLine(bc, image.Point{1, 28}, image.Point{2, 26})
testdraw.MustBrailleLine(bc, image.Point{2, 26}, image.Point{2, 25})
testdraw.MustBrailleLine(bc, image.Point{2, 25}, image.Point{3, 23})
testdraw.MustBrailleLine(bc, image.Point{3, 23}, image.Point{3, 21})
testdraw.MustBrailleLine(bc, image.Point{3, 21}, image.Point{4, 20})
testdraw.MustBrailleLine(bc, image.Point{7, 12}, image.Point{8, 10})
testdraw.MustBrailleLine(bc, image.Point{8, 10}, image.Point{8, 8})
testdraw.MustBrailleLine(bc, image.Point{8, 8}, image.Point{9, 7})
testdraw.MustBrailleLine(bc, image.Point{9, 7}, image.Point{9, 5})
testdraw.MustBrailleLine(bc, image.Point{9, 5}, image.Point{10, 4})
testdraw.MustBrailleLine(bc, image.Point{10, 4}, image.Point{10, 2})
testdraw.MustBrailleLine(bc, image.Point{10, 2}, image.Point{11, 0})
testbraille.MustCopyTo(bc, c)
testcanvas.MustApply(c, ft) testcanvas.MustApply(c, ft)
return ft return ft
}, },