mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-06 09:55:40 +00:00
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:
@@ -67,6 +67,8 @@ type Window struct {
|
|||||||
hasNextFrame bool
|
hasNextFrame bool
|
||||||
nextFrame time.Time
|
nextFrame time.Time
|
||||||
delayedDraw *time.Timer
|
delayedDraw *time.Timer
|
||||||
|
// viewport is the latest frame size with insets applied.
|
||||||
|
viewport image.Rectangle
|
||||||
|
|
||||||
queue queue
|
queue queue
|
||||||
cursor pointer.Cursor
|
cursor pointer.Cursor
|
||||||
@@ -505,6 +507,7 @@ func (w *Window) moveFocus(dir router.FocusDirection, d driver) {
|
|||||||
w.queue.q.MoveFocus(dir)
|
w.queue.q.MoveFocus(dir)
|
||||||
w.setNextFrame(time.Time{})
|
w.setNextFrame(time.Time{})
|
||||||
w.updateAnimation(d)
|
w.updateAnimation(d)
|
||||||
|
w.queue.q.ScrollFocus(w.viewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *callbacks) ClickFocus() {
|
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.
|
// Prepare the decorations and update the frame insets.
|
||||||
wrapper := &w.decorations.Ops
|
wrapper := &w.decorations.Ops
|
||||||
wrapper.Reset()
|
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.
|
size := e2.Size // save the initial window size as the decorations will change it.
|
||||||
e2.FrameEvent.Size = w.decorate(d, e2.FrameEvent, wrapper)
|
e2.FrameEvent.Size = w.decorate(d, e2.FrameEvent, wrapper)
|
||||||
w.out <- e2.FrameEvent
|
w.out <- e2.FrameEvent
|
||||||
|
|||||||
+10
-4
@@ -54,6 +54,7 @@ type keyCollector struct {
|
|||||||
type dirFocusEntry struct {
|
type dirFocusEntry struct {
|
||||||
tag event.Tag
|
tag event.Tag
|
||||||
row int
|
row int
|
||||||
|
area int
|
||||||
bounds image.Rectangle
|
bounds image.Rectangle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +257,11 @@ func (q *keyQueue) BoundsFor(t event.Tag) image.Rectangle {
|
|||||||
return q.dirOrder[order].bounds
|
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) {
|
func (q *keyQueue) setFocus(focus event.Tag, events *handlerEvents) {
|
||||||
if focus != nil {
|
if focus != nil {
|
||||||
if _, exists := q.handlers[focus]; !exists {
|
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]
|
h, ok := k.q.handlers[tag]
|
||||||
if !ok {
|
if !ok {
|
||||||
h = &keyHandler{new: true, order: -1}
|
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 {
|
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})
|
k.q.dirOrder = append(k.q.dirOrder, dirFocusEntry{tag: tag, area: area, bounds: bounds})
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *keyCollector) inputOp(op key.InputOp, bounds image.Rectangle) {
|
func (k *keyCollector) inputOp(op key.InputOp, area int, bounds image.Rectangle) {
|
||||||
h := k.handlerFor(op.Tag, bounds)
|
h := k.handlerFor(op.Tag, area, bounds)
|
||||||
h.visible = true
|
h.visible = true
|
||||||
h.hint = op.Hint
|
h.hint = op.Hint
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gioui.org/f32"
|
||||||
"gioui.org/io/event"
|
"gioui.org/io/event"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
"gioui.org/op/clip"
|
"gioui.org/op/clip"
|
||||||
)
|
)
|
||||||
@@ -267,6 +269,27 @@ func TestDirectionalFocus(t *testing.T) {
|
|||||||
assertFocus(t, r, &handlers[0])
|
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) {
|
func assertKeyEvent(t *testing.T, events []event.Event, expected bool, expectedInputs ...event.Event) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var evtFocus int
|
var evtFocus int
|
||||||
|
|||||||
@@ -597,6 +597,40 @@ func (q *pointerQueue) pointerOf(e pointer.Event) int {
|
|||||||
return len(q.pointers) - 1
|
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) {
|
func (q *pointerQueue) Push(e pointer.Event, events *handlerEvents) {
|
||||||
if e.Type == pointer.Cancel {
|
if e.Type == pointer.Cancel {
|
||||||
q.pointers = q.pointers[:0]
|
q.pointers = q.pointers[:0]
|
||||||
|
|||||||
+54
-1
@@ -153,6 +153,58 @@ func (q *Router) MoveFocus(dir FocusDirection) {
|
|||||||
q.key.queue.MoveFocus(dir, &q.handlers)
|
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() {
|
func (q *Router) ClickFocus() {
|
||||||
focus := q.key.queue.focus
|
focus := q.key.queue.focus
|
||||||
if focus == nil {
|
if focus == nil {
|
||||||
@@ -338,8 +390,9 @@ 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]),
|
||||||
}
|
}
|
||||||
|
a := pc.currentArea()
|
||||||
b := pc.currentAreaBounds()
|
b := pc.currentAreaBounds()
|
||||||
kc.inputOp(op, b)
|
kc.inputOp(op, a, 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