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 ``` [buttondemo](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 ``` [textinputdemo](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 +``` + +[formdemo](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 ``` [gaugedemo](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 ``` [donutdemo](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 ``` [textdemo](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 ``` [sparklinedemo](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 ``` [barchartdemo](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 ``` [linechartdemo](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 ``` [segmentdisplaydemo](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 {