diff --git a/README.md b/README.md index 9d5c40d..8280973 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/container/container_test.go b/container/container_test.go index f6ba7f5..d7512ca 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -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) { diff --git a/experimental/focus/focus.go b/experimental/focus/focus.go index 675adc5..15d8bed 100644 --- a/experimental/focus/focus.go +++ b/experimental/focus/focus.go @@ -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 - } - } } diff --git a/termdash.go b/termdash.go new file mode 100644 index 0000000..d005486 --- /dev/null +++ b/termdash.go @@ -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 +} diff --git a/termdash_test.go b/termdash_test.go new file mode 100644 index 0000000..b93bec7 --- /dev/null +++ b/termdash_test.go @@ -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) + } + }) + } +} diff --git a/terminal/faketerm/faketerm.go b/terminal/faketerm/faketerm.go index 53aaf8f..569a22b 100644 --- a/terminal/faketerm/faketerm.go +++ b/terminal/faketerm/faketerm.go @@ -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 }