mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-06 09:55:40 +00:00
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:
+12
-8
@@ -897,14 +897,6 @@ func runInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv)) {
|
|||||||
func convertKeyCode(code C.jint) (string, bool) {
|
func convertKeyCode(code C.jint) (string, bool) {
|
||||||
var n string
|
var n string
|
||||||
switch code {
|
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:
|
case C.AKEYCODE_FORWARD_DEL:
|
||||||
n = key.NameDeleteForward
|
n = key.NameDeleteForward
|
||||||
case C.AKEYCODE_DEL:
|
case C.AKEYCODE_DEL:
|
||||||
@@ -930,6 +922,18 @@ func convertKeyCode(code C.jint) (string, bool) {
|
|||||||
//export Java_org_gioui_GioView_onKeyEvent
|
//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) {
|
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]
|
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 {
|
if n, ok := convertKeyCode(keyCode); ok {
|
||||||
state := key.Release
|
state := key.Release
|
||||||
if pressed == C.JNI_TRUE {
|
if pressed == C.JNI_TRUE {
|
||||||
|
|||||||
@@ -504,6 +504,12 @@ func (c *callbacks) SetEditorSnippet(r key.Range) {
|
|||||||
c.Event(key.SnippetEvent(r))
|
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) {
|
func (e *editorState) Replace(r key.Range, text string) {
|
||||||
if r.Start > r.End {
|
if r.Start > r.End {
|
||||||
r.Start, r.End = r.End, r.Start
|
r.Start, r.End = r.End, r.Start
|
||||||
|
|||||||
+128
-7
@@ -3,6 +3,9 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"gioui.org/f32"
|
"gioui.org/f32"
|
||||||
"gioui.org/io/event"
|
"gioui.org/io/event"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
@@ -23,6 +26,7 @@ type TextInputState uint8
|
|||||||
type keyQueue struct {
|
type keyQueue struct {
|
||||||
focus event.Tag
|
focus event.Tag
|
||||||
order []event.Tag
|
order []event.Tag
|
||||||
|
dirOrder []dirFocusEntry
|
||||||
handlers map[event.Tag]*keyHandler
|
handlers map[event.Tag]*keyHandler
|
||||||
state TextInputState
|
state TextInputState
|
||||||
hint key.InputHint
|
hint key.InputHint
|
||||||
@@ -32,10 +36,11 @@ type keyQueue struct {
|
|||||||
type keyHandler struct {
|
type keyHandler struct {
|
||||||
// visible will be true if the InputOp is present
|
// visible will be true if the InputOp is present
|
||||||
// in the current frame.
|
// in the current frame.
|
||||||
visible bool
|
visible bool
|
||||||
new bool
|
new bool
|
||||||
hint key.InputHint
|
hint key.InputHint
|
||||||
order int
|
order int
|
||||||
|
dirOrder int
|
||||||
}
|
}
|
||||||
|
|
||||||
// keyCollector tracks state required to update a keyQueue
|
// keyCollector tracks state required to update a keyQueue
|
||||||
@@ -46,12 +51,27 @@ type keyCollector struct {
|
|||||||
changed bool
|
changed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type dirFocusEntry struct {
|
||||||
|
tag event.Tag
|
||||||
|
row int
|
||||||
|
bounds f32.Rectangle
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TextInputKeep TextInputState = iota
|
TextInputKeep TextInputState = iota
|
||||||
TextInputClose
|
TextInputClose
|
||||||
TextInputOpen
|
TextInputOpen
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type FocusDirection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
FocusRight FocusDirection = iota
|
||||||
|
FocusLeft
|
||||||
|
FocusUp
|
||||||
|
FocusDown
|
||||||
|
)
|
||||||
|
|
||||||
// InputState returns the last text input state as
|
// InputState returns the last text input state as
|
||||||
// determined in Frame.
|
// determined in Frame.
|
||||||
func (q *keyQueue) InputState() TextInputState {
|
func (q *keyQueue) InputState() TextInputState {
|
||||||
@@ -83,6 +103,7 @@ func (q *keyQueue) Reset() {
|
|||||||
h.order = -1
|
h.order = -1
|
||||||
}
|
}
|
||||||
q.order = q.order[:0]
|
q.order = q.order[:0]
|
||||||
|
q.dirOrder = q.dirOrder[:0]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *keyQueue) Frame(events *handlerEvents, collector keyCollector) {
|
func (q *keyQueue) Frame(events *handlerEvents, collector keyCollector) {
|
||||||
@@ -103,6 +124,105 @@ func (q *keyQueue) Frame(events *handlerEvents, collector keyCollector) {
|
|||||||
if changed {
|
if changed {
|
||||||
q.setFocus(focus, events)
|
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) {
|
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]
|
h, ok := k.q.handlers[tag]
|
||||||
if !ok {
|
if !ok {
|
||||||
h = &keyHandler{new: true, order: -1}
|
h = &keyHandler{new: true, order: -1}
|
||||||
@@ -177,12 +297,13 @@ func (k *keyCollector) handlerFor(tag event.Tag) *keyHandler {
|
|||||||
if h.order == -1 {
|
if h.order == -1 {
|
||||||
h.order = len(k.q.order)
|
h.order = len(k.q.order)
|
||||||
k.q.order = append(k.q.order, tag)
|
k.q.order = append(k.q.order, tag)
|
||||||
|
k.q.dirOrder = append(k.q.dirOrder, dirFocusEntry{tag: tag, bounds: bounds})
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *keyCollector) inputOp(op key.InputOp) {
|
func (k *keyCollector) inputOp(op key.InputOp, bounds f32.Rectangle) {
|
||||||
h := k.handlerFor(op.Tag)
|
h := k.handlerFor(op.Tag, bounds)
|
||||||
h.visible = true
|
h.visible = true
|
||||||
h.hint = op.Hint
|
h.hint = op.Hint
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gioui.org/io/event"
|
"gioui.org/io/event"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/clip"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestKeyWakeup(t *testing.T) {
|
func TestKeyWakeup(t *testing.T) {
|
||||||
@@ -246,6 +248,41 @@ func TestTabFocus(t *testing.T) {
|
|||||||
assertFocus(t, r, &handlers[2])
|
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) {
|
func assertKeyEvent(t *testing.T, events []event.Event, expected bool, expectedInputs ...event.Event) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var evtFocus int
|
var evtFocus int
|
||||||
|
|||||||
+16
-4
@@ -222,6 +222,14 @@ func (c *pointerCollector) currentArea() int {
|
|||||||
return -1
|
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) {
|
func (c *pointerCollector) addHitNode(n hitNode) {
|
||||||
n.next = c.state.nodePlusOne - 1
|
n.next = c.state.nodePlusOne - 1
|
||||||
c.q.hitTree = append(c.q.hitTree, n)
|
c.q.hitTree = append(c.q.hitTree, n)
|
||||||
@@ -382,10 +390,7 @@ func (q *pointerQueue) appendSemanticChildren(nodes []SemanticNode, areaIdx int)
|
|||||||
nodes = append(nodes, SemanticNode{
|
nodes = append(nodes, SemanticNode{
|
||||||
ID: semID,
|
ID: semID,
|
||||||
Desc: SemanticDesc{
|
Desc: SemanticDesc{
|
||||||
Bounds: f32.Rectangle{
|
Bounds: a.bounds(),
|
||||||
Min: a.trans.Transform(a.area.rect.Min),
|
|
||||||
Max: a.trans.Transform(a.area.rect.Max),
|
|
||||||
},
|
|
||||||
Label: cnt.label,
|
Label: cnt.label,
|
||||||
Description: cnt.desc,
|
Description: cnt.desc,
|
||||||
Class: cnt.class,
|
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) {
|
func setScrollEvent(scroll float32, min, max int) (left, scrolled float32) {
|
||||||
if v := float32(max); scroll > v {
|
if v := float32(max); scroll > v {
|
||||||
return scroll - v, v
|
return scroll - v, v
|
||||||
|
|||||||
+6
-1
@@ -148,6 +148,10 @@ func (q *Router) Queue(events ...event.Event) bool {
|
|||||||
return q.handlers.HadEvents()
|
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
|
// TextInputState returns the input state from the most recent
|
||||||
// call to Frame.
|
// call to Frame.
|
||||||
func (q *Router) TextInputState() TextInputState {
|
func (q *Router) TextInputState() TextInputState {
|
||||||
@@ -316,7 +320,8 @@ func (q *Router) collect() {
|
|||||||
Tag: encOp.Refs[0].(event.Tag),
|
Tag: encOp.Refs[0].(event.Tag),
|
||||||
Hint: key.InputHint(encOp.Data[1]),
|
Hint: key.InputHint(encOp.Data[1]),
|
||||||
}
|
}
|
||||||
kc.inputOp(op)
|
b := pc.currentAreaBounds()
|
||||||
|
kc.inputOp(op, b)
|
||||||
case ops.TypeSnippet:
|
case ops.TypeSnippet:
|
||||||
op := key.SnippetOp{
|
op := key.SnippetOp{
|
||||||
Tag: encOp.Refs[0].(event.Tag),
|
Tag: encOp.Refs[0].(event.Tag),
|
||||||
|
|||||||
Reference in New Issue
Block a user