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

Merge pull request #149 from mum4k/button

Implementing the Button widget.
This commit is contained in:
Jakub Sobon 2019-02-23 20:22:28 -05:00 committed by GitHub
commit 1d3071e969
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2414 additions and 111 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- The Button widget.
- A function that draws text vertically.
- The LineChart widget can display X axis labels in vertical orientation.
- The LineChart widget allows the user to specify a custom scale for the Y
@ -34,10 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
subscriber.
- The infrastructure now throttles event driven screen redraw rather than
redrawing for each input event.
- Widgets can now specify the scope at which they want to receive keyboard
events, i.e. KeyScopeNone for no events, KeyScopeFocused to receive events
only if the parent container is focused and KeyScopeGlobal to receive all
keyboard events.
- Widgets can now specify the scope at which they want to receive keyboard and
mouse events.
#### Breaking API changes
@ -55,8 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
distribution system. This shouldn't affect users as the removed methods
aren't needed by container users.
- The widgetapi.Options struct now uses an enum instead of a boolean when
widget specifies if it wants keyboard events. This affects development of new
widgets.
widget specifies if it wants keyboard or mouse events. This only impacts
development of new widgets.
### Fixed

View File

@ -8,7 +8,7 @@
# termdash
[<img src="./images/termdashdemo_0_6_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
[<img src="./images/termdashdemo_0_7_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
This project implements a cross-platform customizable terminal based dashboard.
The feature set is inspired by the
@ -62,6 +62,18 @@ Project documentation is available in the [doc](doc/) directory.
## Implemented Widgets
### The Button
Allows users to interact with the application, each button press runs a callback function.
Run the
[buttondemo](widgets/button/buttondemo/buttondemo.go).
```go
go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go
```
[<img src="./images/buttondemo.gif" alt="buttondemo" type="image/gif">](widgets/button/buttondemo/buttondemo.go)
### The Gauge
Displays the progress of an operation. Run the

View File

@ -295,7 +295,7 @@ func TestBraille(t *testing.T) {
wantErr: true,
},
{
desc: "SetCellOptions sets the cell options in full area",
desc: "SetAreaCellOptions sets the cell options in full area",
ar: image.Rect(0, 0, 1, 1),
pixelOps: func(c *Canvas) error {
return c.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
@ -315,7 +315,7 @@ func TestBraille(t *testing.T) {
},
},
{
desc: "SetCellOptions sets the cell options in a sub-area",
desc: "SetAreaCellOptions sets the cell options in a sub-area",
ar: image.Rect(0, 0, 3, 3),
pixelOps: func(c *Canvas) error {
return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))

View File

@ -21,6 +21,7 @@ import (
"github.com/mum4k/termdash/area"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/cell/runewidth"
"github.com/mum4k/termdash/terminalapi"
)
@ -94,6 +95,72 @@ func (c *Canvas) Cell(p image.Point) (*cell.Cell, error) {
return c.buffer[p.X][p.Y].Copy(), nil
}
// SetCellOpts sets options on the specified cell of the canvas without
// modifying the content of the cell.
// Sets the default cell options if no options are provided.
// This method is idempotent.
func (c *Canvas) SetCellOpts(p image.Point, opts ...cell.Option) error {
curCell, err := c.Cell(p)
if err != nil {
return err
}
if len(opts) == 0 {
// Set the default options.
opts = []cell.Option{
cell.FgColor(cell.ColorDefault),
cell.BgColor(cell.ColorDefault),
}
}
if _, err := c.SetCell(p, curCell.Rune, opts...); err != nil {
return err
}
return nil
}
// SetAreaCells is like SetCell, but sets the specified rune and options on all
// the cells within the provided area.
// This method is idempotent.
func (c *Canvas) SetAreaCells(cellArea image.Rectangle, r rune, opts ...cell.Option) error {
haveArea := c.Area()
if !cellArea.In(haveArea) {
return fmt.Errorf("unable to set cell runes in area %v, it must fit inside the available cell area is %v", cellArea, haveArea)
}
rw := runewidth.RuneWidth(r)
for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ {
for col := cellArea.Min.X; col < cellArea.Max.X; {
p := image.Point{col, row}
if col+rw > cellArea.Max.X {
break
}
cells, err := c.SetCell(p, r, opts...)
if err != nil {
return err
}
col += cells
}
}
return nil
}
// SetAreaCellOpts is like SetCellOpts, but sets the specified options on all
// the cells within the provided area.
func (c *Canvas) SetAreaCellOpts(cellArea image.Rectangle, opts ...cell.Option) error {
haveArea := c.Area()
if !cellArea.In(haveArea) {
return fmt.Errorf("unable to set cell options in area %v, it must fit inside the available cell area is %v", cellArea, haveArea)
}
for col := cellArea.Min.X; col < cellArea.Max.X; col++ {
for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ {
if err := c.SetCellOpts(image.Point{col, row}, opts...); err != nil {
return err
}
}
}
return nil
}
// setCellFunc is a function that sets cell content on a terminal or a canvas.
type setCellFunc func(image.Point, rune, ...cell.Option) error

View File

@ -100,6 +100,445 @@ func TestNew(t *testing.T) {
}
}
func TestCanvas(t *testing.T) {
tests := []struct {
desc string
canvas image.Rectangle
ops func(*Canvas) error
want func(size image.Point) (*faketerm.Terminal, error)
wantErr bool
}{
{
desc: "SetCellOpts fails on a point outside of the canvas",
canvas: image.Rect(0, 0, 1, 1),
ops: func(cvs *Canvas) error {
return cvs.SetCellOpts(image.Point{1, 1})
},
wantErr: true,
},
{
desc: "SetCellOpts sets options on a cell with no options",
canvas: image.Rect(0, 0, 2, 2),
ops: func(cvs *Canvas) error {
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
c, err := cvs.Cell(image.Point{0, 1})
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 1}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetCellOpts preserves cell rune",
canvas: image.Rect(0, 0, 2, 2),
ops: func(cvs *Canvas) error {
if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil {
return err
}
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetCellOpts overwrites options set previously",
canvas: image.Rect(0, 0, 2, 2),
ops: func(cvs *Canvas) error {
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return err
}
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow))
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow)); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetCellOpts sets default options when no options provided",
canvas: image.Rect(0, 0, 2, 2),
ops: func(cvs *Canvas) error {
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return err
}
return cvs.SetCellOpts(image.Point{0, 1})
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetCellOpts is idempotent",
canvas: image.Rect(0, 0, 2, 2),
ops: func(cvs *Canvas) error {
if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil {
return err
}
if err := cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return err
}
return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCellOpts fails on area too large",
canvas: image.Rect(0, 0, 1, 1),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
wantErr: true,
},
{
desc: "SetAreaCellOpts sets options in the full canvas",
canvas: image.Rect(0, 0, 1, 1),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
for _, p := range []image.Point{
{0, 0},
} {
c, err := cvs.Cell(p)
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return nil, err
}
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCellOpts sets options in a sub-area",
canvas: image.Rect(0, 0, 3, 3),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
for _, p := range []image.Point{
{0, 0},
{0, 1},
{1, 0},
{1, 1},
} {
c, err := cvs.Cell(p)
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return nil, err
}
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCells sets cells in the full canvas",
canvas: image.Rect(0, 0, 1, 1),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r')
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 0}, 'r'); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCells is idempotent",
canvas: image.Rect(0, 0, 1, 1),
ops: func(cvs *Canvas) error {
if err := cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r'); err != nil {
return err
}
return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r')
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 0}, 'r'); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCells fails on area too large",
canvas: image.Rect(0, 0, 1, 1),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
wantErr: true,
},
{
desc: "SetAreaCells sets cell options",
canvas: image.Rect(0, 0, 1, 1),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
if _, err := cvs.SetCell(image.Point{0, 0}, 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
return nil, err
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCells sets cell in a sub-area",
canvas: image.Rect(0, 0, 3, 3),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), 'p')
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
for _, p := range []image.Point{
{0, 0},
{0, 1},
{1, 0},
{1, 1},
} {
if _, err := cvs.SetCell(p, 'p'); err != nil {
return nil, err
}
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCells sets full-width runes that fit",
canvas: image.Rect(0, 0, 3, 3),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), '世')
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
for _, p := range []image.Point{
{0, 0},
{0, 1},
} {
if _, err := cvs.SetCell(p, '世'); err != nil {
return nil, err
}
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
{
desc: "SetAreaCells sets full-width runes that will leave a gap at the end of each row",
canvas: image.Rect(0, 0, 3, 3),
ops: func(cvs *Canvas) error {
return cvs.SetAreaCells(image.Rect(0, 0, 3, 3), '世')
},
want: func(size image.Point) (*faketerm.Terminal, error) {
ft := faketerm.MustNew(size)
cvs, err := New(ft.Area())
if err != nil {
return nil, err
}
for _, p := range []image.Point{
{0, 0},
{0, 1},
{0, 2},
} {
if _, err := cvs.SetCell(p, '世'); err != nil {
return nil, err
}
}
if err := cvs.Apply(ft); err != nil {
return nil, err
}
return ft, nil
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
cvs, err := New(tc.canvas)
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
if tc.ops != nil {
err := tc.ops(cvs)
if (err != nil) != tc.wantErr {
t.Errorf("tc.ops => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
if err != nil {
return
}
}
size := cvs.Size()
got, err := faketerm.New(size)
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
if err := cvs.Apply(got); err != nil {
t.Fatalf("cvs.Apply => %v", err)
}
var want *faketerm.Terminal
if tc.want != nil {
want, err = tc.want(size)
if err != nil {
t.Fatalf("tc.want => unexpected error: %v", err)
}
} else {
w, err := faketerm.New(size)
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
want = w
}
if diff := faketerm.Diff(want, got); diff != "" {
t.Errorf("cvs.SetCellOpts => %v", diff)
}
})
}
}
func TestSetCellAndApply(t *testing.T) {
tests := []struct {
desc string

View File

@ -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)

View File

@ -200,26 +200,18 @@ func (c *Container) updateFocus(m *terminalapi.Mouse) {
}
// keyboardToWidget forwards the keyboard event to the widget unconditionally.
func (c *Container) keyboardToWidget(k *terminalapi.Keyboard) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.opts.widget.Keyboard(k)
}
// keyboardToFocusedWidget forwards the keyboard event to the widget if its
// container is focused.
func (c *Container) keyboardToFocusedWidget(k *terminalapi.Keyboard) error {
func (c *Container) keyboardToWidget(k *terminalapi.Keyboard, scope widgetapi.KeyScope) error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.focusTracker.isActive(c) {
if scope == widgetapi.KeyScopeFocused && !c.focusTracker.isActive(c) {
return nil
}
return c.opts.widget.Keyboard(k)
}
// mouseToWidget forwards the mouse event to the widget.
func (c *Container) mouseToWidget(m *terminalapi.Mouse) error {
func (c *Container) mouseToWidget(m *terminalapi.Mouse, scope widgetapi.MouseScope) error {
c.mu.Lock()
defer c.mu.Unlock()
@ -229,7 +221,7 @@ func (c *Container) mouseToWidget(m *terminalapi.Mouse) error {
}
// Ignore clicks falling outside of the container.
if !m.Position.In(target.usable()) {
if scope != widgetapi.MouseScopeGlobal && !m.Position.In(c.area) {
return nil
}
@ -238,7 +230,7 @@ func (c *Container) mouseToWidget(m *terminalapi.Mouse) error {
if err != nil {
return err
}
if !m.Position.In(wa) {
if scope == widgetapi.MouseScopeWidget && !m.Position.In(wa) {
return nil
}
@ -246,9 +238,17 @@ func (c *Container) mouseToWidget(m *terminalapi.Mouse) error {
// based, even though the widget might not be in the top left corner on the
// terminal.
offset := wa.Min
wm := &terminalapi.Mouse{
Position: m.Position.Sub(offset),
Button: m.Button,
var wm *terminalapi.Mouse
if m.Position.In(wa) {
wm = &terminalapi.Mouse{
Position: m.Position.Sub(offset),
Button: m.Button,
}
} else {
wm = &terminalapi.Mouse{
Position: image.Point{-1, -1},
Button: m.Button,
}
}
return c.opts.widget.Mouse(wm)
}
@ -274,28 +274,25 @@ func (c *Container) Subscribe(eds *event.DistributionSystem) {
preOrder(root, &errStr, visitFunc(func(c *Container) error {
if c.hasWidget() {
wOpt := c.opts.widget.Options()
switch wOpt.WantKeyboard {
switch scope := wOpt.WantKeyboard; scope {
case widgetapi.KeyScopeNone:
// Widget doesn't want any keyboard events.
case widgetapi.KeyScopeFocused:
default:
eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) {
if err := c.keyboardToFocusedWidget(ev.(*terminalapi.Keyboard)); err != nil {
eds.Event(terminalapi.NewErrorf("failed to send keyboard event %v to widget %T: %v", ev, c.opts.widget, err))
}
}, event.MaxRepetitive(maxReps))
case widgetapi.KeyScopeGlobal:
eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) {
if err := c.keyboardToWidget(ev.(*terminalapi.Keyboard)); err != nil {
if err := c.keyboardToWidget(ev.(*terminalapi.Keyboard), scope); err != nil {
eds.Event(terminalapi.NewErrorf("failed to send global keyboard event %v to widget %T: %v", ev, c.opts.widget, err))
}
}, event.MaxRepetitive(maxReps))
}
if wOpt.WantMouse {
switch scope := wOpt.WantMouse; scope {
case widgetapi.MouseScopeNone:
// Widget doesn't want any mouse events.
default:
eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) {
if err := c.mouseToWidget(ev.(*terminalapi.Mouse)); err != nil {
if err := c.mouseToWidget(ev.(*terminalapi.Mouse), scope); err != nil {
eds.Event(terminalapi.NewErrorf("failed to send mouse event %v to widget %T: %v", ev, c.opts.widget, err))
}
}, event.MaxRepetitive(maxReps))

View File

@ -841,7 +841,7 @@ func TestMouse(t *testing.T) {
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
)
},
events: []terminalapi.Event{
@ -883,15 +883,15 @@ func TestMouse(t *testing.T) {
ft,
SplitVertical(
Left(
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
),
Right(
SplitHorizontal(
Top(
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
),
Bottom(
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
),
),
),
@ -913,7 +913,7 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
testcanvas.MustNew(image.Rect(25, 10, 50, 20)),
widgetapi.Options{WantMouse: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Keyboard{},
)
@ -921,7 +921,7 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
testcanvas.MustNew(image.Rect(25, 0, 50, 10)),
widgetapi.Options{WantMouse: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonLeft},
&terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonRelease},
)
@ -935,7 +935,7 @@ func TestMouse(t *testing.T) {
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: false})),
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeNone})),
)
},
events: []terminalapi.Event{
@ -954,14 +954,14 @@ func TestMouse(t *testing.T) {
wantProcessed: 1,
},
{
desc: "event not forwarded if it falls on the container's border",
desc: "MouseScopeWidget, event not forwarded if it falls on the container's border",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
Border(draw.LineStyleLight),
PlaceWidget(
fakewidget.New(widgetapi.Options{WantMouse: true}),
fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}),
),
)
},
@ -989,14 +989,86 @@ func TestMouse(t *testing.T) {
wantProcessed: 2,
},
{
desc: "event not forwarded if it falls outside of widget's canvas",
desc: "MouseScopeContainer, event forwarded if it falls on the container's border",
termSize: image.Point{21, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
Border(draw.LineStyleLight),
PlaceWidget(
fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeContainer}),
),
)
},
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())
testdraw.MustBorder(
cvs,
ft.Area(),
draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
)
testcanvas.MustApply(cvs, ft)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
)
return ft
},
wantProcessed: 2,
},
{
desc: "MouseScopeGlobal, event forwarded if it falls on the container's border",
termSize: image.Point{21, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
Border(draw.LineStyleLight),
PlaceWidget(
fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeGlobal}),
),
)
},
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())
testdraw.MustBorder(
cvs,
ft.Area(),
draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)),
)
testcanvas.MustApply(cvs, ft)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
)
return ft
},
wantProcessed: 2,
},
{
desc: "MouseScopeWidget event not forwarded if it falls outside of widget's canvas",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
Ratio: image.Point{2, 1},
}),
),
@ -1020,14 +1092,187 @@ func TestMouse(t *testing.T) {
wantProcessed: 2,
},
{
desc: "mouse poisition adjusted relative to widget's canvas, vertical offset",
desc: "MouseScopeContainer event forwarded if it falls outside of widget's canvas",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: true,
WantMouse: widgetapi.MouseScopeContainer,
Ratio: image.Point{2, 1},
}),
),
AlignVertical(align.VerticalMiddle),
AlignHorizontal(align.HorizontalCenter),
)
},
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(image.Rect(0, 5, 20, 15)),
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
)
return ft
},
wantProcessed: 2,
},
{
desc: "MouseScopeGlobal event forwarded if it falls outside of widget's canvas",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: widgetapi.MouseScopeGlobal,
Ratio: image.Point{2, 1},
}),
),
AlignVertical(align.VerticalMiddle),
AlignHorizontal(align.HorizontalCenter),
)
},
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(image.Rect(0, 5, 20, 15)),
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
)
return ft
},
wantProcessed: 2,
},
{
desc: "MouseScopeWidget event not forwarded if it falls to another container",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitHorizontal(
Top(),
Bottom(
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: widgetapi.MouseScopeWidget,
Ratio: image.Point{2, 1},
}),
),
AlignVertical(align.VerticalMiddle),
AlignHorizontal(align.HorizontalCenter),
),
),
)
},
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(image.Rect(0, 10, 20, 20)),
widgetapi.Options{},
)
return ft
},
wantProcessed: 2,
},
{
desc: "MouseScopeContainer event not forwarded if it falls to another container",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitHorizontal(
Top(),
Bottom(
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: widgetapi.MouseScopeContainer,
Ratio: image.Point{2, 1},
}),
),
AlignVertical(align.VerticalMiddle),
AlignHorizontal(align.HorizontalCenter),
),
),
)
},
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(image.Rect(0, 10, 20, 20)),
widgetapi.Options{},
)
return ft
},
wantProcessed: 2,
},
{
desc: "MouseScopeGlobal event forwarded if it falls to another container",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitHorizontal(
Top(),
Bottom(
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: widgetapi.MouseScopeGlobal,
Ratio: image.Point{2, 1},
}),
),
AlignVertical(align.VerticalMiddle),
AlignHorizontal(align.HorizontalCenter),
),
),
)
},
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(image.Rect(0, 10, 20, 20)),
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
)
return ft
},
wantProcessed: 2,
},
{
desc: "mouse position adjusted relative to widget's canvas, vertical offset",
termSize: image.Point{20, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: widgetapi.MouseScopeWidget,
Ratio: image.Point{2, 1},
}),
),
@ -1044,7 +1289,7 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
widgetapi.Options{WantMouse: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
)
return ft
@ -1059,7 +1304,7 @@ func TestMouse(t *testing.T) {
ft,
PlaceWidget(
fakewidget.New(widgetapi.Options{
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
Ratio: image.Point{9, 10},
}),
),
@ -1076,7 +1321,7 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
testcanvas.MustNew(image.Rect(6, 0, 24, 20)),
widgetapi.Options{WantMouse: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
)
return ft
@ -1089,7 +1334,7 @@ func TestMouse(t *testing.T) {
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})),
PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})),
)
},
events: []terminalapi.Event{

BIN
images/buttondemo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

View File

@ -90,6 +90,12 @@ func (fsm *FSM) Event(m *terminalapi.Mouse) (bool, State) {
return clicked, bs
}
// UpdateArea informs FSM of an area change.
// This method is idempotent.
func (fsm *FSM) UpdateArea(area image.Rectangle) {
fsm.area = area
}
// stateFn is a single state in the state machine.
// Returns bool indicating if a click happened, the state of the button and the
// next state of the FSM.

View File

@ -25,6 +25,9 @@ import (
// eventTestCase is one mouse event and the output expectation.
type eventTestCase struct {
// area if specified, will be provided to UpdateArea *before* processing the event.
area *image.Rectangle
// event is the mouse event to send.
event *terminalapi.Mouse
@ -59,6 +62,69 @@ func TestFSM(t *testing.T) {
},
},
},
{
desc: "updates area so the clicks falls outside",
button: mouse.ButtonLeft,
area: image.Rect(0, 0, 1, 1),
eventCases: []*eventTestCase{
{
area: func() *image.Rectangle {
ar := image.Rect(1, 1, 2, 2)
return &ar
}(),
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
wantClick: false,
wantState: Up,
},
{
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
wantClick: false,
wantState: Up,
},
},
},
{
desc: "updates area before release, so the release falls outside",
button: mouse.ButtonLeft,
area: image.Rect(0, 0, 1, 1),
eventCases: []*eventTestCase{
{
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
wantClick: false,
wantState: Down,
},
{
area: func() *image.Rectangle {
ar := image.Rect(1, 1, 2, 2)
return &ar
}(),
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
wantClick: false,
wantState: Up,
},
},
},
{
desc: "increased area makes the release count",
button: mouse.ButtonLeft,
area: image.Rect(0, 0, 1, 1),
eventCases: []*eventTestCase{
{
event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
wantClick: false,
wantState: Down,
},
{
area: func() *image.Rectangle {
ar := image.Rect(0, 0, 2, 2)
return &ar
}(),
event: &terminalapi.Mouse{Position: image.Point{1, 1}, Button: mouse.ButtonRelease},
wantClick: true,
wantState: Up,
},
},
},
{
desc: "tracks single right button click",
button: mouse.ButtonRight,
@ -234,6 +300,10 @@ func TestFSM(t *testing.T) {
t.Run(fmt.Sprintf(tc.desc), func(t *testing.T) {
fsm := NewFSM(tc.button, tc.area)
for _, etc := range tc.eventCases {
if etc.area != nil {
fsm.UpdateArea(*etc.area)
}
gotClick, gotState := fsm.Event(etc.event)
t.Logf("Called fsm.Event(%v) => %v, %v", etc.event, gotClick, gotState)
if gotClick != etc.wantClick || gotState != etc.wantState {

View File

@ -49,7 +49,7 @@ func Example() {
wOpts := widgetapi.Options{
MinimumSize: fakewidget.MinimumSize,
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
}
// Create the container with two fake widgets.
@ -89,7 +89,7 @@ func Example_triggered() {
wOpts := widgetapi.Options{
MinimumSize: fakewidget.MinimumSize,
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
}
// Create the container with a widget.
@ -239,7 +239,7 @@ func TestRun(t *testing.T) {
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
)
@ -265,7 +265,7 @@ func TestRun(t *testing.T) {
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
)
@ -360,7 +360,7 @@ func TestRun(t *testing.T) {
ft,
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
&terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp},
)
@ -394,7 +394,7 @@ func TestRun(t *testing.T) {
got,
container.PlaceWidget(fakewidget.New(widgetapi.Options{
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
})),
)
if err != nil {
@ -460,7 +460,7 @@ func TestController(t *testing.T) {
testcanvas.MustNew(ft.Area()),
widgetapi.Options{
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
)
@ -580,7 +580,7 @@ func TestController(t *testing.T) {
mi := fakewidget.New(widgetapi.Options{
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
})
cont, err := container.New(
got,

View File

@ -21,15 +21,18 @@ import (
"fmt"
"math"
"math/rand"
"sync"
"time"
"github.com/mum4k/termdash"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminal/termbox"
"github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widgets/barchart"
"github.com/mum4k/termdash/widgets/button"
"github.com/mum4k/termdash/widgets/donut"
"github.com/mum4k/termdash/widgets/gauge"
"github.com/mum4k/termdash/widgets/linechart"
@ -128,10 +131,34 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container,
return nil, err
}
sineLC, err := newSines(ctx)
leftB, rightB, sineLC, err := newSines(ctx)
if err != nil {
return nil, err
}
lcAndButtons := []container.Option{
container.SplitHorizontal(
container.Top(
container.Border(draw.LineStyleLight),
container.BorderTitle("Multiple series"),
container.BorderTitleAlignRight(),
container.PlaceWidget(sineLC),
),
container.Bottom(
container.SplitVertical(
container.Left(
container.PlaceWidget(leftB),
container.AlignHorizontal(align.HorizontalRight),
),
container.Right(
container.PlaceWidget(rightB),
container.AlignHorizontal(align.HorizontalLeft),
),
),
),
container.SplitPercent(80),
),
}
rightSide := []container.Option{
container.SplitHorizontal(
container.Top(
@ -148,12 +175,7 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container,
container.BorderTitleAlignRight(),
container.PlaceWidget(don),
),
container.Bottom(
container.Border(draw.LineStyleLight),
container.BorderTitle("Multiple series"),
container.BorderTitleAlignRight(),
container.PlaceWidget(sineLC),
),
container.Bottom(lcAndButtons...),
container.SplitPercent(30),
),
),
@ -422,23 +444,47 @@ func newBarChart(ctx context.Context) (*barchart.BarChart, error) {
return bc, nil
}
// newSines returns a line chart that displays multiple sine series.
func newSines(ctx context.Context) (*linechart.LineChart, error) {
// distance is a thread-safe int value used by the newSince method.
// Buttons write it and the line chart reads it.
type distance struct {
v int
mu sync.Mutex
}
// add adds the provided value to the one stored.
func (d *distance) add(v int) {
d.mu.Lock()
defer d.mu.Unlock()
d.v += v
}
// get returns the current value.
func (d *distance) get() int {
d.mu.Lock()
defer d.mu.Unlock()
return d.v
}
// newSines returns a line chart that displays multiple sine series and two buttons.
// The left button shifts the second series relative to the first series to
// the left and the right button shifts it to the right.
func newSines(ctx context.Context) (left, right *button.Button, lc *linechart.LineChart, err error) {
var inputs []float64
for i := 0; i < 200; i++ {
v := math.Sin(float64(i) / 100 * math.Pi)
inputs = append(inputs, v)
}
lc, err := linechart.New(
sineLc, err := linechart.New(
linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)),
linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)),
linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)),
)
if err != nil {
return nil, err
return nil, nil, nil, err
}
step1 := 0
secondDist := &distance{v: 100}
go periodic(ctx, redrawInterval/3, func() error {
step1 = (step1 + 1) % len(inputs)
if err := lc.Series("first", rotateFloats(inputs, step1),
@ -447,10 +493,30 @@ func newSines(ctx context.Context) (*linechart.LineChart, error) {
return err
}
step2 := (step1 + 100) % len(inputs)
step2 := (step1 + secondDist.get()) % len(inputs)
return lc.Series("second", rotateFloats(inputs, step2), linechart.SeriesCellOpts(cell.FgColor(cell.ColorWhite)))
})
return lc, nil
// diff is the difference a single button press adds or removes to the
// second series.
const diff = 20
leftB, err := button.New("(l)eft", func() error {
secondDist.add(diff)
return nil
},
button.GlobalKey('l'),
button.WidthFor("(r)ight"),
button.FillColor(cell.ColorNumber(220)),
)
rightB, err := button.New("(r)ight", func() error {
secondDist.add(-diff)
return nil
},
button.GlobalKey('r'),
button.FillColor(cell.ColorNumber(196)),
)
return leftB, rightB, sineLc, nil
}
// rotateFloats returns a new slice with inputs rotated by step.

View File

@ -55,6 +55,54 @@ const (
KeyScopeGlobal
)
// MouseScope indicates the scope at which the widget wants to receive mouse
// events.
type MouseScope int
// String implements fmt.Stringer()
func (ms MouseScope) String() string {
if n, ok := mouseScopeNames[ms]; ok {
return n
}
return "MouseScopeUnknown"
}
// mouseScopeNames maps MouseScope values to human readable names.
var mouseScopeNames = map[MouseScope]string{
MouseScopeNone: "MouseScopeNone",
MouseScopeWidget: "MouseScopeWidget",
MouseScopeContainer: "MouseScopeContainer",
MouseScopeGlobal: "MouseScopeGlobal",
}
const (
// MouseScopeNone is used when the widget doesn't want to receive any mouse
// events.
MouseScopeNone MouseScope = iota
// MouseScopeWidget is used when the widget only wants mouse events that
// fall onto its canvas.
// The position of these widgets is always relative to widget's canvas.
MouseScopeWidget
// MouseScopeContainer is used when the widget only wants mouse events that
// fall onto its container. The area size of a container is always larger
// or equal to the one of the widget's canvas. So a widget selecting
// MouseScopeContainer will either receive the same or larger amount of
// events as compared to MouseScopeWidget.
// The position of mouse events that fall outside of widget's canvas is
// reset to image.Point{-1, -1}.
// The widgets are allowed to process the button event.
MouseScopeContainer
// MouseScopeGlobal is used when the widget wants to receive all mouse
// events regardless on where on the terminal they land.
// The position of mouse events that fall outside of widget's canvas is
// reset to image.Point{-1, -1} and must not be used by the widgets.
// The widgets are allowed to process the button event.
MouseScopeGlobal
)
// Options contains registration options for a widget.
// This is how the widget indicates its needs to the infrastructure.
type Options struct {
@ -82,11 +130,13 @@ type Options struct {
// forwarded to the widget.
WantKeyboard KeyScope
// WantMouse allows a widget to request mouse events.
// If false, mouse events won't be forwarded to the widget.
// If true, the widget receives all mouse events whose coordinates fall
// within its canvas.
WantMouse bool
// WantMouse allows a widget to request mouse events and specify their
// desired scope. If set to MouseScopeNone, no mouse events are forwarded
// to the widget.
// Note that the widget is only able to see the position of the mouse event
// if it falls onto its canvas. See the documentation next to individual
// MouseScope values for details.
WantMouse MouseScope
}
// Widget is a single widget on the dashboard.

View File

@ -270,7 +270,7 @@ func (bc *BarChart) Options() widgetapi.Options {
return widgetapi.Options{
MinimumSize: bc.minSize(),
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
}
}

View File

@ -629,7 +629,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -642,7 +642,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -660,7 +660,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -678,7 +678,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{3, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -699,7 +699,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{8, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -720,7 +720,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{8, 2},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
}

209
widgets/button/button.go Normal file
View File

@ -0,0 +1,209 @@
// Copyright 2019 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 button implements an interactive widget that can be pressed to
// activate.
package button
import (
"errors"
"image"
"sync"
"time"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/canvas"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/mouse"
"github.com/mum4k/termdash/mouse/button"
"github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widgetapi"
)
// CallbackFn is the function called when the button is pressed.
// The callback function must be light-weight, ideally just storing a value and
// returning, since more button presses might occur.
//
// The callback function must be thread-safe as the mouse or keyboard events
// that press the button are processed in a separate goroutine.
//
// If the function returns an error, the widget will forward it back to the
// termdash infrastructure which causes a panic, unless the user provided a
// termdash.ErrorHandler.
type CallbackFn func() error
// Button can be pressed using a mouse click or a configured keyboard key.
//
// Upon each press, the button invokes a callback provided by the user.
//
// Implements widgetapi.Widget. This object is thread-safe.
type Button struct {
// text in the text label displayed in the button.
text string
// mouseFSM tracks left mouse clicks.
mouseFSM *button.FSM
// 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
// mu protects the widget.
mu sync.Mutex
// opts are the provided options.
opts *options
}
// 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)
}
if err := opt.validate(); err != nil {
return nil, err
}
return &Button{
text: text,
mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
callback: cFn,
opts: opt,
}, nil
}
// Vars to be replaced from tests.
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 = ' '
// timeSince is a function that calculates duration since some time.
timeSince = time.Since
)
// 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 := timeSince(*b.keyTriggerTime)
if since > b.opts.keyUpDelay {
b.state = button.Up
}
}
cvsAr := cvs.Area()
b.mouseFSM.UpdateArea(cvsAr)
shadowAr := image.Rect(shadowWidth, shadowWidth, cvsAr.Dx(), cvsAr.Dy())
if err := cvs.SetAreaCells(shadowAr, shadowRune, cell.BgColor(b.opts.shadowColor)); err != nil {
return err
}
var buttonAr image.Rectangle
if b.state == button.Up {
buttonAr = image.Rect(0, 0, cvsAr.Dx()-shadowWidth, cvsAr.Dy()-shadowWidth)
} else {
buttonAr = shadowAr
}
if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil {
return err
}
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)
if err != nil {
return err
}
if err := draw.Text(cvs, b.text, start,
draw.TextOverrunMode(draw.OverrunModeThreeDot),
draw.TextMaxX(buttonAr.Max.X),
draw.TextCellOpts(cell.FgColor(b.opts.textColor)),
); err != nil {
return err
}
return nil
}
// Keyboard processes keyboard events, acts as a button press on the configured
// Key.
//
// Implements widgetapi.Widget.Keyboard.
func (b *Button) Keyboard(k *terminalapi.Keyboard) error {
b.mu.Lock()
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
}
// Mouse processes mouse events, acts as a button press if both the press and
// the release happen inside the button.
//
// Implements widgetapi.Widget.Mouse.
func (b *Button) Mouse(m *terminalapi.Mouse) error {
b.mu.Lock()
defer b.mu.Unlock()
clicked, state := b.mouseFSM.Event(m)
b.state = state
b.keyTriggerTime = nil
if clicked {
return b.callback()
}
return nil
}
// shadowWidth is the width of the shadow under the button in cell.
const shadowWidth = 1
// Options implements widgetapi.Widget.Options.
func (b *Button) Options() widgetapi.Options {
// No need to lock, as the height and width get fixed when New is called.
width := b.opts.width + shadowWidth
height := b.opts.height + shadowWidth
return widgetapi.Options{
MinimumSize: image.Point{width, height},
MaximumSize: image.Point{width, height},
WantKeyboard: b.opts.keyScope,
WantMouse: widgetapi.MouseScopeGlobal,
}
}

View File

@ -0,0 +1,846 @@
// Copyright 2019 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 button
import (
"errors"
"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"
)
// callbackTracker tracks whether callback was called.
type callbackTracker struct {
// wantErr when set to true, makes callback return an error.
wantErr bool
// called asserts whether the callback was called.
called bool
// count is the number of times the callback was called.
count int
// mu protects the tracker.
mu sync.Mutex
}
// callback is the callback function.
func (ct *callbackTracker) callback() error {
ct.mu.Lock()
defer ct.mu.Unlock()
if ct.wantErr {
return errors.New("ct.wantErr set to true")
}
ct.count++
ct.called = true
return nil
}
func TestButton(t *testing.T) {
tests := []struct {
desc string
text string
callback *callbackTracker
opts []Option
events []terminalapi.Event
canvas image.Rectangle
// timeSince is used to replace time.Since for tests, leave nil to use
// the original.
timeSince func(time.Time) time.Duration
want func(size image.Point) *faketerm.Terminal
wantCallback *callbackTracker
wantNewErr bool
wantDrawErr bool
wantCallbackErr 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: "draw fails on canvas too small",
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 1, 1),
wantDrawErr: 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{},
},
{
desc: "keyboard event triggers the button, trigger time didn't expire so button is down",
callback: &callbackTracker{},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
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 triggers the button, custom trigger time expired so button is up",
callback: &callbackTracker{},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
KeyUpDelay(100 * time.Millisecond),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
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{
called: true,
count: 1,
},
},
{
desc: "keyboard event triggers the button multiple times",
callback: &callbackTracker{},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
KeyUpDelay(100 * time.Millisecond),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
&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{
called: true,
count: 3,
},
},
{
desc: "mouse event triggers the button multiple times",
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},
&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: 2,
},
},
{
desc: "the callback returns an error after a mouse event",
callback: &callbackTracker{
wantErr: true,
},
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},
},
wantCallbackErr: true,
},
{
desc: "the callback returns an error after a keyboard event",
callback: &callbackTracker{
wantErr: true,
},
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
KeyUpDelay(100 * time.Millisecond),
},
timeSince: func(time.Time) time.Duration {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
events: []terminalapi.Event{
&terminalapi.Keyboard{Key: keyboard.KeyEnter},
},
wantCallbackErr: true,
},
{
desc: "draws button with custom height (infra gives smaller canvas)",
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 8, 2),
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, 2), 's', cell.BgColor(cell.ColorNumber(240)))
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 1), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "hello", image.Point{1, 0},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{},
},
{
desc: "button width adjusts to width (infra gives smaller canvas)",
callback: &callbackTracker{},
text: "h",
canvas: image.Rect(0, 0, 4, 2),
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
// Shadow.
testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 4, 2), 's', cell.BgColor(cell.ColorNumber(240)))
// Button.
testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 3, 1), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
testdraw.MustText(cvs, "h", image.Point{1, 0},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
)
testcanvas.MustApply(cvs, ft)
return ft
},
wantCallback: &callbackTracker{},
},
{
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) {
if tc.timeSince != nil {
timeSince = tc.timeSince
} else {
timeSince = time.Since
}
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)
}
if err != nil {
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 i, ev := range tc.events {
switch e := ev.(type) {
case *terminalapi.Mouse:
err := b.Mouse(e)
// Only the last event in test cases is the one that triggers the callback.
if i == len(tc.events)-1 {
if (err != nil) != tc.wantCallbackErr {
t.Errorf("Mouse => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr)
}
if err != nil {
return
}
} else {
if err != nil {
t.Fatalf("Mouse => unexpected error: %v", err)
}
}
case *terminalapi.Keyboard:
err := b.Keyboard(e)
// Only the last event in test cases is the one that triggers the callback.
if i == len(tc.events)-1 {
if (err != nil) != tc.wantCallbackErr {
t.Errorf("Keyboard => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr)
}
if err != nil {
return
}
} else {
if err != nil {
t.Fatalf("Keyboard => unexpected error: %v", err)
}
}
default:
t.Fatalf("unsupported event type: %T", ev)
}
}
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
}
got, err := faketerm.New(c.Size())
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
if err := c.Apply(got); err != nil {
t.Fatalf("Apply => unexpected error: %v", err)
}
var want *faketerm.Terminal
if tc.want != nil {
want = tc.want(c.Size())
} else {
want = faketerm.MustNew(c.Size())
}
if diff := faketerm.Diff(want, got); diff != "" {
t.Errorf("Draw => %v", diff)
}
if diff := pretty.Compare(tc.wantCallback, gotCallback); diff != "" {
t.Errorf("CallbackFn => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}
func TestOptions(t *testing.T) {
tests := []struct {
desc string
text string
opts []Option
want widgetapi.Options
}{
{
desc: "width is based on the text width by default",
text: "hello world",
want: widgetapi.Options{
MinimumSize: image.Point{14, 4},
MaximumSize: image.Point{14, 4},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
desc: "width supports full-width unicode characters",
text: "■㈱の世界①",
want: widgetapi.Options{
MinimumSize: image.Point{13, 4},
MaximumSize: image.Point{13, 4},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
desc: "width specified via WidthFor",
text: "hello",
opts: []Option{
WidthFor("■㈱の世界①"),
},
want: widgetapi.Options{
MinimumSize: image.Point{13, 4},
MaximumSize: image.Point{13, 4},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
desc: "custom height specified",
text: "hello",
opts: []Option{
Height(10),
},
want: widgetapi.Options{
MinimumSize: image.Point{8, 11},
MaximumSize: image.Point{8, 11},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
desc: "custom width specified",
text: "hello",
opts: []Option{
Width(10),
},
want: widgetapi.Options{
MinimumSize: image.Point{11, 4},
MaximumSize: image.Point{11, 4},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
desc: "doesn't want keyboard by default",
text: "hello",
want: widgetapi.Options{
MinimumSize: image.Point{8, 4},
MaximumSize: image.Point{8, 4},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
desc: "registers for focused keyboard events",
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
},
want: widgetapi.Options{
MinimumSize: image.Point{8, 4},
MaximumSize: image.Point{8, 4},
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
desc: "registers for global keyboard events",
text: "hello",
opts: []Option{
GlobalKey(keyboard.KeyEnter),
},
want: widgetapi.Options{
MinimumSize: image.Point{8, 4},
MaximumSize: image.Point{8, 4},
WantKeyboard: widgetapi.KeyScopeGlobal,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
ct := &callbackTracker{}
b, err := New(tc.text, ct.callback, tc.opts...)
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
got := b.Options()
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}

View File

@ -0,0 +1,116 @@
// Copyright 2019 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.
// Binary buttondemo shows the functionality of a button widget.
package main
import (
"context"
"fmt"
"time"
"github.com/mum4k/termdash"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminal/termbox"
"github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widgets/button"
"github.com/mum4k/termdash/widgets/segmentdisplay"
)
func main() {
t, err := termbox.New()
if err != nil {
panic(err)
}
defer t.Close()
ctx, cancel := context.WithCancel(context.Background())
val := 0
display, err := segmentdisplay.New()
if err != nil {
panic(err)
}
if err := display.Write([]*segmentdisplay.TextChunk{
segmentdisplay.NewChunk(fmt.Sprintf("%d", val)),
}); err != nil {
panic(err)
}
addB, err := button.New("(a)dd", func() error {
val++
return display.Write([]*segmentdisplay.TextChunk{
segmentdisplay.NewChunk(fmt.Sprintf("%d", val)),
})
},
button.GlobalKey('a'),
button.WidthFor("(s)ubtract"),
)
if err != nil {
panic(err)
}
subB, err := button.New("(s)ubtract", func() error {
val--
return display.Write([]*segmentdisplay.TextChunk{
segmentdisplay.NewChunk(fmt.Sprintf("%d", val)),
})
},
button.FillColor(cell.ColorNumber(220)),
button.GlobalKey('s'),
)
if err != nil {
panic(err)
}
c, err := container.New(
t,
container.Border(draw.LineStyleLight),
container.BorderTitle("PRESS Q TO QUIT"),
container.SplitHorizontal(
container.Top(
container.PlaceWidget(display),
),
container.Bottom(
container.SplitVertical(
container.Left(
container.PlaceWidget(addB),
container.AlignHorizontal(align.HorizontalRight),
),
container.Right(
container.PlaceWidget(subB),
container.AlignHorizontal(align.HorizontalLeft),
),
),
),
container.SplitPercent(60),
),
)
if err != nil {
panic(err)
}
quitter := func(k *terminalapi.Keyboard) {
if k.Key == 'q' || k.Key == 'Q' {
cancel()
}
}
if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(100*time.Millisecond)); err != nil {
panic(err)
}
}

171
widgets/button/options.go Normal file
View File

@ -0,0 +1,171 @@
// Copyright 2019 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 button
// options.go contains configurable options for Button.
import (
"fmt"
"time"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/cell/runewidth"
"github.com/mum4k/termdash/keyboard"
"github.com/mum4k/termdash/widgetapi"
)
// Option is used to provide options.
type Option interface {
// set sets the provided option.
set(*options)
}
// option implements Option.
type option func(*options)
// set implements Option.set.
func (o option) set(opts *options) {
o(opts)
}
// options holds the provided options.
type options struct {
fillColor cell.Color
textColor cell.Color
shadowColor cell.Color
height int
width int
key keyboard.Key
keyScope widgetapi.KeyScope
keyUpDelay time.Duration
}
// validate validates the provided options.
func (o *options) validate() error {
if min := 1; o.height < min {
return fmt.Errorf("invalid height %d, must be %d <= height", o.height, min)
}
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
}
// newOptions returns options with the default values set.
func newOptions(text string) *options {
return &options{
fillColor: cell.ColorNumber(117),
textColor: cell.ColorBlack,
shadowColor: cell.ColorNumber(240),
height: DefaultHeight,
width: widthFor(text),
keyUpDelay: DefaultKeyUpDelay,
}
}
// FillColor sets the fill color of the button.
func FillColor(c cell.Color) Option {
return option(func(opts *options) {
opts.fillColor = c
})
}
// TextColor sets the color of the text label in the button.
func TextColor(c cell.Color) Option {
return option(func(opts *options) {
opts.textColor = c
})
}
// ShadowColor sets the color of the shadow under the button.
func ShadowColor(c cell.Color) Option {
return option(func(opts *options) {
opts.shadowColor = c
})
}
// DefaultHeight is the default for the Height option.
const DefaultHeight = 3
// Height sets the height of the button in cells.
// Must be a positive non-zero integer.
// Defaults to DefaultHeight.
func Height(cells int) Option {
return option(func(opts *options) {
opts.height = cells
})
}
// Width sets the width of the button in cells.
// Must be a positive non-zero integer.
// Defaults to the auto-width based on the length of the text label.
func Width(cells int) Option {
return option(func(opts *options) {
opts.width = cells
})
}
// WidthFor sets the width of the button as if it was displaying the provided text.
// Useful when displaying multiple buttons with the intention to set all of
// their sizes equal to the one with the longest text.
func WidthFor(text string) Option {
return option(func(opts *options) {
opts.width = widthFor(text)
})
}
// Key configures the keyboard key that presses the button.
// The widget responds to this key only if its container if focused.
// When not provided, the widget ignores all keyboard events.
func Key(k keyboard.Key) Option {
return option(func(opts *options) {
opts.key = k
opts.keyScope = widgetapi.KeyScopeFocused
})
}
// GlobalKey is like Key, but makes the widget respond to the key even if its
// container isn't focused.
// When not provided, the widget ignores all keyboard events.
func GlobalKey(k keyboard.Key) Option {
return option(func(opts *options) {
opts.key = k
opts.keyScope = widgetapi.KeyScopeGlobal
})
}
// 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.
}

View File

@ -248,6 +248,6 @@ func (d *Donut) Options() widgetapi.Options {
// The smallest circle that "looks" like a circle on the canvas.
MinimumSize: image.Point{3, 3},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
}
}

View File

@ -666,7 +666,7 @@ func TestOptions(t *testing.T) {
Ratio: image.Point{4, 2},
MinimumSize: image.Point{3, 3},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
}
if diff := pretty.Compare(want, got); diff != "" {
t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)

View File

@ -173,7 +173,7 @@ func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas,
for _, ev := range events {
switch e := ev.(type) {
case *terminalapi.Mouse:
if !mirror.opts.WantMouse {
if mirror.opts.WantMouse == widgetapi.MouseScopeNone {
continue
}
if err := mirror.Mouse(e); err != nil {

View File

@ -324,7 +324,7 @@ func TestDraw(t *testing.T) {
desc: "draws both keyboard and mouse events",
opts: widgetapi.Options{
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)),
events: []terminalapi.Event{

View File

@ -330,6 +330,6 @@ func (g *Gauge) Options() widgetapi.Options {
MaximumSize: g.maxSize(),
MinimumSize: g.minSize(),
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
}
}

View File

@ -814,7 +814,7 @@ func TestOptions(t *testing.T) {
MaximumSize: image.Point{0, 0}, // Unlimited.
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -826,7 +826,7 @@ func TestOptions(t *testing.T) {
MaximumSize: image.Point{0, 2},
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -839,7 +839,7 @@ func TestOptions(t *testing.T) {
MaximumSize: image.Point{0, 4},
MinimumSize: image.Point{3, 3},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
}

View File

@ -492,7 +492,7 @@ func (lc *LineChart) Options() widgetapi.Options {
return widgetapi.Options{
MinimumSize: lc.minSize(),
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
}
}

View File

@ -1514,7 +1514,7 @@ func TestOptions(t *testing.T) {
desc: "reserves space for axis without series",
want: widgetapi.Options{
MinimumSize: image.Point{3, 4},
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
},
{
@ -1524,7 +1524,7 @@ func TestOptions(t *testing.T) {
},
want: widgetapi.Options{
MinimumSize: image.Point{5, 4},
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
},
{
@ -1534,7 +1534,7 @@ func TestOptions(t *testing.T) {
},
want: widgetapi.Options{
MinimumSize: image.Point{6, 4},
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
},
{
@ -1547,7 +1547,7 @@ func TestOptions(t *testing.T) {
},
want: widgetapi.Options{
MinimumSize: image.Point{4, 5},
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
},
{
@ -1560,7 +1560,7 @@ func TestOptions(t *testing.T) {
},
want: widgetapi.Options{
MinimumSize: image.Point{5, 7},
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
},
}

View File

@ -259,6 +259,6 @@ func (sd *SegmentDisplay) Options() widgetapi.Options {
// The smallest supported size of a display segment.
MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
}
}

View File

@ -822,7 +822,7 @@ func TestOptions(t *testing.T) {
want := widgetapi.Options{
MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
}
if diff := pretty.Compare(want, got); diff != "" {
t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)

View File

@ -231,6 +231,6 @@ func (sl *SparkLine) Options() widgetapi.Options {
MinimumSize: min,
MaximumSize: max,
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
}
}

View File

@ -470,7 +470,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -481,7 +481,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{1, 2},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -493,7 +493,7 @@ func TestOptions(t *testing.T) {
MinimumSize: image.Point{1, 3},
MaximumSize: image.Point{1, 3},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
{
@ -506,7 +506,7 @@ func TestOptions(t *testing.T) {
MinimumSize: image.Point{1, 4},
MaximumSize: image.Point{1, 4},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
}

View File

@ -303,16 +303,19 @@ func (t *Text) Mouse(m *terminalapi.Mouse) error {
// Options of the widget
func (t *Text) Options() widgetapi.Options {
var ks widgetapi.KeyScope
var ms widgetapi.MouseScope
if t.opts.disableScrolling {
ks = widgetapi.KeyScopeNone
ms = widgetapi.MouseScopeNone
} else {
ks = widgetapi.KeyScopeFocused
ms = widgetapi.MouseScopeWidget
}
return widgetapi.Options{
// At least one line with at least one full-width rune.
MinimumSize: image.Point{1, 1},
WantMouse: !t.opts.disableScrolling,
WantMouse: ms,
WantKeyboard: ks,
}
}

View File

@ -785,7 +785,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true,
WantMouse: widgetapi.MouseScopeWidget,
},
},
{
@ -796,7 +796,7 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{1, 1},
WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: false,
WantMouse: widgetapi.MouseScopeNone,
},
},
}