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

Merge pull request #98 from mum4k/thread-safe-widgets

Thread safe widgets
This commit is contained in:
Jakub Sobon 2019-01-27 00:14:54 -05:00 committed by GitHub
commit c80cb19c07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 213 additions and 19 deletions

View File

@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The LineChart now has an option to change the behavior of the Y axis from
zero anchored to adaptive.
- Lint errors reported on the Go report card.
- Widgets now correctly handle a race when new user data are supplied between
calls to their Options() and Draw() methods.
## [0.5.0] - 21-Jan-2019

View File

@ -135,8 +135,7 @@ func drawResize(c *Container, area image.Rectangle) error {
if err != nil {
return err
}
if err := draw.Text(cvs, "⇄", image.Point{0, 0}); err != nil {
if err := draw.ResizeNeeded(cvs); err != nil {
return err
}
return cvs.Apply(c.term)

View File

@ -10,8 +10,8 @@ callers to set the displayed percentage.
## Thread safety
All widget implementations must be thread safe, since the infrastructure calls
the widget's **Draw()** method concurrently with the user of the widget setting
the displayed values.
the widget's **Options** and **Draw()** method concurrently with the user of
the widget setting the displayed values.
## Drawing the widget's content
@ -38,12 +38,36 @@ canvas in order to handle under sized or over sized terminals gracefully.
If the current size of the terminal and the configured container splits result
in a canvas smaller than the **MinimumSize**, the infrastructure won't call the
widget's **Draw()** method. The widgets can use this to prevent impossible
scenarios where an error would have to be returned.
scenarios where an error would have to be returned. Note that if the values
returned on a call to the **Options** method aren't static, but depend on the
user data provided to the widget, the widget **must** protect against the
scenario where the infrastructure provides a canvas that doesn't match the
returned options. This is because the infrastructure cannot guarantee the user
won't change the data between calls to **Options** and **Draw**.
A widget can draw a character indicating that a resize is needed in such cases:
```go
func (w *Widget) Draw(cvs *canvas.Canvas) error {
min := w.minSize() // Output depends on the current state.
needAr, err := area.FromSize(min)
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
// Draw the widget.
return nil
}
```
If the container configuration results in a canvas larger than **MaximumSize**
the canvas will be limited to the specified size. Widgets can either specify a
limit for both the maximum width and height or limit just one of them.
## Unit tests
Unit tests utilize the **faketerm** package which is a fake implementation of a

View File

@ -65,3 +65,10 @@ func MustBrailleCircle(bc *braille.Canvas, mid image.Point, radius int, opts ...
panic(fmt.Sprintf("draw.BrailleCircle => unexpected error: %v", err))
}
}
// MustResizeNeeded draws the character or panics.
func MustResizeNeeded(cvs *canvas.Canvas) {
if err := draw.ResizeNeeded(cvs); err != nil {
panic(fmt.Sprintf("draw.ResizeNeeded => unexpected error: %v", err))
}
}

View File

@ -187,3 +187,9 @@ func Text(c *canvas.Canvas, text string, start image.Point, opts ...TextOption)
}
return nil
}
// ResizeNeeded draws an unicode character indicating that the canvas size is
// too small to draw meaningful content.
func ResizeNeeded(cvs *canvas.Canvas) error {
return Text(cvs, "⇄", image.Point{0, 0})
}

View File

@ -601,3 +601,56 @@ func TestText(t *testing.T) {
})
}
}
func TestResizeNeeded(t *testing.T) {
tests := []struct {
desc string
canvas image.Rectangle
want func(size image.Point) *faketerm.Terminal
}{
{
desc: "draws the resize needed character",
canvas: image.Rect(0, 0, 1, 1),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⇄')
testcanvas.MustApply(cvs, ft)
return ft
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
cvs, err := canvas.New(tc.canvas)
if err != nil {
t.Fatalf("canvas.New => unexpected error: %v", err)
}
if err := ResizeNeeded(cvs); err != nil {
t.Fatalf("ResizeNeeded => unexpected error: %v", err)
}
got, err := faketerm.New(cvs.Size())
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
if err := cvs.Apply(got); err != nil {
t.Fatalf("Apply => unexpected error: %v", err)
}
want, err := faketerm.New(cvs.Size())
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
if tc.want != nil {
want = tc.want(cvs.Size())
}
if diff := faketerm.Diff(want, got); diff != "" {
t.Errorf("ResizeNeeded => %v", diff)
}
})
}
}

View File

@ -83,5 +83,13 @@ type Widget interface {
// This is how the widget indicates to the infrastructure whether it is
// interested in keyboard or mouse shortcuts, what is its minimum canvas
// size, etc.
//
// Most widgets will return statically compiled options (minimum and
// maximum size, etc.). If the returned options depend on the runtime state
// of the widget (e.g. the user data provided to the widget), the widget
// must protect against a case where the infrastructure calls the Draw
// method with a canvas that doesn't meet the requested options. This is
// because the data in the widget might change between calls to Options and
// Draw.
Options() Options
}

View File

@ -23,6 +23,7 @@ import (
"sync"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/draw"
@ -68,6 +69,14 @@ func (bc *BarChart) Draw(cvs *canvas.Canvas) error {
bc.mu.Lock()
defer bc.mu.Unlock()
needAr, err := area.FromSize(bc.minSize())
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
for i, v := range bc.values {
r, err := bc.barRect(cvs, i, v)
if err != nil {

View File

@ -107,6 +107,24 @@ func TestGauge(t *testing.T) {
},
wantUpdateErr: true,
},
{
desc: "draws resize needed character when canvas is smaller than requested",
bc: New(
Char('o'),
),
update: func(bc *BarChart) error {
return bc.Values([]int{0, 2, 5, 10}, 10)
},
canvas: image.Rect(0, 0, 1, 1),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustResizeNeeded(c)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "displays bars",
bc: New(

View File

@ -249,6 +249,14 @@ func (g *Gauge) Draw(cvs *canvas.Canvas) error {
g.mu.Lock()
defer g.mu.Unlock()
needAr, err := area.FromSize(g.minSize())
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
if g.hasBorder() {
if err := draw.Border(cvs, cvs.Area(),
draw.BorderLineStyle(g.opts.border),

View File

@ -74,6 +74,23 @@ func TestGauge(t *testing.T) {
return ft
},
},
{
desc: "draws resize needed character when canvas is smaller than requested",
gauge: New(
Char('o'),
Border(draw.LineStyleLight),
),
percent: &percentCall{p: 35},
canvas: image.Rect(0, 0, 1, 1),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustResizeNeeded(c)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "aligns the progress text top and left",
gauge: New(

View File

@ -22,6 +22,7 @@ import (
"sort"
"sync"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/canvas/braille"
"github.com/mum4k/termdash/cell"
@ -173,6 +174,14 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error {
lc.mu.Lock()
defer lc.mu.Unlock()
needAr, err := area.FromSize(lc.minSize())
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
yd, err := lc.yAxis.Details(cvs.Area(), lc.opts.yAxisMode)
if err != nil {
return fmt.Errorf("lc.yAxis.Details => %v", err)
@ -285,19 +294,24 @@ func (lc *LineChart) Mouse(m *terminalapi.Mouse) error {
return errors.New("the LineChart widget doesn't support mouse events")
}
// Options implements widgetapi.Widget.Options.
func (lc *LineChart) Options() widgetapi.Options {
lc.mu.Lock()
defer lc.mu.Unlock()
// minSize determines the minimum required size to draw the line chart.
func (lc *LineChart) minSize() image.Point {
// At the very least we need:
// - n cells width for the Y axis and its labels as reported by it.
// - at least 1 cell width for the graph.
reqWidth := lc.yAxis.RequiredWidth() + 1
// - 2 cells height the X axis and its values and 2 for min and max labels on Y.
const reqHeight = 4
return image.Point{reqWidth, reqHeight}
}
// Options implements widgetapi.Widget.Options.
func (lc *LineChart) Options() widgetapi.Options {
lc.mu.Lock()
defer lc.mu.Unlock()
return widgetapi.Options{
MinimumSize: image.Point{reqWidth, reqHeight},
MinimumSize: lc.minSize(),
}
}

View File

@ -64,14 +64,16 @@ func TestLineChartDraws(t *testing.T) {
wantWriteErr: true,
},
{
desc: "draw fails when canvas not wide enough",
canvas: image.Rect(0, 0, 2, 4),
wantErr: true,
},
{
desc: "draw fails when canvas not tall enough",
canvas: image.Rect(0, 0, 3, 3),
wantErr: true,
desc: "draws resize needed character when canvas is smaller than requested",
canvas: image.Rect(0, 0, 1, 1),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustResizeNeeded(c)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "empty without series",

View File

@ -21,6 +21,7 @@ import (
"image"
"sync"
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/draw"
@ -62,6 +63,14 @@ func (sl *SparkLine) Draw(cvs *canvas.Canvas) error {
sl.mu.Lock()
defer sl.mu.Unlock()
needAr, err := area.FromSize(sl.minSize())
if err != nil {
return err
}
if !needAr.In(cvs.Area()) {
return draw.ResizeNeeded(cvs)
}
ar := sl.area(cvs)
visible, max := visibleMax(sl.data, ar.Dx())
var curX int

View File

@ -291,6 +291,24 @@ func TestSparkLine(t *testing.T) {
return ft
},
},
{
desc: "draws resize needed character when canvas is smaller than requested",
sparkLine: New(
Height(2),
),
update: func(sl *SparkLine) error {
return sl.Add([]int{0, 100, 50, 85})
},
canvas: image.Rect(0, 0, 1, 1),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
c := testcanvas.MustNew(ft.Area())
testdraw.MustResizeNeeded(c)
testcanvas.MustApply(c, ft)
return ft
},
},
{
desc: "respects fixed height with label",
sparkLine: New(