1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-05-01 22:17:51 +08:00

Merge pull request #270 from mum4k/243-key-based-focus

Ability to move focus using keyboard.
This commit is contained in:
Jakub Sobon 2020-11-24 19:48:32 -05:00 committed by GitHub
commit 42af42e991
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 675 additions and 4 deletions

View File

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- ability to configure keyboard keys that move focus to the next or the
previous container.
## [0.13.0] - 17-Nov-2020
### Added

View File

@ -122,6 +122,12 @@ 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.
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 +263,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 +274,18 @@ func (c *Container) updateFocus(m *terminalapi.Mouse) {
c.focusTracker.mouse(target, m)
}
// 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) {
switch {
case c.opts.global.keyFocusNext != nil && *c.opts.global.keyFocusNext == k.Key:
c.focusTracker.next()
case c.opts.global.keyFocusPrevious != nil && *c.opts.global.keyFocusPrevious == k.Key:
c.focusTracker.previous()
}
}
// processEvent processes events delivered to the container.
func (c *Container) processEvent(ev terminalapi.Event) error {
// This is done in two stages.
@ -293,7 +311,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 {
@ -310,6 +328,12 @@ func (c *Container) prepareEvTargets(ev terminalapi.Event) (func() error, error)
case *terminalapi.Keyboard:
targets := c.keyEvTargets()
// Update the focused container based on the pressed key.
// Done after collecting "targets" above. If the key changes which
// widget is focused, they key press itself should go to the widget
// that was focused when the key was pressed.
c.updateFocusFromKeyboard(ev.(*terminalapi.Keyboard))
return func() error {
for _, w := range targets {
if err := w.Keyboard(e); err != nil {

View File

@ -1510,6 +1510,182 @@ func TestMouse(t *testing.T) {
return ft
},
},
{
desc: "key event focuses the next container, widget with KeyScopeFocused does not get the key as it was not focused yet",
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.MustDraw(
ft,
testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
&widgetapi.Meta{},
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
&terminalapi.Keyboard{},
)
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},
&terminalapi.Keyboard{},
)
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.MustDraw(
ft,
testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
&widgetapi.Meta{},
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeGlobal},
&terminalapi.Keyboard{Key: keyboard.KeyTab},
)
return ft
},
},
{
desc: "key event moves focus from a widget with KeyScopeFocused, the originally 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},
&terminalapi.Keyboard{Key: keyboard.KeyTab},
)
fakewidget.MustDraw(
ft,
testcanvas.MustNew(image.Rect(25, 0, 50, 20)),
&widgetapi.Meta{Focused: true},
widgetapi.Options{WantKeyboard: widgetapi.KeyScopeFocused},
)
return ft
},
},
{
desc: "event not forwarded if the widget didn't request it",
termSize: image.Point{20, 20},

View File

@ -78,6 +78,80 @@ func (ft *focusTracker) setActive(c *Container) {
ft.container = c
}
// next moves focus to the next container.
func (ft *focusTracker) next() {
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 c.isLeaf() && firstCont == nil {
// Remember the first eligible container in case we "wrap" over,
// i.e. finish the iteration before finding the next container.
firstCont = c
}
if ft.container == c {
// Visiting the currently focused container, going to focus the
// next one.
focusNext = true
return nil
}
if c.isLeaf() && focusNext {
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.
nextCont = firstCont
}
if nextCont != nil {
ft.setActive(nextCont)
}
}
// previous moves focus to the previous container.
func (ft *focusTracker) previous() {
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() {
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.

View File

@ -21,6 +21,7 @@ import (
"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"
@ -453,3 +454,333 @@ func TestFocusTrackerMouse(t *testing.T) {
})
}
}
// 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
)
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
container func(ft *faketerm.Terminal) (*Container, error)
events []*terminalapi.Keyboard
wantFocused contLoc
wantProcessed int
}{
{
desc: "initially the root is focused",
container: func(ft *faketerm.Terminal) (*Container, error) {
return New(
ft,
SplitVertical(
Left(),
Right(),
),
KeyFocusNext(keyNext),
)
},
wantFocused: contLocRoot,
},
{
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: contLocRoot,
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: contLocLeft,
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: contLocRight,
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: contLocLeft,
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: contLocRight,
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: contLocLeft,
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: contLocRoot,
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: contLocRight,
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: contLocLeft,
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: contLocRight,
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: contLocLeft,
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: contLocRight,
wantProcessed: 5,
},
}
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 contLocRoot:
wantFocused = root
case contLocLeft:
wantFocused = root.first
case contLocRight:
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",
tc.wantFocused,
contLocRoot, root.focusTracker.isActive(root),
contLocLeft, root.focusTracker.isActive(root.first),
contLocRight, root.focusTracker.isActive(root.second),
)
}
})
}
}

View File

@ -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.
@ -181,11 +190,23 @@ type inherited struct {
focusedColor cell.Color
}
// 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
}
// 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{},
inherited: inherited{
focusedColor: cell.ColorYellow,
},
@ -195,6 +216,7 @@ func newOptions(parent *options) *options {
splitFixed: DefaultSplitFixed,
}
if parent != nil {
opts.global = parent.global
opts.inherited = parent.inherited
}
return opts
@ -815,3 +837,39 @@ 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
})
}

View File

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