-
Notifications
You must be signed in to change notification settings - Fork 138
/
termdash.go
362 lines (309 loc) · 9.93 KB
/
termdash.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
// 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
}