From 4326fee704b0a45b761de265db6c70fa246cc59f Mon Sep 17 00:00:00 2001 From: Elias Naur Date: Wed, 30 Mar 2022 11:52:51 +0200 Subject: [PATCH] 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 --- app/window.go | 18 ++++++++++++++ io/router/key.go | 14 +++++++---- io/router/key_test.go | 23 ++++++++++++++++++ io/router/pointer.go | 34 ++++++++++++++++++++++++++ io/router/router.go | 55 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 139 insertions(+), 5 deletions(-) diff --git a/app/window.go b/app/window.go index af2349af..abf86447 100644 --- a/app/window.go +++ b/app/window.go @@ -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 diff --git a/io/router/key.go b/io/router/key.go index 9d71652c..3ef60dc2 100644 --- a/io/router/key.go +++ b/io/router/key.go @@ -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 } diff --git a/io/router/key_test.go b/io/router/key_test.go index fd048d76..b53fae5f 100644 --- a/io/router/key_test.go +++ b/io/router/key_test.go @@ -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 diff --git a/io/router/pointer.go b/io/router/pointer.go index a6f45b12..ab28df66 100644 --- a/io/router/pointer.go +++ b/io/router/pointer.go @@ -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] diff --git a/io/router/router.go b/io/router/router.go index f7fb059e..16c82669 100644 --- a/io/router/router.go +++ b/io/router/router.go @@ -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),