1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-27 13:48:49 +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.
- The SegmentDisplay widget now has a method that returns the observed character
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
@ -119,7 +124,6 @@ identifiers shouldn't be used externally.
- The Text widget now has a Write option that atomically replaces the entire
text content.
#### Improvements to the infrastructure
- A function that draws text vertically.
@ -248,7 +252,7 @@ identifiers shouldn't be used externally.
- The Gauge 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.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

View File

@ -109,18 +109,19 @@ func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.O
switch e := elem.(type) {
case *row:
if len(elems) > 0 {
perc := innerPerc(e.heightPerc, parentHeightPerc)
childHeightPerc := parentHeightPerc - e.heightPerc
return []container.Option{
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.SplitPercent(perc),
),
}
}
return build(e.subElem, 100, parentWidthPerc)
return append(e.cOpts, build(e.subElem, 100, parentWidthPerc)...)
case *col:
if len(elems) > 0 {
@ -128,13 +129,13 @@ func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.O
childWidthPerc := parentWidthPerc - e.widthPerc
return []container.Option{
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.SplitPercent(perc),
),
}
}
return build(e.subElem, parentHeightPerc, 100)
return append(e.cOpts, build(e.subElem, parentHeightPerc, 100)...)
case *widget:
opts := e.cOpts
@ -186,6 +187,9 @@ type row struct {
// subElem are the sub Rows or Columns or a single widget.
subElem []Element
// cOpts are the options for the row's container.
cOpts []container.Option
}
// isElement implements Element.isElement.
@ -204,6 +208,9 @@ type col struct {
// subElem are the sub Rows or Columns or a single widget.
subElem []Element
// cOpts are the options for the column's container.
cOpts []container.Option
}
// 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.
// 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
@ -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.
// The options will be applied to the container that directly holds this
// widget.

View File

@ -389,6 +389,45 @@ func TestBuilder(t *testing.T) {
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",
termSize: image.Point{10, 10},
@ -406,6 +445,45 @@ func TestBuilder(t *testing.T) {
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",
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.
// 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) {
if len(values) == 0 {
return 0, 0
}
min = math.MaxFloat64
max = -1 * math.MaxFloat64
allNaN := true
for _, v := range values {
if math.IsNaN(v) {
continue
}
allNaN = false
if v < min {
min = v
}
@ -124,6 +131,11 @@ func MinMax(values []float64) (min, max float64) {
max = v
}
}
if allNaN {
return math.NaN(), math.NaN()
}
return min, max
}

View File

@ -215,13 +215,28 @@ func TestMinMax(t *testing.T) {
wantMin: -11.3,
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 {
t.Run(tc.desc, func(t *testing.T) {
gotMin, gotMax := MinMax(tc.values)
if gotMin != tc.wantMin || gotMax != tc.wantMax {
t.Errorf("MinMax => (%v, %v), want (%v, %v)", gotMin, gotMax, tc.wantMin, tc.wantMax)
if diff := pretty.Compare(tc.wantMin, gotMin); diff != "" {
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"),
),
),
grid.ColWidthPerc(50,
grid.RowHeightPerc(50,
grid.Widget(w.spGreen,
container.Border(linestyle.Light),
@ -179,6 +180,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
),
),
),
),
grid.RowHeightPerc(7,
grid.Widget(w.gauge,
container.Border(linestyle.Light),

View File

@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"image"
"math"
"sort"
"sync"
@ -56,7 +57,7 @@ func newSeriesValues(values []float64) *seriesValues {
v := make([]float64, len(values))
copy(v, values)
min, max := numbers.MinMax(v)
min, max := minMax(v)
return &seriesValues{
values: v,
min: min,
@ -175,8 +176,9 @@ func (lc *LineChart) yMinMax() (float64, float64) {
maximums = append(maximums, lc.opts.yAxisCustomScale.max)
}
min, _ := numbers.MinMax(minimums)
_, max := numbers.MinMax(maximums)
min, _ := minMax(minimums)
_, max := minMax(maximums)
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
// 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.
func (lc *LineChart) Series(label string, values []float64, opts ...SeriesOption) error {
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.
// 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.
// 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) {
graphAr := lc.graphAr(cvs, xd, yd)
bc, err := braille.New(graphAr)
@ -406,7 +411,14 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.
var prev float64
for i := 1; i < len(sv.values); i++ {
v := sv.values[i]
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) {
// Don't draw lines for values that aren't supposed to be visible.
// 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 {
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)
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)
@ -519,3 +531,17 @@ func (lc *LineChart) maxXValue() int {
}
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})
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)
return ft
},