diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32958af..12e42ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 4202035..6b72b34 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
# termdash
-[
](termdashdemo/termdashdemo.go)
+[
](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
+```
+
+[
](widgets/button/buttondemo/buttondemo.go)
+
### The Gauge
Displays the progress of an operation. Run the
diff --git a/canvas/braille/braille_test.go b/canvas/braille/braille_test.go
index b32fe97..4435c6a 100644
--- a/canvas/braille/braille_test.go
+++ b/canvas/braille/braille_test.go
@@ -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))
diff --git a/canvas/canvas.go b/canvas/canvas.go
index 5f02023..f7bf0ae 100644
--- a/canvas/canvas.go
+++ b/canvas/canvas.go
@@ -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
diff --git a/canvas/canvas_test.go b/canvas/canvas_test.go
index 071ec93..be44f53 100644
--- a/canvas/canvas_test.go
+++ b/canvas/canvas_test.go
@@ -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
diff --git a/canvas/testcanvas/testcanvas.go b/canvas/testcanvas/testcanvas.go
index 166fddb..0439640 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/container/container.go b/container/container.go
index 4d5e3d1..4d3e76c 100644
--- a/container/container.go
+++ b/container/container.go
@@ -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))
diff --git a/container/container_test.go b/container/container_test.go
index 52b6241..66c3580 100644
--- a/container/container_test.go
+++ b/container/container_test.go
@@ -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{
diff --git a/images/buttondemo.gif b/images/buttondemo.gif
new file mode 100644
index 0000000..c621f28
Binary files /dev/null and b/images/buttondemo.gif differ
diff --git a/images/termdashdemo_0_6_0.gif b/images/termdashdemo_0_6_0.gif
deleted file mode 100644
index df63a8f..0000000
Binary files a/images/termdashdemo_0_6_0.gif and /dev/null differ
diff --git a/images/termdashdemo_0_7_0.gif b/images/termdashdemo_0_7_0.gif
new file mode 100644
index 0000000..b49214b
Binary files /dev/null and b/images/termdashdemo_0_7_0.gif differ
diff --git a/mouse/button/button.go b/mouse/button/button.go
index 3ced57e..5362f39 100644
--- a/mouse/button/button.go
+++ b/mouse/button/button.go
@@ -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.
diff --git a/mouse/button/button_test.go b/mouse/button/button_test.go
index 1b6cd1f..16e230d 100644
--- a/mouse/button/button_test.go
+++ b/mouse/button/button_test.go
@@ -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 {
diff --git a/termdash_test.go b/termdash_test.go
index 2f8d38a..c302e27 100644
--- a/termdash_test.go
+++ b/termdash_test.go
@@ -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,
diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go
index f39da99..44c54dd 100644
--- a/termdashdemo/termdashdemo.go
+++ b/termdashdemo/termdashdemo.go
@@ -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.
diff --git a/widgetapi/widgetapi.go b/widgetapi/widgetapi.go
index 4ad85e3..afa6c3b 100644
--- a/widgetapi/widgetapi.go
+++ b/widgetapi/widgetapi.go
@@ -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.
diff --git a/widgets/barchart/barchart.go b/widgets/barchart/barchart.go
index ae2787f..dc94941 100644
--- a/widgets/barchart/barchart.go
+++ b/widgets/barchart/barchart.go
@@ -270,7 +270,7 @@ func (bc *BarChart) Options() widgetapi.Options {
return widgetapi.Options{
MinimumSize: bc.minSize(),
WantKeyboard: widgetapi.KeyScopeNone,
- WantMouse: false,
+ WantMouse: widgetapi.MouseScopeNone,
}
}
diff --git a/widgets/barchart/barchart_test.go b/widgets/barchart/barchart_test.go
index ea892e9..4c36dc3 100644
--- a/widgets/barchart/barchart_test.go
+++ b/widgets/barchart/barchart_test.go
@@ -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,
},
},
}
diff --git a/widgets/button/button.go b/widgets/button/button.go
new file mode 100644
index 0000000..8c64a54
--- /dev/null
+++ b/widgets/button/button.go
@@ -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,
+ }
+}
diff --git a/widgets/button/button_test.go b/widgets/button/button_test.go
new file mode 100644
index 0000000..c4f92d1
--- /dev/null
+++ b/widgets/button/button_test.go
@@ -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)
+ }
+ })
+ }
+
+}
diff --git a/widgets/button/buttondemo/buttondemo.go b/widgets/button/buttondemo/buttondemo.go
new file mode 100644
index 0000000..2b4595c
--- /dev/null
+++ b/widgets/button/buttondemo/buttondemo.go
@@ -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)
+ }
+}
diff --git a/widgets/button/options.go b/widgets/button/options.go
new file mode 100644
index 0000000..3ac03d8
--- /dev/null
+++ b/widgets/button/options.go
@@ -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.
+}
diff --git a/widgets/donut/donut.go b/widgets/donut/donut.go
index 8340221..47f0d6e 100644
--- a/widgets/donut/donut.go
+++ b/widgets/donut/donut.go
@@ -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,
}
}
diff --git a/widgets/donut/donut_test.go b/widgets/donut/donut_test.go
index 0a5e27a..27239bd 100644
--- a/widgets/donut/donut_test.go
+++ b/widgets/donut/donut_test.go
@@ -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)
diff --git a/widgets/fakewidget/fakewidget.go b/widgets/fakewidget/fakewidget.go
index 25617e5..fd675e5 100644
--- a/widgets/fakewidget/fakewidget.go
+++ b/widgets/fakewidget/fakewidget.go
@@ -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 {
diff --git a/widgets/fakewidget/fakewidget_test.go b/widgets/fakewidget/fakewidget_test.go
index cd11943..1dbe06c 100644
--- a/widgets/fakewidget/fakewidget_test.go
+++ b/widgets/fakewidget/fakewidget_test.go
@@ -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{
diff --git a/widgets/gauge/gauge.go b/widgets/gauge/gauge.go
index b1706fe..c657cf2 100644
--- a/widgets/gauge/gauge.go
+++ b/widgets/gauge/gauge.go
@@ -330,6 +330,6 @@ func (g *Gauge) Options() widgetapi.Options {
MaximumSize: g.maxSize(),
MinimumSize: g.minSize(),
WantKeyboard: widgetapi.KeyScopeNone,
- WantMouse: false,
+ WantMouse: widgetapi.MouseScopeNone,
}
}
diff --git a/widgets/gauge/gauge_test.go b/widgets/gauge/gauge_test.go
index 35f6223..d32df9d 100644
--- a/widgets/gauge/gauge_test.go
+++ b/widgets/gauge/gauge_test.go
@@ -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,
},
},
}
diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go
index 18d9d20..02f7931 100644
--- a/widgets/linechart/linechart.go
+++ b/widgets/linechart/linechart.go
@@ -492,7 +492,7 @@ func (lc *LineChart) Options() widgetapi.Options {
return widgetapi.Options{
MinimumSize: lc.minSize(),
- WantMouse: true,
+ WantMouse: widgetapi.MouseScopeWidget,
}
}
diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go
index bbaf4a1..c573642 100644
--- a/widgets/linechart/linechart_test.go
+++ b/widgets/linechart/linechart_test.go
@@ -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,
},
},
}
diff --git a/widgets/segmentdisplay/segmentdisplay.go b/widgets/segmentdisplay/segmentdisplay.go
index bc42f01..a1e02f6 100644
--- a/widgets/segmentdisplay/segmentdisplay.go
+++ b/widgets/segmentdisplay/segmentdisplay.go
@@ -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,
}
}
diff --git a/widgets/segmentdisplay/segmentdisplay_test.go b/widgets/segmentdisplay/segmentdisplay_test.go
index b5f898b..4a5a484 100644
--- a/widgets/segmentdisplay/segmentdisplay_test.go
+++ b/widgets/segmentdisplay/segmentdisplay_test.go
@@ -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)
diff --git a/widgets/sparkline/sparkline.go b/widgets/sparkline/sparkline.go
index e0a1750..edf513c 100644
--- a/widgets/sparkline/sparkline.go
+++ b/widgets/sparkline/sparkline.go
@@ -231,6 +231,6 @@ func (sl *SparkLine) Options() widgetapi.Options {
MinimumSize: min,
MaximumSize: max,
WantKeyboard: widgetapi.KeyScopeNone,
- WantMouse: false,
+ WantMouse: widgetapi.MouseScopeNone,
}
}
diff --git a/widgets/sparkline/sparkline_test.go b/widgets/sparkline/sparkline_test.go
index 0a234ef..b85a3c7 100644
--- a/widgets/sparkline/sparkline_test.go
+++ b/widgets/sparkline/sparkline_test.go
@@ -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,
},
},
}
diff --git a/widgets/text/text.go b/widgets/text/text.go
index b9f0a15..61ca64b 100644
--- a/widgets/text/text.go
+++ b/widgets/text/text.go
@@ -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,
}
}
diff --git a/widgets/text/text_test.go b/widgets/text/text_test.go
index 45d10ba..0f3b35b 100644
--- a/widgets/text/text_test.go
+++ b/widgets/text/text_test.go
@@ -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,
},
},
}