mirror of https://github.com/mum4k/termdash.git
363 lines
9.9 KiB
Go
363 lines
9.9 KiB
Go
// Copyright 2018 Google Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
/*
|
|
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"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mum4k/termdash/container"
|
|
"github.com/mum4k/termdash/private/event"
|
|
"github.com/mum4k/termdash/terminal/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. Use the controller to disable the
|
|
// periodic redraw.
|
|
func RedrawInterval(t time.Duration) Option {
|
|
return option(func(td *termdash) {
|
|
td.redrawInterval = t
|
|
})
|
|
}
|
|
|
|
// ErrorHandler is used to provide a function that will be called with all
|
|
// errors that occur while the dashboard is running. If not provided, any
|
|
// errors panic the application.
|
|
// The provided function must be thread-safe.
|
|
func ErrorHandler(f func(error)) Option {
|
|
return option(func(td *termdash) {
|
|
td.errorHandler = 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 thread-safe.
|
|
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 thread-safe.
|
|
func MouseSubscriber(f func(*terminalapi.Mouse)) Option {
|
|
return option(func(td *termdash) {
|
|
td.mouseSubscriber = f
|
|
})
|
|
}
|
|
|
|
// withEDS indicates that termdash should run with the provided event
|
|
// distribution system instead of creating one.
|
|
// Useful for tests.
|
|
func withEDS(eds *event.DistributionSystem) Option {
|
|
return option(func(td *termdash) {
|
|
td.eds = eds
|
|
})
|
|
}
|
|
|
|
// 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...)
|
|
|
|
err := td.start(ctx)
|
|
// Only return the status (error or nil) after the termdash event
|
|
// processing goroutine actually exits.
|
|
td.stop()
|
|
return err
|
|
}
|
|
|
|
// 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 {
|
|
// term is the terminal the dashboard runs on.
|
|
term terminalapi.Terminal
|
|
|
|
// container maintains terminal splits and places widgets.
|
|
container *container.Container
|
|
|
|
// eds distributes input events to subscribers.
|
|
eds *event.DistributionSystem
|
|
|
|
// 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
|
|
errorHandler 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,
|
|
eds: event.NewDistributionSystem(),
|
|
closeCh: make(chan struct{}),
|
|
exitCh: make(chan struct{}),
|
|
redrawInterval: DefaultRedrawInterval,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt.set(td)
|
|
}
|
|
td.subscribers()
|
|
c.Subscribe(td.eds)
|
|
return td
|
|
}
|
|
|
|
// subscribers subscribes event receivers that live in this package to EDS.
|
|
func (td *termdash) subscribers() {
|
|
// Handler for all errors that occur during input event processing.
|
|
td.eds.Subscribe([]terminalapi.Event{terminalapi.NewError("")}, func(ev terminalapi.Event) {
|
|
td.handleError(ev.(*terminalapi.Error).Error())
|
|
})
|
|
|
|
// Handles terminal resize events.
|
|
td.eds.Subscribe([]terminalapi.Event{&terminalapi.Resize{}}, func(terminalapi.Event) {
|
|
td.setClearNeeded()
|
|
})
|
|
|
|
// Redraws the screen on Keyboard and Mouse events.
|
|
// These events very likely change the content of the widgets (e.g. zooming
|
|
// a LineChart) so a redraw is needed to make that visible.
|
|
td.eds.Subscribe([]terminalapi.Event{
|
|
&terminalapi.Keyboard{},
|
|
&terminalapi.Mouse{},
|
|
}, func(terminalapi.Event) {
|
|
td.evRedraw()
|
|
}, event.MaxRepetitive(0)) // No repetitive events that cause terminal redraw.
|
|
|
|
// Keyboard and Mouse subscribers specified via options.
|
|
if td.keyboardSubscriber != nil {
|
|
td.eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) {
|
|
td.keyboardSubscriber(ev.(*terminalapi.Keyboard))
|
|
})
|
|
}
|
|
if td.mouseSubscriber != nil {
|
|
td.eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) {
|
|
td.mouseSubscriber(ev.(*terminalapi.Mouse))
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleError forwards the error to the error handler if one was
|
|
// provided or panics.
|
|
func (td *termdash) handleError(err error) {
|
|
if td.errorHandler != nil {
|
|
td.errorHandler(err)
|
|
} else {
|
|
panic(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
|
|
}
|
|
|
|
// evRedraw redraws the container and its widgets.
|
|
func (td *termdash) evRedraw() error {
|
|
td.mu.Lock()
|
|
defer td.mu.Unlock()
|
|
|
|
// Don't redraw immediately, give widgets that are performing enough time
|
|
// to update.
|
|
// We don't want to actually synchronize until all widgets update, we are
|
|
// purposefully leaving slow widgets behind.
|
|
time.Sleep(25 * time.Millisecond)
|
|
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 {
|
|
ev := td.term.Event(ctx)
|
|
if ev != nil {
|
|
td.eds.Event(ev)
|
|
}
|
|
|
|
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 {
|
|
// Redraw once to initialize the container sizes.
|
|
if err := td.periodicRedraw(); err != nil {
|
|
close(td.exitCh)
|
|
return err
|
|
}
|
|
|
|
redrawTimer := time.NewTicker(td.redrawInterval)
|
|
defer redrawTimer.Stop()
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// 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
|
|
}
|