mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35: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
|
||||
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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user