diff --git a/.gitignore b/.gitignore
index 97e9bcba..d9842897 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
# Exclude MacOS attribute files.
.DS_Store
+
+# Exclude IDE files.
+.idea/
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29acdea3..790c1faa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.14.0] - 30-Dec-2020
+
+### Breaking API changes
+
+- The `widgetapi.Widget.Keyboard` and `widgetapi.Widget.Mouse` methods now
+ accepts a second argument which provides widgets with additional metadata.
+ All widgets implemented outside of the `termdash` repository will need to be
+ updated similarly to the `Barchart` example below. Change the original method
+ signatures:
+ ```go
+ func (*BarChart) Keyboard(k *terminalapi.Keyboard) error { ... }
+
+ func (*BarChart) Mouse(m *terminalapi.Mouse) error { ... }
+
+ ```
+
+ By adding the new `*widgetapi.EventMeta` argument as follows:
+ ```go
+ func (*BarChart) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error { ... }
+
+ func (*BarChart) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error { ... }
+ ```
+
+### Fixed
+
+- `termdash` no longer crashes when `tcell` is used and the terminal window
+ downsizes while content is being drawn.
+
+### Added
+
+#### Text input form functionality with keyboard navigation
+
+- added a new `formdemo` that demonstrates a text input form with keyboard
+ navigation.
+
+#### Infrastructure changes
+
+- `container` now allows users to configure keyboard keys that move focus to
+ the next or the previous container.
+- containers can request to be skipped when focus is moved using keyboard keys.
+- containers can register into separate focus groups and specific keyboard keys
+ can be configured to move the focus within each focus group.
+- widgets can now request keyboard events exclusively when focused.
+- users can now set a `container` as focused using the new `container.Focused`
+ option.
+
+#### Updates to the `button` widget
+
+- the `button` widget allows users to specify multiple trigger keys.
+- the `button` widget now supports different keys for the global and focused
+ scope.
+- the `button` widget can now be drawn without the shadow or the press
+ animation.
+- the `button` widget can now be drawn without horizontal padding around its
+ text.
+- the `button` widget now allows specifying cell options for each cell of the
+ displayed text. Separate cell options can be specified for each of button's
+ main states (up, focused and up, down).
+- the `button` widget allows specifying separate fill color values for each of
+ its main states (up, focused and up, down).
+- the `button` widget now has a method `SetCallback` that allows updating the
+ callback function on an existing `button` instance.
+
+#### Updates to the `textinput` widget
+
+- the `textinput` widget can now be configured to request keyboard events
+ exclusively when focused.
+- the `textinput` widget can now be initialized with a default text in the
+ input box.
+
## [0.13.0] - 17-Nov-2020
### Added
@@ -369,7 +439,8 @@ identifiers shouldn't be used externally.
- The Gauge widget.
- The Text widget.
-[unreleased]: https://github.com/mum4k/termdash/compare/v0.13.0...devel
+[unreleased]: https://github.com/mum4k/termdash/compare/v0.14.0...devel
+[0.14.0]: https://github.com/mum4k/termdash/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/mum4k/termdash/compare/v0.12.2...v0.13.0
[0.12.2]: https://github.com/mum4k/termdash/compare/v0.12.1...v0.12.2
[0.12.1]: https://github.com/mum4k/termdash/compare/v0.12.0...v0.12.1
diff --git a/README.md b/README.md
index 466df328..93cee5cb 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ To install this library, run the following:
```go
go get -u github.com/mum4k/termdash
+cd github.com/mum4k/termdash
```
# Usage
@@ -63,7 +64,7 @@ The usage of most of these elements is demonstrated in
[termdashdemo.go](termdashdemo/termdashdemo.go). To execute the demo:
```go
-go run github.com/mum4k/termdash/termdashdemo/termdashdemo.go
+go run termdashdemo/termdashdemo.go
```
# Documentation
@@ -80,7 +81,7 @@ Run the
[buttondemo](widgets/button/buttondemo/buttondemo.go).
```go
-go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go
+go run widgets/button/buttondemo/buttondemo.go
```
[
](widgets/button/buttondemo/buttondemo.go)
@@ -92,18 +93,26 @@ submitting text data. Run the
[textinputdemo](widgets/textinput/textinputdemo/textinputdemo.go).
```go
-go run github.com/mum4k/termdash/widgets/textinput/textinputdemo/textinputdemo.go
+go run widgets/textinput/textinputdemo/textinputdemo.go
```
[
](widgets/textinput/textinputdemo/textinputdemo.go)
+Can be used to create text input forms that support keyboard navigation:
+
+```go
+go run widgets/textinput/formdemo/formdemo.go
+```
+
+[
](widgets/textinput/formdemo/formdemo.go)
+
## The Gauge
Displays the progress of an operation. Run the
[gaugedemo](widgets/gauge/gaugedemo/gaugedemo.go).
```go
-go run github.com/mum4k/termdash/widgets/gauge/gaugedemo/gaugedemo.go
+go run widgets/gauge/gaugedemo/gaugedemo.go
```
[
](widgets/gauge/gaugedemo/gaugedemo.go)
@@ -114,7 +123,7 @@ Visualizes progress of an operation as a partial or a complete donut. Run the
[donutdemo](widgets/donut/donutdemo/donutdemo.go).
```go
-go run github.com/mum4k/termdash/widgets/donut/donutdemo/donutdemo.go
+go run widgets/donut/donutdemo/donutdemo.go
```
[
](widgets/donut/donutdemo/donutdemo.go)
@@ -125,7 +134,7 @@ Displays text content, supports trimming and scrolling of content. Run the
[textdemo](widgets/text/textdemo/textdemo.go).
```go
-go run github.com/mum4k/termdash/widgets/text/textdemo/textdemo.go
+go run widgets/text/textdemo/textdemo.go
```
[
](widgets/text/textdemo/textdemo.go)
@@ -137,7 +146,7 @@ sub-cell height. Run the
[sparklinedemo](widgets/sparkline/sparklinedemo/sparklinedemo.go).
```go
-go run github.com/mum4k/termdash/widgets/sparkline/sparklinedemo/sparklinedemo.go
+go run widgets/sparkline/sparklinedemo/sparklinedemo.go
```
[
](widgets/sparkline/sparklinedemo/sparklinedemo.go)
@@ -148,7 +157,7 @@ Displays multiple bars showing relative ratios of values. Run the
[barchartdemo](widgets/barchart/barchartdemo/barchartdemo.go).
```go
-go run github.com/mum4k/termdash/widgets/barchart/barchartdemo/barchartdemo.go
+go run widgets/barchart/barchartdemo/barchartdemo.go
```
[
](widgets/barchart/barchartdemo/barchartdemo.go)
@@ -160,7 +169,7 @@ events. Run the
[linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go).
```go
-go run github.com/mum4k/termdash/widgets/linechart/linechartdemo/linechartdemo.go
+go run widgets/linechart/linechartdemo/linechartdemo.go
```
[
](widgets/linechart/linechartdemo/linechartdemo.go)
@@ -171,7 +180,7 @@ Displays text by simulating a 16-segment display. Run the
[segmentdisplaydemo](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go).
```go
-go run github.com/mum4k/termdash/widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go
+go run widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go
```
[
](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go)
diff --git a/container/container.go b/container/container.go
index 54cef785..6070efe4 100644
--- a/container/container.go
+++ b/container/container.go
@@ -122,6 +122,13 @@ func (c *Container) hasWidget() bool {
return c.opts.widget != nil
}
+// isLeaf determines if this container is a leaf container in the binary tree of containers.
+// Only leaf containers are guaranteed to be "visible" on the screen, because
+// they are on the top of other non-leaf containers.
+func (c *Container) isLeaf() bool {
+ return c.first == nil && c.second == nil
+}
+
// usable returns the usable area in this container.
// This depends on whether the container has a border, etc.
func (c *Container) usable() image.Rectangle {
@@ -257,10 +264,10 @@ func (c *Container) Update(id string, opts ...Option) error {
return nil
}
-// updateFocus processes the mouse event and determines if it changes the
-// focused container.
+// updateFocusFromMouse processes the mouse event and determines if it changes
+// the focused container.
// Caller must hold c.mu.
-func (c *Container) updateFocus(m *terminalapi.Mouse) {
+func (c *Container) updateFocusFromMouse(m *terminalapi.Mouse) {
target := pointCont(c, m.Position)
if target == nil { // Ignore mouse clicks where no containers are.
return
@@ -268,6 +275,39 @@ func (c *Container) updateFocus(m *terminalapi.Mouse) {
c.focusTracker.mouse(target, m)
}
+// inFocusGroup returns true if this container is in the specified focus group.
+func (c *Container) inFocusGroup(fg FocusGroup) bool {
+ for _, cg := range c.opts.keyFocusGroups {
+ if cg == fg {
+ return true
+ }
+ }
+ return false
+}
+
+// updateFocusFromKeyboard processes the keyboard event and determines if it
+// changes the focused container.
+// Caller must hold c.mu.
+func (c *Container) updateFocusFromKeyboard(k *terminalapi.Keyboard) {
+ active := c.focusTracker.active()
+ nextGroupsForKey, isGroupKeyForNext := active.opts.global.keyFocusGroupsNext[k.Key]
+ prevGroupsForKey, isGroupKeyForPrev := active.opts.global.keyFocusGroupsPrevious[k.Key]
+
+ nextMatchesContGroup, nextG := nextGroupsForKey.firstMatching(active.opts.keyFocusGroups)
+ prevMatchesContGroup, prevG := prevGroupsForKey.firstMatching(active.opts.keyFocusGroups)
+
+ switch {
+ case active.opts.global.keyFocusNext != nil && *active.opts.global.keyFocusNext == k.Key:
+ c.focusTracker.next( /* group = */ nil)
+ case active.opts.global.keyFocusPrevious != nil && *active.opts.global.keyFocusPrevious == k.Key:
+ c.focusTracker.previous( /* group = */ nil)
+ case isGroupKeyForNext && nextMatchesContGroup:
+ c.focusTracker.next(&nextG)
+ case isGroupKeyForPrev && prevMatchesContGroup:
+ c.focusTracker.previous(&prevG)
+ }
+}
+
// processEvent processes events delivered to the container.
func (c *Container) processEvent(ev terminalapi.Event) error {
// This is done in two stages.
@@ -293,7 +333,7 @@ func (c *Container) processEvent(ev terminalapi.Event) error {
func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error) {
switch e := ev.(type) {
case *terminalapi.Mouse:
- c.updateFocus(ev.(*terminalapi.Mouse))
+ c.updateFocusFromMouse(ev.(*terminalapi.Mouse))
targets, err := c.mouseEvTargets(e)
if err != nil {
@@ -301,7 +341,7 @@ func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error)
}
return func() error {
for _, mt := range targets {
- if err := mt.widget.Mouse(mt.ev); err != nil {
+ if err := mt.widget.Mouse(mt.ev, mt.meta); err != nil {
return err
}
}
@@ -309,10 +349,12 @@ func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error)
}, nil
case *terminalapi.Keyboard:
+ c.updateFocusFromKeyboard(ev.(*terminalapi.Keyboard))
+
targets := c.keyEvTargets()
return func() error {
- for _, w := range targets {
- if err := w.Keyboard(e); err != nil {
+ for _, kt := range targets {
+ if err := kt.widget.Keyboard(e, kt.meta); err != nil {
return err
}
}
@@ -324,55 +366,92 @@ func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error)
}
}
+// keyEvTarget contains a widget that should receive an event and the metadata
+// for the event.
+type keyEvTarget struct {
+ // widget is the widget that should receive the keyboard event.
+ widget widgetapi.Widget
+ // meta is the metadata about the event.
+ meta *widgetapi.EventMeta
+}
+
+// newKeyEvTarget returns a new keyEvTarget.
+func newKeyEvTarget(w widgetapi.Widget, meta *widgetapi.EventMeta) *keyEvTarget {
+ return &keyEvTarget{
+ widget: w,
+ meta: meta,
+ }
+}
+
// keyEvTargets returns those widgets found in the container that should
// receive this keyboard event.
// Caller must hold c.mu.
-func (c *Container) keyEvTargets() []widgetapi.Widget {
+func (c *Container) keyEvTargets() []*keyEvTarget {
var (
errStr string
- widgets []widgetapi.Widget
+ targets []*keyEvTarget
+ // If the currently focused widget set the ExclusiveKeyboardOnFocus
+ // option, this pointer is set to that widget.
+ exclusiveWidget widgetapi.Widget
)
- // All the widgets that should receive this event.
+ // All the targets that should receive this event.
// For now stable ordering (preOrder).
preOrder(c, &errStr, visitFunc(func(cur *Container) error {
if !cur.hasWidget() {
return nil
}
+ focused := cur.focusTracker.isActive(cur)
+ meta := &widgetapi.EventMeta{
+ Focused: focused,
+ }
wOpt := cur.opts.widget.Options()
+ if focused && wOpt.ExclusiveKeyboardOnFocus {
+ exclusiveWidget = cur.opts.widget
+ }
+
switch wOpt.WantKeyboard {
case widgetapi.KeyScopeNone:
// Widget doesn't want any keyboard events.
return nil
case widgetapi.KeyScopeFocused:
- if cur.focusTracker.isActive(cur) {
- widgets = append(widgets, cur.opts.widget)
+ if focused {
+ targets = append(targets, newKeyEvTarget(cur.opts.widget, meta))
}
case widgetapi.KeyScopeGlobal:
- widgets = append(widgets, cur.opts.widget)
+ targets = append(targets, newKeyEvTarget(cur.opts.widget, meta))
}
return nil
}))
- return widgets
+
+ if exclusiveWidget != nil {
+ targets = []*keyEvTarget{
+ newKeyEvTarget(exclusiveWidget, &widgetapi.EventMeta{Focused: true}),
+ }
+ }
+ return targets
}
-// mouseEvTarget contains a mouse event adjusted relative to the widget's area
-// and the widget that should receive it.
+// mouseEvTarget contains a mouse event adjusted relative to the widget's area,
+// the widget that should receive it and metadata about the event.
type mouseEvTarget struct {
// widget is the widget that should receive the mouse event.
widget widgetapi.Widget
// ev is the adjusted mouse event.
ev *terminalapi.Mouse
+ // meta is the metadata about the event.
+ meta *widgetapi.EventMeta
}
-// newMouseEvTarget returns a new newMouseEvTarget.
-func newMouseEvTarget(w widgetapi.Widget, wArea image.Rectangle, ev *terminalapi.Mouse) *mouseEvTarget {
+// newMouseEvTarget returns a new mouseEvTarget.
+func newMouseEvTarget(w widgetapi.Widget, wArea image.Rectangle, ev *terminalapi.Mouse, meta *widgetapi.EventMeta) *mouseEvTarget {
return &mouseEvTarget{
widget: w,
ev: adjustMouseEv(ev, wArea),
+ meta: meta,
}
}
@@ -398,6 +477,9 @@ func (c *Container) mouseEvTargets(m *terminalapi.Mouse) ([]*mouseEvTarget, erro
return err
}
+ meta := &widgetapi.EventMeta{
+ Focused: cur.focusTracker.isActive(cur),
+ }
switch wOpts.WantMouse {
case widgetapi.MouseScopeNone:
// Widget doesn't want any mouse events.
@@ -406,18 +488,18 @@ func (c *Container) mouseEvTargets(m *terminalapi.Mouse) ([]*mouseEvTarget, erro
case widgetapi.MouseScopeWidget:
// Only if the event falls inside of the widget's canvas.
if m.Position.In(wa) {
- widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
+ widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m, meta))
}
case widgetapi.MouseScopeContainer:
// Only if the event falls inside the widget's parent container.
if m.Position.In(cur.area) {
- widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
+ widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m, meta))
}
case widgetapi.MouseScopeGlobal:
// Widget wants all mouse events.
- widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m))
+ widgets = append(widgets, newMouseEvTarget(cur.opts.widget, wa, m, meta))
}
return nil
}))
diff --git a/container/container_test.go b/container/container_test.go
index 1fd2c6da..d4d4704a 100644
--- a/container/container_test.go
+++ b/container/container_test.go
@@ -529,6 +529,63 @@ func TestNew(t *testing.T) {
},
wantContainerErr: true,
},
+ {
+ desc: "fails on KeyFocusGroups with a negative group",
+ termSize: image.Point{10, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(0, -1),
+ )
+ },
+ wantContainerErr: true,
+ },
+ {
+ desc: "fails on KeyFocusGroupsNext with a negative group",
+ termSize: image.Point{10, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroupsNext('n', 0, -1),
+ )
+ },
+ wantContainerErr: true,
+ },
+ {
+ desc: "fails on KeyFocusGroupsPrevious with a negative group",
+ termSize: image.Point{10, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroupsPrevious('p', 0, -1),
+ )
+ },
+ wantContainerErr: true,
+ },
+ {
+ desc: "fails on KeyFocusGroupsNext with a key already assigned as KeyFocusGroupsPrevious",
+ termSize: image.Point{10, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroupsPrevious('n', 0),
+ KeyFocusGroupsNext('n', 0),
+ )
+ },
+ wantContainerErr: true,
+ },
+ {
+ desc: "fails on KeyFocusGroupsPrevious with a key already assigned as KeyFocusGroupsNext",
+ termSize: image.Point{10, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroupsNext('n', 0),
+ KeyFocusGroupsPrevious('n', 0),
+ )
+ },
+ wantContainerErr: true,
+ },
{
desc: "empty container",
termSize: image.Point{10, 10},
@@ -1143,7 +1200,10 @@ func TestKeyboard(t *testing.T) {
testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -1188,7 +1248,156 @@ func TestKeyboard(t *testing.T) {
testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
&widgetapi.Meta{},
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{},
+ },
+ )
+
+ // Widget that isn't focused and only wants focused events.
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(20, 0, 40, 10)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ )
+
+ // The focused widget receives the key.
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
+ &widgetapi.Meta{Focused: true},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ )
+ return ft
+ },
+ },
+ {
+ desc: "keyboard event forwarded to exclusive widget only when focused",
+ termSize: image.Point{40, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal})),
+ ),
+ Right(
+ SplitHorizontal(
+ Top(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ ),
+ Bottom(
+ PlaceWidget(fakewidget.New(
+ widgetapi.Options{
+ WantKeyboard: widgetapi.KeyScopeFocused,
+ ExclusiveKeyboardOnFocus: true,
+ },
+ )),
+ ),
+ ),
+ ),
+ ),
+ )
+ },
+ events: []terminalapi.Event{
+ // Move focus to the target container.
+ &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
+ &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
+ // Send the keyboard event.
+ &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+
+ // Widget that isn't focused, but registered for global
+ // keyboard events.
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
+ )
+
+ // Widget that isn't focused and only wants focused events.
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(20, 0, 40, 10)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ )
+
+ // The focused widget receives the key.
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
+ &widgetapi.Meta{Focused: true},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ )
+ return ft
+ },
+ },
+ {
+ desc: "the ExclusiveKeyboardOnFocus option has no effect when widget not focused",
+ termSize: image.Point{40, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal})),
+ ),
+ Right(
+ SplitHorizontal(
+ Top(
+ PlaceWidget(fakewidget.New(
+ widgetapi.Options{
+ WantKeyboard: widgetapi.KeyScopeFocused,
+ ExclusiveKeyboardOnFocus: true,
+ },
+ )),
+ ),
+ Bottom(
+ PlaceWidget(fakewidget.New(
+ widgetapi.Options{
+ WantKeyboard: widgetapi.KeyScopeFocused,
+ },
+ )),
+ ),
+ ),
+ ),
+ ),
+ )
+ },
+ events: []terminalapi.Event{
+ // Move focus to the target container.
+ &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonLeft},
+ &terminalapi.Mouse{Position: image.Point{39, 19}, Button: mouse.ButtonRelease},
+ // Send the keyboard event.
+ &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+
+ // Widget that isn't focused, but registered for global
+ // keyboard events.
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(0, 0, 20, 20)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: false},
+ },
)
// Widget that isn't focused and only wants focused events.
@@ -1205,7 +1414,10 @@ func TestKeyboard(t *testing.T) {
testcanvas.MustNew(image.Rect(20, 10, 40, 20)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -1412,7 +1624,6 @@ func TestMouse(t *testing.T) {
testcanvas.MustNew(image.Rect(25, 10, 50, 20)),
&widgetapi.Meta{},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Keyboard{},
)
// The target widget receives the mouse event.
@@ -1421,8 +1632,14 @@ func TestMouse(t *testing.T) {
testcanvas.MustNew(image.Rect(25, 0, 50, 10)),
&widgetapi.Meta{Focused: 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},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{},
+ },
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonRelease},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -1495,7 +1712,6 @@ func TestMouse(t *testing.T) {
testcanvas.MustNew(image.Rect(25, 10, 50, 20)),
&widgetapi.Meta{},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Keyboard{},
)
// The target widget receives the mouse event.
@@ -1504,8 +1720,212 @@ func TestMouse(t *testing.T) {
testcanvas.MustNew(image.Rect(26, 1, 49, 9)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonLeft},
- &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonRelease},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{},
+ },
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{22, 7}, Button: mouse.ButtonRelease},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ )
+ return ft
+ },
+ },
+ {
+ desc: "key event focuses the next container, widget with KeyScopeFocused gets the key as it is now focused",
+ termSize: image.Point{50, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ c, err := New(
+ ft,
+ SplitVertical(
+ Left(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ ),
+ Right(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ ),
+ ),
+ KeyFocusNext(keyboard.KeyTab),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return c, nil
+ },
+ events: []terminalapi.Event{
+ &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
+ &widgetapi.Meta{Focused: true},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ )
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ )
+ return ft
+ },
+ },
+ {
+ desc: "key event focuses the previous container, option set on both parent and child, the last option is used since focus keys are global options",
+ termSize: image.Point{50, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ c, err := New(
+ ft,
+ KeyFocusPrevious(keyboard.KeyEnter),
+ SplitVertical(
+ Left(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ ),
+ Right(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ KeyFocusPrevious(keyboard.KeyTab),
+ ),
+ ),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return c, nil
+ },
+ events: []terminalapi.Event{
+ &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ )
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
+ &widgetapi.Meta{Focused: true},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ )
+ return ft
+ },
+ },
+ {
+ desc: "key event focuses the next container, widget with KeyScopeGlobal also gets the key",
+ termSize: image.Point{50, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ c, err := New(
+ ft,
+ SplitVertical(
+ Left(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ ),
+ Right(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal})),
+ ),
+ ),
+ KeyFocusNext(keyboard.KeyTab),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return c, nil
+ },
+ events: []terminalapi.Event{
+ &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
+ &widgetapi.Meta{Focused: true},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ )
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ Meta: &widgetapi.EventMeta{},
+ },
+ )
+ return ft
+ },
+ },
+ {
+ desc: "key event moves focus from a widget with KeyScopeFocused, the newly focused widget gets the key",
+ termSize: image.Point{50, 20},
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ c, err := New(
+ ft,
+ SplitVertical(
+ Left(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ ),
+ Right(
+ PlaceWidget(fakewidget.New(widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused})),
+ ),
+ ),
+ KeyFocusNext(keyboard.KeyTab),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return c, nil
+ },
+ events: []terminalapi.Event{
+ // Focus the left container.
+ &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ // Move focus from left to right.
+ &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(0, 0, 25, 20)),
+ &widgetapi.Meta{},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ // Also gets the key, since we are sending two events above.
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ // Also is focused at the time of the first event.
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ )
+ fakewidget.MustDraw(
+ ft,
+ testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
+ &widgetapi.Meta{Focused: true},
+ widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -1571,7 +1991,7 @@ func TestMouse(t *testing.T) {
},
{
desc: "MouseScopeContainer, event forwarded if it falls on the container's border",
- termSize: image.Point{21, 20},
+ termSize: image.Point{23, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
@@ -1597,17 +2017,20 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
- testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
+ testcanvas.MustNew(image.Rect(1, 1, 22, 19)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
},
{
desc: "MouseScopeGlobal, event forwarded if it falls on the container's border",
- termSize: image.Point{21, 20},
+ termSize: image.Point{23, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
@@ -1633,10 +2056,13 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
- testcanvas.MustNew(image.Rect(1, 1, 20, 19)),
+ testcanvas.MustNew(image.Rect(1, 1, 22, 19)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -1674,7 +2100,7 @@ func TestMouse(t *testing.T) {
},
{
desc: "MouseScopeContainer event forwarded if it falls outside of widget's canvas",
- termSize: image.Point{20, 20},
+ termSize: image.Point{22, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
@@ -1696,17 +2122,20 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
- testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
+ testcanvas.MustNew(image.Rect(0, 4, 22, 15)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
},
{
desc: "MouseScopeGlobal event forwarded if it falls outside of widget's canvas",
- termSize: image.Point{20, 20},
+ termSize: image.Point{22, 20},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
@@ -1728,10 +2157,13 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
- testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
+ testcanvas.MustNew(image.Rect(0, 4, 22, 15)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -1840,7 +2272,10 @@ func TestMouse(t *testing.T) {
testcanvas.MustNew(image.Rect(0, 10, 20, 20)),
&widgetapi.Meta{},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{},
+ },
)
return ft
},
@@ -1872,14 +2307,17 @@ func TestMouse(t *testing.T) {
testcanvas.MustNew(image.Rect(0, 5, 20, 15)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
},
{
desc: "mouse position adjusted relative to widget's canvas, horizontal offset",
- termSize: image.Point{30, 20},
+ termSize: image.Point{40, 30},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
@@ -1901,10 +2339,13 @@ func TestMouse(t *testing.T) {
fakewidget.MustDraw(
ft,
- testcanvas.MustNew(image.Rect(6, 0, 24, 20)),
+ testcanvas.MustNew(image.Rect(6, 0, 33, 30)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -2307,7 +2748,7 @@ func TestUpdate(t *testing.T) {
},
{
desc: "newly placed widget gets keyboard events",
- termSize: image.Point{10, 10},
+ termSize: image.Point{12, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
@@ -2336,7 +2777,10 @@ func TestUpdate(t *testing.T) {
cvs,
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
testcanvas.MustApply(cvs, ft)
return ft
@@ -2344,7 +2788,7 @@ func TestUpdate(t *testing.T) {
},
{
desc: "newly placed widget gets mouse events",
- termSize: image.Point{20, 10},
+ termSize: image.Point{22, 10},
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
@@ -2368,7 +2812,10 @@ func TestUpdate(t *testing.T) {
cvs,
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget},
- &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
testcanvas.MustApply(cvs, ft)
return ft
diff --git a/container/focus.go b/container/focus.go
index 4320eea7..fd93fe59 100644
--- a/container/focus.go
+++ b/container/focus.go
@@ -68,6 +68,11 @@ func newFocusTracker(c *Container) *focusTracker {
}
}
+// active returns container that is currently active.
+func (ft *focusTracker) active() *Container {
+ return ft.container
+}
+
// isActive determines if the provided container is the currently active container.
func (ft *focusTracker) isActive(c *Container) bool {
return ft.container == c
@@ -78,6 +83,98 @@ func (ft *focusTracker) setActive(c *Container) {
ft.container = c
}
+// next moves focus to the next container.
+// If group is not nil, focus will only move between containers with a matching
+// focus group number.
+func (ft *focusTracker) next(group *FocusGroup) {
+ var (
+ errStr string
+ firstCont *Container
+ nextCont *Container
+ focusNext bool
+ )
+ preOrder(rootCont(ft.container), &errStr, visitFunc(func(c *Container) error {
+ if nextCont != nil {
+ // Already found the next container, nothing to do.
+ return nil
+ }
+
+ if firstCont == nil && c.isLeaf() {
+ // Remember the first eligible container in case we "wrap" over,
+ // i.e. finish the iteration before finding the next container.
+ switch {
+ case group == nil && !c.opts.keyFocusSkip:
+ fallthrough
+ case group != nil && c.inFocusGroup(*group):
+ firstCont = c
+ }
+ }
+
+ if ft.container == c {
+ // Visiting the currently focused container, going to focus the
+ // next one.
+ focusNext = true
+ return nil
+ }
+
+ if focusNext && c.isLeaf() {
+ switch {
+ case group == nil && !c.opts.keyFocusSkip:
+ fallthrough
+ case group != nil && c.inFocusGroup(*group):
+ nextCont = c
+ }
+ }
+ return nil
+ }))
+
+ if nextCont == nil && firstCont != nil {
+ // If the traversal finishes without finding the next container, move
+ // focus back to the first container.
+ ft.setActive(firstCont)
+ } else if nextCont != nil {
+ ft.setActive(nextCont)
+ }
+}
+
+// previous moves focus to the previous container.
+// If group is not nil, focus will only move between containers with a matching
+// focus group number.
+func (ft *focusTracker) previous(group *FocusGroup) {
+ var (
+ errStr string
+ prevCont *Container
+ lastCont *Container
+ visitedCurr bool
+ )
+ preOrder(rootCont(ft.container), &errStr, visitFunc(func(c *Container) error {
+ if ft.container == c {
+ visitedCurr = true
+ }
+
+ if c.isLeaf() {
+ switch {
+ case group == nil && !c.opts.keyFocusSkip:
+ fallthrough
+ case group != nil && c.inFocusGroup(*group):
+ if !visitedCurr {
+ // Remember the last eligible container closest to the one
+ // currently focused.
+ prevCont = c
+ }
+ lastCont = c
+ }
+ }
+ return nil
+ }))
+
+ if prevCont != nil {
+ ft.setActive(prevCont)
+ } else if lastCont != nil {
+ ft.setActive(lastCont)
+ }
+}
+
// mouse identifies mouse events that change the focused container and track
// the focused container in the tree.
// The argument c is the container onto which the mouse event landed.
diff --git a/container/focus_test.go b/container/focus_test.go
index 4a31ad76..58c8ce38 100644
--- a/container/focus_test.go
+++ b/container/focus_test.go
@@ -17,10 +17,12 @@ package container
import (
"fmt"
"image"
+ "strings"
"testing"
"time"
"github.com/mum4k/termdash/cell"
+ "github.com/mum4k/termdash/keyboard"
"github.com/mum4k/termdash/linestyle"
"github.com/mum4k/termdash/mouse"
"github.com/mum4k/termdash/private/event"
@@ -259,6 +261,34 @@ func TestPointCont(t *testing.T) {
}
}
+// contLocIntro3 prints out an introduction explaining the used container
+// locations on test failures.
+func contLocIntro3() string {
+ var s strings.Builder
+ s.WriteString("Container locations refer to containers in the following tree, i.e. contLocA is the root container:\n")
+ s.WriteString(`
+ A
+ / \
+ B C
+`)
+ return s.String()
+}
+
+// contLocIntro5 prints out an introduction explaining the used container
+// locations on test failures.
+func contLocIntro5() string {
+ var s strings.Builder
+ s.WriteString("Container locations refer to containers in the following tree, i.e. contLocA is the root container:\n")
+ s.WriteString(`
+ A
+ / \
+ B C
+ / \
+D E
+`)
+ return s.String()
+}
+
// contLoc is used in tests to indicate the desired location of a container.
type contLoc int
@@ -272,27 +302,33 @@ func (cl contLoc) String() string {
// contLocNames maps contLoc values to human readable names.
var contLocNames = map[contLoc]string{
- contLocRoot: "Root",
- contLocLeft: "Left",
- contLocRight: "Right",
+ contLocA: "contLocA",
+ contLocB: "contLocB",
+ contLocC: "contLocC",
+ contLocD: "contLocD",
+ contLocE: "contLocE",
}
const (
contLocUnknown contLoc = iota
- contLocRoot
- contLocLeft
- contLocRight
+ contLocA
+ contLocB
+ contLocC
+ contLocD
+ contLocE
)
func TestFocusTrackerMouse(t *testing.T) {
+ t.Log(contLocIntro3())
+
ft, err := faketerm.New(image.Point{10, 10})
if err != nil {
t.Fatalf("faketerm.New => unexpected error: %v", err)
}
var (
- insideLeft = image.Point{1, 1}
- insideRight = image.Point{6, 6}
+ insideB = image.Point{1, 1}
+ insideC = image.Point{6, 6}
)
tests := []struct {
@@ -304,7 +340,7 @@ func TestFocusTrackerMouse(t *testing.T) {
}{
{
desc: "initially the root is focused",
- wantFocused: contLocRoot,
+ wantFocused: contLocA,
},
{
desc: "click and release moves focus to the left",
@@ -312,7 +348,7 @@ func TestFocusTrackerMouse(t *testing.T) {
{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
{Position: image.Point{1, 1}, Button: mouse.ButtonRelease},
},
- wantFocused: contLocLeft,
+ wantFocused: contLocB,
wantProcessed: 2,
},
{
@@ -321,50 +357,50 @@ func TestFocusTrackerMouse(t *testing.T) {
{Position: image.Point{5, 5}, Button: mouse.ButtonLeft},
{Position: image.Point{6, 6}, Button: mouse.ButtonRelease},
},
- wantFocused: contLocRight,
+ wantFocused: contLocC,
wantProcessed: 2,
},
{
desc: "click in the same container is a no-op",
events: []*terminalapi.Mouse{
- {Position: insideRight, Button: mouse.ButtonLeft},
- {Position: insideRight, Button: mouse.ButtonRelease},
- {Position: insideRight, Button: mouse.ButtonLeft},
- {Position: insideRight, Button: mouse.ButtonRelease},
+ {Position: insideC, Button: mouse.ButtonLeft},
+ {Position: insideC, Button: mouse.ButtonRelease},
+ {Position: insideC, Button: mouse.ButtonLeft},
+ {Position: insideC, Button: mouse.ButtonRelease},
},
- wantFocused: contLocRight,
+ wantFocused: contLocC,
wantProcessed: 4,
},
{
desc: "click in the same container and release never happens",
events: []*terminalapi.Mouse{
- {Position: insideRight, Button: mouse.ButtonLeft},
- {Position: insideLeft, Button: mouse.ButtonLeft},
- {Position: insideLeft, Button: mouse.ButtonRelease},
+ {Position: insideC, Button: mouse.ButtonLeft},
+ {Position: insideB, Button: mouse.ButtonLeft},
+ {Position: insideB, Button: mouse.ButtonRelease},
},
- wantFocused: contLocLeft,
+ wantFocused: contLocB,
wantProcessed: 3,
},
{
desc: "click in the same container, release elsewhere",
events: []*terminalapi.Mouse{
- {Position: insideRight, Button: mouse.ButtonLeft},
- {Position: insideLeft, Button: mouse.ButtonRelease},
+ {Position: insideC, Button: mouse.ButtonLeft},
+ {Position: insideB, Button: mouse.ButtonRelease},
},
- wantFocused: contLocRoot,
+ wantFocused: contLocA,
wantProcessed: 2,
},
{
desc: "other buttons are ignored",
events: []*terminalapi.Mouse{
- {Position: insideLeft, Button: mouse.ButtonMiddle},
- {Position: insideLeft, Button: mouse.ButtonRelease},
- {Position: insideLeft, Button: mouse.ButtonRight},
- {Position: insideLeft, Button: mouse.ButtonRelease},
- {Position: insideLeft, Button: mouse.ButtonWheelUp},
- {Position: insideLeft, Button: mouse.ButtonWheelDown},
- },
- wantFocused: contLocRoot,
+ {Position: insideB, Button: mouse.ButtonMiddle},
+ {Position: insideB, Button: mouse.ButtonRelease},
+ {Position: insideB, Button: mouse.ButtonRight},
+ {Position: insideB, Button: mouse.ButtonRelease},
+ {Position: insideB, Button: mouse.ButtonWheelUp},
+ {Position: insideB, Button: mouse.ButtonWheelDown},
+ },
+ wantFocused: contLocA,
wantProcessed: 6,
},
{
@@ -374,27 +410,27 @@ func TestFocusTrackerMouse(t *testing.T) {
{Position: image.Point{1, 1}, Button: mouse.ButtonLeft},
{Position: image.Point{2, 2}, Button: mouse.ButtonRelease},
},
- wantFocused: contLocLeft,
+ wantFocused: contLocB,
wantProcessed: 3,
},
{
desc: "click ignored if followed by another click of the same button elsewhere",
events: []*terminalapi.Mouse{
- {Position: insideRight, Button: mouse.ButtonLeft},
- {Position: insideLeft, Button: mouse.ButtonLeft},
- {Position: insideRight, Button: mouse.ButtonRelease},
+ {Position: insideC, Button: mouse.ButtonLeft},
+ {Position: insideB, Button: mouse.ButtonLeft},
+ {Position: insideC, Button: mouse.ButtonRelease},
},
- wantFocused: contLocRoot,
+ wantFocused: contLocA,
wantProcessed: 3,
},
{
desc: "click ignored if followed by another click of a different button",
events: []*terminalapi.Mouse{
- {Position: insideRight, Button: mouse.ButtonLeft},
- {Position: insideRight, Button: mouse.ButtonMiddle},
- {Position: insideRight, Button: mouse.ButtonRelease},
+ {Position: insideC, Button: mouse.ButtonLeft},
+ {Position: insideC, Button: mouse.ButtonMiddle},
+ {Position: insideC, Button: mouse.ButtonRelease},
},
- wantFocused: contLocRoot,
+ wantFocused: contLocA,
wantProcessed: 3,
},
}
@@ -432,24 +468,1051 @@ func TestFocusTrackerMouse(t *testing.T) {
var wantFocused *Container
switch wf := tc.wantFocused; wf {
- case contLocRoot:
+ case contLocA:
wantFocused = root
- case contLocLeft:
+ case contLocB:
wantFocused = root.first
- case contLocRight:
+ case contLocC:
wantFocused = root.second
default:
t.Fatalf("unsupported wantFocused value => %v", wf)
}
if !root.focusTracker.isActive(wantFocused) {
- t.Errorf("isActive(%v) => false, want true, status: root(%v):%v, left(%v):%v, right(%v):%v",
+ t.Errorf("isActive(%v) => false, want true, status: contLocA(%v):%v, contLocB(%v):%v, contLocC(%v):%v",
tc.wantFocused,
- contLocRoot, root.focusTracker.isActive(root),
- contLocLeft, root.focusTracker.isActive(root.first),
- contLocRight, root.focusTracker.isActive(root.second),
+ contLocA, root.focusTracker.isActive(root),
+ contLocB, root.focusTracker.isActive(root.first),
+ contLocC, root.focusTracker.isActive(root.second),
)
}
})
}
}
+
+// contDir represents a direction in which we want to change container focus.
+type contDir int
+
+// String implements fmt.Stringer()
+func (cd contDir) String() string {
+ if n, ok := contDirNames[cd]; ok {
+ return n
+ }
+ return "contDirUnknown"
+}
+
+// contDirNames maps contDir values to human readable names.
+var contDirNames = map[contDir]string{
+ contDirNext: "contDirNext",
+ contDirPrevious: "contDirPrevious",
+}
+
+const (
+ contDirUnknown contDir = iota
+ contDirNext
+ contDirPrevious
+)
+
+// contSize determines the size of the container used in the test.
+type contSize int
+
+const (
+ contSize3 contSize = iota
+ contSize5
+)
+
+func TestFocusTrackerNextAndPrevious(t *testing.T) {
+ ft, err := faketerm.New(image.Point{10, 10})
+ if err != nil {
+ t.Fatalf("faketerm.New => unexpected error: %v", err)
+ }
+
+ const (
+ keyNext keyboard.Key = keyboard.KeyTab
+ keyPrevious keyboard.Key = '~'
+ )
+
+ tests := []struct {
+ desc string
+ contSize contSize
+ container func(ft *faketerm.Terminal) (*Container, error)
+ events []*terminalapi.Keyboard
+ wantFocused contLoc
+ wantProcessed int
+ }{
+ {
+ desc: "initially the root is focused by default",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ wantFocused: contLocA,
+ },
+ {
+ desc: "focus root explicitly",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ Focused(),
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ wantFocused: contLocA,
+ },
+ {
+ desc: "focus can be set to a container other than root",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(Focused()),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ wantFocused: contLocB,
+ },
+ {
+ desc: "option Focused used on multiple containers, the last one takes effect",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(Focused()),
+ Right(Focused()),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ wantFocused: contLocC,
+ },
+ {
+ desc: "keyNext does nothing when only root exists",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ },
+ wantFocused: contLocA,
+ wantProcessed: 1,
+ },
+ {
+ desc: "keyNext focuses the first container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 1,
+ },
+ {
+ desc: "two keyNext presses focuses the second container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ {Key: keyNext},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ {
+ desc: "three keyNext presses focuses the first container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ {Key: keyNext},
+ {Key: keyNext},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 3,
+ },
+ {
+ desc: "four keyNext presses focuses the second container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ {Key: keyNext},
+ {Key: keyNext},
+ {Key: keyNext},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 4,
+ },
+ {
+ desc: "five keyNext presses focuses the first container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ {Key: keyNext},
+ {Key: keyNext},
+ {Key: keyNext},
+ {Key: keyNext},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 5,
+ },
+ {
+ desc: "keyPrevious does nothing when only root exists",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocA,
+ wantProcessed: 1,
+ },
+ {
+ desc: "keyPrevious focuses the last container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 1,
+ },
+ {
+ desc: "two keyPrevious presses focuses the first container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 2,
+ },
+ {
+ desc: "three keyPrevious presses focuses the second container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 3,
+ },
+ {
+ desc: "four keyPrevious presses focuses the first container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 4,
+ },
+ {
+ desc: "five keyPrevious presses focuses the second container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 5,
+ },
+ {
+ desc: "first container requests to be skipped on key based focus changes, using next",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(
+ KeyFocusSkip(),
+ ),
+ Right(),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 1,
+ },
+ {
+ desc: "last container requests to be skipped on key based focus changes, using next",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(
+ KeyFocusSkip(),
+ ),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ {Key: keyNext},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 2,
+ },
+ {
+ desc: "all containers request to be skipped on key based focus changes, using next",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(
+ KeyFocusSkip(),
+ ),
+ Right(
+ KeyFocusSkip(),
+ ),
+ ),
+ KeyFocusNext(keyNext),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyNext},
+ },
+ wantFocused: contLocA,
+ wantProcessed: 1,
+ },
+ {
+ desc: "first container requests to be skipped on key based focus changes, using previous",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(
+ KeyFocusSkip(),
+ ),
+ Right(),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ {
+ desc: "last container requests to be skipped on key based focus changes, using previous",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(
+ KeyFocusSkip(),
+ ),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 1,
+ },
+ {
+ desc: "all containers request to be skipped on key based focus changes, using previous",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(
+ KeyFocusSkip(),
+ ),
+ Right(
+ KeyFocusSkip(),
+ ),
+ ),
+ KeyFocusPrevious(keyPrevious),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: keyPrevious},
+ },
+ wantFocused: contLocA,
+ wantProcessed: 1,
+ },
+ {
+ desc: "containers don't belong to focus group by default",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ SplitVertical(
+ Left(),
+ Right(),
+ ),
+ KeyFocusGroupsNext('n', 0),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'},
+ },
+ wantFocused: contLocA,
+ wantProcessed: 1,
+ },
+ {
+ desc: "moves to the next container in focus group, pressing KeysFocusGroupNext once focuses the first container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 1,
+ },
+ {
+ desc: "moves to the next container in focus group, pressing KeysFocusGroupNext twice focuses the second container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'},
+ {Key: 'n'},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ {
+ desc: "moves to the next container in focus group, pressing KeysFocusGroupNext three times focuses the first container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(2),
+ SplitVertical(
+ Left(
+ KeyFocusGroups(2),
+ ),
+ Right(
+ KeyFocusGroups(2),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 2),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'},
+ {Key: 'n'},
+ {Key: 'n'},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 3,
+ },
+ {
+ desc: "moves to the previous container in focus group, pressing KeysFocusGroupPrevious once focuses the second container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsPrevious('p', 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'p'},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 1,
+ },
+ {
+ desc: "moves to the previous container in focus group, pressing KeysFocusGroupPrevious twice focuses the first container",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsPrevious('p', 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'p'},
+ {Key: 'p'},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 2,
+ },
+ {
+ desc: "moves to the previous container in focus group, pressing KeysFocusGroupPrevious three times focuses the second container again",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsPrevious('p', 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'p'},
+ {Key: 'p'},
+ {Key: 'p'},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 3,
+ },
+ {
+ desc: "configuring container with KeyFocusSkip has no effect on a closed focus group",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusSkip(),
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusSkip(),
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 1,
+ },
+ {
+ desc: "a focus group can have multiple keys configured for next",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusSkip(),
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusSkip(),
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ KeyFocusGroupsNext(keyboard.KeyArrowRight, 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'},
+ {Key: keyboard.KeyArrowRight},
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ {
+ desc: "a focus group can have multiple keys configured for previous",
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New(
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left(
+ KeyFocusGroups(1),
+ ),
+ Right(
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsPrevious('n', 1),
+ KeyFocusGroupsPrevious(keyboard.KeyArrowRight, 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'},
+ {Key: keyboard.KeyArrowRight},
+ },
+ wantFocused: contLocB,
+ wantProcessed: 2,
+ },
+ {
+ desc: "a container can be in multiple focus groups, rotates within group while on next",
+ contSize: contSize5,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocB
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocD
+ KeyFocusGroups(1),
+ ),
+ Right( // contLocE
+ KeyFocusGroups(1, 2),
+ ),
+ ),
+ ),
+ Right( // contLocC
+ KeyFocusGroups(1, 2),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ KeyFocusGroupsNext(keyboard.KeyArrowRight, 2),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'}, // focuses contLocD
+ {Key: 'n'}, // focuses contLocE
+ {Key: keyboard.KeyArrowRight}, // focuses contLocC
+ {Key: keyboard.KeyArrowRight}, // rotates focus to contLocE
+ },
+ wantFocused: contLocE,
+ wantProcessed: 4,
+ },
+ {
+ desc: "a container can be in multiple focus groups, rotates within group while on previous",
+ contSize: contSize5,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocB
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocD
+ KeyFocusGroups(1),
+ ),
+ Right( // contLocE
+ KeyFocusGroups(1, 2),
+ ),
+ ),
+ ),
+ Right( // contLocC
+ KeyFocusGroups(1, 2),
+ ),
+ ),
+ KeyFocusGroupsPrevious('n', 1),
+ KeyFocusGroupsPrevious(keyboard.KeyArrowLeft, 2),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'}, // focuses contLocC
+ {Key: keyboard.KeyArrowLeft}, // focuses contLocE
+ {Key: keyboard.KeyArrowLeft}, // rotates focus back to contLocC
+ },
+ wantFocused: contLocC,
+ wantProcessed: 3,
+ },
+ {
+ desc: "same key and group, first group takes priority, group 1 is first",
+ contSize: contSize5,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocB
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocD
+ KeyFocusGroups(1, 2),
+ ),
+ Right( // contLocE
+ KeyFocusGroups(1),
+ ),
+ ),
+ ),
+ Right( // contLocC
+ KeyFocusGroups(1, 2),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ KeyFocusGroupsNext('n', 2),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'}, // focuses contLocD
+ {Key: 'n'}, // focuses contLocE
+ },
+ wantFocused: contLocE,
+ wantProcessed: 2,
+ },
+ {
+ desc: "same key and group, first group takes priority, group 2 is first",
+ contSize: contSize5,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocB
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocD
+ KeyFocusGroups(2, 1),
+ ),
+ Right( // contLocE
+ KeyFocusGroups(1),
+ ),
+ ),
+ ),
+ Right( // contLocC
+ KeyFocusGroups(1, 2),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ KeyFocusGroupsNext('n', 2),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'}, // focuses contLocD
+ {Key: 'n'}, // focuses contLocC
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ {
+ desc: "KeyFocusGroups called multiple times, same key and group, first group takes priority, group 2 is first",
+ contSize: contSize5,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocB
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocD
+ KeyFocusGroups(2),
+ KeyFocusGroups(1),
+ ),
+ Right( // contLocE
+ KeyFocusGroups(1),
+ ),
+ ),
+ ),
+ Right( // contLocC
+ KeyFocusGroups(1, 2),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ KeyFocusGroupsNext('n', 2),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'}, // focuses contLocD
+ {Key: 'n'}, // focuses contLocC
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ {
+ desc: "global KeyFocusNext moves focus out of a focus group",
+ contSize: contSize3,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ SplitVertical(
+ Left( // contLocB
+ KeyFocusGroups(1),
+ ),
+ Right( // contLocC
+ ),
+ ),
+ KeyFocusNext('n'),
+ KeyFocusGroupsNext(keyboard.KeyArrowRight, 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'}, // focuses contLocB in focus group 1
+ {Key: 'n'}, // focuses contLocC
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ {
+ desc: "global KeyFocusPrevious moves focus out of a focus group",
+ contSize: contSize3,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ SplitVertical(
+ Left( // contLocB
+ ),
+ Right( // contLocC
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusPrevious('p'),
+ KeyFocusGroupsPrevious(keyboard.KeyArrowLeft, 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'p'}, // focuses contLocC in focus group 1
+ {Key: 'p'}, // focuses contLocB
+ },
+ wantFocused: contLocB,
+ wantProcessed: 2,
+ },
+ {
+ desc: "KeyFocusGroups with no arguments removes all groups",
+ contSize: contSize5,
+ container: func(ft *faketerm.Terminal) (*Container, error) {
+ return New( // contLocA
+ ft,
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocB
+ KeyFocusGroups(1),
+ SplitVertical(
+ Left( // contLocD
+ KeyFocusGroups(1),
+ ),
+ Right( // contLocE
+ KeyFocusGroups(1),
+ KeyFocusGroups(),
+ ),
+ ),
+ ),
+ Right( // contLocC
+ KeyFocusGroups(1),
+ ),
+ ),
+ KeyFocusGroupsNext('n', 1),
+ )
+ },
+ events: []*terminalapi.Keyboard{
+ {Key: 'n'}, // focuses contLocD
+ {Key: 'n'}, // focuses contLocC
+ },
+ wantFocused: contLocC,
+ wantProcessed: 2,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.desc, func(t *testing.T) {
+ root, err := tc.container(ft)
+ if err != nil {
+ t.Fatalf("tc.container => unexpected error: %v", err)
+ }
+
+ eds := event.NewDistributionSystem()
+ root.Subscribe(eds)
+ for _, ev := range tc.events {
+ eds.Event(ev)
+ }
+ if err := testevent.WaitFor(5*time.Second, func() error {
+ if got, want := eds.Processed(), tc.wantProcessed; got != want {
+ return fmt.Errorf("the event distribution system processed %d events, want %d", got, want)
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("testevent.WaitFor => %v", err)
+ }
+
+ var wantFocused *Container
+ switch wf := tc.wantFocused; wf {
+ case contLocA:
+ wantFocused = root
+ case contLocB:
+ wantFocused = root.first
+ case contLocC:
+ wantFocused = root.second
+ case contLocD:
+ wantFocused = root.first.first
+ case contLocE:
+ wantFocused = root.first.second
+ default:
+ t.Fatalf("unsupported wantFocused value => %v", wf)
+ }
+
+ switch tc.contSize {
+ case contSize3:
+ t.Log(contLocIntro3())
+ if !root.focusTracker.isActive(wantFocused) {
+ t.Errorf("isActive(%v) => false, want true, status: %v:%v, %v:%v, %v:%v",
+ tc.wantFocused,
+ contLocA, root.focusTracker.isActive(root),
+ contLocB, root.focusTracker.isActive(root.first),
+ contLocC, root.focusTracker.isActive(root.second),
+ )
+ }
+
+ case contSize5:
+ t.Log(contLocIntro5())
+ if !root.focusTracker.isActive(wantFocused) {
+ t.Errorf("isActive(%v) => false, want true, status: %v:%v, %v:%v, %v:%v, %v:%v, %v:%v",
+ tc.wantFocused,
+ contLocA, root.focusTracker.isActive(root),
+ contLocB, root.focusTracker.isActive(root.first),
+ contLocC, root.focusTracker.isActive(root.second),
+ contLocD, root.focusTracker.isActive(root.first.first),
+ contLocE, root.focusTracker.isActive(root.first.second),
+ )
+ }
+ default:
+ t.Errorf("unknown contSize: %v", tc.contSize)
+ }
+
+ })
+ }
+}
diff --git a/container/options.go b/container/options.go
index 2d34af4d..795e61c5 100644
--- a/container/options.go
+++ b/container/options.go
@@ -23,6 +23,7 @@ import (
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/cell"
+ "github.com/mum4k/termdash/keyboard"
"github.com/mum4k/termdash/linestyle"
"github.com/mum4k/termdash/private/area"
"github.com/mum4k/termdash/widgetapi"
@@ -95,7 +96,15 @@ type options struct {
// id is the identifier provided by the user.
id string
+ // global are options that apply globally to all containers in the tree.
+ // There is only one instance of these options in the entire tree, if any
+ // of the child containers change their values, the new values apply to the
+ // entire container tree.
+ global *globalOptions
+
// inherited are options that are inherited by child containers.
+ // After inheriting these options, the child container can set them to
+ // different values.
inherited inherited
// split identifies how is this container split.
@@ -123,6 +132,12 @@ type options struct {
// margin is a space reserved on the outside of the container.
margin margin
+
+ // keyFocusSkip asserts whether this container should be skipped when focus
+ // is being moved using either of KeyFocusNext or KeyFocusPrevious.
+ keyFocusSkip bool
+ // keyFocusGroups are the focus groups this container belongs to.
+ keyFocusGroups []FocusGroup
}
// margin stores the configured margin for the container.
@@ -181,11 +196,50 @@ type inherited struct {
focusedColor cell.Color
}
+// focusGroups maps focus group numbers that have the same key assigned.
+// The value is always true for all the keys.
+type focusGroups map[FocusGroup]bool
+
+// firstMatching examines the focus groups the container is assigned to and
+// returns the first matching focus group that is also present in this
+// instance. The bool return value indicates if match was found.
+func (fg focusGroups) firstMatching(contGroups []FocusGroup) (bool, FocusGroup) {
+ for _, cg := range contGroups {
+ if fg[cg] {
+ return true, cg
+ }
+ }
+ return false, 0
+}
+
+// globalOptions are options that can only have a single value across the
+// entire tree of containers.
+// Regardless of which container they get set on, the new value will take
+// effect on all the containers in the tree.
+type globalOptions struct {
+ // keyFocusNext when set is the key that moves the focus to the next container.
+ keyFocusNext *keyboard.Key
+ // keyFocusPrevious when set is the key that moves the focus to the previous container.
+ keyFocusPrevious *keyboard.Key
+ // keysFocusGroupNext maps keyboard keys that move to the next container
+ // within a focus group to the focus groups they should work on in the
+ // order they were configured.
+ keyFocusGroupsNext map[keyboard.Key]focusGroups
+ // keysFocusGroupPrevious maps keyboard keys that move to the previous
+ // container within a focus group to the focus groups they should work on
+ // in the order they were configured.
+ keyFocusGroupsPrevious map[keyboard.Key]focusGroups
+}
+
// newOptions returns a new options instance with the default values.
// Parent are the inherited options from the parent container or nil if these
// options are for a container with no parent (the root).
func newOptions(parent *options) *options {
opts := &options{
+ global: &globalOptions{
+ keyFocusGroupsNext: map[keyboard.Key]focusGroups{},
+ keyFocusGroupsPrevious: map[keyboard.Key]focusGroups{},
+ },
inherited: inherited{
focusedColor: cell.ColorYellow,
},
@@ -195,6 +249,7 @@ func newOptions(parent *options) *options {
splitFixed: DefaultSplitFixed,
}
if parent != nil {
+ opts.global = parent.global
opts.inherited = parent.inherited
}
return opts
@@ -815,3 +870,177 @@ func Bottom(opts ...Option) BottomOption {
return opts
})
}
+
+// KeyFocusNext configures a key that moves the keyboard focus to the next
+// container when pressed.
+//
+// Containers are organized in a binary tree, when the focus moves to the next
+// container, it targets the next leaf container in a DFS (Depth-first search) traversal.
+// Non-leaf containers are skipped. If the currently focused container is the
+// last container, the focus moves back to the first container.
+//
+// This option is global and applies to all created containers.
+// If neither of (KeyFocusNext, KeyFocusPrevious) is specified, the keyboard
+// focus can only be changed by using the mouse.
+func KeyFocusNext(key keyboard.Key) Option {
+ return option(func(c *Container) error {
+ c.opts.global.keyFocusNext = &key
+ return nil
+ })
+}
+
+// KeyFocusPrevious configures a key that moves the keyboard focus to the
+// previous container when pressed.
+//
+// Containers are organized in a binary tree, when the focus moves to the previous
+// container, it targets the previous leaf container in a DFS (Depth-first search) traversal.
+// Non-leaf containers are skipped. If the currently focused container is the
+// first container, the focus moves back to the last container.
+//
+// This option is global and applies to all created containers.
+// If neither of (KeyFocusNext, KeyFocusPrevious) is specified, the keyboard
+// focus can only be changed by using the mouse.
+func KeyFocusPrevious(key keyboard.Key) Option {
+ return option(func(c *Container) error {
+ c.opts.global.keyFocusPrevious = &key
+ return nil
+ })
+}
+
+// KeyFocusSkip indicates that this container should never receive the keyboard
+// focus when KeyFocusNext or KeyFocusPrevious is pressed.
+//
+// A container configured like this would still receive the keyboard focus when
+// directly clicked on with a mouse or when via KeysFocusGroupNext or
+// KeysFocusGroupPrevious.
+func KeyFocusSkip() Option {
+ return option(func(c *Container) error {
+ c.opts.keyFocusSkip = true
+ return nil
+ })
+}
+
+// FocusGroup represents a group of containers that can have the keyboard focus
+// moved between them sharing the same keyboard key.
+type FocusGroup int
+
+// KeyFocusGroups assigns this container to focus groups with the specified
+// numbers.
+//
+// See either of (KeysFocusGroupNext, KeysFocusGroupPrevious) for a description
+// of focus groups.
+//
+// If both the pressed key and the currently focused container are configured
+// to be in multiple matching focus groups, focus will follow the first
+// focus group defined on the container, i.e. the order of the supplied groups
+// matters.
+//
+// If not specified, the container doesn't belong to any focus groups.
+// If called with zero groups, the container will be removed from all focus
+// groups.
+func KeyFocusGroups(groups ...FocusGroup) Option {
+ return option(func(c *Container) error {
+ if len(groups) == 0 {
+ c.opts.keyFocusGroups = nil
+ }
+ for _, g := range groups {
+ if min := FocusGroup(0); g < min {
+ return fmt.Errorf("invalid KeyFocusGroups %d, must be 0 <= group", g)
+ }
+ c.opts.keyFocusGroups = append(c.opts.keyFocusGroups, g)
+ }
+ return nil
+ })
+}
+
+// KeyFocusGroupsNext configures a key that moves the keyboard focus to the
+// next container within the specified focus groups.
+//
+// Containers are assigned to focus groups using the KeyFocusGroup option.
+// The group parameter indicates which groups is the key attached to. This
+// option can be specified multiple times to define multiple keys for the same
+// focus groups.
+//
+// A key configured using KeyFocusGroupsNext only moves focus if the container
+// that is currently focused is part of the same focus group as one of the
+// group specified in this option. The keyboard focus only gets moved to the
+// next container in the same focus group, other containers are ignored.
+//
+// The order in which the containers in the group are visited is the same as
+// with the KeyFocusNext option.
+//
+// This option is global and applies to all created containers.
+// Pressing either of (KeyFocusNext, KeyFocusPrevious) still moves the focus to
+// any container regardless of its focus group.
+func KeyFocusGroupsNext(key keyboard.Key, groups ...FocusGroup) Option {
+ return option(func(c *Container) error {
+ for _, g := range groups {
+ if min := FocusGroup(0); g < min {
+ return fmt.Errorf("invalid group %d in KeyFocusGroupsNext for key %q, must be 0 <= group", g, key)
+ }
+ if g, ok := c.opts.global.keyFocusGroupsPrevious[key]; ok {
+ return fmt.Errorf("key %q is already assigned as a KeyFocusGroupsPrevious for focus groups %v", key, g)
+ }
+
+ fg, ok := c.opts.global.keyFocusGroupsNext[key]
+ if !ok {
+ fg = focusGroups{}
+ c.opts.global.keyFocusGroupsNext[key] = fg
+ }
+ fg[g] = true
+ }
+ return nil
+ })
+}
+
+// KeyFocusGroupsPrevious configures a key that moves the keyboard focus to the
+// previous container within the specified focus groups.
+//
+// Containers are assigned to focus groups using the KeyFocusGroup option.
+// The group parameter indicates which groups is the key attached to. This
+// option can be specified multiple times to define multiple keys for the same
+// focus groups.
+//
+// A key configured using KeyFocusGroupsPrevious only moves focus if the
+// container that is currently focused is part of the same focus group as one
+// of the group specified in this option. The keyboard focus only gets moved to
+// the previous container in the same focus group, other containers are
+// ignored.
+//
+// The order in which the containers in the group are visited is the same as
+// with the KeyFocusPrevious option.
+//
+// This option is global and applies to all created containers.
+// Pressing either of (KeyFocusNext, KeyFocusPrevious) still moves the focus to
+// any container regardless of its focus group.
+func KeyFocusGroupsPrevious(key keyboard.Key, groups ...FocusGroup) Option {
+ return option(func(c *Container) error {
+ for _, g := range groups {
+ if min := FocusGroup(0); g < min {
+ return fmt.Errorf("invalid group %d in KeyFocusGroupsNext for key %q, must be 0 <= group", g, key)
+ }
+ if g, ok := c.opts.global.keyFocusGroupsNext[key]; ok {
+ return fmt.Errorf("key %q is already assigned as a KeyFocusGroupsNext for focus groups %v", key, g)
+ }
+
+ fg, ok := c.opts.global.keyFocusGroupsPrevious[key]
+ if !ok {
+ fg = focusGroups{}
+ c.opts.global.keyFocusGroupsPrevious[key] = fg
+ }
+ fg[g] = true
+ }
+ return nil
+ })
+}
+
+// Focused moves the keyboard focus to this container.
+// If not specified, termdash will start with the root container focused.
+// If specified on multiple containers, the last container with this option
+// will be focused.
+func Focused() Option {
+ return option(func(c *Container) error {
+ c.focusTracker.setActive(c)
+ return nil
+ })
+}
diff --git a/doc/images/formdemo.gif b/doc/images/formdemo.gif
new file mode 100644
index 00000000..c11b5af1
Binary files /dev/null and b/doc/images/formdemo.gif differ
diff --git a/private/canvas/canvas.go b/private/canvas/canvas.go
index 65a1e696..9682d984 100644
--- a/private/canvas/canvas.go
+++ b/private/canvas/canvas.go
@@ -195,21 +195,14 @@ func (c *Canvas) copyTo(offset image.Point, dstSetCell setCellFunc) error {
}
// Apply applies the canvas to the corresponding area of the terminal.
-// Guarantees to stay within limits of the area the canvas was created with.
func (c *Canvas) Apply(t terminalapi.Terminal) error {
- termArea, err := area.FromSize(t.Size())
- if err != nil {
- return err
- }
-
- bufArea, err := area.FromSize(c.buffer.Size())
- if err != nil {
- return err
- }
-
- if !bufArea.In(termArea) {
- return fmt.Errorf("the canvas area %+v doesn't fit onto the terminal %+v", bufArea, termArea)
- }
+ // Note - the size of the terminal might have changed since we started
+ // drawing, since terminal windows are inherently racy (the user can resize
+ // them at any time).
+ //
+ // This is ok, since the underlying terminal layer will just ignore cells
+ // that are out of bounds and termdash will redraw again once it receives
+ // the resize event. Regression for #281.
// The image.Point{0, 0} of this canvas isn't always exactly at
// image.Point{0, 0} on the terminal.
diff --git a/private/faketerm/diff.go b/private/faketerm/diff.go
index 80b20e8a..902a40ab 100644
--- a/private/faketerm/diff.go
+++ b/private/faketerm/diff.go
@@ -100,6 +100,9 @@ func Diff(want, got *Terminal) string {
for col := 0; col < size.X; col++ {
got := got.BackBuffer()[col][row].Rune
want := want.BackBuffer()[col][row].Rune
+ if got == want {
+ continue
+ }
b.WriteString(fmt.Sprintf(" cell(%v, %v) => got '%c' (rune %d), want '%c' (rune %d)\n", col, row, got, got, want, want))
}
}
diff --git a/private/fakewidget/fakewidget.go b/private/fakewidget/fakewidget.go
index 5392aced..b94b52a2 100644
--- a/private/fakewidget/fakewidget.go
+++ b/private/fakewidget/fakewidget.go
@@ -43,13 +43,25 @@ const (
// MinimumSize is the minimum size required to draw this widget.
var MinimumSize = image.Point{24, 5}
+// Event is an event that should be delivered to the fake widget.
+type Event struct {
+ // Ev is the event to deliver.
+ Ev terminalapi.Event
+ // Meta is metadata about the event.
+ Meta *widgetapi.EventMeta
+}
+
// Mirror is a fake widget. The fake widget draws a border around its assigned
// canvas and writes the size of its assigned canvas on the first line of the
-// canvas. It writes the last received keyboard event onto the second line. It
-// writes the last received mouse event onto the third line. If a non-empty
-// string is provided via the Text() method, that text will be written right
-// after the canvas size on the first line. If the widget's container is
-// focused it writes "focus" onto the fourth line.
+// canvas.
+//
+// It writes the last received keyboard event onto the second line. It
+// writes the last received mouse event onto the third line. If the widget was
+// focused at the time of the event, the event will be prepended with a "F:".
+//
+// If a non-empty string is provided via the Text() method, that text will be
+// written right after the canvas size on the first line. If the widget's
+// container is focused it writes "focus" onto the fourth line.
//
// The widget requests the same options that are provided to the constructor.
// If the options or canvas size don't allow for the lines mentioned above, the
@@ -126,7 +138,7 @@ func (mi *Mirror) Text(txt string) {
// Sending the keyboard.KeyEsc causes this widget to forget the last keyboard
// event and return an error instead.
// Keyboard implements widgetapi.Widget.Keyboard.
-func (mi *Mirror) Keyboard(k *terminalapi.Keyboard) error {
+func (mi *Mirror) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
mi.mu.Lock()
defer mi.mu.Unlock()
@@ -134,7 +146,11 @@ func (mi *Mirror) Keyboard(k *terminalapi.Keyboard) error {
mi.lines[keyboardLine] = ""
return fmt.Errorf("fakewidget received keyboard event: %v", k)
}
- mi.lines[keyboardLine] = k.Key.String()
+ if meta.Focused {
+ mi.lines[keyboardLine] = fmt.Sprintf("F:%s", k.Key.String())
+ } else {
+ mi.lines[keyboardLine] = k.Key.String()
+ }
return nil
}
@@ -143,7 +159,7 @@ func (mi *Mirror) Keyboard(k *terminalapi.Keyboard) error {
// Sending the mouse.ButtonRight causes this widget to forget the last mouse
// event and return an error instead.
// Mouse implements widgetapi.Widget.Mouse.
-func (mi *Mirror) Mouse(m *terminalapi.Mouse) error {
+func (mi *Mirror) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
mi.mu.Lock()
defer mi.mu.Unlock()
@@ -151,7 +167,11 @@ func (mi *Mirror) Mouse(m *terminalapi.Mouse) error {
mi.lines[mouseLine] = ""
return fmt.Errorf("fakewidget received mouse event: %v", m)
}
- mi.lines[mouseLine] = fmt.Sprintf("%v%v", m.Position, m.Button)
+ if meta.Focused {
+ mi.lines[mouseLine] = fmt.Sprintf("F:%v%v", m.Position, m.Button)
+ } else {
+ mi.lines[mouseLine] = fmt.Sprintf("%v%v", m.Position, m.Button)
+ }
return nil
}
@@ -162,34 +182,34 @@ func (mi *Mirror) Options() widgetapi.Options {
// Draw draws the content that would be expected after placing the Mirror
// widget onto the provided canvas and forwarding the given events.
-func Draw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...terminalapi.Event) error {
+func Draw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...*Event) error {
mirror := New(opts)
return DrawWithMirror(mirror, t, cvs, meta, events...)
}
// MustDraw is like Draw, but panics on all errors.
-func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...terminalapi.Event) {
+func MustDraw(t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, opts widgetapi.Options, events ...*Event) {
if err := Draw(t, cvs, meta, opts, events...); err != nil {
panic(fmt.Sprintf("Draw => %v", err))
}
}
// DrawWithMirror is like Draw, but uses the provided Mirror instead of creating one.
-func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...terminalapi.Event) error {
+func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...*Event) error {
for _, ev := range events {
- switch e := ev.(type) {
+ switch e := ev.Ev.(type) {
case *terminalapi.Mouse:
if mirror.opts.WantMouse == widgetapi.MouseScopeNone {
continue
}
- if err := mirror.Mouse(e); err != nil {
+ if err := mirror.Mouse(e, ev.Meta); err != nil {
return err
}
case *terminalapi.Keyboard:
if mirror.opts.WantKeyboard == widgetapi.KeyScopeNone {
continue
}
- if err := mirror.Keyboard(e); err != nil {
+ if err := mirror.Keyboard(e, ev.Meta); err != nil {
return err
}
default:
@@ -204,7 +224,7 @@ func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas,
}
// MustDrawWithMirror is like DrawWithMirror, but panics on all errors.
-func MustDrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...terminalapi.Event) {
+func MustDrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, meta *widgetapi.Meta, events ...*Event) {
if err := DrawWithMirror(mirror, t, cvs, meta, events...); err != nil {
panic(fmt.Sprintf("DrawWithMirror => %v", err))
}
diff --git a/private/fakewidget/fakewidget_test.go b/private/fakewidget/fakewidget_test.go
index 223471f6..4afb718e 100644
--- a/private/fakewidget/fakewidget_test.go
+++ b/private/fakewidget/fakewidget_test.go
@@ -32,12 +32,14 @@ import (
// keyEvents are keyboard events to send to the widget.
type keyEvents struct {
k *terminalapi.Keyboard
+ meta *widgetapi.EventMeta
wantErr bool
}
// mouseEvents are mouse events to send to the widget.
type mouseEvents struct {
m *terminalapi.Mouse
+ meta *widgetapi.EventMeta
wantErr bool
}
@@ -131,10 +133,12 @@ func TestMirror(t *testing.T) {
desc: "draws the last keyboard event",
keyEvents: []keyEvents{
{
- k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{},
},
{
- k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
+ k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
+ meta: &widgetapi.EventMeta{},
},
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 8, 4)),
@@ -149,11 +153,36 @@ func TestMirror(t *testing.T) {
return ft
},
},
+ {
+ desc: "draws the last keyboard event when focused",
+ keyEvents: []keyEvents{
+ {
+ k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
+ {
+ k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
+ },
+ cvs: testcanvas.MustNew(image.Rect(0, 0, 10, 4)),
+ meta: &widgetapi.Meta{},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+ testdraw.MustBorder(cvs, cvs.Area())
+ testdraw.MustText(cvs, "(10,4)", image.Point{1, 1})
+ testdraw.MustText(cvs, "F:KeyEnd", image.Point{1, 2})
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ },
{
desc: "skips the keyboard event if there isn't a line for it",
keyEvents: []keyEvents{
{
- k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
+ k: &terminalapi.Keyboard{Key: keyboard.KeyEnd},
+ meta: &widgetapi.EventMeta{},
},
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 8, 3)),
@@ -171,12 +200,15 @@ func TestMirror(t *testing.T) {
desc: "draws the last mouse event",
mouseEvents: []mouseEvents{
{
- m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
},
{
m: &terminalapi.Mouse{
Position: image.Point{1, 2},
- Button: mouse.ButtonMiddle},
+ Button: mouse.ButtonMiddle,
+ },
+ meta: &widgetapi.EventMeta{},
},
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 19, 5)),
@@ -191,11 +223,39 @@ func TestMirror(t *testing.T) {
return ft
},
},
+ {
+ desc: "draws the last mouse event when focused",
+ mouseEvents: []mouseEvents{
+ {
+ m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ m: &terminalapi.Mouse{
+ Position: image.Point{1, 2},
+ Button: mouse.ButtonMiddle,
+ },
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
+ },
+ cvs: testcanvas.MustNew(image.Rect(0, 0, 21, 5)),
+ meta: &widgetapi.Meta{},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+ testdraw.MustBorder(cvs, cvs.Area())
+ testdraw.MustText(cvs, "(21,5)", image.Point{1, 1})
+ testdraw.MustText(cvs, "F:(1,2)ButtonMiddle", image.Point{1, 3})
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ },
{
desc: "skips the mouse event if there isn't a line for it",
mouseEvents: []mouseEvents{
{
- m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
},
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 13, 4)),
@@ -213,12 +273,14 @@ func TestMirror(t *testing.T) {
desc: "draws both keyboard and mouse events",
keyEvents: []keyEvents{
{
- k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{},
},
},
mouseEvents: []mouseEvents{
{
- m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
},
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)),
@@ -238,19 +300,23 @@ func TestMirror(t *testing.T) {
desc: "KeyEsc and ButtonRight reset the last event and return error",
keyEvents: []keyEvents{
{
- k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ k: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{},
},
{
k: &terminalapi.Keyboard{Key: keyboard.KeyEsc},
+ meta: &widgetapi.EventMeta{},
wantErr: true,
},
},
mouseEvents: []mouseEvents{
{
- m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ m: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
},
{
m: &terminalapi.Mouse{Button: mouse.ButtonRight},
+ meta: &widgetapi.EventMeta{},
wantErr: true,
},
},
@@ -276,14 +342,14 @@ func TestMirror(t *testing.T) {
}
for _, keyEv := range tc.keyEvents {
- err := w.Keyboard(keyEv.k)
+ err := w.Keyboard(keyEv.k, keyEv.meta)
if (err != nil) != keyEv.wantErr {
t.Errorf("Keyboard => got error:%v, wantErr: %v", err, keyEv.wantErr)
}
}
for _, mouseEv := range tc.mouseEvents {
- err := w.Mouse(mouseEv.m)
+ err := w.Mouse(mouseEv.m, mouseEv.meta)
if (err != nil) != mouseEv.wantErr {
t.Errorf("Mouse => got error:%v, wantErr: %v", err, mouseEv.wantErr)
}
@@ -325,7 +391,7 @@ func TestDraw(t *testing.T) {
opts widgetapi.Options
cvs *canvas.Canvas
meta *widgetapi.Meta
- events []terminalapi.Event
+ events []*Event
want func(size image.Point) *faketerm.Terminal
wantErr bool
}{
@@ -359,9 +425,15 @@ func TestDraw(t *testing.T) {
},
cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)),
meta: &widgetapi.Meta{},
- events: []terminalapi.Event{
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
- &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ events: []*Event{
+ {
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{},
+ },
+ {
+ Ev: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -374,6 +446,35 @@ func TestDraw(t *testing.T) {
return ft
},
},
+ {
+ desc: "draws both keyboard and mouse events while focused",
+ opts: widgetapi.Options{
+ WantKeyboard: widgetapi.KeyScopeFocused,
+ WantMouse: widgetapi.MouseScopeWidget,
+ },
+ cvs: testcanvas.MustNew(image.Rect(0, 0, 19, 5)),
+ meta: &widgetapi.Meta{},
+ events: []*Event{
+ {
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ {
+ Ev: &terminalapi.Mouse{Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+ testdraw.MustBorder(cvs, cvs.Area())
+ testdraw.MustText(cvs, "(19,5)", image.Point{1, 1})
+ testdraw.MustText(cvs, "F:KeyEnter", image.Point{1, 2})
+ testdraw.MustText(cvs, "F:(0,0)ButtonLeft", image.Point{1, 3})
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ },
}
for _, tc := range tests {
diff --git a/termdash_test.go b/termdash_test.go
index 7165eb2d..3cb24f2e 100644
--- a/termdash_test.go
+++ b/termdash_test.go
@@ -253,7 +253,10 @@ func TestRun(t *testing.T) {
widgetapi.Options{
WantMouse: widgetapi.MouseScopeWidget,
},
- &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -281,7 +284,10 @@ func TestRun(t *testing.T) {
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: widgetapi.MouseScopeWidget,
},
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -347,7 +353,10 @@ func TestRun(t *testing.T) {
widgetapi.Options{
WantKeyboard: widgetapi.KeyScopeFocused,
},
- &terminalapi.Keyboard{Key: keyboard.KeyF1},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyF1},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -382,7 +391,10 @@ func TestRun(t *testing.T) {
widgetapi.Options{
WantMouse: widgetapi.MouseScopeWidget,
},
- &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp},
+ &fakewidget.Event{
+ Ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
},
@@ -491,7 +503,10 @@ func TestController(t *testing.T) {
WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: widgetapi.MouseScopeWidget,
},
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ &fakewidget.Event{
+ Ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ Meta: &widgetapi.EventMeta{Focused: true},
+ },
)
return ft
diff --git a/widgetapi/widgetapi.go b/widgetapi/widgetapi.go
index ee27136a..9ca2f70f 100644
--- a/widgetapi/widgetapi.go
+++ b/widgetapi/widgetapi.go
@@ -130,6 +130,13 @@ type Options struct {
// forwarded to the widget.
WantKeyboard KeyScope
+ // ExclusiveKeyboardOnFocus allows a widget to request exclusive access to
+ // keyboard events when its container is focused. When set to true, no
+ // other widgets will receive any keyboard events that happen while the
+ // container of this widget is focused even if they registered for
+ // KeyScopeGlobal.
+ ExclusiveKeyboardOnFocus 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.
@@ -145,6 +152,14 @@ type Meta struct {
Focused bool
}
+// EventMeta provides additional metadata about events to widgets.
+type EventMeta struct {
+ // Focused asserts whether the widget's container is focused at the time of the event.
+ // If the event itself changes focus, the value here reflects the state of
+ // the focus after the change.
+ Focused bool
+}
+
// Widget is a single widget on the dashboard.
// Implementations must be thread safe.
type Widget interface {
@@ -159,15 +174,17 @@ type Widget interface {
// The argument meta is guaranteed to be valid (i.e. non-nil).
Draw(cvs *canvas.Canvas, meta *Meta) error
- // Keyboard is called when the widget is focused on the dashboard and a key
- // shortcut the widget registered for was pressed. Only called if the widget
- // registered for keyboard events.
- Keyboard(k *terminalapi.Keyboard) error
+ // Keyboard is called with every keyboard event whose scope the widget
+ // registered for.
+ //
+ // The argument meta is guaranteed to be valid (i.e. non-nil).
+ Keyboard(k *terminalapi.Keyboard, meta *EventMeta) error
- // Mouse is called when the widget is focused on the dashboard and a mouse
- // event happens on its canvas. Only called if the widget registered for mouse
- // events.
- Mouse(m *terminalapi.Mouse) error
+ // Mouse is called with every mouse event whose scope the widget registered
+ // for.
+ //
+ // The argument meta is guaranteed to be valid (i.e. non-nil).
+ Mouse(m *terminalapi.Mouse, meta *EventMeta) error
// Options returns registration options for the widget.
// This is how the widget indicates to the infrastructure whether it is
diff --git a/widgets/barchart/barchart.go b/widgets/barchart/barchart.go
index 413d3e2e..c439299e 100644
--- a/widgets/barchart/barchart.go
+++ b/widgets/barchart/barchart.go
@@ -280,12 +280,12 @@ func (bc *BarChart) Values(values []int, max int, opts ...Option) error {
}
// Keyboard input isn't supported on the BarChart widget.
-func (*BarChart) Keyboard(k *terminalapi.Keyboard) error {
+func (*BarChart) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the BarChart widget doesn't support keyboard events")
}
// Mouse input isn't supported on the BarChart widget.
-func (*BarChart) Mouse(m *terminalapi.Mouse) error {
+func (*BarChart) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
return errors.New("the BarChart widget doesn't support mouse events")
}
diff --git a/widgets/button/button.go b/widgets/button/button.go
index d0c848f2..6f090a77 100644
--- a/widgets/button/button.go
+++ b/widgets/button/button.go
@@ -18,7 +18,9 @@ package button
import (
"errors"
+ "fmt"
"image"
+ "strings"
"sync"
"time"
@@ -26,6 +28,7 @@ import (
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/mouse"
"github.com/mum4k/termdash/private/alignfor"
+ "github.com/mum4k/termdash/private/attrrange"
"github.com/mum4k/termdash/private/button"
"github.com/mum4k/termdash/private/canvas"
"github.com/mum4k/termdash/private/draw"
@@ -45,6 +48,20 @@ import (
// termdash.ErrorHandler.
type CallbackFn func() error
+// TextChunk is a part of or the full text displayed in the button.
+type TextChunk struct {
+ text string
+ tOpts *textOptions
+}
+
+// NewChunk creates a new text chunk. Each chunk of text can have its own cell options.
+func NewChunk(text string, tOpts ...TextOption) *TextChunk {
+ return &TextChunk{
+ text: text,
+ tOpts: newTextOptions(tOpts...),
+ }
+}
+
// 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.
@@ -52,7 +69,12 @@ type CallbackFn func() error
// Implements widgetapi.Widget. This object is thread-safe.
type Button struct {
// text in the text label displayed in the button.
- text string
+ text strings.Builder
+
+ // givenTOpts are text options given for the button's of text.
+ givenTOpts []*textOptions
+ // tOptsTracker tracks the positions in a text to which the givenTOpts apply.
+ tOptsTracker *attrrange.Tracker
// mouseFSM tracks left mouse clicks.
mouseFSM *button.FSM
@@ -77,26 +99,66 @@ type Button struct {
// New returns a new Button that will display the provided text.
// Each press of the button will invoke the callback function.
+// The callback function can be nil in which case pressing the button is a
+// no-op.
func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) {
- if cFn == nil {
- return nil, errors.New("the CallbackFn argument cannot be nil")
+ return NewFromChunks([]*TextChunk{NewChunk(text)}, cFn, opts...)
+}
+
+// NewFromChunks is like New, but allows specifying write options for
+// individual chunks of text displayed in the button.
+func NewFromChunks(chunks []*TextChunk, cFn CallbackFn, opts ...Option) (*Button, error) {
+ if len(chunks) == 0 {
+ return nil, errors.New("at least one text chunk must be specified")
}
- opt := newOptions(text)
+ var (
+ text strings.Builder
+ givenTOpts []*textOptions
+ )
+ tOptsTracker := attrrange.NewTracker()
+ for i, tc := range chunks {
+ if tc.text == "" {
+ return nil, fmt.Errorf("text chunk[%d] is empty, all chunks must contains some text", i)
+ }
+
+ pos := text.Len()
+ givenTOpts = append(givenTOpts, tc.tOpts)
+ tOptsIdx := len(givenTOpts) - 1
+ if err := tOptsTracker.Add(pos, pos+len(tc.text), tOptsIdx); err != nil {
+ return nil, err
+ }
+ text.WriteString(tc.text)
+ }
+
+ opt := newOptions(text.String())
for _, o := range opts {
o.set(opt)
}
if err := opt.validate(); err != nil {
return nil, err
}
+
+ for _, tOpts := range givenTOpts {
+ tOpts.setDefaultFgColor(opt.textColor)
+ }
return &Button{
- text: text,
- mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
- callback: cFn,
- opts: opt,
+ text: text,
+ givenTOpts: givenTOpts,
+ tOptsTracker: tOptsTracker,
+ mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
+ callback: cFn,
+ opts: opt,
}, nil
}
+// SetCallback replaces the callback function of the button with the one provided.
+func (b *Button) SetCallback(cFn CallbackFn) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.callback = cFn
+}
+
// Vars to be replaced from tests.
var (
// Runes to use in cells that contain the button.
@@ -126,40 +188,90 @@ func (b *Button) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
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
+ sw := b.shadowWidth()
+ shadowAr := image.Rect(sw, sw, cvsAr.Dx(), cvsAr.Dy())
+ if !b.opts.disableShadow {
+ 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 := image.Rect(0, 0, cvsAr.Dx()-sw, cvsAr.Dy()-sw)
+ if b.state == button.Down && !b.opts.disableShadow {
buttonAr = shadowAr
}
- if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil {
+ var fillColor cell.Color
+ switch {
+ case b.state == button.Down && b.opts.pressedFillColor != nil:
+ fillColor = *b.opts.pressedFillColor
+ case meta.Focused && b.opts.focusedFillColor != nil:
+ fillColor = *b.opts.focusedFillColor
+ default:
+ fillColor = b.opts.fillColor
+ }
+
+ if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(fillColor)); err != nil {
return err
}
+ return b.drawText(cvs, meta, buttonAr)
+}
- textAr := image.Rect(buttonAr.Min.X+1, buttonAr.Min.Y, buttonAr.Dx()-1, buttonAr.Max.Y)
- start, err := alignfor.Text(textAr, b.text, align.HorizontalCenter, align.VerticalMiddle)
+// drawText draws the text inside the button.
+func (b *Button) drawText(cvs *canvas.Canvas, meta *widgetapi.Meta, buttonAr image.Rectangle) error {
+ pad := b.opts.textHorizontalPadding
+ textAr := image.Rect(buttonAr.Min.X+pad, buttonAr.Min.Y, buttonAr.Dx()-pad, buttonAr.Max.Y)
+ start, err := alignfor.Text(textAr, b.text.String(), align.HorizontalCenter, align.VerticalMiddle)
if err != nil {
return err
}
- return draw.Text(cvs, b.text, start,
- draw.TextOverrunMode(draw.OverrunModeThreeDot),
- draw.TextMaxX(buttonAr.Max.X),
- draw.TextCellOpts(cell.FgColor(b.opts.textColor)),
- )
+
+ maxCells := buttonAr.Max.X - start.X
+ trimmed, err := draw.TrimText(b.text.String(), maxCells, draw.OverrunModeThreeDot)
+ if err != nil {
+ return err
+ }
+
+ optRange, err := b.tOptsTracker.ForPosition(0) // Text options for the current byte.
+ if err != nil {
+ return err
+ }
+
+ cur := start
+ for i, r := range trimmed {
+ if i >= optRange.High { // Get the next write options.
+ or, err := b.tOptsTracker.ForPosition(i)
+ if err != nil {
+ return err
+ }
+ optRange = or
+ }
+
+ tOpts := b.givenTOpts[optRange.AttrIdx]
+ var cellOpts []cell.Option
+ switch {
+ case b.state == button.Down && len(tOpts.pressedCellOpts) > 0:
+ cellOpts = tOpts.pressedCellOpts
+ case meta.Focused && len(tOpts.focusedCellOpts) > 0:
+ cellOpts = tOpts.focusedCellOpts
+ default:
+ cellOpts = tOpts.cellOpts
+ }
+ cells, err := cvs.SetCell(cur, r, cellOpts...)
+ if err != nil {
+ return err
+ }
+ cur = image.Point{cur.X + cells, cur.Y}
+ }
+ return nil
}
// activated asserts whether the keyboard event activated the button.
-func (b *Button) keyActivated(k *terminalapi.Keyboard) bool {
+func (b *Button) keyActivated(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) bool {
b.mu.Lock()
defer b.mu.Unlock()
- if k.Key == b.opts.key {
+ if b.opts.globalKeys[k.Key] || (b.opts.focusedKeys[k.Key] && meta.Focused) {
b.state = button.Down
now := time.Now().UTC()
b.keyTriggerTime = &now
@@ -172,12 +284,14 @@ func (b *Button) keyActivated(k *terminalapi.Keyboard) bool {
// Key.
//
// Implements widgetapi.Widget.Keyboard.
-func (b *Button) Keyboard(k *terminalapi.Keyboard) error {
- if b.keyActivated(k) {
- // Mutex must be released when calling the callback.
- // Users might call container methods from the callback like the
- // Container.Update, see #205.
- return b.callback()
+func (b *Button) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
+ if b.keyActivated(k, meta) {
+ if b.callback != nil {
+ // Mutex must be released when calling the callback.
+ // Users might call container methods from the callback like the
+ // Container.Update, see #205.
+ return b.callback()
+ }
}
return nil
}
@@ -198,29 +312,44 @@ func (b *Button) mouseActivated(m *terminalapi.Mouse) bool {
// the release happen inside the button.
//
// Implements widgetapi.Widget.Mouse.
-func (b *Button) Mouse(m *terminalapi.Mouse) error {
+func (b *Button) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
if b.mouseActivated(m) {
- // Mutex must be released when calling the callback.
- // Users might call container methods from the callback like the
- // Container.Update, see #205.
- return b.callback()
+ if b.callback != nil {
+ // Mutex must be released when calling the callback.
+ // Users might call container methods from the callback like the
+ // Container.Update, see #205.
+ return b.callback()
+ }
}
return nil
}
-// shadowWidth is the width of the shadow under the button in cell.
-const shadowWidth = 1
+// shadowWidth returns the width of the shadow under the button or zero if the
+// button shouldn't have any shadow.
+func (b *Button) shadowWidth() int {
+ if b.opts.disableShadow {
+ return 0
+ }
+ return 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
+ width := b.opts.width + b.shadowWidth() + 2*b.opts.textHorizontalPadding
+ height := b.opts.height + b.shadowWidth()
+
+ var keyScope widgetapi.KeyScope
+ if len(b.opts.focusedKeys) > 0 || len(b.opts.globalKeys) > 0 {
+ keyScope = widgetapi.KeyScopeGlobal
+ } else {
+ keyScope = widgetapi.KeyScopeNone
+ }
return widgetapi.Options{
MinimumSize: image.Point{width, height},
MaximumSize: image.Point{width, height},
- WantKeyboard: b.opts.keyScope,
+ WantKeyboard: keyScope,
WantMouse: widgetapi.MouseScopeGlobal,
}
}
diff --git a/widgets/button/button_test.go b/widgets/button/button_test.go
index 20721b7e..efc1d138 100644
--- a/widgets/button/button_test.go
+++ b/widgets/button/button_test.go
@@ -45,6 +45,10 @@ type callbackTracker struct {
// count is the number of times the callback was called.
count int
+ // useSetCallback when set to true instructs the test to set the callback
+ // via button.SetCallback instead of button.New or button.NewFromChunks.
+ useSetCallback bool
+
// mu protects the tracker.
mu sync.Mutex
}
@@ -63,13 +67,23 @@ func (ct *callbackTracker) callback() error {
return nil
}
+// event represents a terminal event for tests.
+type event struct {
+ ev terminalapi.Event
+ meta *widgetapi.EventMeta
+}
+
func TestButton(t *testing.T) {
tests := []struct {
- desc string
- text string
+ desc string
+
+ // Only one of these must be specified.
+ text string // Calls New() as the constructor.
+ textChunks []*TextChunk // Calls NewFromChunks() as the constructor.
+
callback *callbackTracker
opts []Option
- events []terminalapi.Event
+ events []*event
canvas image.Rectangle
meta *widgetapi.Meta
@@ -84,35 +98,175 @@ func TestButton(t *testing.T) {
wantCallbackErr bool
}{
{
- desc: "New fails with nil callback",
+ desc: "New fails with negative keyUpDelay",
+ callback: &callbackTracker{},
+ opts: []Option{
+ KeyUpDelay(-1 * time.Second),
+ },
canvas: image.Rect(0, 0, 1, 1),
+ text: "hello",
+ meta: &widgetapi.Meta{Focused: false},
wantNewErr: true,
},
{
- desc: "New fails with negative keyUpDelay",
+ desc: "New fails with zero Height",
+ callback: &callbackTracker{},
+ opts: []Option{
+ Height(0),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ text: "hello",
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "New fails with zero Width",
+ callback: &callbackTracker{},
+ opts: []Option{
+ Width(0),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ text: "hello",
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "New fails with negative textHorizontalPadding",
+ callback: &callbackTracker{},
+ opts: []Option{
+ TextHorizontalPadding(-1),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ text: "hello",
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "New fails when duplicate Key and GlobalKey are specified",
+ callback: &callbackTracker{},
+ opts: []Option{
+ Key('a'),
+ GlobalKey('a'),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ text: "hello",
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "New fails when duplicate Keys and GlobalKeys are specified",
+ callback: &callbackTracker{},
+ opts: []Option{
+ Keys('a'),
+ GlobalKeys('a'),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ text: "hello",
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "NewFromChunks fails with negative keyUpDelay",
+ textChunks: []*TextChunk{
+ NewChunk("text"),
+ },
callback: &callbackTracker{},
opts: []Option{
KeyUpDelay(-1 * time.Second),
},
canvas: image.Rect(0, 0, 1, 1),
+ meta: &widgetapi.Meta{Focused: false},
wantNewErr: true,
},
{
- desc: "New fails with zero Height",
+ desc: "NewFromChunks fails with zero Height",
+ textChunks: []*TextChunk{
+ NewChunk("text"),
+ },
callback: &callbackTracker{},
opts: []Option{
Height(0),
},
canvas: image.Rect(0, 0, 1, 1),
+ meta: &widgetapi.Meta{Focused: false},
wantNewErr: true,
},
{
- desc: "New fails with zero Width",
+ desc: "NewFromChunks fails with zero Width",
+ textChunks: []*TextChunk{
+ NewChunk("text"),
+ },
callback: &callbackTracker{},
opts: []Option{
Width(0),
},
canvas: image.Rect(0, 0, 1, 1),
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "NewFromChunks fails with negative textHorizontalPadding",
+ textChunks: []*TextChunk{
+ NewChunk("text"),
+ },
+ callback: &callbackTracker{},
+ opts: []Option{
+ TextHorizontalPadding(-1),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "NewFromChunks fails when duplicate Key and GlobalKey are specified",
+ callback: &callbackTracker{},
+ opts: []Option{
+ Key('a'),
+ GlobalKey('a'),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ textChunks: []*TextChunk{
+ NewChunk("text"),
+ },
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "NewFromChunks fails when duplicate Keys and GlobalKeys are specified",
+ callback: &callbackTracker{},
+ opts: []Option{
+ Keys('a'),
+ GlobalKeys('a'),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ textChunks: []*TextChunk{
+ NewChunk("text"),
+ },
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "NewFromChunks fails with zero chunks",
+ textChunks: []*TextChunk{},
+ callback: &callbackTracker{},
+ opts: []Option{
+ TextHorizontalPadding(-1),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ meta: &widgetapi.Meta{Focused: false},
+ wantNewErr: true,
+ },
+ {
+ desc: "NewFromChunks fails with an empty chunk",
+ textChunks: []*TextChunk{
+ NewChunk(""),
+ },
+ callback: &callbackTracker{},
+ opts: []Option{
+ TextHorizontalPadding(-1),
+ },
+ canvas: image.Rect(0, 0, 1, 1),
+ meta: &widgetapi.Meta{Focused: false},
wantNewErr: true,
},
{
@@ -120,6 +274,7 @@ func TestButton(t *testing.T) {
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 1, 1),
+ meta: &widgetapi.Meta{Focused: false},
wantDrawErr: true,
},
{
@@ -127,6 +282,7 @@ func TestButton(t *testing.T) {
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
@@ -149,13 +305,313 @@ func TestButton(t *testing.T) {
},
wantCallback: &callbackTracker{},
},
+ {
+ desc: "draws button without a shadow in up state",
+ callback: &callbackTracker{},
+ opts: []Option{
+ DisableShadow(),
+ },
+ text: "hello",
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), '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},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ 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: "draws button in down state without a shadow",
+ callback: &callbackTracker{},
+ opts: []Option{
+ DisableShadow(),
+ },
+ text: "hello",
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), '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: "mouse triggered a button with nil callback",
+ callback: nil,
+ text: "hello",
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ 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
+ },
+ },
+ {
+ desc: "mouse triggered a callback set via the constructor",
+ callback: &callbackTracker{},
+ text: "hello",
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ 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: "mouse triggered a callback set via SetCallback",
+ callback: &callbackTracker{
+ useSetCallback: true,
+ },
+ text: "hello",
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ 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,
+ useSetCallback: true,
+ },
+ },
+ {
+ 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),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
+ },
+ 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: "ignores keyboard event configured with Key when not focused",
+ callback: &callbackTracker{},
+ text: "hello",
+ opts: []Option{
+ Key(keyboard.KeyEnter),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: false},
+ },
+ },
+ 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: false,
+ count: 0,
+ },
+ },
+
+ {
+ desc: "draws button in down state due to a keyboard event when multiple keys are specified",
+ callback: &callbackTracker{},
+ text: "hello",
+ opts: []Option{
+ Keys(keyboard.KeyEnter, keyboard.KeyTab),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -174,29 +630,35 @@ func TestButton(t *testing.T) {
testcanvas.MustApply(cvs, ft)
return ft
},
- wantCallback: &callbackTracker{},
+ wantCallback: &callbackTracker{
+ called: true,
+ count: 1,
+ },
},
{
- desc: "mouse triggered the callback",
+ desc: "draws button in down state due to a keyboard event when single global key is specified",
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},
+ opts: []Option{
+ GlobalKey(keyboard.KeyTab),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ meta: &widgetapi.EventMeta{},
+ },
},
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)))
+ testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117)))
// Text.
- testdraw.MustText(cvs, "hello", image.Point{1, 1},
+ testdraw.MustText(cvs, "hello", image.Point{2, 2},
draw.TextCellOpts(
cell.FgColor(cell.ColorBlack),
cell.BgColor(cell.ColorNumber(117))),
@@ -211,15 +673,19 @@ func TestButton(t *testing.T) {
},
},
{
- desc: "draws button in down state due to a keyboard event, callback triggered",
+ desc: "draws button in down state due to a keyboard event when multiple global keys are specified",
callback: &callbackTracker{},
text: "hello",
opts: []Option{
- Key(keyboard.KeyEnter),
+ GlobalKeys(keyboard.KeyEnter, keyboard.KeyTab),
},
canvas: image.Rect(0, 0, 8, 4),
- events: []terminalapi.Event{
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyTab},
+ meta: &widgetapi.EventMeta{},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -248,8 +714,12 @@ func TestButton(t *testing.T) {
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 8, 4),
- events: []terminalapi.Event{
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -273,6 +743,42 @@ func TestButton(t *testing.T) {
},
wantCallback: &callbackTracker{},
},
+ {
+ desc: "keyboard event triggers a button with nil callback",
+ callback: nil,
+ text: "hello",
+ opts: []Option{
+ Key(keyboard.KeyEnter),
+ },
+ timeSince: func(time.Time) time.Duration {
+ return 200 * time.Millisecond
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
+ },
+ 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
+ },
+ },
{
desc: "keyboard event triggers the button, trigger time didn't expire so button is down",
callback: &callbackTracker{},
@@ -284,8 +790,12 @@ func TestButton(t *testing.T) {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
- events: []terminalapi.Event{
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -321,8 +831,12 @@ func TestButton(t *testing.T) {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
- events: []terminalapi.Event{
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -361,10 +875,20 @@ func TestButton(t *testing.T) {
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},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -396,11 +920,24 @@ func TestButton(t *testing.T) {
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},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ meta: &widgetapi.EventMeta{},
+ },
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -434,9 +971,16 @@ func TestButton(t *testing.T) {
},
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},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease},
+ meta: &widgetapi.EventMeta{},
+ },
},
wantCallbackErr: true,
},
@@ -454,8 +998,12 @@ func TestButton(t *testing.T) {
return 200 * time.Millisecond
},
canvas: image.Rect(0, 0, 8, 4),
- events: []terminalapi.Event{
- &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.Meta{Focused: false},
+ events: []*event{
+ {
+ ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter},
+ meta: &widgetapi.EventMeta{Focused: true},
+ },
},
wantCallbackErr: true,
},
@@ -464,6 +1012,7 @@ func TestButton(t *testing.T) {
callback: &callbackTracker{},
text: "hello",
canvas: image.Rect(0, 0, 8, 2),
+ meta: &widgetapi.Meta{Focused: false},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
@@ -491,6 +1040,7 @@ func TestButton(t *testing.T) {
callback: &callbackTracker{},
text: "h",
canvas: image.Rect(0, 0, 4, 2),
+ meta: &widgetapi.Meta{Focused: false},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
@@ -521,6 +1071,7 @@ func TestButton(t *testing.T) {
TextColor(cell.ColorRed),
},
canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
@@ -551,6 +1102,7 @@ func TestButton(t *testing.T) {
FillColor(cell.ColorRed),
},
canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
@@ -581,6 +1133,7 @@ func TestButton(t *testing.T) {
ShadowColor(cell.ColorRed),
},
canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
cvs := testcanvas.MustNew(ft.Area())
@@ -603,6 +1156,312 @@ func TestButton(t *testing.T) {
},
wantCallback: &callbackTracker{},
},
+ {
+ desc: "draws button with text chunks and custom fill color in up state",
+ callback: &callbackTracker{},
+ opts: []Option{
+ FillColor(cell.ColorBlue),
+ FocusedFillColor(cell.ColorYellow),
+ PressedFillColor(cell.ColorRed),
+ DisableShadow(),
+ },
+ textChunks: []*TextChunk{
+ NewChunk(
+ "h",
+ TextCellOpts(cell.FgColor(cell.ColorBlack)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
+ ),
+ NewChunk(
+ "ello",
+ TextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ ),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue))
+
+ // Text.
+ testdraw.MustText(cvs, "h", image.Point{1, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorBlack),
+ cell.BgColor(cell.ColorBlue)),
+ )
+ testdraw.MustText(cvs, "ello", image.Point{2, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorMagenta),
+ cell.BgColor(cell.ColorBlue)),
+ )
+
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ wantCallback: &callbackTracker{},
+ },
+ {
+ desc: "draws button with text chunks and custom fill color in focused up state",
+ callback: &callbackTracker{},
+ opts: []Option{
+ FillColor(cell.ColorBlue),
+ FocusedFillColor(cell.ColorYellow),
+ PressedFillColor(cell.ColorRed),
+ DisableShadow(),
+ },
+ textChunks: []*TextChunk{
+ NewChunk(
+ "h",
+ TextCellOpts(cell.FgColor(cell.ColorBlack)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
+ ),
+ NewChunk(
+ "ello",
+ TextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ ),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: true},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorYellow))
+
+ // Text.
+ testdraw.MustText(cvs, "h", image.Point{1, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorWhite),
+ cell.BgColor(cell.ColorYellow)),
+ )
+ testdraw.MustText(cvs, "ello", image.Point{2, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorMagenta),
+ cell.BgColor(cell.ColorYellow)),
+ )
+
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ wantCallback: &callbackTracker{},
+ },
+ {
+ desc: "draws button with text chunks in up state, focused colors default to regular colors",
+ callback: &callbackTracker{},
+ opts: []Option{
+ FillColor(cell.ColorBlue),
+ PressedFillColor(cell.ColorRed),
+ DisableShadow(),
+ },
+ textChunks: []*TextChunk{
+ NewChunk(
+ "h",
+ TextCellOpts(cell.FgColor(cell.ColorBlack)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
+ ),
+ NewChunk(
+ "ello",
+ TextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ ),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: true},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue))
+
+ // Text.
+ testdraw.MustText(cvs, "h", image.Point{1, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorBlack),
+ cell.BgColor(cell.ColorBlue)),
+ )
+ testdraw.MustText(cvs, "ello", image.Point{2, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorMagenta),
+ cell.BgColor(cell.ColorBlue)),
+ )
+
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ wantCallback: &callbackTracker{},
+ },
+ {
+ desc: "draws button with text chunks and custom fill color in down state",
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ callback: &callbackTracker{},
+ opts: []Option{
+ FillColor(cell.ColorBlue),
+ FocusedFillColor(cell.ColorYellow),
+ PressedFillColor(cell.ColorRed),
+ DisableShadow(),
+ },
+ textChunks: []*TextChunk{
+ NewChunk(
+ "h",
+ TextCellOpts(cell.FgColor(cell.ColorBlack)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
+ ),
+ NewChunk(
+ "ello",
+ TextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ ),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorRed))
+
+ // Text.
+ testdraw.MustText(cvs, "h", image.Point{1, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorGreen),
+ cell.BgColor(cell.ColorRed)),
+ )
+ testdraw.MustText(cvs, "ello", image.Point{2, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorMagenta),
+ cell.BgColor(cell.ColorRed)),
+ )
+
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ wantCallback: &callbackTracker{},
+ },
+ {
+ desc: "draws button with text chunks and custom fill color in down focused state (focus has no impact)",
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ callback: &callbackTracker{},
+ opts: []Option{
+ FillColor(cell.ColorBlue),
+ FocusedFillColor(cell.ColorYellow),
+ PressedFillColor(cell.ColorRed),
+ DisableShadow(),
+ },
+ textChunks: []*TextChunk{
+ NewChunk(
+ "h",
+ TextCellOpts(cell.FgColor(cell.ColorBlack)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorGreen)),
+ ),
+ NewChunk(
+ "ello",
+ TextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ ),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: true},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorRed))
+
+ // Text.
+ testdraw.MustText(cvs, "h", image.Point{1, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorGreen),
+ cell.BgColor(cell.ColorRed)),
+ )
+ testdraw.MustText(cvs, "ello", image.Point{2, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorMagenta),
+ cell.BgColor(cell.ColorRed)),
+ )
+
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ wantCallback: &callbackTracker{},
+ },
+ {
+ desc: "draws button with text chunks in down satte, pressed colors default to regular colors",
+ events: []*event{
+ {
+ ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft},
+ meta: &widgetapi.EventMeta{},
+ },
+ },
+ callback: &callbackTracker{},
+ opts: []Option{
+ FillColor(cell.ColorBlue),
+ FocusedFillColor(cell.ColorYellow),
+ DisableShadow(),
+ },
+ textChunks: []*TextChunk{
+ NewChunk(
+ "h",
+ TextCellOpts(cell.FgColor(cell.ColorBlack)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)),
+ ),
+ NewChunk(
+ "ello",
+ TextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)),
+ ),
+ },
+ canvas: image.Rect(0, 0, 8, 4),
+ meta: &widgetapi.Meta{Focused: false},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ // Button.
+ testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue))
+
+ // Text.
+ testdraw.MustText(cvs, "h", image.Point{1, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorBlack),
+ cell.BgColor(cell.ColorBlue)),
+ )
+ testdraw.MustText(cvs, "ello", image.Point{2, 1},
+ draw.TextCellOpts(
+ cell.FgColor(cell.ColorMagenta),
+ cell.BgColor(cell.ColorBlue)),
+ )
+
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ wantCallback: &callbackTracker{},
+ },
}
buttonRune = 'x'
@@ -619,15 +1478,41 @@ func TestButton(t *testing.T) {
var cFn CallbackFn
if gotCallback == nil {
cFn = nil
+ } else if gotCallback.useSetCallback {
+ // Set an no-op callback via the constructor.
+ // It will be updated to the real one via SetCallback.
+ cFn = func() error { return 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 tc.text != "" && tc.textChunks != nil {
+ t.Fatalf("cannot specify both text and textChunks in the testdata")
}
- if err != nil {
- return
+
+ var btn *Button
+ if tc.textChunks != nil {
+ b, err := NewFromChunks(tc.textChunks, cFn, tc.opts...)
+ if (err != nil) != tc.wantNewErr {
+ t.Errorf("NewFromChunks => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr)
+ }
+ if err != nil {
+ return
+ }
+ btn = b
+ } else {
+ 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
+ }
+ btn = b
+ }
+
+ if gotCallback != nil && gotCallback.useSetCallback {
+ btn.SetCallback(gotCallback.callback)
}
{
@@ -636,7 +1521,7 @@ func TestButton(t *testing.T) {
if err != nil {
t.Fatalf("canvas.New => unexpected error: %v", err)
}
- err = b.Draw(c, tc.meta)
+ err = btn.Draw(c, tc.meta)
if (err != nil) != tc.wantDrawErr {
t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr)
}
@@ -645,10 +1530,10 @@ func TestButton(t *testing.T) {
}
}
- for i, ev := range tc.events {
- switch e := ev.(type) {
+ for i, event := range tc.events {
+ switch e := event.ev.(type) {
case *terminalapi.Mouse:
- err := b.Mouse(e)
+ err := btn.Mouse(e, event.meta)
// 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 {
@@ -664,7 +1549,7 @@ func TestButton(t *testing.T) {
}
case *terminalapi.Keyboard:
- err := b.Keyboard(e)
+ err := btn.Keyboard(e, event.meta)
// 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 {
@@ -680,7 +1565,7 @@ func TestButton(t *testing.T) {
}
default:
- t.Fatalf("unsupported event type: %T", ev)
+ t.Fatalf("unsupported event type: %T", event.ev)
}
}
@@ -689,7 +1574,7 @@ func TestButton(t *testing.T) {
t.Fatalf("canvas.New => unexpected error: %v", err)
}
- err = b.Draw(c, tc.meta)
+ err = btn.Draw(c, tc.meta)
if (err != nil) != tc.wantDrawErr {
t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr)
}
@@ -778,10 +1663,24 @@ func TestOptions(t *testing.T) {
},
},
{
- desc: "custom width specified",
+ desc: "custom width specified with default padding",
+ text: "hello",
+ opts: []Option{
+ Width(10),
+ },
+ want: widgetapi.Options{
+ MinimumSize: image.Point{13, 4},
+ MaximumSize: image.Point{13, 4},
+ WantKeyboard: widgetapi.KeyScopeNone,
+ WantMouse: widgetapi.MouseScopeGlobal,
+ },
+ },
+ {
+ desc: "custom width specified with custom padding",
text: "hello",
opts: []Option{
Width(10),
+ TextHorizontalPadding(0),
},
want: widgetapi.Options{
MinimumSize: image.Point{11, 4},
@@ -790,9 +1689,23 @@ func TestOptions(t *testing.T) {
WantMouse: widgetapi.MouseScopeGlobal,
},
},
-
{
- desc: "doesn't want keyboard by default",
+ desc: "without shadow or padding",
+ text: "hello",
+ opts: []Option{
+ Width(10),
+ TextHorizontalPadding(0),
+ DisableShadow(),
+ },
+ want: widgetapi.Options{
+ MinimumSize: image.Point{10, 3},
+ MaximumSize: image.Point{10, 3},
+ WantKeyboard: widgetapi.KeyScopeNone,
+ WantMouse: widgetapi.MouseScopeGlobal,
+ },
+ },
+ {
+ desc: "doesn't want keyboard by default without any keys",
text: "hello",
want: widgetapi.Options{
MinimumSize: image.Point{8, 4},
@@ -802,7 +1715,7 @@ func TestOptions(t *testing.T) {
},
},
{
- desc: "registers for focused keyboard events",
+ desc: "registers for keyboard events when Key used",
text: "hello",
opts: []Option{
Key(keyboard.KeyEnter),
@@ -810,12 +1723,25 @@ func TestOptions(t *testing.T) {
want: widgetapi.Options{
MinimumSize: image.Point{8, 4},
MaximumSize: image.Point{8, 4},
- WantKeyboard: widgetapi.KeyScopeFocused,
+ WantKeyboard: widgetapi.KeyScopeGlobal,
+ WantMouse: widgetapi.MouseScopeGlobal,
+ },
+ },
+ {
+ desc: "registers for keyboard events when Keys used",
+ text: "hello",
+ opts: []Option{
+ Keys(keyboard.KeyEnter, keyboard.KeyTab),
+ },
+ want: widgetapi.Options{
+ MinimumSize: image.Point{8, 4},
+ MaximumSize: image.Point{8, 4},
+ WantKeyboard: widgetapi.KeyScopeGlobal,
WantMouse: widgetapi.MouseScopeGlobal,
},
},
{
- desc: "registers for global keyboard events",
+ desc: "registers for keyboard events when GlobalKey used",
text: "hello",
opts: []Option{
GlobalKey(keyboard.KeyEnter),
@@ -827,6 +1753,19 @@ func TestOptions(t *testing.T) {
WantMouse: widgetapi.MouseScopeGlobal,
},
},
+ {
+ desc: "registers for keyboard events when GlobalKeys used",
+ text: "hello",
+ opts: []Option{
+ GlobalKeys(keyboard.KeyEnter, keyboard.KeyTab),
+ },
+ want: widgetapi.Options{
+ MinimumSize: image.Point{8, 4},
+ MaximumSize: image.Point{8, 4},
+ WantKeyboard: widgetapi.KeyScopeGlobal,
+ WantMouse: widgetapi.MouseScopeGlobal,
+ },
+ },
}
for _, tc := range tests {
diff --git a/widgets/button/options.go b/widgets/button/options.go
index ab0db047..030b8a28 100644
--- a/widgets/button/options.go
+++ b/widgets/button/options.go
@@ -42,18 +42,25 @@ func (o option) set(opts *options) {
// 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
+ fillColor cell.Color
+ focusedFillColor *cell.Color
+ pressedFillColor *cell.Color
+ textColor cell.Color
+ textHorizontalPadding int
+ shadowColor cell.Color
+ disableShadow bool
+ height int
+ width int
+ focusedKeys map[keyboard.Key]bool
+ globalKeys map[keyboard.Key]bool
+ keyUpDelay time.Duration
}
// validate validates the provided options.
func (o *options) validate() error {
+ if min := 0; o.textHorizontalPadding < min {
+ return fmt.Errorf("invalid textHorizontalPadding %d, must be %d <= textHorizontalPadding", o.textHorizontalPadding, min)
+ }
if min := 1; o.height < min {
return fmt.Errorf("invalid height %d, must be %d <= height", o.height, min)
}
@@ -63,18 +70,33 @@ func (o *options) validate() error {
if min := time.Duration(0); o.keyUpDelay < min {
return fmt.Errorf("invalid keyUpDelay %v, must be %v <= keyUpDelay", o.keyUpDelay, min)
}
+
+ for k := range o.globalKeys {
+ if o.focusedKeys[k] {
+ return fmt.Errorf("key %q cannot be configured as both a focused key (options Key or Keys) and a global key (options GlobalKey or GlobalKeys)", k)
+ }
+ }
return nil
}
+// keyScope stores a key and its scope.
+type keyScope struct {
+ key keyboard.Key
+ scope widgetapi.KeyScope
+}
+
// 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: cell.ColorNumber(117),
+ textColor: cell.ColorBlack,
+ textHorizontalPadding: DefaultTextHorizontalPadding,
+ shadowColor: cell.ColorNumber(240),
+ height: DefaultHeight,
+ width: widthFor(text),
+ keyUpDelay: DefaultKeyUpDelay,
+ focusedKeys: map[keyboard.Key]bool{},
+ globalKeys: map[keyboard.Key]bool{},
}
}
@@ -85,6 +107,23 @@ func FillColor(c cell.Color) Option {
})
}
+// FocusedFillColor sets the fill color of the button when the widget's
+// container is focused.
+// Defaults to FillColor.
+func FocusedFillColor(c cell.Color) Option {
+ return option(func(opts *options) {
+ opts.focusedFillColor = &c
+ })
+}
+
+// PressedFillColor sets the fill color of the button when it is pressed.
+// Defaults to FillColor.
+func PressedFillColor(c cell.Color) Option {
+ return option(func(opts *options) {
+ opts.pressedFillColor = &c
+ })
+}
+
// TextColor sets the color of the text label in the button.
func TextColor(c cell.Color) Option {
return option(func(opts *options) {
@@ -114,6 +153,8 @@ func Height(cells int) Option {
// 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.
+// Not all the width may be available to the text if TextHorizontalPadding is
+// set to a non-zero integer.
func Width(cells int) Option {
return option(func(opts *options) {
opts.width = cells
@@ -131,21 +172,47 @@ func WidthFor(text string) Option {
// Key configures the keyboard key that presses the button.
// The widget responds to this key only if its container is focused.
-// When not provided, the widget ignores all keyboard events.
+//
+// Clears all keys set by Key() or Keys() previously.
func Key(k keyboard.Key) Option {
return option(func(opts *options) {
- opts.key = k
- opts.keyScope = widgetapi.KeyScopeFocused
+ opts.focusedKeys = map[keyboard.Key]bool{}
+ opts.focusedKeys[k] = true
})
}
// 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.
+//
+// Clears all keys set by GlobalKey() or GlobalKeys() previously.
func GlobalKey(k keyboard.Key) Option {
return option(func(opts *options) {
- opts.key = k
- opts.keyScope = widgetapi.KeyScopeGlobal
+ opts.globalKeys = map[keyboard.Key]bool{}
+ opts.globalKeys[k] = true
+ })
+}
+
+// Keys is like Key, but allows to configure multiple keys.
+//
+// Clears all keys set by Key() or Keys() previously.
+func Keys(keys ...keyboard.Key) Option {
+ return option(func(opts *options) {
+ opts.focusedKeys = map[keyboard.Key]bool{}
+ for _, k := range keys {
+ opts.focusedKeys[k] = true
+ }
+ })
+}
+
+// GlobalKeys is like GlobalKey, but allows to configure multiple keys.
+//
+// Clears all keys set by GlobalKey() or GlobalKeys() previously.
+func GlobalKeys(keys ...keyboard.Key) Option {
+ return option(func(opts *options) {
+ opts.globalKeys = map[keyboard.Key]bool{}
+ for _, k := range keys {
+ opts.globalKeys[k] = true
+ }
})
}
@@ -165,7 +232,26 @@ func KeyUpDelay(d time.Duration) Option {
})
}
+// DisableShadow when provided the button will not have a shadow area and will
+// have no animation when pressed.
+func DisableShadow() Option {
+ return option(func(opts *options) {
+ opts.disableShadow = true
+ })
+}
+
+// DefaultTextHorizontalPadding is the default value for the HorizontalPadding option.
+const DefaultTextHorizontalPadding = 1
+
+// TextHorizontalPadding sets padding on the left and right side of the
+// button's text as the amount of cells.
+func TextHorizontalPadding(p int) Option {
+ return option(func(opts *options) {
+ opts.textHorizontalPadding = p
+ })
+}
+
// 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.
+ return runewidth.StringWidth(text)
}
diff --git a/widgets/button/text_options.go b/widgets/button/text_options.go
new file mode 100644
index 00000000..33112298
--- /dev/null
+++ b/widgets/button/text_options.go
@@ -0,0 +1,85 @@
+// Copyright 2020 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
+
+// text_options.go contains options used for the text displayed by the button.
+
+import "github.com/mum4k/termdash/cell"
+
+// TextOption is used to provide options to NewChunk().
+type TextOption interface {
+ // set sets the provided option.
+ set(*textOptions)
+}
+
+// textOptions stores the provided options.
+type textOptions struct {
+ cellOpts []cell.Option
+ focusedCellOpts []cell.Option
+ pressedCellOpts []cell.Option
+}
+
+// setDefaultFgColor configures a default color for text if one isn't specified
+// in the text options.
+func (to *textOptions) setDefaultFgColor(c cell.Color) {
+ to.cellOpts = append(
+ []cell.Option{cell.FgColor(c)},
+ to.cellOpts...,
+ )
+}
+
+// newTextOptions returns new textOptions instance.
+func newTextOptions(tOpts ...TextOption) *textOptions {
+ to := &textOptions{}
+ for _, o := range tOpts {
+ o.set(to)
+ }
+ return to
+}
+
+// textOption implements TextOption.
+type textOption func(*textOptions)
+
+// set implements TextOption.set.
+func (to textOption) set(tOpts *textOptions) {
+ to(tOpts)
+}
+
+// TextCellOpts sets options on the cells that contain the button text.
+// If not specified, all cells will just have their foreground color set to the
+// value of TextColor().
+func TextCellOpts(opts ...cell.Option) TextOption {
+ return textOption(func(tOpts *textOptions) {
+ tOpts.cellOpts = opts
+ })
+}
+
+// FocusedTextCellOpts sets options on the cells that contain the button text
+// when the widget's container is focused.
+// If not specified, TextCellOpts will be used instead.
+func FocusedTextCellOpts(opts ...cell.Option) TextOption {
+ return textOption(func(tOpts *textOptions) {
+ tOpts.focusedCellOpts = opts
+ })
+}
+
+// PressedTextCellOpts sets options on the cells that contain the button text
+// when it is pressed.
+// If not specified, TextCellOpts will be used instead.
+func PressedTextCellOpts(opts ...cell.Option) TextOption {
+ return textOption(func(tOpts *textOptions) {
+ tOpts.pressedCellOpts = opts
+ })
+}
diff --git a/widgets/donut/donut.go b/widgets/donut/donut.go
index 52c2fbfa..7d987921 100644
--- a/widgets/donut/donut.go
+++ b/widgets/donut/donut.go
@@ -273,12 +273,12 @@ func (d *Donut) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
}
// Keyboard input isn't supported on the Donut widget.
-func (*Donut) Keyboard(k *terminalapi.Keyboard) error {
+func (*Donut) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the Donut widget doesn't support keyboard events")
}
// Mouse input isn't supported on the Donut widget.
-func (*Donut) Mouse(m *terminalapi.Mouse) error {
+func (*Donut) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
return errors.New("the Donut widget doesn't support mouse events")
}
diff --git a/widgets/donut/donut_test.go b/widgets/donut/donut_test.go
index bdb0d505..2ff8ddb7 100644
--- a/widgets/donut/donut_test.go
+++ b/widgets/donut/donut_test.go
@@ -883,7 +883,7 @@ func TestKeyboard(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := d.Keyboard(&terminalapi.Keyboard{}); err == nil {
+ if err := d.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
t.Errorf("Keyboard => got nil err, wanted one")
}
}
@@ -893,7 +893,7 @@ func TestMouse(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := d.Mouse(&terminalapi.Mouse{}); err == nil {
+ if err := d.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err == nil {
t.Errorf("Mouse => got nil err, wanted one")
}
}
diff --git a/widgets/gauge/gauge.go b/widgets/gauge/gauge.go
index fd7f8523..e850b596 100644
--- a/widgets/gauge/gauge.go
+++ b/widgets/gauge/gauge.go
@@ -288,12 +288,12 @@ func (g *Gauge) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
}
// Keyboard input isn't supported on the Gauge widget.
-func (g *Gauge) Keyboard(k *terminalapi.Keyboard) error {
+func (g *Gauge) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the Gauge widget doesn't support keyboard events")
}
// Mouse input isn't supported on the Gauge widget.
-func (g *Gauge) Mouse(m *terminalapi.Mouse) error {
+func (g *Gauge) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
return errors.New("the Gauge widget doesn't support mouse events")
}
diff --git a/widgets/gauge/gauge_test.go b/widgets/gauge/gauge_test.go
index ac6331d4..cb43297b 100644
--- a/widgets/gauge/gauge_test.go
+++ b/widgets/gauge/gauge_test.go
@@ -908,7 +908,7 @@ func TestKeyboard(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := g.Keyboard(&terminalapi.Keyboard{}); err == nil {
+ if err := g.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
t.Errorf("Keyboard => got nil err, wanted one")
}
}
@@ -918,7 +918,7 @@ func TestMouse(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := g.Mouse(&terminalapi.Mouse{}); err == nil {
+ if err := g.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err == nil {
t.Errorf("Mouse => got nil err, wanted one")
}
}
diff --git a/widgets/heatmap/heatmap.go b/widgets/heatmap/heatmap.go
new file mode 100644
index 00000000..12f3293f
--- /dev/null
+++ b/widgets/heatmap/heatmap.go
@@ -0,0 +1,153 @@
+// Copyright 2020 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 heatmap contains a widget that displays heat maps.
+package heatmap
+
+import (
+ "errors"
+ "image"
+ "sync"
+
+ "github.com/mum4k/termdash/cell"
+ "github.com/mum4k/termdash/private/canvas"
+ "github.com/mum4k/termdash/terminal/terminalapi"
+ "github.com/mum4k/termdash/widgetapi"
+ "github.com/mum4k/termdash/widgets/heatmap/internal/axes"
+)
+
+// HeatMap draws heat map charts.
+//
+// Heatmap consists of several cells. Each cell represents a value.
+// The larger the value, the darker the color of the cell (from white to black).
+//
+// The two dimensions of the values (cells) array are determined by the length of
+// the xLabels and yLabels arrays respectively.
+//
+// HeatMap does not support mouse based zoom.
+//
+// Implements widgetapi.Widget. This object is thread-safe.
+type HeatMap struct {
+ // values are the values in the heat map.
+ values [][]float64
+
+ // xLabels are the labels on the X axis in an increasing order.
+ xLabels []string
+ // yLabels are the labels on the Y axis in an increasing order.
+ yLabels []string
+
+ // minValue and maxValue are the Min and Max values in the values,
+ // which will be used to calculate the color of each cell.
+ minValue, maxValue float64
+
+ // lastWidth is the width of the canvas as of the last time when Draw was called.
+ lastWidth int
+
+ // opts are the provided options.
+ opts *options
+
+ // mu protects the HeatMap widget.
+ mu sync.RWMutex
+}
+
+// New returns a new HeatMap widget.
+func New(opts ...Option) (*HeatMap, error) {
+ return nil, errors.New("not implemented")
+}
+
+// Values sets the values to be displayed by the HeatMap.
+//
+// Each value in values has a xLabel and a yLabel, which means
+// len(yLabels) == len(values) and len(xLabels) == len(values[i]).
+// But labels could be empty strings.
+// When no labels are provided, labels will be "0", "1", "2"...
+//
+// Each call to Values overwrites any previously provided values.
+// Provided options override values set when New() was called.
+func (hp *HeatMap) Values(xLabels []string, yLabels []string, values [][]float64, opts ...Option) error {
+ return errors.New("not implemented")
+}
+
+// ClearXLabels clear the X labels.
+func (hp *HeatMap) ClearXLabels() {
+ hp.xLabels = nil
+}
+
+// ClearYLabels clear the Y labels.
+func (hp *HeatMap) ClearYLabels() {
+ hp.yLabels = nil
+}
+
+// ValueCapacity returns the number of values that can fit into the canvas.
+// This is essentially the number of available cells on the canvas as observed
+// on the last call to draw. Returns zero if draw wasn't called.
+//
+// Note that this capacity changes each time the terminal resizes, so there is
+// no guarantee this remains the same next time Draw is called.
+// Should be used as a hint only.
+func (hp *HeatMap) ValueCapacity() int {
+ return 0
+}
+
+// axesDetails determines the details about the X and Y axes.
+func (hp *HeatMap) axesDetails(cvs *canvas.Canvas) (*axes.XDetails, *axes.YDetails, error) {
+ return nil, nil, errors.New("not implemented")
+}
+
+// Draw draws cells, X labels and Y labels as HeatMap.
+// Implements widgetapi.Widget.Draw.
+func (hp *HeatMap) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
+ return errors.New("not implemented")
+}
+
+// drawCells draws m*n cells (rectangles) representing the stored values.
+// The height of each cell is 1 and the default width is 3.
+func (hp *HeatMap) drawCells(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
+ return errors.New("not implemented")
+}
+
+// drawAxes draws X labels (under the cells) and Y Labels (on the left side of the cell).
+func (hp *HeatMap) drawLabels(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
+ return errors.New("not implemented")
+}
+
+// minSize determines the minimum required size to draw HeatMap.
+func (hp *HeatMap) minSize() image.Point {
+ return image.Point{}
+}
+
+// Keyboard input isn't supported on the HeatMap widget.
+func (*HeatMap) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
+ return errors.New("the HeatMap widget doesn't support keyboard events")
+}
+
+// Mouse input isn't supported on the HeatMap widget.
+func (*HeatMap) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
+ return errors.New("the HeatMap widget doesn't support mouse events")
+}
+
+// Options implements widgetapi.Widget.Options.
+func (hp *HeatMap) Options() widgetapi.Options {
+ hp.mu.Lock()
+ defer hp.mu.Unlock()
+ return widgetapi.Options{}
+}
+
+// getCellColor returns the color of the cell according to its value.
+// The larger the value, the darker the color.
+// The color range is in Xterm color, from 232 to 255.
+// Refer to https://jonasjacek.github.io/colors/.
+func (hp *HeatMap) getCellColor(value float64) cell.Color {
+ return cell.ColorDefault
+}
diff --git a/widgets/heatmap/heatmap_test.go b/widgets/heatmap/heatmap_test.go
new file mode 100644
index 00000000..819f2e9f
--- /dev/null
+++ b/widgets/heatmap/heatmap_test.go
@@ -0,0 +1,15 @@
+// Copyright 2020 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 heatmap
diff --git a/widgets/heatmap/heatmapdemo/heatmapdemo.go b/widgets/heatmap/heatmapdemo/heatmapdemo.go
new file mode 100644
index 00000000..4e878dbc
--- /dev/null
+++ b/widgets/heatmap/heatmapdemo/heatmapdemo.go
@@ -0,0 +1,64 @@
+// Copyright 2020 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 heatmapdemo displays a heatmap widget.
+// Exist when 'q' is pressed.
+package main
+
+import (
+ "context"
+ "github.com/mum4k/termdash"
+ "github.com/mum4k/termdash/container"
+ "github.com/mum4k/termdash/linestyle"
+ "github.com/mum4k/termdash/terminal/tcell"
+ "github.com/mum4k/termdash/terminal/terminalapi"
+ "github.com/mum4k/termdash/widgets/heatmap"
+)
+
+func main() {
+ t, err := tcell.New()
+ if err != nil {
+ panic(err)
+ }
+ defer t.Close()
+
+ hp, err := heatmap.New()
+ if err != nil {
+ panic(err)
+ }
+
+ // TODO: set heatmap's data
+
+ c, err := container.New(
+ t,
+ container.Border(linestyle.Light),
+ container.BorderTitle("PRESS Q TO QUIT"),
+ container.PlaceWidget(hp),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ quitter := func(k *terminalapi.Keyboard) {
+ if k.Key == 'q' || k.Key == 'Q' {
+ cancel()
+ }
+ }
+
+ if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter)); err != nil {
+ panic(err)
+ }
+}
diff --git a/widgets/heatmap/internal/README.md b/widgets/heatmap/internal/README.md
new file mode 100644
index 00000000..a989273b
--- /dev/null
+++ b/widgets/heatmap/internal/README.md
@@ -0,0 +1,4 @@
+# Internal termdash libraries
+
+The packages under this directory are private to termdash. Stability of the
+private packages isn't guaranteed and changes won't be backward compatible.
diff --git a/widgets/heatmap/internal/axes/axes.go b/widgets/heatmap/internal/axes/axes.go
new file mode 100644
index 00000000..825b701b
--- /dev/null
+++ b/widgets/heatmap/internal/axes/axes.go
@@ -0,0 +1,87 @@
+// Copyright 2020 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 axes calculates the required layout and draws the X and Y axes of a heat map.
+package axes
+
+import (
+ "errors"
+ "image"
+
+ "github.com/mum4k/termdash/private/runewidth"
+)
+
+// axisWidth is width of an axis.
+const axisWidth = 1
+
+// YDetails contain information about the Y axis
+// that will NOT be drawn onto the canvas, but will take up space.
+type YDetails struct {
+ // Width in character cells of the Y axis and its character labels.
+ Width int
+
+ // Start is the point where the Y axis starts.
+ // The Y coordinate of Start is less than the Y coordinate of End.
+ Start image.Point
+
+ // End is the point where the Y axis ends.
+ End image.Point
+
+ // Labels are the labels for values on the Y axis in an increasing order.
+ Labels []*Label
+}
+
+// RequiredWidth calculates the minimum width required
+// in order to draw the Y axis and its labels.
+// The parameter ls is the longest string in yLabels.
+func RequiredWidth(ls string) int {
+ return runewidth.StringWidth(ls) + axisWidth
+}
+
+// NewYDetails retrieves details about the Y axis required
+// to draw it on a canvas of the provided area.
+func NewYDetails(labels []string) (*YDetails, error) {
+ return nil, errors.New("not implemented")
+}
+
+// LongestString returns the length of the longest string in the string array.
+func LongestString(strings []string) int {
+ var widest int
+ for _, s := range strings {
+ if l := runewidth.StringWidth(s); l > widest {
+ widest = l
+ }
+ }
+ return widest
+}
+
+// XDetails contain information about the X axis
+// that will NOT be drawn onto the canvas.
+type XDetails struct {
+ // Start is the point where the X axis starts.
+ // Both coordinates of Start are less than End.
+ Start image.Point
+ // End is the point where the X axis ends.
+ End image.Point
+
+ // Labels are the labels for values on the X axis in an increasing order.
+ Labels []*Label
+}
+
+// NewXDetails retrieves details about the X axis required to draw it on a canvas
+// of the provided area.
+// The yEnd is the point where the Y axis ends.
+func NewXDetails(cvsAr image.Rectangle, yEnd image.Point, labels []string, cellWidth int) (*XDetails, error) {
+ return nil, errors.New("not implemented")
+}
diff --git a/widgets/heatmap/internal/axes/axes_test.go b/widgets/heatmap/internal/axes/axes_test.go
new file mode 100644
index 00000000..622bf548
--- /dev/null
+++ b/widgets/heatmap/internal/axes/axes_test.go
@@ -0,0 +1,15 @@
+// Copyright 2020 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 axes
diff --git a/widgets/heatmap/internal/axes/label.go b/widgets/heatmap/internal/axes/label.go
new file mode 100644
index 00000000..a64a28fc
--- /dev/null
+++ b/widgets/heatmap/internal/axes/label.go
@@ -0,0 +1,64 @@
+// Copyright 2020 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 axes
+
+// label.go contains code that calculates the positions of labels on the axes.
+
+import (
+ "errors"
+ "image"
+)
+
+// Label is one text label on an axis.
+type Label struct {
+ // Label content.
+ Text string
+
+ // Position of the label within the canvas.
+ Pos image.Point
+}
+
+// yLabels returns labels that should be placed next to the cells.
+// The labelWidth is the width of the area from the left-most side of the
+// canvas until the Y axis (not including the Y axis). This is the area where
+// the labels will be placed and aligned.
+// Labels are returned with Y coordinates in ascending order.
+// Y coordinates grow down.
+func yLabels(graphHeight, labelWidth int, labels []string) ([]*Label, error) {
+ return nil, errors.New("not implemented")
+}
+
+// rowLabel returns one label for the specified row.
+// The row is the Y coordinate of the row, Y coordinates grow down.
+func rowLabel(row int, label string, labelWidth int) (*Label, error) {
+ return nil, errors.New("not implemented")
+}
+
+// xLabels returns labels that should be placed under the cells.
+// Labels are returned with X coordinates in ascending order.
+// X coordinates grow right.
+func xLabels(yEnd image.Point, graphWidth int, labels []string, cellWidth int) ([]*Label, error) {
+ return nil, errors.New("not implemented")
+}
+
+// paddedLabelLength calculates the length of the padded X label and
+// the column index corresponding to the label.
+// For example, the longest X label's length is 5, like '12:34', and the cell's width is 3.
+// So in order to better display, every three columns of cells will display a X label,
+// the X label belongs to the middle column of the three columns,
+// and the padded length is 3*3 (cellWidth multiplies the number of columns), which is 9.
+func paddedLabelLength(graphWidth, longest, cellWidth int) (l, index int) {
+ return
+}
diff --git a/widgets/heatmap/internal/axes/label_test.go b/widgets/heatmap/internal/axes/label_test.go
new file mode 100644
index 00000000..622bf548
--- /dev/null
+++ b/widgets/heatmap/internal/axes/label_test.go
@@ -0,0 +1,15 @@
+// Copyright 2020 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 axes
diff --git a/widgets/heatmap/options.go b/widgets/heatmap/options.go
new file mode 100644
index 00000000..fe8bd254
--- /dev/null
+++ b/widgets/heatmap/options.go
@@ -0,0 +1,82 @@
+// Copyright 2020 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 heatmap
+
+import (
+ "errors"
+ "github.com/mum4k/termdash/cell"
+)
+
+// options.go contains configurable options for HeatMap.
+
+// Option is used to provide options.
+type Option interface {
+ // set sets the provided option.
+ set(*options)
+}
+
+// options stores the provided options.
+type options struct {
+ // The default value is 3
+ cellWidth int
+ xLabelCellOpts []cell.Option
+ yLabelCellOpts []cell.Option
+}
+
+// validate validates the provided options.
+func (o *options) validate() error {
+ return errors.New("not implemented")
+}
+
+// newOptions returns a new options instance.
+func newOptions(opts ...Option) *options {
+ opt := &options{
+ cellWidth: 3,
+ }
+ for _, o := range opts {
+ o.set(opt)
+ }
+ return opt
+}
+
+// option implements Option.
+type option func(*options)
+
+// set implements Option.set.
+func (o option) set(opts *options) {
+ o(opts)
+}
+
+// CellWidth set the width of cells (or grids) in the heat map, not the terminal cell.
+// The default height of each cell (grid) is 1 and the width is 3.
+func CellWidth(w int) Option {
+ return option(func(opts *options) {
+ opts.cellWidth = w
+ })
+}
+
+// XLabelCellOpts set the cell options for the labels on the X axis.
+func XLabelCellOpts(co ...cell.Option) Option {
+ return option(func(opts *options) {
+ opts.xLabelCellOpts = co
+ })
+}
+
+// YLabelCellOpts set the cell options for the labels on the Y axis.
+func YLabelCellOpts(co ...cell.Option) Option {
+ return option(func(opts *options) {
+ opts.yLabelCellOpts = co
+ })
+}
diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go
index 0a4f83d1..5880d450 100644
--- a/widgets/linechart/linechart.go
+++ b/widgets/linechart/linechart.go
@@ -478,12 +478,12 @@ func (lc *LineChart) highlightRange(bc *braille.Canvas, hRange *zoom.Range) erro
}
// Keyboard implements widgetapi.Widget.Keyboard.
-func (lc *LineChart) Keyboard(k *terminalapi.Keyboard) error {
+func (lc *LineChart) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the LineChart widget doesn't support keyboard events")
}
// Mouse implements widgetapi.Widget.Mouse.
-func (lc *LineChart) Mouse(m *terminalapi.Mouse) error {
+func (lc *LineChart) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
lc.mu.Lock()
defer lc.mu.Unlock()
diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go
index 6c6ad631..3d44fbeb 100644
--- a/widgets/linechart/linechart_test.go
+++ b/widgets/linechart/linechart_test.go
@@ -1171,7 +1171,7 @@ func TestLineChartDraws(t *testing.T) {
return lc.Mouse(&terminalapi.Mouse{
Position: image.Point{6, 5},
Button: mouse.ButtonLeft,
- })
+ }, &widgetapi.EventMeta{})
},
wantCapacity: 28,
want: func(size image.Point) *faketerm.Terminal {
@@ -1222,7 +1222,7 @@ func TestLineChartDraws(t *testing.T) {
return lc.Mouse(&terminalapi.Mouse{
Position: image.Point{6, 5},
Button: mouse.ButtonLeft,
- })
+ }, &widgetapi.EventMeta{})
},
wantCapacity: 28,
want: func(size image.Point) *faketerm.Terminal {
@@ -1273,7 +1273,7 @@ func TestLineChartDraws(t *testing.T) {
return lc.Mouse(&terminalapi.Mouse{
Position: image.Point{8, 5},
Button: mouse.ButtonWheelUp,
- })
+ }, &widgetapi.EventMeta{})
},
wantCapacity: 28,
want: func(size image.Point) *faketerm.Terminal {
@@ -1331,7 +1331,7 @@ func TestLineChartDraws(t *testing.T) {
return lc.Mouse(&terminalapi.Mouse{
Position: image.Point{6, 7},
Button: mouse.ButtonLeft,
- })
+ }, &widgetapi.EventMeta{})
},
wantCapacity: 28,
want: func(size image.Point) *faketerm.Terminal {
@@ -1388,7 +1388,7 @@ func TestLineChartDraws(t *testing.T) {
return lc.Mouse(&terminalapi.Mouse{
Position: image.Point{5, 0},
Button: mouse.ButtonWheelUp,
- })
+ }, &widgetapi.EventMeta{})
},
wantCapacity: 10,
want: func(size image.Point) *faketerm.Terminal {
@@ -1443,7 +1443,7 @@ func TestLineChartDraws(t *testing.T) {
if err := lc.Mouse(&terminalapi.Mouse{
Position: image.Point{5, 0},
Button: mouse.ButtonWheelUp,
- }); err != nil {
+ }, &widgetapi.EventMeta{}); err != nil {
return err
}
@@ -1901,7 +1901,7 @@ func TestKeyboard(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := lc.Keyboard(&terminalapi.Keyboard{}); err == nil {
+ if err := lc.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
t.Errorf("Keyboard => got nil err, wanted one")
}
}
@@ -1911,7 +1911,7 @@ func TestMouseDoesNothingWithoutZoomTracker(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := lc.Mouse(&terminalapi.Mouse{}); err != nil {
+ if err := lc.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err != nil {
t.Errorf("Mouse => unexpected error: %v", err)
}
}
diff --git a/widgets/segmentdisplay/segmentdisplay.go b/widgets/segmentdisplay/segmentdisplay.go
index ed6b7501..cb4c1f2a 100644
--- a/widgets/segmentdisplay/segmentdisplay.go
+++ b/widgets/segmentdisplay/segmentdisplay.go
@@ -292,12 +292,12 @@ func (sd *SegmentDisplay) drawChar(dCvs *canvas.Canvas, c rune, wOpts *writeOpti
}
// Keyboard input isn't supported on the SegmentDisplay widget.
-func (*SegmentDisplay) Keyboard(k *terminalapi.Keyboard) error {
+func (*SegmentDisplay) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the SegmentDisplay widget doesn't support keyboard events")
}
// Mouse input isn't supported on the SegmentDisplay widget.
-func (*SegmentDisplay) Mouse(m *terminalapi.Mouse) error {
+func (*SegmentDisplay) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
return errors.New("the SegmentDisplay widget doesn't support mouse events")
}
diff --git a/widgets/segmentdisplay/segmentdisplay_test.go b/widgets/segmentdisplay/segmentdisplay_test.go
index b2dcbf63..a0114fde 100644
--- a/widgets/segmentdisplay/segmentdisplay_test.go
+++ b/widgets/segmentdisplay/segmentdisplay_test.go
@@ -982,7 +982,7 @@ func TestKeyboard(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := sd.Keyboard(&terminalapi.Keyboard{}); err == nil {
+ if err := sd.Keyboard(&terminalapi.Keyboard{}, &widgetapi.EventMeta{}); err == nil {
t.Errorf("Keyboard => got nil err, wanted one")
}
}
@@ -992,7 +992,7 @@ func TestMouse(t *testing.T) {
if err != nil {
t.Fatalf("New => unexpected error: %v", err)
}
- if err := sd.Mouse(&terminalapi.Mouse{}); err == nil {
+ if err := sd.Mouse(&terminalapi.Mouse{}, &widgetapi.EventMeta{}); err == nil {
t.Errorf("Mouse => got nil err, wanted one")
}
}
diff --git a/widgets/sparkline/sparkline.go b/widgets/sparkline/sparkline.go
index 117248cd..aaab9d42 100644
--- a/widgets/sparkline/sparkline.go
+++ b/widgets/sparkline/sparkline.go
@@ -183,12 +183,12 @@ func (sl *SparkLine) Clear() {
}
// Keyboard input isn't supported on the SparkLine widget.
-func (*SparkLine) Keyboard(k *terminalapi.Keyboard) error {
+func (*SparkLine) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
return errors.New("the SparkLine widget doesn't support keyboard events")
}
// Mouse input isn't supported on the SparkLine widget.
-func (*SparkLine) Mouse(m *terminalapi.Mouse) error {
+func (*SparkLine) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
return errors.New("the SparkLine widget doesn't support mouse events")
}
diff --git a/widgets/text/text.go b/widgets/text/text.go
index 645a3db6..913874b3 100644
--- a/widgets/text/text.go
+++ b/widgets/text/text.go
@@ -234,7 +234,7 @@ func (t *Text) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
}
// Keyboard implements widgetapi.Widget.Keyboard.
-func (t *Text) Keyboard(k *terminalapi.Keyboard) error {
+func (t *Text) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
t.mu.Lock()
defer t.mu.Unlock()
@@ -252,7 +252,7 @@ func (t *Text) Keyboard(k *terminalapi.Keyboard) error {
}
// Mouse implements widgetapi.Widget.Mouse.
-func (t *Text) Mouse(m *terminalapi.Mouse) error {
+func (t *Text) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
t.mu.Lock()
defer t.mu.Unlock()
diff --git a/widgets/text/text_test.go b/widgets/text/text_test.go
index b6b9111a..2c96a339 100644
--- a/widgets/text/text_test.go
+++ b/widgets/text/text_test.go
@@ -310,7 +310,7 @@ func TestTextDraws(t *testing.T) {
events: func(widget *Text) {
widget.Mouse(&terminalapi.Mouse{
Button: mouse.ButtonWheelDown,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -332,7 +332,7 @@ func TestTextDraws(t *testing.T) {
events: func(widget *Text) {
widget.Mouse(&terminalapi.Mouse{
Button: mouse.ButtonWheelDown,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -353,7 +353,7 @@ func TestTextDraws(t *testing.T) {
events: func(widget *Text) {
widget.Keyboard(&terminalapi.Keyboard{
Key: keyboard.KeyArrowDown,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -375,7 +375,7 @@ func TestTextDraws(t *testing.T) {
events: func(widget *Text) {
widget.Keyboard(&terminalapi.Keyboard{
Key: keyboard.KeyPgDn,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -400,7 +400,7 @@ func TestTextDraws(t *testing.T) {
events: func(widget *Text) {
widget.Mouse(&terminalapi.Mouse{
Button: mouse.ButtonRight,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -425,7 +425,7 @@ func TestTextDraws(t *testing.T) {
events: func(widget *Text) {
widget.Keyboard(&terminalapi.Keyboard{
Key: 'd',
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -450,7 +450,7 @@ func TestTextDraws(t *testing.T) {
events: func(widget *Text) {
widget.Keyboard(&terminalapi.Keyboard{
Key: 'l',
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -648,7 +648,7 @@ func TestTextDraws(t *testing.T) {
}
widget.Mouse(&terminalapi.Mouse{
Button: mouse.ButtonWheelUp,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -677,7 +677,7 @@ func TestTextDraws(t *testing.T) {
}
widget.Keyboard(&terminalapi.Keyboard{
Key: keyboard.KeyArrowUp,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -706,7 +706,7 @@ func TestTextDraws(t *testing.T) {
}
widget.Keyboard(&terminalapi.Keyboard{
Key: keyboard.KeyPgUp,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -736,7 +736,7 @@ func TestTextDraws(t *testing.T) {
}
widget.Mouse(&terminalapi.Mouse{
Button: mouse.ButtonLeft,
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -766,7 +766,7 @@ func TestTextDraws(t *testing.T) {
}
widget.Keyboard(&terminalapi.Keyboard{
Key: 'u',
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
@@ -796,7 +796,7 @@ func TestTextDraws(t *testing.T) {
}
widget.Keyboard(&terminalapi.Keyboard{
Key: 'k',
- })
+ }, &widgetapi.EventMeta{})
},
want: func(size image.Point) *faketerm.Terminal {
ft := faketerm.MustNew(size)
diff --git a/widgets/textinput/formdemo/formdemo.go b/widgets/textinput/formdemo/formdemo.go
new file mode 100644
index 00000000..723318a4
--- /dev/null
+++ b/widgets/textinput/formdemo/formdemo.go
@@ -0,0 +1,326 @@
+// Copyright 2020 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 formdemo creates a form that accepts text inputs and supports
+// keyboard navigation.
+package main
+
+import (
+ "context"
+ "fmt"
+ "os/user"
+ "time"
+
+ "github.com/mum4k/termdash"
+ "github.com/mum4k/termdash/align"
+ "github.com/mum4k/termdash/cell"
+ "github.com/mum4k/termdash/container"
+ "github.com/mum4k/termdash/keyboard"
+ "github.com/mum4k/termdash/linestyle"
+ "github.com/mum4k/termdash/terminal/tcell"
+ "github.com/mum4k/termdash/widgets/button"
+ "github.com/mum4k/termdash/widgets/text"
+ "github.com/mum4k/termdash/widgets/textinput"
+)
+
+// buttonChunks creates the text chunks for a button from the provided text.
+func buttonChunks(text string) []*button.TextChunk {
+ if len(text) == 0 {
+ return nil
+ }
+ first := string(text[0])
+ rest := string(text[1:])
+
+ return []*button.TextChunk{
+ button.NewChunk(
+ "<",
+ button.TextCellOpts(cell.FgColor(cell.ColorWhite)),
+ button.FocusedTextCellOpts(cell.FgColor(cell.ColorBlack)),
+ button.PressedTextCellOpts(cell.FgColor(cell.ColorBlack)),
+ ),
+ button.NewChunk(
+ first,
+ button.TextCellOpts(cell.FgColor(cell.ColorRed)),
+ ),
+ button.NewChunk(
+ rest,
+ button.TextCellOpts(cell.FgColor(cell.ColorWhite)),
+ button.FocusedTextCellOpts(cell.FgColor(cell.ColorBlack)),
+ button.PressedTextCellOpts(cell.FgColor(cell.ColorBlack)),
+ ),
+ button.NewChunk(
+ ">",
+ button.TextCellOpts(cell.FgColor(cell.ColorWhite)),
+ button.FocusedTextCellOpts(cell.FgColor(cell.ColorBlack)),
+ button.PressedTextCellOpts(cell.FgColor(cell.ColorBlack)),
+ ),
+ }
+}
+
+// form contains the elements of a text input form.
+type form struct {
+ // userInput is a text input that accepts user name.
+ userInput *textinput.TextInput
+ // uidInput is a text input that accepts UID.
+ uidInput *textinput.TextInput
+ // gidInput is a text input that accepts GID.
+ gidInput *textinput.TextInput
+ // homeInput is a text input that accepts path to the home folder.
+ homeInput *textinput.TextInput
+
+ // submitB is a button that submits the form.
+ submitB *button.Button
+ // cancelB is a button that exist the application.
+ cancelB *button.Button
+}
+
+// newForm returns a new form instance.
+// The cancel argument is a function that terminates the application when called.
+func newForm(cancel context.CancelFunc) (*form, error) {
+ var username string
+ u, err := user.Current()
+ if err != nil {
+ username = "mum4k"
+ } else {
+ username = u.Username
+ }
+
+ userInput, err := textinput.New(
+ textinput.Label("Username: ", cell.FgColor(cell.ColorNumber(33))),
+ textinput.DefaultText(username),
+ textinput.MaxWidthCells(20),
+ textinput.ExclusiveKeyboardOnFocus(),
+ )
+ uidInput, err := textinput.New(
+ textinput.Label("UID: ", cell.FgColor(cell.ColorNumber(33))),
+ textinput.DefaultText("1000"),
+ textinput.MaxWidthCells(20),
+ textinput.ExclusiveKeyboardOnFocus(),
+ )
+ gidInput, err := textinput.New(
+ textinput.Label("GID: ", cell.FgColor(cell.ColorNumber(33))),
+ textinput.DefaultText("1000"),
+ textinput.MaxWidthCells(20),
+ textinput.ExclusiveKeyboardOnFocus(),
+ )
+ homeInput, err := textinput.New(
+ textinput.Label("Home: ", cell.FgColor(cell.ColorNumber(33))),
+ textinput.DefaultText(fmt.Sprintf("/home/%s", username)),
+ textinput.MaxWidthCells(20),
+ textinput.ExclusiveKeyboardOnFocus(),
+ )
+
+ submitB, err := button.NewFromChunks(buttonChunks("Submit"), nil,
+ button.Key(keyboard.KeyEnter),
+ button.GlobalKeys('s', 'S'),
+ button.DisableShadow(),
+ button.Height(1),
+ button.TextHorizontalPadding(0),
+ button.FillColor(cell.ColorBlack),
+ button.FocusedFillColor(cell.ColorNumber(117)),
+ button.PressedFillColor(cell.ColorNumber(220)),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ cancelB, err := button.NewFromChunks(buttonChunks("Cancel"), func() error {
+ cancel()
+ return nil
+ },
+ button.FillColor(cell.ColorNumber(220)),
+ button.Key(keyboard.KeyEnter),
+ button.GlobalKeys('c', 'C'),
+ button.DisableShadow(),
+ button.Height(1),
+ button.TextHorizontalPadding(0),
+ button.FillColor(cell.ColorBlack),
+ button.FocusedFillColor(cell.ColorNumber(117)),
+ button.PressedFillColor(cell.ColorNumber(220)),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ return &form{
+ userInput: userInput,
+ uidInput: uidInput,
+ gidInput: gidInput,
+ homeInput: homeInput,
+ submitB: submitB,
+ cancelB: cancelB,
+ }, nil
+}
+
+// formLayout updates the container into a layout with text inputs and buttons.
+func formLayout(c *container.Container, f *form) error {
+ return c.Update("root",
+ container.KeyFocusNext(keyboard.KeyTab),
+ container.KeyFocusGroupsNext(keyboard.KeyArrowDown, 1),
+ container.KeyFocusGroupsPrevious(keyboard.KeyArrowUp, 1),
+ container.KeyFocusGroupsNext(keyboard.KeyArrowRight, 2),
+ container.KeyFocusGroupsPrevious(keyboard.KeyArrowLeft, 2),
+ container.SplitHorizontal(
+ container.Top(
+ container.Border(linestyle.Light),
+ container.SplitHorizontal(
+ container.Top(
+ container.SplitHorizontal(
+ container.Top(
+ container.Focused(),
+ container.KeyFocusGroups(1),
+ container.PlaceWidget(f.userInput),
+ ),
+ container.Bottom(
+ container.KeyFocusGroups(1),
+ container.KeyFocusSkip(),
+ container.PlaceWidget(f.uidInput),
+ ),
+ ),
+ ),
+ container.Bottom(
+ container.SplitHorizontal(
+ container.Top(
+ container.KeyFocusGroups(1),
+ container.KeyFocusSkip(),
+ container.PlaceWidget(f.gidInput),
+ ),
+ container.Bottom(
+ container.KeyFocusGroups(1),
+ container.KeyFocusSkip(),
+ container.PlaceWidget(f.homeInput),
+ ),
+ ),
+ ),
+ ),
+ ),
+ container.Bottom(
+ container.SplitHorizontal(
+ container.Top(
+ container.SplitVertical(
+ container.Left(
+ container.KeyFocusGroups(1, 2),
+ container.PlaceWidget(f.submitB),
+ container.AlignHorizontal(align.HorizontalRight),
+ container.PaddingRight(5),
+ ),
+ container.Right(
+ container.KeyFocusGroups(1, 2),
+ container.PlaceWidget(f.cancelB),
+ container.AlignHorizontal(align.HorizontalLeft),
+ container.PaddingLeft(5),
+ ),
+ ),
+ ),
+ container.Bottom(
+ container.KeyFocusSkip(),
+ ),
+ container.SplitFixed(3),
+ ),
+ ),
+ container.SplitFixed(6),
+ ),
+ )
+}
+
+// submitLayout updates the container into a layout that displays the submitted data.
+// The cancel argument is a function that terminates Termdash when called.
+func submitLayout(c *container.Container, f *form, cancel context.CancelFunc) error {
+ t, err := text.New()
+ if err != nil {
+ return err
+ }
+
+ if err := t.Write("Submitted data:\n\n"); err != nil {
+ return err
+ }
+ if err := t.Write(fmt.Sprintf("Username: %s\n", f.userInput.Read())); err != nil {
+ return err
+ }
+ if err := t.Write(fmt.Sprintf("UID: %s\n", f.uidInput.Read())); err != nil {
+ return err
+ }
+ if err := t.Write(fmt.Sprintf("GID: %s\n", f.gidInput.Read())); err != nil {
+ return err
+ }
+ if err := t.Write(fmt.Sprintf("Home: %s\n", f.homeInput.Read())); err != nil {
+ return err
+ }
+
+ okB, err := button.NewFromChunks(buttonChunks("OK"), func() error {
+ cancel()
+ return nil
+ },
+ button.FillColor(cell.ColorNumber(220)),
+ button.Key(keyboard.KeyEnter),
+ button.GlobalKeys('o', 'O'),
+ button.DisableShadow(),
+ button.Height(1),
+ button.TextHorizontalPadding(0),
+ button.FillColor(cell.ColorBlack),
+ button.FocusedFillColor(cell.ColorNumber(117)),
+ button.PressedFillColor(cell.ColorNumber(220)),
+ )
+ if err != nil {
+ return err
+ }
+
+ return c.Update("root",
+ container.SplitHorizontal(
+ container.Top(
+ container.SplitVertical(
+ container.Left(),
+ container.Right(
+ container.PlaceWidget(t),
+ ),
+ container.SplitPercent(33),
+ ),
+ ),
+ container.Bottom(
+ container.Focused(),
+ container.PlaceWidget(okB),
+ ),
+ container.SplitFixed(7),
+ ),
+ )
+}
+
+func main() {
+ t, err := tcell.New()
+ if err != nil {
+ panic(err)
+ }
+ defer t.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ c, err := container.New(t, container.ID("root"))
+ if err != nil {
+ panic(err)
+ }
+
+ f, err := newForm(cancel)
+ if err != nil {
+ panic(err)
+ }
+ f.submitB.SetCallback(func() error {
+ return submitLayout(c, f, cancel)
+ })
+ if err := formLayout(c, f); err != nil {
+ panic(err)
+ }
+
+ if err := termdash.Run(ctx, t, c, termdash.RedrawInterval(100*time.Millisecond)); err != nil {
+ panic(err)
+ }
+}
diff --git a/widgets/textinput/options.go b/widgets/textinput/options.go
index 915fe9ac..995d029c 100644
--- a/widgets/textinput/options.go
+++ b/widgets/textinput/options.go
@@ -17,6 +17,7 @@ package textinput
// options.go contains configurable options for TextInput.
import (
+ "errors"
"fmt"
"github.com/mum4k/termdash/align"
@@ -58,10 +59,12 @@ type options struct {
placeHolder string
hideTextWith rune
+ defaultText string
- filter FilterFn
- onSubmit SubmitFn
- clearOnSubmit bool
+ filter FilterFn
+ onSubmit SubmitFn
+ clearOnSubmit bool
+ exclusiveKeyboardOnFocus bool
}
// validate validates the provided options.
@@ -80,6 +83,16 @@ func (o *options) validate() error {
return fmt.Errorf("invalid HideTextWidth rune %c(%d), has rune width of %d cells, only runes with width of %d are accepted", r, r, got, want)
}
}
+ if o.defaultText != "" {
+ if err := wrap.ValidText(o.defaultText); err != nil {
+ return fmt.Errorf("invalid DefaultText: %v", err)
+ }
+ for _, r := range o.defaultText {
+ if r == '\n' {
+ return errors.New("invalid DefaultText: newline characters aren't allowed")
+ }
+ }
+ }
return nil
}
@@ -263,3 +276,20 @@ func ClearOnSubmit() Option {
opts.clearOnSubmit = true
})
}
+
+// ExclusiveKeyboardOnFocus when set ensures that when this widget is focused,
+// no other widget receives any keyboard events.
+func ExclusiveKeyboardOnFocus() Option {
+ return option(func(opts *options) {
+ opts.exclusiveKeyboardOnFocus = true
+ })
+}
+
+// DefaultText sets the text to be present in a newly created input field.
+// The text must not contain any control or space characters other than ' '.
+// The user can edit this text as normal.
+func DefaultText(text string) Option {
+ return option(func(opts *options) {
+ opts.defaultText = text
+ })
+}
diff --git a/widgets/textinput/textinput.go b/widgets/textinput/textinput.go
index 13f7beef..393e27d8 100644
--- a/widgets/textinput/textinput.go
+++ b/widgets/textinput/textinput.go
@@ -69,10 +69,15 @@ func New(opts ...Option) (*TextInput, error) {
if err := opt.validate(); err != nil {
return nil, err
}
- return &TextInput{
+ ti := &TextInput{
editor: newFieldEditor(),
opts: opt,
- }, nil
+ }
+
+ for _, r := range ti.opts.defaultText {
+ ti.editor.insert(r)
+ }
+ return ti, nil
}
// Vars to be replaced from tests.
@@ -267,7 +272,7 @@ func (ti *TextInput) keyboard(k *terminalapi.Keyboard) (bool, string) {
// Keyboard processes keyboard events.
// Implements widgetapi.Widget.Keyboard.
-func (ti *TextInput) Keyboard(k *terminalapi.Keyboard) error {
+func (ti *TextInput) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
if submitted, text := ti.keyboard(k); submitted {
// Mutex must be released when calling the callback.
// Users might call container methods from the callback like the
@@ -279,7 +284,7 @@ func (ti *TextInput) Keyboard(k *terminalapi.Keyboard) error {
// Mouse processes mouse events.
// Implements widgetapi.Widget.Mouse.
-func (ti *TextInput) Mouse(m *terminalapi.Mouse) error {
+func (ti *TextInput) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
ti.mu.Lock()
defer ti.mu.Unlock()
@@ -326,8 +331,9 @@ func (ti *TextInput) Options() widgetapi.Options {
maxWidth,
needHeight,
},
- WantKeyboard: widgetapi.KeyScopeFocused,
- WantMouse: widgetapi.MouseScopeWidget,
+ WantKeyboard: widgetapi.KeyScopeFocused,
+ WantMouse: widgetapi.MouseScopeWidget,
+ ExclusiveKeyboardOnFocus: ti.opts.exclusiveKeyboardOnFocus,
}
}
diff --git a/widgets/textinput/textinput_test.go b/widgets/textinput/textinput_test.go
index 1af333e9..7344590e 100644
--- a/widgets/textinput/textinput_test.go
+++ b/widgets/textinput/textinput_test.go
@@ -117,6 +117,20 @@ func TestTextInput(t *testing.T) {
},
wantNewErr: true,
},
+ {
+ desc: "fails on invalid DefaultText which has control characters",
+ opts: []Option{
+ DefaultText("\r"),
+ },
+ wantNewErr: true,
+ },
+ {
+ desc: "fails on invalid DefaultText which has newline",
+ opts: []Option{
+ DefaultText("\n"),
+ },
+ wantNewErr: true,
+ },
{
desc: "takes all space without label",
canvas: image.Rect(0, 0, 10, 1),
@@ -559,6 +573,62 @@ func TestTextInput(t *testing.T) {
return ft
},
},
+ {
+ desc: "displays default text",
+ opts: []Option{
+ DefaultText("text"),
+ },
+ canvas: image.Rect(0, 0, 10, 1),
+ meta: &widgetapi.Meta{},
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ testcanvas.MustSetAreaCells(
+ cvs,
+ image.Rect(0, 0, 10, 1),
+ textFieldRune,
+ cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)),
+ )
+ testdraw.MustText(
+ cvs,
+ "text",
+ image.Point{0, 0},
+ )
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ },
+ {
+ desc: "default text can be edited",
+ opts: []Option{
+ DefaultText("text"),
+ },
+ canvas: image.Rect(0, 0, 10, 1),
+ meta: &widgetapi.Meta{},
+ events: []terminalapi.Event{
+ &terminalapi.Keyboard{Key: keyboard.KeyBackspace},
+ &terminalapi.Keyboard{Key: 'a'},
+ },
+ want: func(size image.Point) *faketerm.Terminal {
+ ft := faketerm.MustNew(size)
+ cvs := testcanvas.MustNew(ft.Area())
+
+ testcanvas.MustSetAreaCells(
+ cvs,
+ image.Rect(0, 0, 10, 1),
+ textFieldRune,
+ cell.BgColor(cell.ColorNumber(DefaultFillColorNumber)),
+ )
+ testdraw.MustText(
+ cvs,
+ "texa",
+ image.Point{0, 0},
+ )
+ testcanvas.MustApply(cvs, ft)
+ return ft
+ },
+ },
{
desc: "displays written text",
canvas: image.Rect(0, 0, 10, 1),
@@ -1441,7 +1511,7 @@ func TestTextInput(t *testing.T) {
for i, ev := range tc.events {
switch e := ev.(type) {
case *terminalapi.Mouse:
- err := ti.Mouse(e)
+ err := ti.Mouse(e, &widgetapi.EventMeta{})
// Only the last event in test cases is the one that triggers the callback.
if i == len(tc.events)-1 {
if (err != nil) != tc.wantEventErr {
@@ -1457,7 +1527,7 @@ func TestTextInput(t *testing.T) {
}
case *terminalapi.Keyboard:
- err := ti.Keyboard(e)
+ err := ti.Keyboard(e, &widgetapi.EventMeta{})
// Only the last event in test cases is the one that triggers the callback.
if i == len(tc.events)-1 {
if (err != nil) != tc.wantEventErr {
@@ -1551,7 +1621,7 @@ func TestTextInputRead(t *testing.T) {
for _, ev := range tc.events {
switch e := ev.(type) {
case *terminalapi.Keyboard:
- err := ti.Keyboard(e)
+ err := ti.Keyboard(e, &widgetapi.EventMeta{})
if err != nil {
t.Fatalf("Keyboard => unexpected error: %v", err)
}
@@ -1704,6 +1774,19 @@ func TestOptions(t *testing.T) {
WantMouse: widgetapi.MouseScopeWidget,
},
},
+ {
+ desc: "requests ExclusiveKeyboardOnFocus",
+ opts: []Option{
+ ExclusiveKeyboardOnFocus(),
+ },
+ want: widgetapi.Options{
+ MinimumSize: image.Point{4, 1},
+ MaximumSize: image.Point{0, 1},
+ WantKeyboard: widgetapi.KeyScopeFocused,
+ WantMouse: widgetapi.MouseScopeWidget,
+ ExclusiveKeyboardOnFocus: true,
+ },
+ },
}
for _, tc := range tests {