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

Complete test coverage for button and tweaks to the demo.

This commit is contained in:
Jakub Sobon 2019-02-23 19:38:47 -05:00
parent e9cf1e1af7
commit b2a1f30fe1
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
7 changed files with 394 additions and 45 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- The Button widget.
- A function that draws text vertically.
- The LineChart widget can display X axis labels in vertical orientation.
- The LineChart widget allows the user to specify a custom scale for the Y

View File

@ -8,7 +8,7 @@
# termdash
[<img src="./images/termdashdemo_0_6_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
[<img src="./images/termdashdemo_0_7_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
This project implements a cross-platform customizable terminal based dashboard.
The feature set is inspired by the
@ -62,6 +62,18 @@ Project documentation is available in the [doc](doc/) directory.
## Implemented Widgets
### The Button
Allows users to interact with the application, each button press runs a callback function.
Run the
[buttondemo](widgets/button/buttondemo/buttondemo.go).
```go
go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go
```
[<img src="./images/buttondemo.gif" alt="buttondemo" type="image/gif">](widgets/button/buttondemo/buttondemo.go)
### The Gauge
Displays the progress of an operation. Run the

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 MiB

View File

@ -21,15 +21,18 @@ import (
"fmt"
"math"
"math/rand"
"sync"
"time"
"github.com/mum4k/termdash"
"github.com/mum4k/termdash/align"
"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/barchart"
"github.com/mum4k/termdash/widgets/button"
"github.com/mum4k/termdash/widgets/donut"
"github.com/mum4k/termdash/widgets/gauge"
"github.com/mum4k/termdash/widgets/linechart"
@ -128,10 +131,34 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container,
return nil, err
}
sineLC, err := newSines(ctx)
leftB, rightB, sineLC, err := newSines(ctx)
if err != nil {
return nil, err
}
lcAndButtons := []container.Option{
container.SplitHorizontal(
container.Top(
container.Border(draw.LineStyleLight),
container.BorderTitle("Multiple series"),
container.BorderTitleAlignRight(),
container.PlaceWidget(sineLC),
),
container.Bottom(
container.SplitVertical(
container.Left(
container.PlaceWidget(leftB),
container.AlignHorizontal(align.HorizontalRight),
),
container.Right(
container.PlaceWidget(rightB),
container.AlignHorizontal(align.HorizontalLeft),
),
),
),
container.SplitPercent(80),
),
}
rightSide := []container.Option{
container.SplitHorizontal(
container.Top(
@ -148,12 +175,7 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container,
container.BorderTitleAlignRight(),
container.PlaceWidget(don),
),
container.Bottom(
container.Border(draw.LineStyleLight),
container.BorderTitle("Multiple series"),
container.BorderTitleAlignRight(),
container.PlaceWidget(sineLC),
),
container.Bottom(lcAndButtons...),
container.SplitPercent(30),
),
),
@ -422,23 +444,47 @@ func newBarChart(ctx context.Context) (*barchart.BarChart, error) {
return bc, nil
}
// newSines returns a line chart that displays multiple sine series.
func newSines(ctx context.Context) (*linechart.LineChart, error) {
// distance is a thread-safe int value used by the newSince method.
// Buttons write it and the line chart reads it.
type distance struct {
v int
mu sync.Mutex
}
// add adds the provided value to the one stored.
func (d *distance) add(v int) {
d.mu.Lock()
defer d.mu.Unlock()
d.v += v
}
// get returns the current value.
func (d *distance) get() int {
d.mu.Lock()
defer d.mu.Unlock()
return d.v
}
// newSines returns a line chart that displays multiple sine series and two buttons.
// The left button shifts the second series relative to the first series to
// the left and the right button shifts it to the right.
func newSines(ctx context.Context) (left, right *button.Button, lc *linechart.LineChart, err error) {
var inputs []float64
for i := 0; i < 200; i++ {
v := math.Sin(float64(i) / 100 * math.Pi)
inputs = append(inputs, v)
}
lc, err := linechart.New(
sineLc, err := linechart.New(
linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)),
linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)),
linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)),
)
if err != nil {
return nil, err
return nil, nil, nil, err
}
step1 := 0
secondDist := &distance{v: 100}
go periodic(ctx, redrawInterval/3, func() error {
step1 = (step1 + 1) % len(inputs)
if err := lc.Series("first", rotateFloats(inputs, step1),
@ -447,10 +493,30 @@ func newSines(ctx context.Context) (*linechart.LineChart, error) {
return err
}
step2 := (step1 + 100) % len(inputs)
step2 := (step1 + secondDist.get()) % len(inputs)
return lc.Series("second", rotateFloats(inputs, step2), linechart.SeriesCellOpts(cell.FgColor(cell.ColorWhite)))
})
return lc, nil
// diff is the difference a single button press adds or removes to the
// second series.
const diff = 20
leftB, err := button.New("(l)eft", func() error {
secondDist.add(diff)
return nil
},
button.GlobalKey('l'),
button.WidthFor("(r)ight"),
button.FillColor(cell.ColorNumber(220)),
)
rightB, err := button.New("(r)ight", func() error {
secondDist.add(-diff)
return nil
},
button.GlobalKey('r'),
button.FillColor(cell.ColorNumber(196)),
)
return leftB, rightB, sineLc, nil
}
// rotateFloats returns a new slice with inputs rotated by step.

View File

@ -96,6 +96,7 @@ func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) {
}, nil
}
// Vars to be replaced from tests.
var (
// Runes to use in cells that contain the button.
// Changed from tests to provide readable test failures.
@ -103,6 +104,9 @@ var (
// Runes to use in cells that contain the shadow.
// Changed from tests to provide readable test failures.
shadowRune = ' '
// timeSince is a function that calculates duration since some time.
timeSince = time.Since
)
// Draw draws the Button widget onto the canvas.
@ -112,7 +116,7 @@ func (b *Button) Draw(cvs *canvas.Canvas) error {
defer b.mu.Unlock()
if b.keyTriggerTime != nil {
since := time.Since(*b.keyTriggerTime)
since := timeSince(*b.keyTriggerTime)
if since > b.opts.keyUpDelay {
b.state = button.Up
}

View File

@ -71,10 +71,16 @@ func TestButton(t *testing.T) {
opts []Option
events []terminalapi.Event
canvas image.Rectangle
// timeSince is used to replace time.Since for tests, leave nil to use
// the original.
timeSince func(time.Time) time.Duration
want func(size image.Point) *faketerm.Terminal
wantCallback *callbackTracker
wantNewErr bool
wantDrawErr bool
wantCallbackErr bool
}{
{
desc: "New fails with nil callback",
@ -108,6 +114,13 @@ func TestButton(t *testing.T) {
canvas: image.Rect(0, 0, 1, 1),
wantNewErr: true,
},
{
desc: "draw fails on canvas too small",
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 1, 1),
wantDrawErr: true,
},
{
desc: "draws button in up state",
callback: &callbackTracker{},
@ -259,23 +272,246 @@ func TestButton(t *testing.T) {
},
wantCallback: &callbackTracker{},
},
{
desc: "keyboard event triggers the button, trigger time didn't expire so button is down",
callback: &callbackTracker{},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
// Keyboard event ignored when no key configured
// Draws button down by key + trigger.
// Releases button after key press.
// Doesn't release button after key press if before KeyUpDelay.
// Ignores unrelated key.
// Key works when KeyScopeFocused.
// sets custom key
// Ignores key outside of the container on KeyScopeFocused.
// Accepts key outside of the container on KeyScopeFlobal.
// Triggers callback multiple times.
// Callback returns an error.
// Custom height.
// Different width due to text.
// Different width due to WidthFor.
// Trims text on custom width.
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "hello", image.Point{2, 2},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{
called: true,
count: 1,
},
},
{
desc: "keyboard event triggers the button, custom trigger time expired so button is up",
callback: &callbackTracker{},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
KeyUpDelay(100 * time.Millisecond),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
// Shadow.
testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240)))
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "hello", image.Point{1, 1},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{
called: true,
count: 1,
},
},
{
desc: "keyboard event triggers the button multiple times",
callback: &callbackTracker{},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
KeyUpDelay(100 * time.Millisecond),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
// Shadow.
testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240)))
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "hello", image.Point{1, 1},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{
called: true,
count: 3,
},
},
{
desc: "mouse event triggers the button multiple times",
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
// Shadow.
testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240)))
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "hello", image.Point{1, 1},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{
called: true,
count: 2,
},
},
{
desc: "the callback returns an error after a mouse event",
callback: &callbackTracker{
wantErr: true,
},
text: "hello",
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
},
wantCallbackErr: true,
},
{
desc: "the callback returns an error after a keyboard event",
callback: &callbackTracker{
wantErr: true,
},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
KeyUpDelay(100 * time.Millisecond),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
},
wantCallbackErr: true,
},
{
desc: "draws button with custom height (infra gives smaller canvas)",
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 8, 2),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
// Shadow.
testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 2), 's', cell.BgColor(cell.ColorNumber(240)))
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 1), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "hello", image.Point{1, 0},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{},
},
{
desc: "button width adjusts to width (infra gives smaller canvas)",
callback: &callbackTracker{},
text: "h",
canvas: image.Rect(0, 0, 4, 2),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
// Shadow.
testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 4, 2), 's', cell.BgColor(cell.ColorNumber(240)))
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 3, 1), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "h", image.Point{1, 0},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{},
},
{
desc: "sets custom text color",
callback: &callbackTracker{},
@ -372,6 +608,12 @@ func TestButton(t *testing.T) {
shadowRune = 's'
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
if tc.timeSince != nil {
timeSince = tc.timeSince
} else {
timeSince = time.Since
}
gotCallback := tc.callback
var cFn CallbackFn
if gotCallback == nil {
@ -402,17 +644,39 @@ func TestButton(t *testing.T) {
}
}
for _, ev := range tc.events {
for i, ev := range tc.events {
switch e := ev.(type) {
case *terminalapi.Mouse:
if err := b.Mouse(e); err != nil {
err := b.Mouse(e)
// Only the last event in test cases is the one that triggers the callback.
if i == len(tc.events)-1 {
if (err != nil) != tc.wantCallbackErr {
t.Errorf("Mouse => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr)
}
if err != nil {
return
}
} else {
if err != nil {
t.Fatalf("Mouse => unexpected error: %v", err)
}
}
case *terminalapi.Keyboard:
if err := b.Keyboard(e); err != nil {
err := b.Keyboard(e)
// Only the last event in test cases is the one that triggers the callback.
if i == len(tc.events)-1 {
if (err != nil) != tc.wantCallbackErr {
t.Errorf("Keyboard => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr)
}
if err != nil {
return
}
} else {
if err != nil {
t.Fatalf("Keyboard => unexpected error: %v", err)
}
}
default:
t.Fatalf("unsupported event type: %T", ev)

View File

@ -22,6 +22,7 @@ import (
"github.com/mum4k/termdash"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminal/termbox"
@ -69,6 +70,7 @@ func main() {
segmentdisplay.NewChunk(fmt.Sprintf("%d", val)),
})
},
button.FillColor(cell.ColorNumber(220)),
button.GlobalKey('s'),
)
if err != nil {