diff --git a/eventqueue/eventqueue.go b/eventqueue/eventqueue.go index da7d320..6735255 100644 --- a/eventqueue/eventqueue.go +++ b/eventqueue/eventqueue.go @@ -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 diff --git a/eventqueue/eventqueue_test.go b/eventqueue/eventqueue_test.go index 2b7dc42..53828c4 100644 --- a/eventqueue/eventqueue_test.go +++ b/eventqueue/eventqueue_test.go @@ -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 != "" { diff --git a/termdash.go b/termdash.go index 4803b2f..98eb710 100644 --- a/termdash.go +++ b/termdash.go @@ -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 { diff --git a/termdash_test.go b/termdash_test.go index cc385b7..8a6184f 100644 --- a/termdash_test.go +++ b/termdash_test.go @@ -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()) + } + } +} diff --git a/widgets/fakewidget/fakewidget.go b/widgets/fakewidget/fakewidget.go index b03fd08..e4ab451 100644 --- a/widgets/fakewidget/fakewidget.go +++ b/widgets/fakewidget/fakewidget.go @@ -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)) } } diff --git a/widgets/fakewidget/fakewidget_test.go b/widgets/fakewidget/fakewidget_test.go index 1ecc201..88f5dd0 100644 --- a/widgets/fakewidget/fakewidget_test.go +++ b/widgets/fakewidget/fakewidget_test.go @@ -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) + } + }) + } +}