mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
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:
+24
-1
@@ -51,6 +51,9 @@ const (
|
||||
TypePointerInput
|
||||
TypeClipboardRead
|
||||
TypeClipboardWrite
|
||||
TypeSource
|
||||
TypeTarget
|
||||
TypeOffer
|
||||
TypeKeyInput
|
||||
TypeKeyFocus
|
||||
TypeKeySoftKeyboard
|
||||
@@ -129,6 +132,9 @@ const (
|
||||
TypePointerInputLen = 1 + 1 + 1*2 + 2*4 + 2*4
|
||||
TypeClipboardReadLen = 1
|
||||
TypeClipboardWriteLen = 1
|
||||
TypeSourceLen = 1
|
||||
TypeTargetLen = 1
|
||||
TypeOfferLen = 1
|
||||
TypeKeyInputLen = 1 + 1
|
||||
TypeKeyFocusLen = 1 + 1
|
||||
TypeKeySoftKeyboardLen = 1 + 1
|
||||
@@ -240,6 +246,12 @@ func Write2(o *Ops, n int, ref1, ref2 interface{}) []byte {
|
||||
return o.data[len(o.data)-n:]
|
||||
}
|
||||
|
||||
func Write3(o *Ops, n int, ref1, ref2, ref3 interface{}) []byte {
|
||||
o.data = append(o.data, make([]byte, n)...)
|
||||
o.refs = append(o.refs, ref1, ref2, ref3)
|
||||
return o.data[len(o.data)-n:]
|
||||
}
|
||||
|
||||
func PCFor(o *Ops) PC {
|
||||
return PC{data: len(o.data), refs: len(o.refs)}
|
||||
}
|
||||
@@ -353,6 +365,9 @@ func (t OpType) Size() int {
|
||||
TypePointerInputLen,
|
||||
TypeClipboardReadLen,
|
||||
TypeClipboardWriteLen,
|
||||
TypeSourceLen,
|
||||
TypeTargetLen,
|
||||
TypeOfferLen,
|
||||
TypeKeyInputLen,
|
||||
TypeKeyFocusLen,
|
||||
TypeKeySoftKeyboardLen,
|
||||
@@ -377,8 +392,10 @@ func (t OpType) NumRefs() int {
|
||||
switch t {
|
||||
case TypeKeyInput, TypeKeyFocus, TypePointerInput, TypeProfile, TypeCall, TypeClipboardRead, TypeClipboardWrite, TypeCursor, TypeSemanticLabel, TypeSemanticDesc:
|
||||
return 1
|
||||
case TypeImage:
|
||||
case TypeImage, TypeSource, TypeTarget:
|
||||
return 2
|
||||
case TypeOffer:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
@@ -418,6 +435,12 @@ func (t OpType) String() string {
|
||||
return "ClipboardRead"
|
||||
case TypeClipboardWrite:
|
||||
return "ClipboardWrite"
|
||||
case TypeSource:
|
||||
return "Source"
|
||||
case TypeTarget:
|
||||
return "Target"
|
||||
case TypeOffer:
|
||||
return "Offer"
|
||||
case TypeKeyInput:
|
||||
return "KeyInput"
|
||||
case TypeKeyFocus:
|
||||
|
||||
+162
-22
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package transfer contains operations and events for brokering data transfers.
|
||||
//
|
||||
// The transfer protocol is as follows:
|
||||
//
|
||||
// - Data sources are registered with SourceOps, data targets with TargetOps.
|
||||
// - A data source receives a RequestEvent when a transfer is initiated.
|
||||
// It must respond with an OfferOp.
|
||||
// - The target receives a DataEvent when transferring to it. It must close
|
||||
// the event data after use.
|
||||
//
|
||||
// When a user initiates a pointer-guided drag and drop transfer, the
|
||||
// source as well as all potential targets receive an InitiateEvent.
|
||||
// Potential targets are targets with at least one MIME type in common
|
||||
// with the source. When a drag gesture completes, a CancelEvent is sent
|
||||
// to the source and all potential targets.
|
||||
//
|
||||
// Note that the RequestEvent is sent to the source upon drop.
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gioui.org/internal/ops"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/op"
|
||||
)
|
||||
|
||||
// SourceOp registers a tag as a data source for a MIME type.
|
||||
// Use multiple SourceOps if a tag supports multiple types.
|
||||
type SourceOp struct {
|
||||
Tag event.Tag
|
||||
// Type is the MIME type supported by this source.
|
||||
Type string
|
||||
}
|
||||
|
||||
// TargetOp registers a tag as a data target.
|
||||
// Use multiple TargetOps if a tag supports multiple types.
|
||||
type TargetOp struct {
|
||||
Tag event.Tag
|
||||
// Type is the MIME type accepted by this target.
|
||||
Type string
|
||||
}
|
||||
|
||||
// OfferOp is used by data sources as a response to a RequestEvent.
|
||||
type OfferOp struct {
|
||||
Tag event.Tag
|
||||
// Type is the MIME type of Data.
|
||||
// It must be the Type from the corresponding RequestEvent.
|
||||
Type string
|
||||
// Data contains the offered data. It is closed when the
|
||||
// transfer is complete or cancelled.
|
||||
// Data must be kept valid until closed, and it may be used from
|
||||
// a goroutine separate from the one processing the frame..
|
||||
Data io.ReadCloser
|
||||
}
|
||||
|
||||
func (op SourceOp) Add(o *op.Ops) {
|
||||
data := ops.Write2(&o.Internal, ops.TypeSourceLen, op.Tag, op.Type)
|
||||
data[0] = byte(ops.TypeSource)
|
||||
}
|
||||
|
||||
func (op TargetOp) Add(o *op.Ops) {
|
||||
data := ops.Write2(&o.Internal, ops.TypeTargetLen, op.Tag, op.Type)
|
||||
data[0] = byte(ops.TypeTarget)
|
||||
}
|
||||
|
||||
// Add the offer to the list of operations.
|
||||
// It panics if the Data field is not set.
|
||||
func (op OfferOp) Add(o *op.Ops) {
|
||||
if op.Data == nil {
|
||||
panic("invalid nil data in OfferOp")
|
||||
}
|
||||
data := ops.Write3(&o.Internal, ops.TypeOfferLen, op.Tag, op.Type, op.Data)
|
||||
data[0] = byte(ops.TypeOffer)
|
||||
}
|
||||
|
||||
// RequestEvent requests data from a data source. The source must
|
||||
// respond with an OfferOp.
|
||||
type RequestEvent struct {
|
||||
// Type is the first matched type between the source and the target.
|
||||
Type string
|
||||
}
|
||||
|
||||
func (RequestEvent) ImplementsEvent() {}
|
||||
|
||||
// InitiateEvent is sent to a data source when a drag-and-drop
|
||||
// transfer gesture is initiated.
|
||||
//
|
||||
// Potential data targets also receive the event.
|
||||
type InitiateEvent struct{}
|
||||
|
||||
func (InitiateEvent) ImplementsEvent() {}
|
||||
|
||||
// CancelEvent is sent to data sources and targets to cancel the
|
||||
// effects of an InitiateEvent.
|
||||
type CancelEvent struct{}
|
||||
|
||||
func (CancelEvent) ImplementsEvent() {}
|
||||
|
||||
// DataEvent is sent to the target receiving the transfer.
|
||||
type DataEvent struct {
|
||||
// Type is the MIME type of Data.
|
||||
Type string
|
||||
// Open returns the transfer data. It is only valid to call Open in the frame
|
||||
// the DataEvent is received. The caller must close the return value after use.
|
||||
Open func() io.ReadCloser
|
||||
}
|
||||
|
||||
func (DataEvent) ImplementsEvent() {}
|
||||
@@ -0,0 +1,95 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/transfer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
)
|
||||
|
||||
// Draggable makes a widget draggable.
|
||||
type Draggable struct {
|
||||
// Type contains the MIME type and matches transfer.SourceOp.
|
||||
Type string
|
||||
|
||||
handle struct{}
|
||||
drag gesture.Drag
|
||||
click f32.Point
|
||||
pos f32.Point
|
||||
requested bool
|
||||
request string
|
||||
}
|
||||
|
||||
func (d *Draggable) Layout(gtx layout.Context, w, drag layout.Widget) layout.Dimensions {
|
||||
pos := d.pos
|
||||
for _, ev := range d.drag.Events(gtx.Metric, gtx.Queue, gesture.Both) {
|
||||
switch ev.Type {
|
||||
case pointer.Press:
|
||||
d.click = ev.Position
|
||||
pos = f32.Point{}
|
||||
case pointer.Drag:
|
||||
pos = ev.Position.Sub(d.click)
|
||||
case pointer.Release:
|
||||
}
|
||||
}
|
||||
d.pos = pos
|
||||
|
||||
for _, ev := range gtx.Queue.Events(&d.handle) {
|
||||
switch e := ev.(type) {
|
||||
case transfer.RequestEvent:
|
||||
d.requested = true
|
||||
d.request = e.Type
|
||||
case transfer.CancelEvent:
|
||||
d.requested = false
|
||||
d.request = ""
|
||||
}
|
||||
}
|
||||
|
||||
dims := w(gtx)
|
||||
|
||||
stack := clip.Rect{Max: dims.Size}.Push(gtx.Ops)
|
||||
d.drag.Add(gtx.Ops)
|
||||
transfer.SourceOp{
|
||||
Tag: &d.handle,
|
||||
Type: d.Type,
|
||||
}.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
|
||||
if drag != nil && d.drag.Pressed() {
|
||||
rec := op.Record(gtx.Ops)
|
||||
op.Offset(pos).Add(gtx.Ops)
|
||||
drag(gtx)
|
||||
op.Defer(gtx.Ops, rec.Stop())
|
||||
}
|
||||
|
||||
return dims
|
||||
}
|
||||
|
||||
// Dragging returns whether d is being dragged.
|
||||
func (d *Draggable) Dragging() bool {
|
||||
return d.drag.Dragging()
|
||||
}
|
||||
|
||||
// Requested returns the MIME type, if any, for which the Draggable was requested to offer data.
|
||||
func (d *Draggable) Requested() (mime string, requested bool) {
|
||||
mime = d.request
|
||||
requested = d.requested
|
||||
d.requested = false
|
||||
d.request = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Offer the data ready for a drop. Must be called after being Requested.
|
||||
// The mime must be one in the requested list.
|
||||
func (d *Draggable) Offer(ops *op.Ops, mime string, data io.ReadCloser) {
|
||||
transfer.OfferOp{
|
||||
Tag: &d.handle,
|
||||
Type: mime,
|
||||
Data: data,
|
||||
}.Add(ops)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/router"
|
||||
"gioui.org/io/transfer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
)
|
||||
|
||||
func TestDraggable(t *testing.T) {
|
||||
var r router.Router
|
||||
gtx := layout.Context{
|
||||
Constraints: layout.Exact(image.Pt(100, 100)),
|
||||
Queue: &r,
|
||||
Ops: new(op.Ops),
|
||||
}
|
||||
|
||||
drag := &Draggable{
|
||||
Type: "file",
|
||||
}
|
||||
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
||||
dims := drag.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
}, nil)
|
||||
stack := clip.Rect{Max: dims.Size}.Push(gtx.Ops)
|
||||
transfer.TargetOp{
|
||||
Tag: drag,
|
||||
Type: drag.Type,
|
||||
}.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
|
||||
r.Frame(gtx.Ops)
|
||||
r.Queue(
|
||||
pointer.Event{
|
||||
Position: f32.Pt(10, 10),
|
||||
Type: pointer.Press,
|
||||
},
|
||||
pointer.Event{
|
||||
Position: f32.Pt(20, 10),
|
||||
Type: pointer.Move,
|
||||
},
|
||||
pointer.Event{
|
||||
Position: f32.Pt(20, 10),
|
||||
Type: pointer.Release,
|
||||
},
|
||||
)
|
||||
ofr := &offer{data: "hello"}
|
||||
drag.Offer(gtx.Ops, "file", ofr)
|
||||
r.Frame(gtx.Ops)
|
||||
|
||||
evs := r.Events(drag)
|
||||
if len(evs) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(evs))
|
||||
}
|
||||
ev := evs[0].(transfer.DataEvent)
|
||||
ev.Open = nil
|
||||
if got, want := ev.Type, "file"; got != want {
|
||||
t.Errorf("expected %v; got %v", got, want)
|
||||
}
|
||||
if ofr.closed {
|
||||
t.Error("offer closed prematurely")
|
||||
}
|
||||
r.Frame(gtx.Ops)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user