widget: add drag and drop support

This patch adds internal Drag and Drop support to app.Windows.

The new package io/transfer adds the ability to
define draggable and droppable targets, which
are leveraged by the new widget.Draggable type.

The API is generic and could handle future use
cases, such as external Drag and Drop.

Updates gio#153

Signed-off-by: Pierre Curto <pierre.curto@gmail.com>
This commit is contained in:
Pierre Curto
2021-11-27 10:22:10 +01:00
committed by Elias Naur
parent 2d75181b51
commit 03016f0c69
7 changed files with 776 additions and 23 deletions
+162 -22
View File
@@ -4,21 +4,24 @@ package router
import (
"image"
"io"
"gioui.org/f32"
"gioui.org/internal/ops"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/io/semantic"
"gioui.org/io/transfer"
)
type pointerQueue struct {
hitTree []hitNode
areas []areaNode
cursors []cursorNode
cursor pointer.CursorName
handlers map[event.Tag]*pointerHandler
pointers []pointerInfo
hitTree []hitNode
areas []areaNode
cursors []cursorNode
cursor pointer.CursorName
handlers map[event.Tag]*pointerHandler
pointers []pointerInfo
transfers []io.ReadCloser // pending data transfers
scratch []event.Tag
@@ -56,6 +59,9 @@ type pointerInfo struct {
// entered tracks the tags that contain the pointer.
entered []event.Tag
dataSource event.Tag // dragging source tag
dataTarget event.Tag // dragging target tag
}
type pointerHandler struct {
@@ -65,6 +71,11 @@ type pointerHandler struct {
types pointer.Type
// min and max horizontal/vertical scroll
scrollRange image.Rectangle
sourceMimes []string
targetMimes []string
offeredMime string
data io.ReadCloser
}
type areaOp struct {
@@ -182,7 +193,7 @@ func frect(r image.Rectangle) f32.Rectangle {
}
}
// fpt converts an point to a f32.Point.
// fpt converts a point to a f32.Point.
func fpt(p image.Point) f32.Point {
return f32.Point{
X: float32(p.X), Y: float32(p.Y),
@@ -217,6 +228,27 @@ func (c *pointerCollector) addHitNode(n hitNode) {
c.state.nodePlusOne = len(c.q.hitTree) - 1 + 1
}
// newHandler returns the current handler or a new one for tag.
func (c *pointerCollector) newHandler(tag event.Tag, events *handlerEvents) *pointerHandler {
areaID := c.currentArea()
c.addHitNode(hitNode{
area: areaID,
tag: tag,
pass: c.state.pass > 0,
})
h, ok := c.q.handlers[tag]
if !ok {
h = new(pointerHandler)
c.q.handlers[tag] = h
// Cancel handlers on (each) first appearance, but don't
// trigger redraw.
events.AddNoRedraw(tag, pointer.Event{Type: pointer.Cancel})
}
h.active = true
h.area = areaID
return h
}
func (c *pointerCollector) inputOp(op pointer.InputOp, events *handlerEvents) {
areaID := c.currentArea()
area := &c.q.areas[areaID]
@@ -225,21 +257,7 @@ func (c *pointerCollector) inputOp(op pointer.InputOp, events *handlerEvents) {
area.semantic.content.gestures |= ClickGesture
}
area.semantic.valid = area.semantic.content.gestures != 0
c.addHitNode(hitNode{
area: areaID,
tag: op.Tag,
pass: c.state.pass > 0,
})
h, ok := c.q.handlers[op.Tag]
if !ok {
h = new(pointerHandler)
c.q.handlers[op.Tag] = h
// Cancel handlers on (each) first appearance, but don't
// trigger redraw.
events.AddNoRedraw(op.Tag, pointer.Event{Type: pointer.Cancel})
}
h.active = true
h.area = areaID
h := c.newHandler(op.Tag, events)
h.wantsGrab = h.wantsGrab || op.Grab
h.types = h.types | op.Types
h.scrollRange = op.ScrollBounds
@@ -287,6 +305,22 @@ func (c *pointerCollector) cursor(name pointer.CursorName) {
})
}
func (c *pointerCollector) sourceOp(op transfer.SourceOp, events *handlerEvents) {
h := c.newHandler(op.Tag, events)
h.sourceMimes = append(h.sourceMimes, op.Type)
}
func (c *pointerCollector) targetOp(op transfer.TargetOp, events *handlerEvents) {
h := c.newHandler(op.Tag, events)
h.targetMimes = append(h.targetMimes, op.Type)
}
func (c *pointerCollector) offerOp(op transfer.OfferOp, events *handlerEvents) {
h := c.newHandler(op.Tag, events)
h.offeredMime = op.Type
h.data = op.Data
}
func (c *pointerCollector) reset(q *pointerQueue) {
q.reset()
c.resetState()
@@ -448,6 +482,8 @@ func (q *pointerQueue) reset() {
h.active = false
h.wantsGrab = false
h.types = 0
h.sourceMimes = h.sourceMimes[:0]
h.targetMimes = h.targetMimes[:0]
}
q.hitTree = q.hitTree[:0]
q.areas = q.areas[:0]
@@ -467,6 +503,12 @@ func (q *pointerQueue) reset() {
delete(q.semantic.contentIDs, k)
}
}
for _, rc := range q.transfers {
if rc != nil {
rc.Close()
}
}
q.transfers = nil
}
func (q *pointerQueue) Frame(events *handlerEvents) {
@@ -498,6 +540,7 @@ func (q *pointerQueue) Frame(events *handlerEvents) {
for i := range q.pointers {
p := &q.pointers[i]
q.deliverEnterLeaveEvents(p, events, p.last)
q.deliverTransferDataEvent(p, events)
}
}
@@ -554,10 +597,14 @@ func (q *pointerQueue) Push(e pointer.Event, events *handlerEvents) {
}
q.deliverEnterLeaveEvents(p, events, e)
q.deliverEvent(p, events, e)
if p.pressed {
q.deliverDragEvent(p, events)
}
case pointer.Release:
q.deliverEvent(p, events, e)
p.pressed = false
q.deliverEnterLeaveEvents(p, events, e)
q.deliverDropEvent(p, events)
case pointer.Scroll:
q.deliverEnterLeaveEvents(p, events, e)
q.deliverScrollEvent(p, events, e)
@@ -671,6 +718,87 @@ func (q *pointerQueue) deliverEnterLeaveEvents(p *pointerInfo, events *handlerEv
p.entered = append(p.entered[:0], hits...)
}
func (q *pointerQueue) deliverDragEvent(p *pointerInfo, events *handlerEvents) {
if p.dataSource != nil {
return
}
// Identify the data source.
for _, k := range p.entered {
src := q.handlers[k]
if len(src.sourceMimes) == 0 {
continue
}
// One data source handler per pointer.
p.dataSource = k
// Notify all potential targets.
for k, tgt := range q.handlers {
if _, ok := firstMimeMatch(src, tgt); ok {
events.Add(k, transfer.InitiateEvent{})
}
}
break
}
}
func (q *pointerQueue) deliverDropEvent(p *pointerInfo, events *handlerEvents) {
if p.dataSource == nil {
return
}
// Request data from the source.
src := q.handlers[p.dataSource]
for _, k := range p.entered {
h := q.handlers[k]
if m, ok := firstMimeMatch(src, h); ok {
p.dataTarget = k
events.Add(p.dataSource, transfer.RequestEvent{Type: m})
return
}
}
// No valid target found, abort.
q.deliverTransferCancelEvent(p, events)
}
func (q *pointerQueue) deliverTransferDataEvent(p *pointerInfo, events *handlerEvents) {
if p.dataSource == nil {
return
}
src := q.handlers[p.dataSource]
if src.data == nil {
// Data not received yet.
return
}
if p.dataTarget == nil {
q.deliverTransferCancelEvent(p, events)
return
}
// Send the offered data to the target.
transferIdx := len(q.transfers)
events.Add(p.dataTarget, transfer.DataEvent{
Type: src.offeredMime,
Open: func() io.ReadCloser {
q.transfers[transferIdx] = nil
return src.data
},
})
q.transfers = append(q.transfers, src.data)
p.dataTarget = nil
}
func (q *pointerQueue) deliverTransferCancelEvent(p *pointerInfo, events *handlerEvents) {
events.Add(p.dataSource, transfer.CancelEvent{})
// Cancel all potential targets.
src := q.handlers[p.dataSource]
for k, h := range q.handlers {
if _, ok := firstMimeMatch(src, h); ok {
events.Add(k, transfer.CancelEvent{})
}
}
src.offeredMime = ""
src.data = nil
p.dataSource = nil
p.dataTarget = nil
}
func searchTag(tags []event.Tag, tag event.Tag) (int, bool) {
for i, t := range tags {
if t == tag {
@@ -690,6 +818,18 @@ func addHandler(tags []event.Tag, tag event.Tag) []event.Tag {
return append(tags, tag)
}
// firstMimeMatch returns the first type match between src and tgt.
func firstMimeMatch(src, tgt *pointerHandler) (first string, matched bool) {
for _, m1 := range tgt.targetMimes {
for _, m2 := range src.sourceMimes {
if m1 == m2 {
return m1, true
}
}
}
return "", false
}
func (op *areaOp) Hit(pos f32.Point) bool {
pos = pos.Sub(op.rect.Min)
size := op.rect.Size()
+279
View File
@@ -6,12 +6,14 @@ import (
"fmt"
"image"
"reflect"
"strings"
"testing"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/transfer"
"gioui.org/op"
"gioui.org/op/clip"
)
@@ -761,6 +763,255 @@ func TestEllipse(t *testing.T) {
assertEventPointerTypeSequence(t, r.Events(h), pointer.Cancel, pointer.Press)
}
func TestTransfer(t *testing.T) {
srcArea := image.Rect(0, 0, 20, 20)
tgtArea := srcArea.Add(image.Pt(40, 0))
setup := func(ops *op.Ops, srcType, tgtType string) (src, tgt event.Tag) {
src, tgt = new(int), new(int)
srcStack := clip.Rect(srcArea).Push(ops)
transfer.SourceOp{
Tag: src,
Type: srcType,
}.Add(ops)
srcStack.Pop()
tgt1Stack := clip.Rect(tgtArea).Push(ops)
transfer.TargetOp{
Tag: tgt,
Type: tgtType,
}.Add(ops)
tgt1Stack.Pop()
return src, tgt
}
// Cancel is received when the pointer is first seen.
cancel := pointer.Event{Type: pointer.Cancel}
t.Run("transfer.Offer should panic on nil Data", func(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("expected panic upon invalid data")
}
}()
transfer.OfferOp{}.Add(new(op.Ops))
})
t.Run("drop on no target", func(t *testing.T) {
ops := new(op.Ops)
src, tgt := setup(ops, "file", "file")
var r Router
r.Frame(ops)
// Initiate a drag.
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
assertEventSequence(t, r.Events(tgt), cancel, transfer.InitiateEvent{})
// Drop.
r.Queue(
pointer.Event{
Position: f32.Pt(30, 10),
Type: pointer.Move,
},
pointer.Event{
Position: f32.Pt(30, 10),
Type: pointer.Release,
},
)
assertEventSequence(t, r.Events(src), transfer.CancelEvent{})
assertEventSequence(t, r.Events(tgt), transfer.CancelEvent{})
})
t.Run("drag with valid and invalid targets", func(t *testing.T) {
ops := new(op.Ops)
src, tgt1 := setup(ops, "file", "file")
tgt2 := new(int)
stack := clip.Rect(tgtArea).Push(ops)
transfer.TargetOp{
Tag: tgt2,
Type: "nofile",
}.Add(ops)
stack.Pop()
var r Router
r.Frame(ops)
// Initiate a drag.
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
assertEventSequence(t, r.Events(tgt1), cancel, transfer.InitiateEvent{})
assertEventSequence(t, r.Events(tgt2), cancel)
})
t.Run("drop on invalid target", func(t *testing.T) {
ops := new(op.Ops)
src, tgt := setup(ops, "file", "nofile")
var r Router
r.Frame(ops)
// Drag.
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
assertEventSequence(t, r.Events(tgt), cancel)
// Drop.
r.Queue(
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
},
)
assertEventSequence(t, r.Events(src), transfer.CancelEvent{})
assertEventSequence(t, r.Events(tgt))
})
t.Run("drop on valid target", func(t *testing.T) {
ops := new(op.Ops)
src, tgt := setup(ops, "file", "file")
// Make the target also a source. This should have no effect.
stack := clip.Rect(tgtArea).Push(ops)
transfer.SourceOp{
Tag: tgt,
Type: "file",
}.Add(ops)
stack.Pop()
var r Router
r.Frame(ops)
// Drag.
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
assertEventSequence(t, r.Events(tgt), cancel, transfer.InitiateEvent{})
// Drop.
r.Queue(
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
},
)
assertEventSequence(t, r.Events(src), transfer.RequestEvent{Type: "file"})
// Offer valid type and data.
ofr := &offer{data: "hello"}
transfer.OfferOp{
Tag: src,
Type: "file",
Data: ofr,
}.Add(ops)
r.Frame(ops)
evs := r.Events(tgt)
if len(evs) != 1 {
t.Fatalf("unexpected number of events: %d, want 1", len(evs))
}
dataEvent, ok := evs[0].(transfer.DataEvent)
if !ok {
t.Fatalf("unexpected event type: %T, want %T", dataEvent, transfer.DataEvent{})
}
if got, want := dataEvent.Type, "file"; got != want {
t.Fatalf("got %s; want %s", got, want)
}
if got, want := dataEvent.Open(), ofr; got != want {
t.Fatalf("got %v; want %v", got, want)
}
// Drag and drop complete.
if ofr.closed {
t.Error("offer closed prematurely")
}
r.Frame(ops)
assertEventSequence(t, r.Events(src), transfer.CancelEvent{})
assertEventSequence(t, r.Events(tgt), transfer.CancelEvent{})
})
t.Run("drop on valid target, DataEvent not used", func(t *testing.T) {
ops := new(op.Ops)
src, tgt := setup(ops, "file", "file")
// Make the target also a source. This should have no effect.
stack := clip.Rect(tgtArea).Push(ops)
transfer.SourceOp{
Tag: tgt,
Type: "file",
}.Add(ops)
stack.Pop()
var r Router
r.Frame(ops)
// Drag.
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
},
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
},
)
ofr := &offer{data: "hello"}
transfer.OfferOp{
Tag: src,
Type: "file",
Data: ofr,
}.Add(ops)
r.Frame(ops)
// DataEvent should be used here. The next frame should close it as unused.
r.Frame(ops)
assertEventSequence(t, r.Events(src), transfer.CancelEvent{})
assertEventSequence(t, r.Events(tgt), transfer.CancelEvent{})
if !ofr.closed {
t.Error("offer was not closed")
}
})
}
// offer satisfies io.ReadCloser for use in data transfers.
type offer struct {
data string
closed bool
}
func (offer) Read([]byte) (int, error) { return 0, nil }
func (o *offer) Close() error {
o.closed = true
return nil
}
// addPointerHandler adds a pointer.InputOp for the tag in a
// rectangular area.
func addPointerHandler(ops *op.Ops, tag event.Tag, area image.Rectangle) {
@@ -794,6 +1045,34 @@ func assertEventPointerTypeSequence(t *testing.T, events []event.Event, expected
}
}
// assertEventSequence checks that the provided events match the expected ones
// in the provided order.
func assertEventSequence(t *testing.T, got []event.Event, expected ...event.Event) {
t.Helper()
if len(expected) == 0 {
if len(got) > 0 {
t.Errorf("unexpected events: %v", eventsToString(got))
}
return
}
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected %s events, got %s", eventsToString(expected), eventsToString(got))
}
}
func eventsToString(evs []event.Event) string {
var s []string
for _, ev := range evs {
switch e := ev.(type) {
case pointer.Event:
s = append(s, fmt.Sprintf("%T{%s}", e, e.Type.String()))
default:
s = append(s, fmt.Sprintf("{%T}", e))
}
}
return "[" + strings.Join(s, ",") + "]"
}
// assertEventPriorities checks that the pointer.Event priorities of events match prios.
func assertEventPriorities(t *testing.T, events []event.Event, prios ...pointer.Priority) {
t.Helper()
+21
View File
@@ -13,6 +13,7 @@ package router
import (
"encoding/binary"
"image"
"io"
"strings"
"time"
@@ -24,6 +25,7 @@ import (
"gioui.org/io/pointer"
"gioui.org/io/profile"
"gioui.org/io/semantic"
"gioui.org/io/transfer"
"gioui.org/op"
)
@@ -267,6 +269,25 @@ func (q *Router) collect() {
case ops.TypeCursor:
name := encOp.Refs[0].(pointer.CursorName)
pc.cursor(name)
case ops.TypeSource:
op := transfer.SourceOp{
Tag: encOp.Refs[0].(event.Tag),
Type: encOp.Refs[1].(string),
}
pc.sourceOp(op, &q.handlers)
case ops.TypeTarget:
op := transfer.TargetOp{
Tag: encOp.Refs[0].(event.Tag),
Type: encOp.Refs[1].(string),
}
pc.targetOp(op, &q.handlers)
case ops.TypeOffer:
op := transfer.OfferOp{
Tag: encOp.Refs[0].(event.Tag),
Type: encOp.Refs[1].(string),
Data: encOp.Refs[2].(io.ReadCloser),
}
pc.offerOp(op, &q.handlers)
// Key ops.
case ops.TypeKeyFocus: