From 0771a92dc255ba0912a552742b66c66502c681f9 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Mon, 23 Nov 2020 22:33:24 -0500 Subject: [PATCH] Ability to move focus to the next container using a key. --- container/container.go | 25 ++++- container/focus.go | 43 ++++++++ container/focus_test.go | 217 ++++++++++++++++++++++++++++++++++++++++ container/options.go | 57 +++++++++++ 4 files changed, 338 insertions(+), 4 deletions(-) diff --git a/container/container.go b/container/container.go index 54cef78..ab60218 100644 --- a/container/container.go +++ b/container/container.go @@ -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,15 @@ 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) { + if c.opts.global.keyFocusNext != nil && *c.opts.global.keyFocusNext == k.Key { + c.focusTracker.next() + } +} + // processEvent processes events delivered to the container. func (c *Container) processEvent(ev terminalapi.Event) error { // This is done in two stages. @@ -293,7 +308,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 { @@ -309,6 +324,8 @@ 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 { diff --git a/container/focus.go b/container/focus.go index 4320eea..6b0b229 100644 --- a/container/focus.go +++ b/container/focus.go @@ -78,6 +78,49 @@ func (ft *focusTracker) setActive(c *Container) { ft.container = c } +// next moves focus to the next container. +func (ft *focusTracker) next() { + var ( + errStr string + first *Container + cont *Container + focusNext bool + ) + postOrder(rootCont(ft.container), &errStr, visitFunc(func(c *Container) error { + if cont != nil { + // Already found the next container, nothing to do. + return nil + } + + if c.isLeaf() && first == nil { + // Remember the first eligible container in case we "wrap" over, + // i.e. finish the iteration before finding the next container. + first = c + } + + if ft.container == c { + // Visiting the currently focused container, going to focus the + // next one. + focusNext = true + return nil + } + + if c.isLeaf() && focusNext { + cont = c + } + return nil + })) + + if cont == nil && first != nil { + // If the traversal finishes without finding the next container, move + // focus back to the first container. + cont = first + } + if cont != nil { + ft.setActive(cont) + } +} + // 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 4a31ad7..451efde 100644 --- a/container/focus_test.go +++ b/container/focus_test.go @@ -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,219 @@ 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, + }, + } + + 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), + ) + } + }) + } +} diff --git a/container/options.go b/container/options.go index 2d34af4..06019db 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. @@ -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,38 @@ 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 traversal that +// contains a widget. Non-leaf containers and containers without widgets 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 not specified, keyboard the focused container 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 traversal that +// contains a widget. Non-leaf containers and containers without widgets 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. +func KeyFocusPrevious(key keyboard.Key) Option { + return option(func(c *Container) error { + c.opts.global.keyFocusPrevious = &key + return nil + }) +}