app,io/router: scroll focused widgets into view

A focused widget may be partially or completely off-screen in which case
the user will have difficulty interacting with it. This change attempts to
scroll the focused widget into view by issuing synthetic scroll events.

For https://github.com/tailscale/tailscale/issues/4278, but doesn't completely
solve it because layout.Lists won't layout focusable widgets outside its visible
bounds. A follow-up change deals with that.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
This commit is contained in:
Elias Naur
2022-03-30 11:52:51 +02:00
parent 2069d5cb2e
commit 4326fee704
5 changed files with 139 additions and 5 deletions
+18
View File
@@ -67,6 +67,8 @@ type Window struct {
hasNextFrame bool
nextFrame time.Time
delayedDraw *time.Timer
// viewport is the latest frame size with insets applied.
viewport image.Rectangle
queue queue
cursor pointer.Cursor
@@ -505,6 +507,7 @@ func (w *Window) moveFocus(dir router.FocusDirection, d driver) {
w.queue.q.MoveFocus(dir)
w.setNextFrame(time.Time{})
w.updateAnimation(d)
w.queue.q.ScrollFocus(w.viewport)
}
func (c *callbacks) ClickFocus() {
@@ -756,6 +759,21 @@ func (w *Window) processEvent(d driver, e event.Event) {
// Prepare the decorations and update the frame insets.
wrapper := &w.decorations.Ops
wrapper.Reset()
viewport := image.Rectangle{
Min: image.Point{
X: e2.Metric.Px(e2.Insets.Left),
Y: e2.Metric.Px(e2.Insets.Top),
},
Max: image.Point{
X: e2.Size.X - e2.Metric.Px(e2.Insets.Right),
Y: e2.Size.Y - e2.Metric.Px(e2.Insets.Bottom),
},
}
// Scroll to focus if viewport is shrinking in any dimension.
if old, new := w.viewport.Size(), viewport.Size(); new.X < old.X || new.Y < old.Y {
w.queue.q.ScrollFocus(viewport)
}
w.viewport = viewport
size := e2.Size // save the initial window size as the decorations will change it.
e2.FrameEvent.Size = w.decorate(d, e2.FrameEvent, wrapper)
w.out <- e2.FrameEvent
+10 -4
View File
@@ -54,6 +54,7 @@ type keyCollector struct {
type dirFocusEntry struct {
tag event.Tag
row int
area int
bounds image.Rectangle
}
@@ -256,6 +257,11 @@ func (q *keyQueue) BoundsFor(t event.Tag) image.Rectangle {
return q.dirOrder[order].bounds
}
func (q *keyQueue) AreaFor(t event.Tag) int {
order := q.handlers[t].dirOrder
return q.dirOrder[order].area
}
func (q *keyQueue) setFocus(focus event.Tag, events *handlerEvents) {
if focus != nil {
if _, exists := q.handlers[focus]; !exists {
@@ -291,7 +297,7 @@ func (k *keyCollector) softKeyboard(show bool) {
}
}
func (k *keyCollector) handlerFor(tag event.Tag, bounds image.Rectangle) *keyHandler {
func (k *keyCollector) handlerFor(tag event.Tag, area int, bounds image.Rectangle) *keyHandler {
h, ok := k.q.handlers[tag]
if !ok {
h = &keyHandler{new: true, order: -1}
@@ -300,13 +306,13 @@ func (k *keyCollector) handlerFor(tag event.Tag, bounds image.Rectangle) *keyHan
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})
k.q.dirOrder = append(k.q.dirOrder, dirFocusEntry{tag: tag, area: area, bounds: bounds})
}
return h
}
func (k *keyCollector) inputOp(op key.InputOp, bounds image.Rectangle) {
h := k.handlerFor(op.Tag, bounds)
func (k *keyCollector) inputOp(op key.InputOp, area int, bounds image.Rectangle) {
h := k.handlerFor(op.Tag, area, bounds)
h.visible = true
h.hint = op.Hint
}
+23
View File
@@ -7,8 +7,10 @@ import (
"reflect"
"testing"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/op"
"gioui.org/op/clip"
)
@@ -267,6 +269,27 @@ func TestDirectionalFocus(t *testing.T) {
assertFocus(t, r, &handlers[0])
}
func TestFocusScroll(t *testing.T) {
ops := new(op.Ops)
r := new(Router)
h := new(int)
cl := clip.Rect(image.Rect(10, -20, 20, 30)).Push(ops)
key.InputOp{Tag: h}.Add(ops)
pointer.InputOp{
Tag: h,
Types: pointer.Scroll,
ScrollBounds: image.Rect(-100, -100, 100, 100),
}.Add(ops)
cl.Pop()
r.Frame(ops)
r.MoveFocus(FocusLeft)
r.ScrollFocus(image.Rect(0, 0, 15, 40))
evts := r.Events(h)
assertScrollEvent(t, evts[len(evts)-1], f32.Pt(5, -10))
}
func assertKeyEvent(t *testing.T, events []event.Event, expected bool, expectedInputs ...event.Event) {
t.Helper()
var evtFocus int
+34
View File
@@ -597,6 +597,40 @@ func (q *pointerQueue) pointerOf(e pointer.Event) int {
return len(q.pointers) - 1
}
// Deliver is like Push, but delivers an event to a particular area.
func (q *pointerQueue) Deliver(areaIdx int, e pointer.Event, events *handlerEvents) {
var sx, sy = e.Scroll.X, e.Scroll.Y
for areaIdx != -1 {
a := &q.areas[areaIdx]
areaIdx = a.parent
if !a.semantic.valid {
continue
}
cnt := a.semantic.content
if cnt.tag == nil {
continue
}
h := q.handlers[cnt.tag]
if e.Type == pointer.Scroll {
if sx == 0 && sy == 0 {
break
}
// Distribute the scroll to the handler based on its ScrollRange.
sx, e.Scroll.X = setScrollEvent(sx, h.scrollRange.Min.X, h.scrollRange.Max.X)
sy, e.Scroll.Y = setScrollEvent(sy, h.scrollRange.Min.Y, h.scrollRange.Max.Y)
}
if e.Type&h.types == 0 {
continue
}
e := e
e.Position = q.invTransform(h.area, e.Position)
events.Add(cnt.tag, e)
if e.Type != pointer.Scroll {
break
}
}
}
func (q *pointerQueue) Push(e pointer.Event, events *handlerEvents) {
if e.Type == pointer.Cancel {
q.pointers = q.pointers[:0]
+54 -1
View File
@@ -153,6 +153,58 @@ func (q *Router) MoveFocus(dir FocusDirection) {
q.key.queue.MoveFocus(dir, &q.handlers)
}
// ScrollFocus scrolls the current focus (if any) into viewport
// if there are scrollable parent handlers.
func (q *Router) ScrollFocus(viewport image.Rectangle) {
focus := q.key.queue.focus
if focus == nil {
return
}
bounds := q.key.queue.BoundsFor(focus)
topleft := bounds.Min.Sub(viewport.Min)
topleft = max(topleft, bounds.Max.Sub(viewport.Max))
topleft = min(image.Pt(0, 0), topleft)
bottomright := bounds.Max.Sub(viewport.Max)
bottomright = min(bottomright, bounds.Min.Sub(viewport.Min))
bottomright = max(image.Pt(0, 0), bottomright)
s := topleft
if s.X == 0 {
s.X = bottomright.X
}
if s.Y == 0 {
s.Y = bottomright.Y
}
area := q.key.queue.AreaFor(focus)
q.pointer.queue.Deliver(area, pointer.Event{
Type: pointer.Scroll,
Source: pointer.Touch,
Scroll: fpt(s),
}, &q.handlers)
}
func max(p1, p2 image.Point) image.Point {
m := p1
if p2.X > m.X {
m.X = p2.X
}
if p2.Y > m.Y {
m.Y = p2.Y
}
return m
}
func min(p1, p2 image.Point) image.Point {
m := p1
if p2.X < m.X {
m.X = p2.X
}
if p2.Y < m.Y {
m.Y = p2.Y
}
return m
}
func (q *Router) ClickFocus() {
focus := q.key.queue.focus
if focus == nil {
@@ -338,8 +390,9 @@ func (q *Router) collect() {
Tag: encOp.Refs[0].(event.Tag),
Hint: key.InputHint(encOp.Data[1]),
}
a := pc.currentArea()
b := pc.currentAreaBounds()
kc.inputOp(op, b)
kc.inputOp(op, a, b)
case ops.TypeSnippet:
op := key.SnippetOp{
Tag: encOp.Refs[0].(event.Tag),