diff --git a/internal/ops/ops.go b/internal/ops/ops.go index faf16a41..fece6d6a 100644 --- a/internal/ops/ops.go +++ b/internal/ops/ops.go @@ -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: diff --git a/io/router/pointer.go b/io/router/pointer.go index 2cef1cd3..a4453aa3 100644 --- a/io/router/pointer.go +++ b/io/router/pointer.go @@ -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() diff --git a/io/router/pointer_test.go b/io/router/pointer_test.go index 67724eac..695a80c6 100644 --- a/io/router/pointer_test.go +++ b/io/router/pointer_test.go @@ -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() diff --git a/io/router/router.go b/io/router/router.go index e6906a44..747fbe7c 100644 --- a/io/router/router.go +++ b/io/router/router.go @@ -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: diff --git a/io/transfer/transfer.go b/io/transfer/transfer.go new file mode 100644 index 00000000..58703484 --- /dev/null +++ b/io/transfer/transfer.go @@ -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() {} diff --git a/widget/dnd.go b/widget/dnd.go new file mode 100644 index 00000000..b7d5f058 --- /dev/null +++ b/widget/dnd.go @@ -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) +} diff --git a/widget/dnd_test.go b/widget/dnd_test.go new file mode 100644 index 00000000..fb3e8020 --- /dev/null +++ b/widget/dnd_test.go @@ -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 +}