From e9cf1e1af774b5fdd6bfc60d60f6e9de0044deb7 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Sat, 23 Feb 2019 02:18:06 -0500 Subject: [PATCH] Partially completed tests for button. --- canvas/testcanvas/testcanvas.go | 7 + widgets/button/button.go | 46 ++- widgets/button/button_test.go | 355 +++++++++++++++++++++++- widgets/button/buttondemo/buttondemo.go | 2 +- widgets/button/options.go | 22 ++ 5 files changed, 412 insertions(+), 20 deletions(-) diff --git a/canvas/testcanvas/testcanvas.go b/canvas/testcanvas/testcanvas.go index 166fddb9..04396408 100644 --- a/canvas/testcanvas/testcanvas.go +++ b/canvas/testcanvas/testcanvas.go @@ -51,6 +51,13 @@ func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) i return cells } +// MustSetAreaCells sets the cells in the area or panics. +func MustSetAreaCells(c *canvas.Canvas, cellArea image.Rectangle, r rune, opts ...cell.Option) { + if err := c.SetAreaCells(cellArea, r, opts...); err != nil { + panic(fmt.Sprintf("canvas.SetAreaCells => unexpected error: %v", err)) + } +} + // MustCell returns the cell or panics. func MustCell(c *canvas.Canvas, p image.Point) *cell.Cell { cell, err := c.Cell(p) diff --git a/widgets/button/button.go b/widgets/button/button.go index 3b7ba363..0b1cfb2e 100644 --- a/widgets/button/button.go +++ b/widgets/button/button.go @@ -17,8 +17,10 @@ package button import ( + "errors" "image" "sync" + "time" "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/canvas" @@ -56,6 +58,12 @@ type Button struct { // state is the current state of the button. state button.State + // keyTriggerTime is the last time the button was pressed using a keyboard + // key. It is nil if the button was triggered by a mouse event. + // Used to draw button presses on keyboard events, since termbox doesn't + // provide us with release events for keys. + keyTriggerTime *time.Time + // callback gets called on each button press. callback CallbackFn @@ -69,6 +77,10 @@ type Button struct { // New returns a new Button that will display the provided text. // Each press of the button will invoke the callback function. func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) { + if cFn == nil { + return nil, errors.New("the CallbackFn argument cannot be nil") + } + opt := newOptions(text) for _, o := range opts { o.set(opt) @@ -84,16 +96,33 @@ func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) { }, nil } +var ( + // Runes to use in cells that contain the button. + // Changed from tests to provide readable test failures. + buttonRune = ' ' + // Runes to use in cells that contain the shadow. + // Changed from tests to provide readable test failures. + shadowRune = ' ' +) + // Draw draws the Button widget onto the canvas. // Implements widgetapi.Widget.Draw. func (b *Button) Draw(cvs *canvas.Canvas) error { b.mu.Lock() defer b.mu.Unlock() + if b.keyTriggerTime != nil { + since := time.Since(*b.keyTriggerTime) + if since > b.opts.keyUpDelay { + b.state = button.Up + } + } + cvsAr := cvs.Area() + b.mouseFSM.UpdateArea(cvsAr) - shadowAr := image.Rect(1, 1, cvsAr.Dx(), cvsAr.Dy()) - if err := cvs.SetAreaCellOpts(shadowAr, cell.BgColor(b.opts.shadowColor)); err != nil { + shadowAr := image.Rect(shadowWidth, shadowWidth, cvsAr.Dx(), cvsAr.Dy()) + if err := cvs.SetAreaCells(shadowAr, shadowRune, cell.BgColor(b.opts.shadowColor)); err != nil { return err } @@ -104,10 +133,9 @@ func (b *Button) Draw(cvs *canvas.Canvas) error { buttonAr = shadowAr } - if err := cvs.SetAreaCellOpts(buttonAr, cell.BgColor(b.opts.fillColor)); err != nil { + if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil { return err } - b.mouseFSM.UpdateArea(buttonAr) textAr := image.Rect(buttonAr.Min.X+1, buttonAr.Min.Y, buttonAr.Dx()-1, buttonAr.Max.Y) start, err := align.Text(textAr, b.text, align.HorizontalCenter, align.VerticalMiddle) @@ -116,12 +144,11 @@ func (b *Button) Draw(cvs *canvas.Canvas) error { } if err := draw.Text(cvs, b.text, start, draw.TextOverrunMode(draw.OverrunModeThreeDot), - draw.TextMaxX(textAr.Max.X), + draw.TextMaxX(buttonAr.Max.X), draw.TextCellOpts(cell.FgColor(b.opts.textColor)), ); err != nil { return err } - return nil } @@ -134,6 +161,9 @@ func (b *Button) Keyboard(k *terminalapi.Keyboard) error { defer b.mu.Unlock() if k.Key == b.opts.key { + b.state = button.Down + now := time.Now().UTC() + b.keyTriggerTime = &now return b.callback() } return nil @@ -149,6 +179,8 @@ func (b *Button) Mouse(m *terminalapi.Mouse) error { clicked, state := b.mouseFSM.Event(m) b.state = state + b.keyTriggerTime = nil + if clicked { return b.callback() } @@ -168,6 +200,6 @@ func (b *Button) Options() widgetapi.Options { MinimumSize: image.Point{width, height}, MaximumSize: image.Point{width, height}, WantKeyboard: b.opts.keyScope, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, } } diff --git a/widgets/button/button_test.go b/widgets/button/button_test.go index cf818490..1add9b61 100644 --- a/widgets/button/button_test.go +++ b/widgets/button/button_test.go @@ -19,10 +19,16 @@ import ( "image" "sync" "testing" + "time" "github.com/kylelemons/godebug/pretty" "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/canvas/testcanvas" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/draw/testdraw" "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/mouse" "github.com/mum4k/termdash/terminal/faketerm" "github.com/mum4k/termdash/terminalapi" "github.com/mum4k/termdash/widgetapi" @@ -61,6 +67,7 @@ func TestButton(t *testing.T) { tests := []struct { desc string text string + callback *callbackTracker opts []Option events []terminalapi.Event canvas image.Rectangle @@ -68,12 +75,311 @@ func TestButton(t *testing.T) { wantCallback *callbackTracker wantNewErr bool wantDrawErr bool - }{} + }{ + { + desc: "New fails with nil callback", + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with negative keyUpDelay", + callback: &callbackTracker{}, + opts: []Option{ + KeyUpDelay(-1 * time.Second), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with zero Height", + callback: &callbackTracker{}, + opts: []Option{ + Height(0), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with zero Width", + callback: &callbackTracker{}, + opts: []Option{ + Width(0), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "draws button in up state", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button in down state due to a mouse event", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "mouse triggered the callback", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "draws button in down state due to a keyboard event, callback triggered", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "keyboard event ignored when no key specified", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + + // Keyboard event ignored when no key configured + // Draws button down by key + trigger. + // Releases button after key press. + // Doesn't release button after key press if before KeyUpDelay. + // Ignores unrelated key. + // Key works when KeyScopeFocused. + // sets custom key + // Ignores key outside of the container on KeyScopeFocused. + // Accepts key outside of the container on KeyScopeFlobal. + // Triggers callback multiple times. + // Callback returns an error. + // Custom height. + // Different width due to text. + // Different width due to WidthFor. + // Trims text on custom width. + + { + desc: "sets custom text color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + TextColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom fill color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + FillColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorRed)) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorRed)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom shadow color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + ShadowColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorRed)) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + } + + buttonRune = 'x' + shadowRune = 's' for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - gotCallback := &callbackTracker{} - b, err := New(tc.text, gotCallback.callback, tc.opts...) + gotCallback := tc.callback + var cFn CallbackFn + if gotCallback == nil { + cFn = nil + } else { + cFn = gotCallback.callback + } + b, err := New(tc.text, cFn, tc.opts...) if (err != nil) != tc.wantNewErr { t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) } @@ -81,8 +387,33 @@ func TestButton(t *testing.T) { return } + { + // Draw once which initializes the mouse state machine with the current canvas area. + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + err = b.Draw(c) + if (err != nil) != tc.wantDrawErr { + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) + } + if err != nil { + return + } + } + for _, ev := range tc.events { - switch ev.(type) { + switch e := ev.(type) { + case *terminalapi.Mouse: + if err := b.Mouse(e); err != nil { + t.Fatalf("Mouse => unexpected error: %v", err) + } + + case *terminalapi.Keyboard: + if err := b.Keyboard(e); err != nil { + t.Fatalf("Keyboard => unexpected error: %v", err) + } + default: t.Fatalf("unsupported event type: %T", ev) } @@ -142,7 +473,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{14, 4}, MaximumSize: image.Point{14, 4}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -152,7 +483,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{13, 4}, MaximumSize: image.Point{13, 4}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -165,7 +496,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{13, 4}, MaximumSize: image.Point{13, 4}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -178,7 +509,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{8, 11}, MaximumSize: image.Point{8, 11}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -191,7 +522,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{11, 4}, MaximumSize: image.Point{11, 4}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, @@ -202,7 +533,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{8, 4}, MaximumSize: image.Point{8, 4}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -215,7 +546,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{8, 4}, MaximumSize: image.Point{8, 4}, WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, { @@ -228,7 +559,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{8, 4}, MaximumSize: image.Point{8, 4}, WantKeyboard: widgetapi.KeyScopeGlobal, - WantMouse: widgetapi.MouseScopeWidget, + WantMouse: widgetapi.MouseScopeGlobal, }, }, } diff --git a/widgets/button/buttondemo/buttondemo.go b/widgets/button/buttondemo/buttondemo.go index e8d5247b..2844db2b 100644 --- a/widgets/button/buttondemo/buttondemo.go +++ b/widgets/button/buttondemo/buttondemo.go @@ -108,7 +108,7 @@ func main() { } } - if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(1*time.Second)); err != nil { + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(100*time.Millisecond)); err != nil { panic(err) } } diff --git a/widgets/button/options.go b/widgets/button/options.go index 8633223f..3ac03d81 100644 --- a/widgets/button/options.go +++ b/widgets/button/options.go @@ -18,6 +18,7 @@ package button import ( "fmt" + "time" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/cell/runewidth" @@ -48,6 +49,7 @@ type options struct { width int key keyboard.Key keyScope widgetapi.KeyScope + keyUpDelay time.Duration } // validate validates the provided options. @@ -58,6 +60,9 @@ func (o *options) validate() error { if min := 1; o.width < min { return fmt.Errorf("invalid width %d, must be %d <= width", o.width, min) } + if min := time.Duration(0); o.keyUpDelay < min { + return fmt.Errorf("invalid keyUpDelay %v, must be %v <= keyUpDelay", o.keyUpDelay, min) + } return nil } @@ -69,6 +74,7 @@ func newOptions(text string) *options { shadowColor: cell.ColorNumber(240), height: DefaultHeight, width: widthFor(text), + keyUpDelay: DefaultKeyUpDelay, } } @@ -143,6 +149,22 @@ func GlobalKey(k keyboard.Key) Option { }) } +// DefaultKeyUpDelay is the default value for the KeyUpDelay option. +const DefaultKeyUpDelay = 250 * time.Millisecond + +// KeyUpDelay is the amount of time the button will remain "pressed down" after +// triggered by the configured key. Termbox doesn't emit events for key +// releases so the button simulates it by timing it. +// This only works if the manual termdash redraw or the periodic redraw +// interval are reasonably close to this delay. +// The duration cannot be negative. +// Defaults to DefaultKeyUpDelay. +func KeyUpDelay(d time.Duration) Option { + return option(func(opts *options) { + opts.keyUpDelay = d + }) +} + // widthFor returns the required width for the specified text. func widthFor(text string) int { return runewidth.StringWidth(text) + 2 // One empty cell at each side.