io/router,app: add support for directional focus moves

Implement support for up/down/right/left directional focus moves
and map Android directional pad keys to focus moves.

Fixes: https://todo.sr.ht/~eliasnaur/gio/195
References: https://github.com/tailscale/tailscale/issues/1611
Signed-off-by: Elias Naur <mail@eliasnaur.com>
This commit is contained in:
Elias Naur
2022-02-27 10:33:22 +01:00
parent 2e9df04a7b
commit 73eabb352d
6 changed files with 205 additions and 20 deletions
+12 -8
View File
@@ -897,14 +897,6 @@ func runInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv)) {
func convertKeyCode(code C.jint) (string, bool) {
var n string
switch code {
case C.AKEYCODE_DPAD_UP:
n = key.NameUpArrow
case C.AKEYCODE_DPAD_DOWN:
n = key.NameDownArrow
case C.AKEYCODE_DPAD_LEFT:
n = key.NameLeftArrow
case C.AKEYCODE_DPAD_RIGHT:
n = key.NameRightArrow
case C.AKEYCODE_FORWARD_DEL:
n = key.NameDeleteForward
case C.AKEYCODE_DEL:
@@ -930,6 +922,18 @@ func convertKeyCode(code C.jint) (string, bool) {
//export Java_org_gioui_GioView_onKeyEvent
func Java_org_gioui_GioView_onKeyEvent(env *C.JNIEnv, class C.jclass, handle C.jlong, keyCode, r C.jint, pressed C.jboolean, t C.jlong) {
w := views[handle]
if pressed == C.JNI_TRUE {
switch keyCode {
case C.AKEYCODE_DPAD_UP:
w.callbacks.MoveFocus(router.FocusUp)
case C.AKEYCODE_DPAD_DOWN:
w.callbacks.MoveFocus(router.FocusDown)
case C.AKEYCODE_DPAD_LEFT:
w.callbacks.MoveFocus(router.FocusLeft)
case C.AKEYCODE_DPAD_RIGHT:
w.callbacks.MoveFocus(router.FocusRight)
}
}
if n, ok := convertKeyCode(keyCode); ok {
state := key.Release
if pressed == C.JNI_TRUE {
+6
View File
@@ -504,6 +504,12 @@ func (c *callbacks) SetEditorSnippet(r key.Range) {
c.Event(key.SnippetEvent(r))
}
func (c *callbacks) MoveFocus(dir router.FocusDirection) {
c.w.queue.q.MoveFocus(dir)
c.w.setNextFrame(time.Time{})
c.w.updateAnimation(c.d)
}
func (e *editorState) Replace(r key.Range, text string) {
if r.Start > r.End {
r.Start, r.End = r.End, r.Start
+128 -7
View File
@@ -3,6 +3,9 @@
package router
import (
"math"
"sort"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/key"
@@ -23,6 +26,7 @@ type TextInputState uint8
type keyQueue struct {
focus event.Tag
order []event.Tag
dirOrder []dirFocusEntry
handlers map[event.Tag]*keyHandler
state TextInputState
hint key.InputHint
@@ -32,10 +36,11 @@ type keyQueue struct {
type keyHandler struct {
// visible will be true if the InputOp is present
// in the current frame.
visible bool
new bool
hint key.InputHint
order int
visible bool
new bool
hint key.InputHint
order int
dirOrder int
}
// keyCollector tracks state required to update a keyQueue
@@ -46,12 +51,27 @@ type keyCollector struct {
changed bool
}
type dirFocusEntry struct {
tag event.Tag
row int
bounds f32.Rectangle
}
const (
TextInputKeep TextInputState = iota
TextInputClose
TextInputOpen
)
type FocusDirection int
const (
FocusRight FocusDirection = iota
FocusLeft
FocusUp
FocusDown
)
// InputState returns the last text input state as
// determined in Frame.
func (q *keyQueue) InputState() TextInputState {
@@ -83,6 +103,7 @@ func (q *keyQueue) Reset() {
h.order = -1
}
q.order = q.order[:0]
q.dirOrder = q.dirOrder[:0]
}
func (q *keyQueue) Frame(events *handlerEvents, collector keyCollector) {
@@ -103,6 +124,105 @@ func (q *keyQueue) Frame(events *handlerEvents, collector keyCollector) {
if changed {
q.setFocus(focus, events)
}
q.updateFocusLayout()
}
// updateFocusLayout partitions input handlers handlers into rows
// for directional focus moves.
//
// The approach is greedy: pick the topmost handler and create a row
// containing it. Then, extend the handler bounds to a horizontal beam
// and add to the row every handler whose center intersect it. Repeat
// until no handlers remain.
func (q *keyQueue) updateFocusLayout() {
order := q.dirOrder
// Sort by ascending y position.
sort.SliceStable(order, func(i, j int) bool {
return order[i].bounds.Min.Y < order[j].bounds.Min.Y
})
row := 0
for len(order) > 0 {
h := &order[0]
h.row = row
bottom := h.bounds.Max.Y
end := 1
for ; end < len(order); end++ {
h := &order[end]
center := (h.bounds.Min.Y + h.bounds.Max.Y) * .5
if center > bottom {
break
}
h.row = row
}
// Sort row by ascending x position.
sort.SliceStable(order[:end], func(i, j int) bool {
return order[i].bounds.Min.X < order[j].bounds.Min.X
})
order = order[end:]
row++
}
for i, o := range q.dirOrder {
q.handlers[o.tag].dirOrder = i
}
}
func (q *keyQueue) MoveFocus(dir FocusDirection, events *handlerEvents) {
order := 0
if q.focus != nil {
order = q.handlers[q.focus].dirOrder
}
focus := q.dirOrder[order]
switch dir {
case FocusRight, FocusLeft:
next := order
if q.focus != nil {
next = order + 1
if dir == FocusLeft {
next = order - 1
}
}
if 0 <= next && next < len(q.dirOrder) {
newFocus := q.dirOrder[next]
if newFocus.row == focus.row {
q.setFocus(newFocus.tag, events)
}
}
case FocusUp, FocusDown:
delta := +1
if dir == FocusUp {
delta = -1
}
nextRow := 0
if q.focus != nil {
nextRow = focus.row + delta
}
var closest event.Tag
dist := float32(math.Inf(+1))
center := (focus.bounds.Min.X + focus.bounds.Max.X) * .5
loop:
for 0 <= order && order < len(q.dirOrder) {
next := q.dirOrder[order]
switch next.row {
case nextRow:
nextCenter := (next.bounds.Min.X + next.bounds.Max.X) * .5
d := center - nextCenter
if d < 0 {
d = -d
}
if d > dist {
break loop
}
dist = d
closest = next.tag
case nextRow + delta:
break loop
}
order += delta
}
if closest != nil {
q.setFocus(closest, events)
}
}
}
func (q *keyQueue) Push(e event.Event, events *handlerEvents) {
@@ -168,7 +288,7 @@ func (k *keyCollector) softKeyboard(show bool) {
}
}
func (k *keyCollector) handlerFor(tag event.Tag) *keyHandler {
func (k *keyCollector) handlerFor(tag event.Tag, bounds f32.Rectangle) *keyHandler {
h, ok := k.q.handlers[tag]
if !ok {
h = &keyHandler{new: true, order: -1}
@@ -177,12 +297,13 @@ func (k *keyCollector) handlerFor(tag event.Tag) *keyHandler {
if h.order == -1 {
h.order = len(k.q.order)
k.q.order = append(k.q.order, tag)
k.q.dirOrder = append(k.q.dirOrder, dirFocusEntry{tag: tag, bounds: bounds})
}
return h
}
func (k *keyCollector) inputOp(op key.InputOp) {
h := k.handlerFor(op.Tag)
func (k *keyCollector) inputOp(op key.InputOp, bounds f32.Rectangle) {
h := k.handlerFor(op.Tag, bounds)
h.visible = true
h.hint = op.Hint
}
+37
View File
@@ -3,12 +3,14 @@
package router
import (
"image"
"reflect"
"testing"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/op"
"gioui.org/op/clip"
)
func TestKeyWakeup(t *testing.T) {
@@ -246,6 +248,41 @@ func TestTabFocus(t *testing.T) {
assertFocus(t, r, &handlers[2])
}
func TestDirectionalFocus(t *testing.T) {
ops := new(op.Ops)
r := new(Router)
handlers := []image.Rectangle{
image.Rect(10, 10, 50, 50),
image.Rect(50, 20, 100, 80),
image.Rect(20, 26, 60, 80),
image.Rect(10, 60, 50, 100),
}
for i, bounds := range handlers {
cl := clip.Rect(bounds).Push(ops)
key.InputOp{Tag: &handlers[i]}.Add(ops)
cl.Pop()
}
r.Frame(ops)
r.MoveFocus(FocusLeft)
assertFocus(t, r, &handlers[0])
r.MoveFocus(FocusLeft)
assertFocus(t, r, &handlers[0])
r.MoveFocus(FocusRight)
assertFocus(t, r, &handlers[1])
r.MoveFocus(FocusRight)
assertFocus(t, r, &handlers[1])
r.MoveFocus(FocusDown)
assertFocus(t, r, &handlers[2])
r.MoveFocus(FocusDown)
assertFocus(t, r, &handlers[2])
r.MoveFocus(FocusLeft)
assertFocus(t, r, &handlers[3])
r.MoveFocus(FocusUp)
assertFocus(t, r, &handlers[0])
}
func assertKeyEvent(t *testing.T, events []event.Event, expected bool, expectedInputs ...event.Event) {
t.Helper()
var evtFocus int
+16 -4
View File
@@ -222,6 +222,14 @@ func (c *pointerCollector) currentArea() int {
return -1
}
func (c *pointerCollector) currentAreaBounds() f32.Rectangle {
a := c.currentArea()
if a == -1 {
panic("no root area")
}
return c.q.areas[a].bounds()
}
func (c *pointerCollector) addHitNode(n hitNode) {
n.next = c.state.nodePlusOne - 1
c.q.hitTree = append(c.q.hitTree, n)
@@ -382,10 +390,7 @@ func (q *pointerQueue) appendSemanticChildren(nodes []SemanticNode, areaIdx int)
nodes = append(nodes, SemanticNode{
ID: semID,
Desc: SemanticDesc{
Bounds: f32.Rectangle{
Min: a.trans.Transform(a.area.rect.Min),
Max: a.trans.Transform(a.area.rect.Max),
},
Bounds: a.bounds(),
Label: cnt.label,
Description: cnt.desc,
Class: cnt.class,
@@ -869,6 +874,13 @@ func (op *areaOp) Hit(pos f32.Point) bool {
}
}
func (a *areaNode) bounds() f32.Rectangle {
return f32.Rectangle{
Min: a.trans.Transform(a.area.rect.Min),
Max: a.trans.Transform(a.area.rect.Max),
}
}
func setScrollEvent(scroll float32, min, max int) (left, scrolled float32) {
if v := float32(max); scroll > v {
return scroll - v, v
+6 -1
View File
@@ -148,6 +148,10 @@ func (q *Router) Queue(events ...event.Event) bool {
return q.handlers.HadEvents()
}
func (q *Router) MoveFocus(dir FocusDirection) {
q.key.queue.MoveFocus(dir, &q.handlers)
}
// TextInputState returns the input state from the most recent
// call to Frame.
func (q *Router) TextInputState() TextInputState {
@@ -316,7 +320,8 @@ func (q *Router) collect() {
Tag: encOp.Refs[0].(event.Tag),
Hint: key.InputHint(encOp.Data[1]),
}
kc.inputOp(op)
b := pc.currentAreaBounds()
kc.inputOp(op, b)
case ops.TypeSnippet:
op := key.SnippetOp{
Tag: encOp.Refs[0].(event.Tag),