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

Merge branch 'devel' into barchart

This commit is contained in:
Jakub Sobon 2018-06-22 16:36:41 -04:00
commit b281b0506f
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
6 changed files with 428 additions and 34 deletions

View File

@ -75,6 +75,13 @@ func (u *Unbound) wake() {
}
}
// Empty determines if the queue is empty.
func (u *Unbound) Empty() bool {
u.mu.Lock()
defer u.mu.Unlock()
return u.empty()
}
// empty determines if the queue is empty.
func (u *Unbound) empty() bool {
return u.first == nil

View File

@ -25,12 +25,14 @@ import (
func TestQueue(t *testing.T) {
tests := []struct {
desc string
pushes []terminalapi.Event
wantPops []terminalapi.Event
desc string
pushes []terminalapi.Event
wantEmpty bool // Checked after pushes and before pops.
wantPops []terminalapi.Event
}{
{
desc: "empty queue returns nil",
desc: "empty queue returns nil",
wantEmpty: true,
wantPops: []terminalapi.Event{
nil,
},
@ -42,6 +44,7 @@ func TestQueue(t *testing.T) {
terminalapi.NewError("error2"),
terminalapi.NewError("error3"),
},
wantEmpty: false,
wantPops: []terminalapi.Event{
terminalapi.NewError("error1"),
terminalapi.NewError("error2"),
@ -59,6 +62,11 @@ func TestQueue(t *testing.T) {
q.Push(ev)
}
gotEmpty := q.Empty()
if gotEmpty != tc.wantEmpty {
t.Errorf("Empty => got %v, want %v", gotEmpty, tc.wantEmpty)
}
for i, want := range tc.wantPops {
got := q.Pop()
if diff := pretty.Compare(want, got); diff != "" {

View File

@ -25,6 +25,7 @@ package termdash
import (
"context"
"errors"
"fmt"
"sync"
"time"
@ -51,7 +52,8 @@ func (o option) set(td *termdash) {
}
// RedrawInterval sets how often termdash redraws the container and all the widgets.
// Defaults to DefaultRedrawInterval.
// Defaults to DefaultRedrawInterval. Use the controller to disable the
// periodic redraw.
func RedrawInterval(t time.Duration) Option {
return option(func(td *termdash) {
td.redrawInterval = t
@ -88,6 +90,8 @@ func MouseSubscriber(f func(*terminalapi.Mouse)) Option {
}
// Run runs the terminal dashboard with the provided container on the terminal.
// Redraws the terminal periodically. If you prefer a manual redraw, use the
// Controller instead.
// Blocks until the context expires.
func Run(ctx context.Context, t terminalapi.Terminal, c *container.Container, opts ...Option) error {
td := newTermdash(t, c, opts...)
@ -96,6 +100,51 @@ func Run(ctx context.Context, t terminalapi.Terminal, c *container.Container, op
return td.start(ctx)
}
// Controller controls a termdash instance.
// The controller instance is only valid until Close() is called.
// The controller is not thread-safe.
type Controller struct {
td *termdash
cancel context.CancelFunc
}
// NewController initializes termdash and returns an instance of the controller.
// Periodic redrawing is disabled when using the controller, the RedrawInterval
// option is ignored.
// Close the controller when it isn't needed anymore.
func NewController(t terminalapi.Terminal, c *container.Container, opts ...Option) (*Controller, error) {
ctx, cancel := context.WithCancel(context.Background())
ctrl := &Controller{
td: newTermdash(t, c, opts...),
cancel: cancel,
}
// stops when Close() is called.
go ctrl.td.processEvents(ctx)
if err := ctrl.td.periodicRedraw(); err != nil {
return nil, err
}
return ctrl, nil
}
// Redraw triggers redraw of the terminal.
func (c *Controller) Redraw() error {
if c.td == nil {
return errors.New("the termdash instance is no longer running, this controller is now invalid")
}
c.td.mu.Lock()
defer c.td.mu.Unlock()
return c.td.redraw()
}
// Close closes the Controller and its termdash instance.
func (c *Controller) Close() {
c.cancel()
c.td.stop()
c.td = nil
}
// termdash is a terminal based dashboard.
// This object is thread-safe.
type termdash struct {

View File

@ -34,7 +34,7 @@ import (
"github.com/mum4k/termdash/widgets/fakewidget"
)
// Example shows how to setup and run termdash.
// Example shows how to setup and run termdash with periodic redraw.
func Example() {
// Create the terminal.
t, err := termbox.New()
@ -70,6 +70,41 @@ func Example() {
}
}
// Example shows how to setup and run termdash with manually triggered redraw.
func Example_triggered() {
// Create the terminal.
t, err := termbox.New()
if err != nil {
panic(err)
}
defer t.Close()
wOpts := widgetapi.Options{
MinimumSize: fakewidget.MinimumSize,
WantKeyboard: true,
WantMouse: true,
}
// Create the container with a widget.
c := container.New(
t,
container.PlaceWidget(fakewidget.New(wOpts)),
)
// Create the controller and disable periodic redraw.
ctrl, err := NewController(t, c)
if err != nil {
panic(err)
}
// Close the controller and termdash once it isn't required anymore.
defer ctrl.Close()
// Redraw the terminal manually.
if err := ctrl.Redraw(); err != nil {
panic(err)
}
}
// errorHandler just stores the last error received.
type errorHandler struct {
err error
@ -143,26 +178,6 @@ func TestRun(t *testing.T) {
},
wantErr: true,
},
{
desc: "resizes the terminal",
size: image.Point{60, 10},
opts: []Option{
RedrawInterval(1),
},
events: []terminalapi.Event{
&terminalapi.Resize{Size: image.Point{70, 10}},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(image.Point{70, 10})
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{},
)
return ft
},
},
{
desc: "forwards mouse events to container",
size: image.Point{60, 10},
@ -340,9 +355,213 @@ func TestRun(t *testing.T) {
t.Errorf("after => unexpected error: %v", err)
}
}
if err := untilEmpty(5*time.Second, eq); err != nil {
t.Fatalf("untilEmpty => %v", err)
}
if diff := faketerm.Diff(tc.want(got.Size()), got); diff != "" {
t.Errorf("Run => %v", diff)
}
})
}
}
func TestController(t *testing.T) {
tests := []struct {
desc string
size image.Point
opts []Option
events []terminalapi.Event
apiEvents func(*fakewidget.Mirror) // Calls to the API of the widget.
controls func(*Controller) error
want func(size image.Point) *faketerm.Terminal
wantErr bool
}{
{
desc: "event triggers a redraw",
size: image.Point{60, 10},
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantKeyboard: true,
WantMouse: true,
},
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
)
return ft
},
},
{
desc: "controller triggers redraw",
size: image.Point{60, 10},
apiEvents: func(mi *fakewidget.Mirror) {
mi.Text("hello")
},
controls: func(ctrl *Controller) error {
return ctrl.Redraw()
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
mirror := fakewidget.New(widgetapi.Options{})
mirror.Text("hello")
fakewidget.MustDrawWithMirror(
mirror,
ft,
testcanvas.MustNew(ft.Area()),
)
return ft
},
},
{
desc: "ignores periodic redraw via the controller",
size: image.Point{60, 10},
opts: []Option{
RedrawInterval(1),
},
apiEvents: func(mi *fakewidget.Mirror) {
mi.Text("hello")
},
controls: func(ctrl *Controller) error {
return nil
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{},
)
return ft
},
},
{
desc: "does not redraw unless triggered when periodic disabled",
size: image.Point{60, 10},
apiEvents: func(mi *fakewidget.Mirror) {
mi.Text("hello")
},
controls: func(ctrl *Controller) error {
return nil
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{},
)
return ft
},
},
{
desc: "fails when redraw fails",
size: image.Point{1, 1},
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
wantErr: true,
},
{
desc: "resizes the terminal",
size: image.Point{60, 10},
events: []terminalapi.Event{
&terminalapi.Resize{Size: image.Point{70, 10}},
},
controls: func(ctrl *Controller) error {
return ctrl.Redraw()
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(image.Point{70, 10})
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{},
)
return ft
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
eq := eventqueue.New()
for _, ev := range tc.events {
eq.Push(ev)
}
got, err := faketerm.New(tc.size, faketerm.WithEventQueue(eq))
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
mi := fakewidget.New(widgetapi.Options{
WantKeyboard: true,
WantMouse: true,
})
cont := container.New(
got,
container.PlaceWidget(mi),
)
ctrl, err := NewController(got, cont, tc.opts...)
if (err != nil) != tc.wantErr {
t.Errorf("NewController => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
defer ctrl.Close()
if tc.apiEvents != nil {
tc.apiEvents(mi)
}
if err := untilEmpty(5*time.Second, eq); err != nil {
t.Fatalf("untilEmpty => %v", err)
}
if tc.controls != nil {
if err := tc.controls(ctrl); err != nil {
t.Errorf("controls => unexpected error: %v", err)
}
}
if diff := faketerm.Diff(tc.want(got.Size()), got); diff != "" {
t.Errorf("Run => %v", diff)
}
})
}
}
// untilEmpty waits until the queue empties.
// Waits at most the specified duration.
func untilEmpty(timeout time.Duration, q *eventqueue.Unbound) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
tick := time.NewTimer(5 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-tick.C:
if q.Empty() {
return nil
}
case <-ctx.Done():
return fmt.Errorf("while waiting for the event queue to empty: %v", ctx.Err())
}
}
}

View File

@ -45,7 +45,9 @@ var MinimumSize = image.Point{24, 5}
// Mirror is a fake widget. The fake widget draws a border around its assigned
// canvas and writes the size of its assigned canvas on the first line of the
// canvas. It writes the last received keyboard event onto the second line. It
// writes the last received mouse event onto the third line.
// writes the last received mouse event onto the third line. If a non-empty
// string is provided via the Text() method, that text will be written right
// after the canvas size on the first line.
//
// The widget requests the same options that are provided to the constructor.
// If the options or canvas size don't allow for the three lines mentioned
@ -57,6 +59,9 @@ type Mirror struct {
// lines are the three lines that will be drawn on the canvas.
lines []string
// text is the text provided by the last call to Text().
text string
// mu protects lines.
mu sync.RWMutex
@ -88,7 +93,7 @@ func (mi *Mirror) Draw(cvs *canvas.Canvas) error {
return err
}
mi.lines[sizeLine] = cvs.Size().String()
mi.lines[sizeLine] = fmt.Sprintf("%s%s", cvs.Size().String(), mi.text)
usable := area.ExcludeBorder(cvs.Area())
start := cvs.Area().Intersect(usable).Min
for i := 0; i < outputLines; i++ {
@ -105,6 +110,12 @@ func (mi *Mirror) Draw(cvs *canvas.Canvas) error {
return nil
}
// Text stores a text that should be displayed right after the canvas size on
// the first line of the output.
func (mi *Mirror) Text(txt string) {
mi.text = txt
}
// Keyboard draws the received key on the canvas.
// Sending the keyboard.KeyEsc causes this widget to forget the last keyboard
// event and return an error instead.
@ -147,17 +158,29 @@ func (mi *Mirror) Options() widgetapi.Options {
// widget onto the provided canvas and forwarding the given events.
func Draw(t terminalapi.Terminal, cvs *canvas.Canvas, opts widgetapi.Options, events ...terminalapi.Event) error {
mirror := New(opts)
return DrawWithMirror(mirror, t, cvs, events...)
}
// MustDraw is like Draw, but panics on all errors.
func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, opts widgetapi.Options, events ...terminalapi.Event) {
if err := Draw(t, cvs, opts, events...); err != nil {
panic(fmt.Sprintf("Draw => %v", err))
}
}
// DrawWithMirror is like Draw, but uses the provided Mirror instead of creating one.
func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, events ...terminalapi.Event) error {
for _, ev := range events {
switch e := ev.(type) {
case *terminalapi.Mouse:
if !opts.WantMouse {
if !mirror.opts.WantMouse {
continue
}
if err := mirror.Mouse(e); err != nil {
return err
}
case *terminalapi.Keyboard:
if !opts.WantKeyboard {
if !mirror.opts.WantKeyboard {
continue
}
if err := mirror.Keyboard(e); err != nil {
@ -174,9 +197,9 @@ func Draw(t terminalapi.Terminal, cvs *canvas.Canvas, opts widgetapi.Options, ev
return cvs.Apply(t)
}
// MustDraw is like Draw, but panics on all errors.
func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, opts widgetapi.Options, events ...terminalapi.Event) {
if err := Draw(t, cvs, opts, events...); err != nil {
panic(fmt.Sprintf("Draw => %v", err))
// MustDrawWithMirror is like DrawWithMirror, but panics on all errors.
func MustDrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, events ...terminalapi.Event) {
if err := DrawWithMirror(mirror, t, cvs, events...); err != nil {
panic(fmt.Sprintf("DrawWithMirror => %v", err))
}
}

View File

@ -46,6 +46,7 @@ func TestMirror(t *testing.T) {
desc string
keyEvents []keyEvents // Keyboard events to send before calling Draw().
mouseEvents []mouseEvents // Mouse events to send before calling Draw().
apiEvents func(*Mirror) // External events via the widget's API.
cvs *canvas.Canvas
want func(size image.Point) *faketerm.Terminal
wantErr bool
@ -78,6 +79,21 @@ func TestMirror(t *testing.T) {
return ft
},
},
{
desc: "draws the box, canvas size and custom text",
apiEvents: func(mi *Mirror) {
mi.Text("hi")
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 9, 3)),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testdraw.MustBorder(cvs, cvs.Area())
testdraw.MustText(cvs, "(9,3)hi", image.Point{1, 1})
testcanvas.MustApply(cvs, ft)
return ft
},
},
{
desc: "skips canvas size if there isn't a line for it",
cvs: testcanvas.MustNew(image.Rect(0, 0, 3, 2)),
@ -227,6 +243,10 @@ func TestMirror(t *testing.T) {
t.Run(tc.desc, func(t *testing.T) {
w := New(widgetapi.Options{})
if tc.apiEvents != nil {
tc.apiEvents(w)
}
for _, keyEv := range tc.keyEvents {
err := w.Keyboard(keyEv.k)
if (err != nil) != keyEv.wantErr {
@ -270,3 +290,71 @@ func TestOptions(t *testing.T) {
t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)
}
}
func TestDraw(t *testing.T) {
tests := []struct {
desc string
opts widgetapi.Options
cvs *canvas.Canvas
events []terminalapi.Event
want func(size image.Point) *faketerm.Terminal
wantErr bool
}{
{
desc: "canvas too small to draw a box",
cvs: testcanvas.MustNew(image.Rect(0, 0, 1, 1)),
want: func(size image.Point) *faketerm.Terminal {
return faketerm.MustNew(size)
},
wantErr: true,
},
{
desc: "draws the box and canvas size",
cvs: testcanvas.MustNew(image.Rect(0, 0, 9, 3)),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testdraw.MustBorder(cvs, cvs.Area())
testdraw.MustText(cvs, "(9,3)", image.Point{1, 1})
testcanvas.MustApply(cvs, ft)
return ft
},
},
{
desc: "draws both keyboard and mouse events",
opts: widgetapi.Options{
WantKeyboard: true,
WantMouse: true,
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)),
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
&terminalapi.Mouse{Button: mouse.ButtonLeft},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testdraw.MustBorder(cvs, cvs.Area())
testdraw.MustText(cvs, "(17,5)", image.Point{1, 1})
testdraw.MustText(cvs, "KeyEnter", image.Point{1, 2})
testdraw.MustText(cvs, "(0,0)ButtonLeft", image.Point{1, 3})
testcanvas.MustApply(cvs, ft)
return ft
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got := faketerm.MustNew(tc.cvs.Size())
err := Draw(got, tc.cvs, tc.opts, tc.events...)
if (err != nil) != tc.wantErr {
t.Errorf("Draw => got error:%v, wantErr: %v", err, tc.wantErr)
}
if diff := faketerm.Diff(tc.want(tc.cvs.Size()), got); diff != "" {
t.Errorf("Draw => %v", diff)
}
})
}
}