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

Implementing the infrastructure layer.

This commit is contained in:
Jakub Sobon 2018-04-23 01:05:54 +01:00
parent 0199ffbb9a
commit 6fe129c940
6 changed files with 646 additions and 50 deletions

View File

@ -65,7 +65,8 @@ guidelines](doc/design_guidelines.md) before contributing.
- [x] Implement the input event pre-processing.
- [x] Add support for tracking mouse and keyboard focus.
- [x] Implement the first widget.
- [ ] Implement the infrastructure layer.
- [x] Implement the infrastructure layer.
- [ ] Write the design guidelines document.
- [ ] Documentation and tooling for widget development.
- [ ] Implement the first widget.
- [ ] Launch and iterate.

View File

@ -54,12 +54,10 @@ func Example() {
),
Right(
Border(draw.LineStyleLight),
PlaceWidget(fakewidget.New(widgetapi.Options{})),
),
),
)
// TODO(mum4k): Allow splits on different ratios.
// TODO(mum4k): Include an example with a widget.
}
func TestNew(t *testing.T) {

View File

@ -21,6 +21,7 @@ import (
"image"
"time"
"github.com/mum4k/termdash"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminal/termbox"
@ -45,21 +46,6 @@ func inputEvents(ctx context.Context, t terminalapi.Terminal, c *container.Conta
return ch
}
// redraw redraws the containers on the terminal.
func redraw(t terminalapi.Terminal, c *container.Container) error {
//if err := t.Clear(); err != nil {
// return err
//}
if err := c.Draw(); err != nil {
return err
}
if err := t.Flush(); err != nil {
return err
}
return nil
}
func main() {
t, err := termbox.New()
if err != nil {
@ -68,6 +54,7 @@ func main() {
defer t.Close()
wOpts := widgetapi.Options{
MinimumSize: fakewidget.MinimumSize,
WantKeyboard: true,
WantMouse: true,
}
@ -79,6 +66,7 @@ func main() {
container.Top(
container.Border(draw.LineStyleLight),
container.PlaceWidget(fakewidget.New(widgetapi.Options{
MinimumSize: fakewidget.MinimumSize,
WantKeyboard: true,
WantMouse: true,
Ratio: image.Point{5, 1},
@ -102,6 +90,7 @@ func main() {
container.Border(draw.LineStyleLight),
container.VerticalAlignMiddle(),
container.PlaceWidget(fakewidget.New(widgetapi.Options{
MinimumSize: fakewidget.MinimumSize,
WantKeyboard: true,
WantMouse: true,
Ratio: image.Point{2, 1},
@ -126,36 +115,8 @@ func main() {
),
)
if err := redraw(t, c); err != nil {
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
if err := termdash.Run(ctx, t, c); err != nil {
panic(err)
}
events := inputEvents(context.Background(), t, c)
redrawTimer := time.NewTicker(100 * time.Millisecond)
defer redrawTimer.Stop()
const exitTime = 10 * time.Second
exitTimer := time.NewTicker(exitTime)
for {
defer exitTimer.Stop()
select {
case ev := <-events:
switch e := ev.(type) {
case *terminalapi.Mouse:
c.Mouse(e)
case *terminalapi.Keyboard:
c.Keyboard(e)
}
exitTimer.Stop()
exitTimer = time.NewTicker(exitTime)
case <-redrawTimer.C:
if err := redraw(t, c); err != nil {
panic(err)
}
case <-exitTimer.C:
return
}
}
}

278
termdash.go Normal file
View File

@ -0,0 +1,278 @@
/*
Package termdash implements a terminal based dashboard.
While running, the terminal dashboard performs the following:
- Periodic redrawing of the canvas and all the widgets.
- Event based redrawing of the widgets (i.e. on Keyboard or Mouse events).
- Forwards input events to widgets and optional subscribers.
- Handles terminal resize events.
*/
package termdash
import (
"context"
"fmt"
"sync"
"time"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/terminalapi"
)
// DefaultRedrawInterval is the default for the RedrawInterval option.
const DefaultRedrawInterval = 250 * time.Millisecond
// Option is used to provide options.
type Option interface {
// set sets the provided option.
set(td *termdash)
}
// option implements Option.
type option func(td *termdash)
// set implements Option.set.
func (o option) set(td *termdash) {
o(td)
}
// RedrawInterval sets how often termdash redraws the container and all the widgets.
// Defaults to DefaultRedrawInterval.
func RedrawInterval(t time.Duration) Option {
return option(func(td *termdash) {
td.redrawInterval = t
})
}
// InputErrorHandler is used to provide a function that will be called with all
// input event errors. If not provided, input event errors are discarded.
func InputErrorHandler(f func(error)) Option {
return option(func(td *termdash) {
td.inputErrorHandler = f
})
}
// KeyboardSubscriber registers a subscriber for Keyboard events. Each
// keyboard event is forwarded to the container and the registered subscriber.
// The provided function must be non-blocking, ideally just storing the value
// and returning as termdash blocks on each subscriber.
func KeyboardSubscriber(f func(*terminalapi.Keyboard)) Option {
return option(func(td *termdash) {
td.keyboardSubscriber = f
})
}
// MouseSubscriber registers a subscriber for Mouse events. Each mouse event
// is forwarded to the container and the registered subscriber.
// The provided function must be non-blocking, ideally just storing the value
// and returning as termdash blocks on each subscriber.
func MouseSubscriber(f func(*terminalapi.Mouse)) Option {
return option(func(td *termdash) {
td.mouseSubscriber = f
})
}
// Run runs the terminal dashboard with the provided container on the terminal.
// Blocks until the context expires.
func Run(ctx context.Context, t terminalapi.Terminal, c *container.Container, opts ...Option) error {
td := newTermdash(t, c, opts...)
defer td.stop()
return td.start(ctx)
}
// termdash is a terminal based dashboard.
// This object is thread-safe.
type termdash struct {
// term is the terminal the dashboard runs on.
term terminalapi.Terminal
// container maintains terminal splits and places widgets.
container *container.Container
// closeCh gets closed when Stop() is called, which tells the event
// collecting goroutine to exit.
closeCh chan struct{}
// exitCh gets closed when the event collecting goroutine actually exits.
exitCh chan struct{}
// clearNeeded indicates if the terminal needs to be cleared next time
// we're drawing it. Terminal needs to be cleared if its sized changed.
clearNeeded bool
// mu protects termdash.
mu sync.Mutex
// Options.
redrawInterval time.Duration
inputErrorHandler func(error)
mouseSubscriber func(*terminalapi.Mouse)
keyboardSubscriber func(*terminalapi.Keyboard)
}
// newTermdash creates a new termdash.
func newTermdash(t terminalapi.Terminal, c *container.Container, opts ...Option) *termdash {
td := &termdash{
term: t,
container: c,
closeCh: make(chan struct{}),
exitCh: make(chan struct{}),
redrawInterval: DefaultRedrawInterval,
}
for _, opt := range opts {
opt.set(td)
}
return td
}
// inputError forwards the input error to the error handler if one was
// provided.
func (td *termdash) inputError(err error) {
if td.inputErrorHandler != nil {
td.inputErrorHandler(err)
}
}
// setClearNeeded flags that the terminal needs to be cleared next time we're
// drawing it.
func (td *termdash) setClearNeeded() {
td.mu.Lock()
defer td.mu.Unlock()
td.clearNeeded = true
}
// redraw redraws the container and its widgets.
// The caller must hold td.mu.
func (td *termdash) redraw() error {
if td.clearNeeded {
if err := td.term.Clear(); err != nil {
return fmt.Errorf("term.Clear => error: %v", err)
}
td.clearNeeded = false
}
if err := td.container.Draw(); err != nil {
return fmt.Errorf("container.Draw => error: %v", err)
}
if err := td.term.Flush(); err != nil {
return fmt.Errorf("term.Flush => error: %v", err)
}
return nil
}
// keyEvRedraw forwards the keyboard event and redraws the container and its
// widgets.
func (td *termdash) keyEvRedraw(ev *terminalapi.Keyboard) error {
td.mu.Lock()
defer td.mu.Unlock()
if err := td.container.Keyboard(ev); err != nil {
return err
}
if td.keyboardSubscriber != nil {
td.keyboardSubscriber(ev)
}
return td.redraw()
}
// mouseEvRedraw forwards the mouse event and redraws the container and its
// widgets.
func (td *termdash) mouseEvRedraw(ev *terminalapi.Mouse) error {
td.mu.Lock()
defer td.mu.Unlock()
if err := td.container.Mouse(ev); err != nil {
return err
}
if td.mouseSubscriber != nil {
td.mouseSubscriber(ev)
}
return td.redraw()
}
// periodicRedraw is called once each RedrawInterval.
func (td *termdash) periodicRedraw() error {
td.mu.Lock()
defer td.mu.Unlock()
return td.redraw()
}
// processEvents processes terminal input events.
// This is the body of the event collecting goroutine.
func (td *termdash) processEvents(ctx context.Context) {
defer close(td.exitCh)
for {
event := td.term.Event(ctx)
switch ev := event.(type) {
case *terminalapi.Keyboard:
if err := td.keyEvRedraw(ev); err != nil {
td.inputError(err)
}
case *terminalapi.Mouse:
if err := td.mouseEvRedraw(ev); err != nil {
td.inputError(err)
}
case *terminalapi.Resize:
td.setClearNeeded()
case *terminalapi.Error:
// Don't forward the error if the context is closed.
// It just says that the context expired.
select {
case <-ctx.Done():
default:
td.inputError(ev.Error())
}
}
select {
case <-ctx.Done():
return
default:
}
}
}
// start starts the terminal dashboard. Blocks until the context expires or
// until stop() is called.
func (td *termdash) start(ctx context.Context) error {
redrawTimer := time.NewTicker(td.redrawInterval)
defer redrawTimer.Stop()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if err := td.periodicRedraw(); err != nil {
return err
}
// stops when stop() is called or the context expires.
go td.processEvents(ctx)
for {
select {
case <-redrawTimer.C:
if err := td.periodicRedraw(); err != nil {
return err
}
case <-ctx.Done():
return nil
case <-td.closeCh:
return nil
}
}
}
// stop tells the event collecting goroutine to stop.
// Blocks until it exits.
func (td *termdash) stop() {
close(td.closeCh)
<-td.exitCh
}

335
termdash_test.go Normal file
View File

@ -0,0 +1,335 @@
package termdash
import (
"context"
"fmt"
"image"
"testing"
"time"
"github.com/kylelemons/godebug/pretty"
"github.com/mum4k/termdash/canvas/testcanvas"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/eventqueue"
"github.com/mum4k/termdash/keyboard"
"github.com/mum4k/termdash/mouse"
"github.com/mum4k/termdash/terminal/faketerm"
"github.com/mum4k/termdash/terminal/termbox"
"github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widgetapi"
"github.com/mum4k/termdash/widgets/fakewidget"
)
// Example shows how to setup and run termdash.
func Example() error {
// 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 two fake widgets.
c := container.New(
t,
container.SplitVertical(
container.Left(
container.PlaceWidget(fakewidget.New(wOpts)),
),
container.Right(
container.PlaceWidget(fakewidget.New(wOpts)),
),
),
)
// Termdash runs until the context expires.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Run(ctx, t, c); err != nil {
return err
}
return nil
}
// errorHandler just stores the last error received.
type errorHandler struct {
err error
}
func (eh *errorHandler) handle(err error) {
eh.err = err
}
// keySubscriber just stores the last pressed key.
type keySubscriber struct {
received terminalapi.Keyboard
}
func (ks *keySubscriber) receive(k *terminalapi.Keyboard) {
ks.received = *k
}
// mouseSubscriber just stores the last mouse event.
type mouseSubscriber struct {
received terminalapi.Mouse
}
func (ms *mouseSubscriber) receive(m *terminalapi.Mouse) {
ms.received = *m
}
func TestRun(t *testing.T) {
var (
handler errorHandler
keySub keySubscriber
mouseSub mouseSubscriber
)
tests := []struct {
desc string
opts []Option
events []terminalapi.Event
// function to execute after the test case, can do additional comparison.
after func() error
want func(size image.Point) *faketerm.Terminal
wantErr bool
}{
{
desc: "draws the dashboard until closed",
opts: []Option{
RedrawInterval(1),
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{},
)
return ft
},
},
{
desc: "resizes the terminal",
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",
opts: []Option{
RedrawInterval(1),
},
events: []terminalapi.Event{
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantMouse: true,
},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
)
return ft
},
},
{
desc: "forwards keyboard events to container",
opts: []Option{
RedrawInterval(1),
},
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: "ignores input errors without error handler",
opts: []Option{
RedrawInterval(1),
},
events: []terminalapi.Event{
terminalapi.NewError("input error"),
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{},
)
return ft
},
},
{
desc: "forwards input errors to the error handler",
opts: []Option{
RedrawInterval(1),
InputErrorHandler(handler.handle),
},
events: []terminalapi.Event{
terminalapi.NewError("input error"),
},
after: func() error {
if want := "input error"; handler.err.Error() != want {
return fmt.Errorf("errorHandler got %v, want %v", handler.err, want)
}
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: "forwards keyboard events to the subscriber",
opts: []Option{
RedrawInterval(1),
KeyboardSubscriber(keySub.receive),
},
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyF1},
},
after: func() error {
want := terminalapi.Keyboard{Key: keyboard.KeyF1}
if diff := pretty.Compare(want, keySub.received); diff != "" {
return fmt.Errorf("keySubscriber got unexpected value, diff (-want, +got):\n%s", diff)
}
return nil
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantKeyboard: true,
},
&terminalapi.Keyboard{Key: keyboard.KeyF1},
)
return ft
},
},
{
desc: "forwards mouse events to the subscriber",
opts: []Option{
RedrawInterval(1),
MouseSubscriber(mouseSub.receive),
},
events: []terminalapi.Event{
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp},
},
after: func() error {
want := terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp}
if diff := pretty.Compare(want, mouseSub.received); diff != "" {
return fmt.Errorf("mouseSubscriber got unexpected value, diff (-want, +got):\n%s", diff)
}
return nil
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantMouse: true,
},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp},
)
return ft
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
handler = errorHandler{}
keySub = keySubscriber{}
mouseSub = mouseSubscriber{}
eq := eventqueue.New()
for _, ev := range tc.events {
eq.Push(ev)
}
got, err := faketerm.New(image.Point{60, 10}, faketerm.WithEventQueue(eq))
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
cont := container.New(
got,
container.PlaceWidget(fakewidget.New(widgetapi.Options{
WantKeyboard: true,
WantMouse: true,
})),
)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
err = Run(ctx, got, cont, tc.opts...)
cancel()
if (err != nil) != tc.wantErr {
t.Errorf("Run => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
if tc.after != nil {
if err := tc.after(); err != nil {
t.Errorf("after => unexpected error: %v", err)
}
}
if diff := faketerm.Diff(tc.want(got.Size()), got); diff != "" {
t.Errorf("Run => %v", diff)
}
})
}
}

View File

@ -21,6 +21,7 @@ import (
"fmt"
"image"
"log"
"sync"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/cell"
@ -52,13 +53,16 @@ func WithEventQueue(eq *eventqueue.Unbound) Option {
}
// Terminal is a fake terminal.
// This implementation is not thread-safe.
// This implementation is thread-safe.
type Terminal struct {
// buffer holds the terminal cells.
buffer cell.Buffer
// events is a queue of input events.
events *eventqueue.Unbound
// mu protects the buffer.
mu sync.Mutex
}
// New returns a new fake Terminal.
@ -89,6 +93,9 @@ func MustNew(size image.Point, opts ...Option) *Terminal {
// Resize resizes the terminal to the provided size.
// This also clears the internal buffer.
func (t *Terminal) Resize(size image.Point) error {
t.mu.Lock()
defer t.mu.Unlock()
b, err := cell.NewBuffer(size)
if err != nil {
return err
@ -100,6 +107,9 @@ func (t *Terminal) Resize(size image.Point) error {
// BackBuffer returns the back buffer of the fake terminal.
func (t *Terminal) BackBuffer() cell.Buffer {
t.mu.Lock()
defer t.mu.Unlock()
return t.buffer
}
@ -124,6 +134,9 @@ func (t *Terminal) String() string {
// Implements terminalapi.Terminal.Size.
func (t *Terminal) Size() image.Point {
t.mu.Lock()
defer t.mu.Unlock()
return t.buffer.Size()
}
@ -135,6 +148,9 @@ func (t *Terminal) Area() image.Rectangle {
// Implements terminalapi.Terminal.Clear.
func (t *Terminal) Clear(opts ...cell.Option) error {
t.mu.Lock()
defer t.mu.Unlock()
b, err := cell.NewBuffer(t.buffer.Size())
if err != nil {
return err
@ -160,6 +176,9 @@ func (t *Terminal) HideCursor() {
// Implements terminalapi.Terminal.SetCell.
func (t *Terminal) SetCell(p image.Point, r rune, opts ...cell.Option) error {
t.mu.Lock()
defer t.mu.Unlock()
ar, err := area.FromSize(t.buffer.Size())
if err != nil {
return err
@ -184,6 +203,10 @@ func (t *Terminal) Event(ctx context.Context) terminalapi.Event {
if err != nil {
return terminalapi.NewErrorf("unable to pull the next event: %v", err)
}
if res, ok := ev.(*terminalapi.Resize); ok {
t.Resize(res.Size)
}
return ev
}