diff --git a/app/os_android.go b/app/os_android.go index a1de4dea..c2c5a94f 100644 --- a/app/os_android.go +++ b/app/os_android.go @@ -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 { diff --git a/app/window.go b/app/window.go index 87e48639..4908882f 100644 --- a/app/window.go +++ b/app/window.go @@ -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 diff --git a/io/router/key.go b/io/router/key.go index a84cdb52..713d95d7 100644 --- a/io/router/key.go +++ b/io/router/key.go @@ -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 } diff --git a/io/router/key_test.go b/io/router/key_test.go index d9d25800..41183037 100644 --- a/io/router/key_test.go +++ b/io/router/key_test.go @@ -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 diff --git a/io/router/pointer.go b/io/router/pointer.go index cb30f639..19e7cd1c 100644 --- a/io/router/pointer.go +++ b/io/router/pointer.go @@ -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 diff --git a/io/router/router.go b/io/router/router.go index daf498dc..08a1c2a9 100644 --- a/io/router/router.go +++ b/io/router/router.go @@ -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),