detached a976009

feat(wayland): implement wl_data_device drag and drop

commit
a976009b8e0cace1c9df0a8c62c2be97555d8997
author
brice · · 2026-10-17 14:53 UTC
parent
4634bd8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1414 -23
M docs/desktop.md +1 -1
@@ -38,7 +38,7 @@ bind.cmd+v = spawn cliphist-pick
3838 cliphist list | fuzzel --dmenu | cliphist decode | wl-copy
3939 ```
4040
41-Drag and drop is not supported yet.
41+Drag and drop works between clients: the surface under the pointer gets the offer, the drop and the chosen action (copy, move or ask, from the modifiers the clients read), and the drag icon follows the cursor. Escape, a refused drop, a dying source or the session lock cancel the drag.
4242
4343 ## Input methods
4444
@@ -38,7 +38,7 @@ bind.cmd+v = spawn cliphist-pick
38 cliphist list | fuzzel --dmenu | cliphist decode | wl-copy38 cliphist list | fuzzel --dmenu | cliphist decode | wl-copy
39 ```39 ```
40 40
41-Drag and drop is not supported yet.41+Drag and drop works between clients: the surface under the pointer gets the offer, the drop and the chosen action (copy, move or ask, from the modifiers the clients read), and the drag icon follows the cursor. Escape, a refused drop, a dying source or the session lock cancel the drag.
42 42
43 ## Input methods43 ## Input methods
44 44
M internal/adapters/wayland/clipboard.go +33 -16
@@ -13,7 +13,9 @@ import (
1313 // keyboard focus may set them and sees them; clipboard managers such as
1414 // cliphist and wl-clipboard use ext_data_control_v1, which sees and sets
1515 // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client:
16-// the data never goes through neferwl. Drag and drop is not implemented.
16+// the data never goes through neferwl. Drag and drop (wl_data_device
17+// start_drag, dnd.go) is a drag core runs and wayland speaks to the source
18+// and the target; the primary selection and ext_data_control have none.
1719
1820 // protected is a defensive DISPLAY admission check, not lock ownership.
1921 func (s *Server) protected() bool {
@@ -35,6 +37,11 @@ type clipSource struct {
3537 // used: the source was set, rejected or dragged; it cannot be set
3638 // again and takes no more MIME types.
3739 used bool
40+ // res is the wl_data_source (nil for the other protocols); actions are
41+ // its set_actions, for drag and drop only (dnd.go).
42+ res *wayland.DataSource
43+ actions uint32
44+ actionsSet bool
3845 }
3946
4047 // addMime records a MIME type offered before the source is used.
@@ -175,6 +182,7 @@ func (s *Server) addClipDevice(d *clipDevice) {
175182
176183 // sourceGone clears the selections held by a destroyed source.
177184 func (s *Server) sourceGone(src *clipSource) {
185+ s.dndSourceGone(src)
178186 for kind := range s.selections {
179187 if s.selections[kind] == src {
180188 s.selections[kind] = nil
@@ -236,6 +244,7 @@ func (m dataManager) CreateDataSource(r *wayland.DataDeviceManager, id uint32) {
236244 if err != nil {
237245 return
238246 }
247+ src.res = res
239248 src.send = func(mime string, fd int) { res.SendSend(mime, fd) }
240249 src.cancel = res.SendCancelled
241250 src.alive = res.Resource.Alive
@@ -251,6 +260,7 @@ func (m dataManager) GetDataDevice(r *wayland.DataDeviceManager, id uint32, _ *w
251260 if err != nil {
252261 return
253262 }
263+ s.addDataDevice(r.Client(), res)
254264 s.addClipDevice(&clipDevice{client: r.Client(), kinds: [2]bool{selClipboard: true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) {
255265 if src == nil {
256266 res.SendSelection(nil)
@@ -273,24 +283,26 @@ type dataSource struct{ src *clipSource }
273283
274284 func (d dataSource) Offer(_ *wayland.DataSource, mime string) { d.src.addMime(mime) }
275285 func (dataSource) Destroy(*wayland.DataSource) {}
276-func (dataSource) SetActions(*wayland.DataSource, uint32) {}
286+
287+// SetActions records the actions the source allows for a drag: once only,
288+// before the drag.
289+func (d dataSource) SetActions(r *wayland.DataSource, actions uint32) {
290+ switch {
291+ case actions&^dndAllActions != 0:
292+ r.PostError(uint32(wayland.DataSourceErrorInvalidActionMask), "invalid action mask")
293+ case d.src.actionsSet || d.src.used:
294+ r.PostError(uint32(wayland.DataSourceErrorInvalidSource), "actions set twice or on a used source")
295+ default:
296+ d.src.actions, d.src.actionsSet = actions, true
297+ }
298+}
277299
278300 type dataDevice struct{ server *Server }
279301
280-// StartDrag is not implemented: the drag is cancelled.
281-func (d dataDevice) StartDrag(_ *wayland.DataDevice, src *wayland.DataSource, _, _ *wayland.Surface, _ uint32) {
282- if src == nil {
283- return
284- }
285- cs := d.server.dataSources[src.Resource]
286- if cs == nil || cs.used {
287- // A used source is the selection or already cancelled.
288- return
289- }
290- cs.used = true
291- if src.Resource.Alive() {
292- src.SendCancelled()
293- }
302+// StartDrag starts a drag answering the client's current press (dnd.go);
303+// anything else cancels the source.
304+func (d dataDevice) StartDrag(r *wayland.DataDevice, src *wayland.DataSource, origin, icon *wayland.Surface, serial uint32) {
305+ d.server.startDrag(r, src, origin, icon, serial)
294306 }
295307 func (d dataDevice) SetSelection(r *wayland.DataDevice, res *wayland.DataSource, _ uint32) {
296308 s := d.server
@@ -300,6 +312,11 @@ func (d dataDevice) SetSelection(r *wayland.DataDevice, res *wayland.DataSource,
300312 if src == nil {
301313 return
302314 }
315+ if src.actionsSet {
316+ // Actions make a source drag-and-drop only.
317+ res.PostError(uint32(wayland.DataSourceErrorInvalidSource), "source with dnd actions used as selection")
318+ return
319+ }
303320 }
304321 s.setFocused(r.Client(), selClipboard, src)
305322 }
@@ -13,7 +13,9 @@ import (
13 // keyboard focus may set them and sees them; clipboard managers such as13 // keyboard focus may set them and sees them; clipboard managers such as
14 // cliphist and wl-clipboard use ext_data_control_v1, which sees and sets14 // cliphist and wl-clipboard use ext_data_control_v1, which sees and sets
15 // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client:15 // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client:
16-// the data never goes through neferwl. Drag and drop is not implemented.16+// the data never goes through neferwl. Drag and drop (wl_data_device
17+// start_drag, dnd.go) is a drag core runs and wayland speaks to the source
18+// and the target; the primary selection and ext_data_control have none.
17 19
18 // protected is a defensive DISPLAY admission check, not lock ownership.20 // protected is a defensive DISPLAY admission check, not lock ownership.
19 func (s *Server) protected() bool {21 func (s *Server) protected() bool {
@@ -35,6 +37,11 @@ type clipSource struct {
35 // used: the source was set, rejected or dragged; it cannot be set37 // used: the source was set, rejected or dragged; it cannot be set
36 // again and takes no more MIME types.38 // again and takes no more MIME types.
37 used bool39 used bool
40+ // res is the wl_data_source (nil for the other protocols); actions are
41+ // its set_actions, for drag and drop only (dnd.go).
42+ res *wayland.DataSource
43+ actions uint32
44+ actionsSet bool
38 }45 }
39 46
40 // addMime records a MIME type offered before the source is used.47 // addMime records a MIME type offered before the source is used.
@@ -175,6 +182,7 @@ func (s *Server) addClipDevice(d *clipDevice) {
175 182
176 // sourceGone clears the selections held by a destroyed source.183 // sourceGone clears the selections held by a destroyed source.
177 func (s *Server) sourceGone(src *clipSource) {184 func (s *Server) sourceGone(src *clipSource) {
185+ s.dndSourceGone(src)
178 for kind := range s.selections {186 for kind := range s.selections {
179 if s.selections[kind] == src {187 if s.selections[kind] == src {
180 s.selections[kind] = nil188 s.selections[kind] = nil
@@ -236,6 +244,7 @@ func (m dataManager) CreateDataSource(r *wayland.DataDeviceManager, id uint32) {
236 if err != nil {244 if err != nil {
237 return245 return
238 }246 }
247+ src.res = res
239 src.send = func(mime string, fd int) { res.SendSend(mime, fd) }248 src.send = func(mime string, fd int) { res.SendSend(mime, fd) }
240 src.cancel = res.SendCancelled249 src.cancel = res.SendCancelled
241 src.alive = res.Resource.Alive250 src.alive = res.Resource.Alive
@@ -251,6 +260,7 @@ func (m dataManager) GetDataDevice(r *wayland.DataDeviceManager, id uint32, _ *w
251 if err != nil {260 if err != nil {
252 return261 return
253 }262 }
263+ s.addDataDevice(r.Client(), res)
254 s.addClipDevice(&clipDevice{client: r.Client(), kinds: [2]bool{selClipboard: true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) {264 s.addClipDevice(&clipDevice{client: r.Client(), kinds: [2]bool{selClipboard: true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) {
255 if src == nil {265 if src == nil {
256 res.SendSelection(nil)266 res.SendSelection(nil)
@@ -273,24 +283,26 @@ type dataSource struct{ src *clipSource }
273 283
274 func (d dataSource) Offer(_ *wayland.DataSource, mime string) { d.src.addMime(mime) }284 func (d dataSource) Offer(_ *wayland.DataSource, mime string) { d.src.addMime(mime) }
275 func (dataSource) Destroy(*wayland.DataSource) {}285 func (dataSource) Destroy(*wayland.DataSource) {}
276-func (dataSource) SetActions(*wayland.DataSource, uint32) {}286+
287+// SetActions records the actions the source allows for a drag: once only,
288+// before the drag.
289+func (d dataSource) SetActions(r *wayland.DataSource, actions uint32) {
290+ switch {
291+ case actions&^dndAllActions != 0:
292+ r.PostError(uint32(wayland.DataSourceErrorInvalidActionMask), "invalid action mask")
293+ case d.src.actionsSet || d.src.used:
294+ r.PostError(uint32(wayland.DataSourceErrorInvalidSource), "actions set twice or on a used source")
295+ default:
296+ d.src.actions, d.src.actionsSet = actions, true
297+ }
298+}
277 299
278 type dataDevice struct{ server *Server }300 type dataDevice struct{ server *Server }
279 301
280-// StartDrag is not implemented: the drag is cancelled.302+// StartDrag starts a drag answering the client's current press (dnd.go);
281-func (d dataDevice) StartDrag(_ *wayland.DataDevice, src *wayland.DataSource, _, _ *wayland.Surface, _ uint32) {303+// anything else cancels the source.
282- if src == nil {304+func (d dataDevice) StartDrag(r *wayland.DataDevice, src *wayland.DataSource, origin, icon *wayland.Surface, serial uint32) {
283- return305+ d.server.startDrag(r, src, origin, icon, serial)
284- }
285- cs := d.server.dataSources[src.Resource]
286- if cs == nil || cs.used {
287- // A used source is the selection or already cancelled.
288- return
289- }
290- cs.used = true
291- if src.Resource.Alive() {
292- src.SendCancelled()
293- }
294 }306 }
295 func (d dataDevice) SetSelection(r *wayland.DataDevice, res *wayland.DataSource, _ uint32) {307 func (d dataDevice) SetSelection(r *wayland.DataDevice, res *wayland.DataSource, _ uint32) {
296 s := d.server308 s := d.server
@@ -300,6 +312,11 @@ func (d dataDevice) SetSelection(r *wayland.DataDevice, res *wayland.DataSource,
300 if src == nil {312 if src == nil {
301 return313 return
302 }314 }
315+ if src.actionsSet {
316+ // Actions make a source drag-and-drop only.
317+ res.PostError(uint32(wayland.DataSourceErrorInvalidSource), "source with dnd actions used as selection")
318+ return
319+ }
303 }320 }
304 s.setFocused(r.Client(), selClipboard, src)321 s.setFocused(r.Client(), selClipboard, src)
305 }322 }
A internal/adapters/wayland/dnd.go +537 -0
@@ -0,0 +1,537 @@
1+package wayland
2+
3+import (
4+ "slices"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Drag and drop (wl_data_device.start_drag). A client starts a drag with the
13+// serial of its current press; wayland validates it and tells core
14+// (ports.DataDragStarted). Core owns the pointer from then on: it names the
15+// surface under the cursor (DataDragFocus, DataDragMotion), the release
16+// (DataDragDrop) and the Escape key (DataDragCancel). Wayland speaks the
17+// data device protocol to the source and the target: enter, motion, leave
18+// and drop with an offer, and the action negotiation the wlroots way. The
19+// icon is a client surface with its own window ID; core draws it at the
20+// cursor from the geometry sent with DataDragIcon.
21+//
22+// Core ends a drag with DataDragDrop or DataDragCancel and is not told
23+// (the lock resets both sides). DataDragEnded tells core about an end it
24+// did not cause: the source or the origin client died.
25+
26+// dndAllActions is every wl_data_device_manager.dnd_action bit.
27+const dndAllActions = uint32(wayland.DataDeviceManagerDndActionCopy | wayland.DataDeviceManagerDndActionMove | wayland.DataDeviceManagerDndActionAsk)
28+
29+// dndState is the drag in progress.
30+type dndState struct {
31+ // src is the source; nil for a drag internal to the origin client.
32+ src *clipSource
33+ client server.Client
34+ // icon is the icon surface (nil: none or destroyed); iconID is the
35+ // window ID core knows it by (0: none).
36+ icon *surface
37+ iconID ports.WindowID
38+ // iconX, iconY is the icon's top left corner relative to the cursor:
39+ // the offsets of its commits add up. iconSent is the last geometry sent.
40+ iconX, iconY int
41+ iconSent ports.DataDragIcon
42+ target *dndTarget
43+}
44+
45+const (
46+ targetActive = iota
47+ targetDropped
48+ targetDone
49+)
50+
51+// dndTarget is the surface a drag entered. Its offers (one per data device
52+// of the client) share the negotiation. After the drop it lives on until
53+// the target finishes or destroys its offers.
54+type dndTarget struct {
55+ d *dndState
56+ surface *wayland.Surface
57+ devices []*wayland.DataDevice
58+ offers []*dndOffer
59+ // accepted is the MIME type the target accepted; action the action
60+ // chosen for the source.
61+ accepted string
62+ action uint32
63+ state int
64+ // legacy: no device speaks wl_data_offer v3, so the accepted MIME type
65+ // alone allows the drop and nothing finishes it.
66+ legacy bool
67+}
68+
69+// dndOffer is a wl_data_offer of a drag.
70+type dndOffer struct {
71+ server *Server
72+ t *dndTarget
73+ res *wayland.DataOffer
74+ // actions and preferred are the target's set_actions; sent is the last
75+ // action told to this offer.
76+ actions, preferred, sent uint32
77+}
78+
79+// srcActions is what the source allows; a source that never said (a
80+// pre-v3 client) only copies.
81+func (src *clipSource) srcActions() uint32 {
82+ if src.actionsSet {
83+ return src.actions
84+ }
85+ return uint32(wayland.DataDeviceManagerDndActionCopy)
86+}
87+
88+// chooseAction picks the action like wlroots: the preferred one if both
89+// allow it, else the first of copy, move, ask.
90+func chooseAction(avail, preferred uint32) uint32 {
91+ if avail == 0 {
92+ return 0
93+ }
94+ if preferred&avail != 0 {
95+ return preferred
96+ }
97+ for _, a := range [...]uint32{uint32(wayland.DataDeviceManagerDndActionCopy), uint32(wayland.DataDeviceManagerDndActionMove), uint32(wayland.DataDeviceManagerDndActionAsk)} {
98+ if avail&a != 0 {
99+ return a
100+ }
101+ }
102+ return 0
103+}
104+
105+// addDataDevice tracks a client's data devices, the drag targets.
106+func (s *Server) addDataDevice(c server.Client, dev *wayland.DataDevice) {
107+ if s.dataDevices == nil {
108+ s.dataDevices = map[server.Client][]*wayland.DataDevice{}
109+ }
110+ s.dataDevices[c] = append(s.dataDevices[c], dev)
111+ dev.OnDestroy = func() {
112+ // Copy on write: a drag may be iterating the old list.
113+ list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })
114+ if len(list) == 0 {
115+ delete(s.dataDevices, c)
116+ // An internal drag has nobody left to tell.
117+ if d := s.dnd; d != nil && d.client == c && d.src == nil {
118+ s.dndEnd(d, false, true)
119+ }
120+ } else {
121+ s.dataDevices[c] = list
122+ }
123+ }
124+}
125+
126+// startDrag handles wl_data_device.start_drag.
127+func (s *Server) startDrag(r *wayland.DataDevice, srcRes *wayland.DataSource, origin, icon *wayland.Surface, serial uint32) {
128+ var cs *clipSource
129+ if srcRes != nil {
130+ if cs = s.dataSources[srcRes.Resource]; cs == nil {
131+ return
132+ }
133+ }
134+ var iconSurf *surface
135+ if icon != nil {
136+ if iconSurf = s.surfaces[icon.Resource]; iconSurf == nil {
137+ return
138+ }
139+ if iconSurf.kind != roleNone && iconSurf.kind != roleDragIcon {
140+ r.PostError(uint32(wayland.DataDeviceErrorRole), "icon surface already has another role")
141+ return
142+ }
143+ }
144+ client := r.Client()
145+ valid := !s.protected() && s.dnd == nil && origin != nil && origin.Client() == client &&
146+ s.seat.press != 0 && serial == s.seat.press && s.seat.pressClient == client
147+ if cs != nil && (cs.used || !valid) {
148+ // A used source is the selection, a drag source or already
149+ // cancelled: only a source nobody holds is cancelled again.
150+ if !s.sourceBusy(cs) {
151+ cs.used = true
152+ if cs.alive() {
153+ cs.cancel()
154+ }
155+ }
156+ return
157+ }
158+ if !valid {
159+ return
160+ }
161+ d := &dndState{src: cs, client: client, icon: iconSurf}
162+ if cs != nil {
163+ cs.used = true
164+ }
165+ if iconSurf != nil {
166+ d.iconID = s.nextWindow
167+ s.nextWindow++
168+ iconSurf.kind = roleDragIcon
169+ iconSurf.role = func(bool) { s.dndIconRole(d, iconSurf) }
170+ }
171+ s.dnd = d
172+ s.log.Info().Uint64("icon", uint64(d.iconID)).Bool("source", cs != nil).Msg("data drag started")
173+ s.emit(ports.DataDragStarted{Icon: d.iconID})
174+ if iconSurf != nil && iconSurf.has {
175+ // The icon was committed before the drag: show what it holds.
176+ iconSurf.committed = damage{full: true}
177+ iconSurf.redraw()
178+ s.dndIconSend(d)
179+ }
180+}
181+
182+// sourceBusy reports whether src is the selection, the source of the drag
183+// or of a drop waiting for its finish.
184+func (s *Server) sourceBusy(src *clipSource) bool {
185+ if s.selections[selClipboard] == src || s.dnd != nil && s.dnd.src == src {
186+ return true
187+ }
188+ return slices.ContainsFunc(s.dndDrops, func(t *dndTarget) bool { return t.d.src == src })
189+}
190+
191+// dndIconRole runs on each commit of the icon surface, and on its
192+// destruction.
193+func (s *Server) dndIconRole(d *dndState, icon *surface) {
194+ if !icon.destroyed || d.icon != icon {
195+ return
196+ }
197+ // The drag goes on without icon.
198+ id := d.iconID
199+ d.icon, d.iconID = nil, 0
200+ s.emit(ports.DataDragIcon{ID: id})
201+ s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true})
202+}
203+
204+// dndIconCommit follows a commit of the active icon: its offsets add up
205+// and its geometry goes to core.
206+func (s *Server) dndIconCommit(icon *surface, dx, dy int32) {
207+ d := s.dnd
208+ if d == nil || d.icon != icon {
209+ return
210+ }
211+ d.iconX += int(dx)
212+ d.iconY += int(dy)
213+ s.dndIconSend(d)
214+}
215+
216+func (s *Server) dndIconSend(d *dndState) {
217+ ic := ports.DataDragIcon{ID: d.iconID, X: d.iconX, Y: d.iconY}
218+ if d.icon != nil && d.icon.has {
219+ ic.W, ic.H = d.icon.content.LogicalW, d.icon.content.LogicalH
220+ }
221+ if ic != d.iconSent {
222+ d.iconSent = ic
223+ s.emit(ic)
224+ }
225+}
226+
227+// applyDrag applies the commands core sends during a drag.
228+func (s *Server) applyDrag(cmd ports.ClientCommand) {
229+ d := s.dnd
230+ if d == nil {
231+ return
232+ }
233+ switch c := cmd.(type) {
234+ case ports.DataDragCancel:
235+ s.dndEnd(d, true, false)
236+ case ports.DataDragDrop:
237+ s.dndDrop(d)
238+ case ports.DataDragFocus:
239+ s.dndFocus(d, c)
240+ case ports.DataDragMotion:
241+ if t := d.target; t != nil && !s.protected() {
242+ surf, _, x, y := s.pointerSurface(c.ID, c.X, c.Y)
243+ if surf == nil || surf.Resource != t.surface.Resource {
244+ return
245+ }
246+ for _, dev := range t.devices {
247+ if dev.Resource.Alive() {
248+ dev.SendMotion(wireMsec(c.Time), server.FixedFromFloat(x), server.FixedFromFloat(y))
249+ }
250+ }
251+ }
252+ }
253+}
254+
255+// dndFocus moves the drag to the surface core names: the old target gets
256+// leave, the new one enter with an offer.
257+func (s *Server) dndFocus(d *dndState, c ports.DataDragFocus) {
258+ s.dndLeave(d)
259+ if c.ID == 0 || s.protected() {
260+ return
261+ }
262+ surf, _, x, y := s.pointerSurface(c.ID, c.X, c.Y)
263+ if surf == nil {
264+ return
265+ }
266+ client := surf.Client()
267+ // Without a source the drag is internal to the origin client.
268+ if d.src == nil && client != d.client {
269+ return
270+ }
271+ devices := s.dataDevices[client]
272+ if len(devices) == 0 {
273+ return
274+ }
275+ t := &dndTarget{d: d, surface: surf, devices: devices, legacy: true}
276+ d.target = t
277+ for _, dev := range devices {
278+ if !dev.Resource.Alive() {
279+ continue
280+ }
281+ if dev.Version() >= 3 {
282+ t.legacy = false
283+ }
284+ var offer *wayland.DataOffer
285+ if d.src != nil {
286+ o := &dndOffer{server: s, t: t}
287+ res, err := wayland.NewDataOffer(dev.Client(), dev.Version(), 0, o)
288+ if err != nil {
289+ continue
290+ }
291+ o.res = res
292+ res.OnDestroy = func() { s.dndOfferGone(o) }
293+ t.offers = append(t.offers, o)
294+ dev.SendDataOffer(res)
295+ for _, mime := range d.src.mimes {
296+ res.SendOffer(mime)
297+ }
298+ if dev.Version() >= 3 {
299+ res.SendSourceActions(d.src.srcActions())
300+ }
301+ offer = res
302+ }
303+ s.serial++
304+ dev.SendEnter(s.serial, surf, server.FixedFromFloat(x), server.FixedFromFloat(y), offer)
305+ }
306+}
307+
308+// dndLeave sends leave to the target of the drag and forgets it: its
309+// offers become inert.
310+func (s *Server) dndLeave(d *dndState) {
311+ t := d.target
312+ if t == nil {
313+ return
314+ }
315+ d.target = nil
316+ t.state = targetDone
317+ for _, dev := range t.devices {
318+ if dev.Resource.Alive() {
319+ dev.SendLeave()
320+ }
321+ }
322+ s.dndResetSource(t)
323+}
324+
325+// dndResetSource tells the source nothing accepts or acts any more.
326+func (s *Server) dndResetSource(t *dndTarget) {
327+ src := t.d.src
328+ if src == nil || !src.alive() {
329+ return
330+ }
331+ if t.accepted != "" {
332+ src.res.SendTarget("")
333+ }
334+ if t.action != 0 && src.res.Version() >= 3 {
335+ src.res.SendAction(0)
336+ }
337+ t.accepted, t.action = "", 0
338+}
339+
340+// dndDrop handles the release of the drag button: a target that accepted
341+// and agreed on an action gets drop; else the drag is cancelled.
342+func (s *Server) dndDrop(d *dndState) {
343+ t := d.target
344+ switch {
345+ case t == nil:
346+ s.dndEnd(d, true, false)
347+ case d.src == nil:
348+ // Internal drag: the origin client decides alone.
349+ d.target = nil
350+ t.state = targetDone
351+ s.dndSendDrop(t)
352+ s.dndEnd(d, false, false)
353+ case t.accepted == "" || t.action == 0 && !t.legacy:
354+ s.dndEnd(d, true, false)
355+ default:
356+ d.target = nil
357+ t.state = targetDropped
358+ if t.legacy {
359+ // A pre-v3 target copies and nothing finishes its transfer.
360+ if t.action == 0 {
361+ t.action = uint32(wayland.DataDeviceManagerDndActionCopy)
362+ if d.src.alive() && d.src.res.Version() >= 3 {
363+ d.src.res.SendAction(t.action)
364+ }
365+ }
366+ } else {
367+ s.dndDrops = append(s.dndDrops, t)
368+ }
369+ s.dndSendDrop(t)
370+ if d.src.alive() && d.src.res.Version() >= 3 {
371+ d.src.res.SendDndDropPerformed()
372+ }
373+ s.log.Info().Str("mime", t.accepted).Uint32("action", t.action).Msg("data drag dropped")
374+ s.dndEnd(d, false, false)
375+ }
376+}
377+
378+func (s *Server) dndSendDrop(t *dndTarget) {
379+ for _, dev := range t.devices {
380+ if dev.Resource.Alive() {
381+ dev.SendDrop()
382+ }
383+ }
384+}
385+
386+// dndEnd ends the drag d: the target gets leave, the source cancelled if
387+// cancel (it is not once dropped), the icon goes. ended tells core about an
388+// end it did not cause.
389+func (s *Server) dndEnd(d *dndState, cancel, ended bool) {
390+ s.dndLeave(d)
391+ if cancel && d.src != nil && d.src.alive() {
392+ d.src.cancel()
393+ }
394+ if s.dnd == d {
395+ s.dnd = nil
396+ }
397+ if d.icon != nil {
398+ d.icon.role = nil
399+ d.icon = nil
400+ }
401+ if d.iconID != 0 {
402+ s.emitContent(ports.SurfaceContent{ID: d.iconID}, damage{full: true})
403+ d.iconID = 0
404+ }
405+ if ended {
406+ s.log.Info().Msg("data drag ended")
407+ s.emit(ports.DataDragEnded{})
408+ }
409+}
410+
411+// dndSourceGone ends the drags of a destroyed source.
412+func (s *Server) dndSourceGone(src *clipSource) {
413+ if d := s.dnd; d != nil && d.src == src {
414+ s.dndEnd(d, false, true)
415+ }
416+ for _, t := range slices.Clone(s.dndDrops) {
417+ if t.d.src == src {
418+ s.dndDropDone(t)
419+ }
420+ }
421+}
422+
423+// dndDropDone forgets a dropped target.
424+func (s *Server) dndDropDone(t *dndTarget) {
425+ t.state = targetDone
426+ s.dndDrops = slices.DeleteFunc(s.dndDrops, func(x *dndTarget) bool { return x == t })
427+}
428+
429+// dndSurfaceGone clears the drag target when its surface is destroyed.
430+func (s *Server) dndSurfaceGone(surf *surface) {
431+ if d := s.dnd; d != nil && d.target != nil && surf.wl != nil && d.target.surface.Resource == surf.wl.Resource {
432+ s.dndLeave(d)
433+ }
434+}
435+
436+// dndCancelAll cancels the drag and the drops waiting for their finish: the
437+// session is protected.
438+func (s *Server) dndCancelAll() {
439+ if d := s.dnd; d != nil {
440+ s.dndEnd(d, true, false)
441+ }
442+ for _, t := range slices.Clone(s.dndDrops) {
443+ if src := t.d.src; src != nil && src.alive() {
444+ src.cancel()
445+ }
446+ s.dndDropDone(t)
447+ }
448+}
449+
450+// dndOfferGone runs when an offer of a drag is destroyed: a dropped target
451+// whose offers are all gone without finish cancels the source.
452+func (s *Server) dndOfferGone(o *dndOffer) {
453+ t := o.t
454+ t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })
455+ if t.state == targetDropped && !t.legacy && len(t.offers) == 0 {
456+ if src := t.d.src; src != nil && src.alive() {
457+ src.cancel()
458+ }
459+ s.dndDropDone(t)
460+ }
461+}
462+
463+// update recomputes the action after the target or the source changed
464+// something, and tells both when it changed.
465+func (o *dndOffer) update() {
466+ t, src := o.t, o.t.d.src
467+ if t.state == targetDone || src == nil || !src.alive() {
468+ return
469+ }
470+ action := chooseAction(o.actions&src.srcActions(), o.preferred)
471+ if action != t.action {
472+ t.action = action
473+ if src.res.Version() >= 3 {
474+ src.res.SendAction(action)
475+ }
476+ }
477+ if action != o.sent {
478+ o.sent = action
479+ if o.res.Version() >= 3 {
480+ o.res.SendAction(action)
481+ }
482+ }
483+}
484+
485+func (o *dndOffer) Accept(_ *wayland.DataOffer, _ uint32, mime string) {
486+ t := o.t
487+ if t.state != targetActive || !t.d.src.alive() {
488+ return
489+ }
490+ t.accepted = mime
491+ t.d.src.res.SendTarget(mime)
492+}
493+
494+func (o *dndOffer) Receive(_ *wayland.DataOffer, mime string, fd int) {
495+ defer unix.Close(fd)
496+ src := o.t.d.src
497+ if o.server.protected() || o.t.state == targetDone || !src.alive() {
498+ return
499+ }
500+ if slices.Contains(src.mimes, mime) {
501+ src.send(mime, fd)
502+ }
503+}
504+
505+func (*dndOffer) Destroy(*wayland.DataOffer) {}
506+
507+func (o *dndOffer) Finish(r *wayland.DataOffer) {
508+ t := o.t
509+ switch t.state {
510+ case targetDone:
511+ return
512+ case targetActive:
513+ r.PostError(uint32(wayland.DataOfferErrorInvalidFinish), "finish before the drop")
514+ return
515+ }
516+ if t.action == 0 || t.action == uint32(wayland.DataDeviceManagerDndActionAsk) {
517+ r.PostError(uint32(wayland.DataOfferErrorInvalidFinish), "finish without a final action")
518+ return
519+ }
520+ o.server.dndDropDone(t)
521+ if src := t.d.src; src.alive() && src.res.Version() >= 3 {
522+ src.res.SendDndFinished()
523+ }
524+}
525+
526+func (o *dndOffer) SetActions(r *wayland.DataOffer, actions, preferred uint32) {
527+ if actions&^dndAllActions != 0 {
528+ r.PostError(uint32(wayland.DataOfferErrorInvalidActionMask), "invalid action mask")
529+ return
530+ }
531+ if preferred != 0 && (preferred&(preferred-1) != 0 || preferred&actions == 0) {
532+ r.PostError(uint32(wayland.DataOfferErrorInvalidAction), "invalid preferred action")
533+ return
534+ }
535+ o.actions, o.preferred = actions, preferred
536+ o.update()
537+}
@@ -0,0 +1,537 @@
1+package wayland
2+
3+import (
4+ "slices"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Drag and drop (wl_data_device.start_drag). A client starts a drag with the
13+// serial of its current press; wayland validates it and tells core
14+// (ports.DataDragStarted). Core owns the pointer from then on: it names the
15+// surface under the cursor (DataDragFocus, DataDragMotion), the release
16+// (DataDragDrop) and the Escape key (DataDragCancel). Wayland speaks the
17+// data device protocol to the source and the target: enter, motion, leave
18+// and drop with an offer, and the action negotiation the wlroots way. The
19+// icon is a client surface with its own window ID; core draws it at the
20+// cursor from the geometry sent with DataDragIcon.
21+//
22+// Core ends a drag with DataDragDrop or DataDragCancel and is not told
23+// (the lock resets both sides). DataDragEnded tells core about an end it
24+// did not cause: the source or the origin client died.
25+
26+// dndAllActions is every wl_data_device_manager.dnd_action bit.
27+const dndAllActions = uint32(wayland.DataDeviceManagerDndActionCopy | wayland.DataDeviceManagerDndActionMove | wayland.DataDeviceManagerDndActionAsk)
28+
29+// dndState is the drag in progress.
30+type dndState struct {
31+ // src is the source; nil for a drag internal to the origin client.
32+ src *clipSource
33+ client server.Client
34+ // icon is the icon surface (nil: none or destroyed); iconID is the
35+ // window ID core knows it by (0: none).
36+ icon *surface
37+ iconID ports.WindowID
38+ // iconX, iconY is the icon's top left corner relative to the cursor:
39+ // the offsets of its commits add up. iconSent is the last geometry sent.
40+ iconX, iconY int
41+ iconSent ports.DataDragIcon
42+ target *dndTarget
43+}
44+
45+const (
46+ targetActive = iota
47+ targetDropped
48+ targetDone
49+)
50+
51+// dndTarget is the surface a drag entered. Its offers (one per data device
52+// of the client) share the negotiation. After the drop it lives on until
53+// the target finishes or destroys its offers.
54+type dndTarget struct {
55+ d *dndState
56+ surface *wayland.Surface
57+ devices []*wayland.DataDevice
58+ offers []*dndOffer
59+ // accepted is the MIME type the target accepted; action the action
60+ // chosen for the source.
61+ accepted string
62+ action uint32
63+ state int
64+ // legacy: no device speaks wl_data_offer v3, so the accepted MIME type
65+ // alone allows the drop and nothing finishes it.
66+ legacy bool
67+}
68+
69+// dndOffer is a wl_data_offer of a drag.
70+type dndOffer struct {
71+ server *Server
72+ t *dndTarget
73+ res *wayland.DataOffer
74+ // actions and preferred are the target's set_actions; sent is the last
75+ // action told to this offer.
76+ actions, preferred, sent uint32
77+}
78+
79+// srcActions is what the source allows; a source that never said (a
80+// pre-v3 client) only copies.
81+func (src *clipSource) srcActions() uint32 {
82+ if src.actionsSet {
83+ return src.actions
84+ }
85+ return uint32(wayland.DataDeviceManagerDndActionCopy)
86+}
87+
88+// chooseAction picks the action like wlroots: the preferred one if both
89+// allow it, else the first of copy, move, ask.
90+func chooseAction(avail, preferred uint32) uint32 {
91+ if avail == 0 {
92+ return 0
93+ }
94+ if preferred&avail != 0 {
95+ return preferred
96+ }
97+ for _, a := range [...]uint32{uint32(wayland.DataDeviceManagerDndActionCopy), uint32(wayland.DataDeviceManagerDndActionMove), uint32(wayland.DataDeviceManagerDndActionAsk)} {
98+ if avail&a != 0 {
99+ return a
100+ }
101+ }
102+ return 0
103+}
104+
105+// addDataDevice tracks a client's data devices, the drag targets.
106+func (s *Server) addDataDevice(c server.Client, dev *wayland.DataDevice) {
107+ if s.dataDevices == nil {
108+ s.dataDevices = map[server.Client][]*wayland.DataDevice{}
109+ }
110+ s.dataDevices[c] = append(s.dataDevices[c], dev)
111+ dev.OnDestroy = func() {
112+ // Copy on write: a drag may be iterating the old list.
113+ list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })
114+ if len(list) == 0 {
115+ delete(s.dataDevices, c)
116+ // An internal drag has nobody left to tell.
117+ if d := s.dnd; d != nil && d.client == c && d.src == nil {
118+ s.dndEnd(d, false, true)
119+ }
120+ } else {
121+ s.dataDevices[c] = list
122+ }
123+ }
124+}
125+
126+// startDrag handles wl_data_device.start_drag.
127+func (s *Server) startDrag(r *wayland.DataDevice, srcRes *wayland.DataSource, origin, icon *wayland.Surface, serial uint32) {
128+ var cs *clipSource
129+ if srcRes != nil {
130+ if cs = s.dataSources[srcRes.Resource]; cs == nil {
131+ return
132+ }
133+ }
134+ var iconSurf *surface
135+ if icon != nil {
136+ if iconSurf = s.surfaces[icon.Resource]; iconSurf == nil {
137+ return
138+ }
139+ if iconSurf.kind != roleNone && iconSurf.kind != roleDragIcon {
140+ r.PostError(uint32(wayland.DataDeviceErrorRole), "icon surface already has another role")
141+ return
142+ }
143+ }
144+ client := r.Client()
145+ valid := !s.protected() && s.dnd == nil && origin != nil && origin.Client() == client &&
146+ s.seat.press != 0 && serial == s.seat.press && s.seat.pressClient == client
147+ if cs != nil && (cs.used || !valid) {
148+ // A used source is the selection, a drag source or already
149+ // cancelled: only a source nobody holds is cancelled again.
150+ if !s.sourceBusy(cs) {
151+ cs.used = true
152+ if cs.alive() {
153+ cs.cancel()
154+ }
155+ }
156+ return
157+ }
158+ if !valid {
159+ return
160+ }
161+ d := &dndState{src: cs, client: client, icon: iconSurf}
162+ if cs != nil {
163+ cs.used = true
164+ }
165+ if iconSurf != nil {
166+ d.iconID = s.nextWindow
167+ s.nextWindow++
168+ iconSurf.kind = roleDragIcon
169+ iconSurf.role = func(bool) { s.dndIconRole(d, iconSurf) }
170+ }
171+ s.dnd = d
172+ s.log.Info().Uint64("icon", uint64(d.iconID)).Bool("source", cs != nil).Msg("data drag started")
173+ s.emit(ports.DataDragStarted{Icon: d.iconID})
174+ if iconSurf != nil && iconSurf.has {
175+ // The icon was committed before the drag: show what it holds.
176+ iconSurf.committed = damage{full: true}
177+ iconSurf.redraw()
178+ s.dndIconSend(d)
179+ }
180+}
181+
182+// sourceBusy reports whether src is the selection, the source of the drag
183+// or of a drop waiting for its finish.
184+func (s *Server) sourceBusy(src *clipSource) bool {
185+ if s.selections[selClipboard] == src || s.dnd != nil && s.dnd.src == src {
186+ return true
187+ }
188+ return slices.ContainsFunc(s.dndDrops, func(t *dndTarget) bool { return t.d.src == src })
189+}
190+
191+// dndIconRole runs on each commit of the icon surface, and on its
192+// destruction.
193+func (s *Server) dndIconRole(d *dndState, icon *surface) {
194+ if !icon.destroyed || d.icon != icon {
195+ return
196+ }
197+ // The drag goes on without icon.
198+ id := d.iconID
199+ d.icon, d.iconID = nil, 0
200+ s.emit(ports.DataDragIcon{ID: id})
201+ s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true})
202+}
203+
204+// dndIconCommit follows a commit of the active icon: its offsets add up
205+// and its geometry goes to core.
206+func (s *Server) dndIconCommit(icon *surface, dx, dy int32) {
207+ d := s.dnd
208+ if d == nil || d.icon != icon {
209+ return
210+ }
211+ d.iconX += int(dx)
212+ d.iconY += int(dy)
213+ s.dndIconSend(d)
214+}
215+
216+func (s *Server) dndIconSend(d *dndState) {
217+ ic := ports.DataDragIcon{ID: d.iconID, X: d.iconX, Y: d.iconY}
218+ if d.icon != nil && d.icon.has {
219+ ic.W, ic.H = d.icon.content.LogicalW, d.icon.content.LogicalH
220+ }
221+ if ic != d.iconSent {
222+ d.iconSent = ic
223+ s.emit(ic)
224+ }
225+}
226+
227+// applyDrag applies the commands core sends during a drag.
228+func (s *Server) applyDrag(cmd ports.ClientCommand) {
229+ d := s.dnd
230+ if d == nil {
231+ return
232+ }
233+ switch c := cmd.(type) {
234+ case ports.DataDragCancel:
235+ s.dndEnd(d, true, false)
236+ case ports.DataDragDrop:
237+ s.dndDrop(d)
238+ case ports.DataDragFocus:
239+ s.dndFocus(d, c)
240+ case ports.DataDragMotion:
241+ if t := d.target; t != nil && !s.protected() {
242+ surf, _, x, y := s.pointerSurface(c.ID, c.X, c.Y)
243+ if surf == nil || surf.Resource != t.surface.Resource {
244+ return
245+ }
246+ for _, dev := range t.devices {
247+ if dev.Resource.Alive() {
248+ dev.SendMotion(wireMsec(c.Time), server.FixedFromFloat(x), server.FixedFromFloat(y))
249+ }
250+ }
251+ }
252+ }
253+}
254+
255+// dndFocus moves the drag to the surface core names: the old target gets
256+// leave, the new one enter with an offer.
257+func (s *Server) dndFocus(d *dndState, c ports.DataDragFocus) {
258+ s.dndLeave(d)
259+ if c.ID == 0 || s.protected() {
260+ return
261+ }
262+ surf, _, x, y := s.pointerSurface(c.ID, c.X, c.Y)
263+ if surf == nil {
264+ return
265+ }
266+ client := surf.Client()
267+ // Without a source the drag is internal to the origin client.
268+ if d.src == nil && client != d.client {
269+ return
270+ }
271+ devices := s.dataDevices[client]
272+ if len(devices) == 0 {
273+ return
274+ }
275+ t := &dndTarget{d: d, surface: surf, devices: devices, legacy: true}
276+ d.target = t
277+ for _, dev := range devices {
278+ if !dev.Resource.Alive() {
279+ continue
280+ }
281+ if dev.Version() >= 3 {
282+ t.legacy = false
283+ }
284+ var offer *wayland.DataOffer
285+ if d.src != nil {
286+ o := &dndOffer{server: s, t: t}
287+ res, err := wayland.NewDataOffer(dev.Client(), dev.Version(), 0, o)
288+ if err != nil {
289+ continue
290+ }
291+ o.res = res
292+ res.OnDestroy = func() { s.dndOfferGone(o) }
293+ t.offers = append(t.offers, o)
294+ dev.SendDataOffer(res)
295+ for _, mime := range d.src.mimes {
296+ res.SendOffer(mime)
297+ }
298+ if dev.Version() >= 3 {
299+ res.SendSourceActions(d.src.srcActions())
300+ }
301+ offer = res
302+ }
303+ s.serial++
304+ dev.SendEnter(s.serial, surf, server.FixedFromFloat(x), server.FixedFromFloat(y), offer)
305+ }
306+}
307+
308+// dndLeave sends leave to the target of the drag and forgets it: its
309+// offers become inert.
310+func (s *Server) dndLeave(d *dndState) {
311+ t := d.target
312+ if t == nil {
313+ return
314+ }
315+ d.target = nil
316+ t.state = targetDone
317+ for _, dev := range t.devices {
318+ if dev.Resource.Alive() {
319+ dev.SendLeave()
320+ }
321+ }
322+ s.dndResetSource(t)
323+}
324+
325+// dndResetSource tells the source nothing accepts or acts any more.
326+func (s *Server) dndResetSource(t *dndTarget) {
327+ src := t.d.src
328+ if src == nil || !src.alive() {
329+ return
330+ }
331+ if t.accepted != "" {
332+ src.res.SendTarget("")
333+ }
334+ if t.action != 0 && src.res.Version() >= 3 {
335+ src.res.SendAction(0)
336+ }
337+ t.accepted, t.action = "", 0
338+}
339+
340+// dndDrop handles the release of the drag button: a target that accepted
341+// and agreed on an action gets drop; else the drag is cancelled.
342+func (s *Server) dndDrop(d *dndState) {
343+ t := d.target
344+ switch {
345+ case t == nil:
346+ s.dndEnd(d, true, false)
347+ case d.src == nil:
348+ // Internal drag: the origin client decides alone.
349+ d.target = nil
350+ t.state = targetDone
351+ s.dndSendDrop(t)
352+ s.dndEnd(d, false, false)
353+ case t.accepted == "" || t.action == 0 && !t.legacy:
354+ s.dndEnd(d, true, false)
355+ default:
356+ d.target = nil
357+ t.state = targetDropped
358+ if t.legacy {
359+ // A pre-v3 target copies and nothing finishes its transfer.
360+ if t.action == 0 {
361+ t.action = uint32(wayland.DataDeviceManagerDndActionCopy)
362+ if d.src.alive() && d.src.res.Version() >= 3 {
363+ d.src.res.SendAction(t.action)
364+ }
365+ }
366+ } else {
367+ s.dndDrops = append(s.dndDrops, t)
368+ }
369+ s.dndSendDrop(t)
370+ if d.src.alive() && d.src.res.Version() >= 3 {
371+ d.src.res.SendDndDropPerformed()
372+ }
373+ s.log.Info().Str("mime", t.accepted).Uint32("action", t.action).Msg("data drag dropped")
374+ s.dndEnd(d, false, false)
375+ }
376+}
377+
378+func (s *Server) dndSendDrop(t *dndTarget) {
379+ for _, dev := range t.devices {
380+ if dev.Resource.Alive() {
381+ dev.SendDrop()
382+ }
383+ }
384+}
385+
386+// dndEnd ends the drag d: the target gets leave, the source cancelled if
387+// cancel (it is not once dropped), the icon goes. ended tells core about an
388+// end it did not cause.
389+func (s *Server) dndEnd(d *dndState, cancel, ended bool) {
390+ s.dndLeave(d)
391+ if cancel && d.src != nil && d.src.alive() {
392+ d.src.cancel()
393+ }
394+ if s.dnd == d {
395+ s.dnd = nil
396+ }
397+ if d.icon != nil {
398+ d.icon.role = nil
399+ d.icon = nil
400+ }
401+ if d.iconID != 0 {
402+ s.emitContent(ports.SurfaceContent{ID: d.iconID}, damage{full: true})
403+ d.iconID = 0
404+ }
405+ if ended {
406+ s.log.Info().Msg("data drag ended")
407+ s.emit(ports.DataDragEnded{})
408+ }
409+}
410+
411+// dndSourceGone ends the drags of a destroyed source.
412+func (s *Server) dndSourceGone(src *clipSource) {
413+ if d := s.dnd; d != nil && d.src == src {
414+ s.dndEnd(d, false, true)
415+ }
416+ for _, t := range slices.Clone(s.dndDrops) {
417+ if t.d.src == src {
418+ s.dndDropDone(t)
419+ }
420+ }
421+}
422+
423+// dndDropDone forgets a dropped target.
424+func (s *Server) dndDropDone(t *dndTarget) {
425+ t.state = targetDone
426+ s.dndDrops = slices.DeleteFunc(s.dndDrops, func(x *dndTarget) bool { return x == t })
427+}
428+
429+// dndSurfaceGone clears the drag target when its surface is destroyed.
430+func (s *Server) dndSurfaceGone(surf *surface) {
431+ if d := s.dnd; d != nil && d.target != nil && surf.wl != nil && d.target.surface.Resource == surf.wl.Resource {
432+ s.dndLeave(d)
433+ }
434+}
435+
436+// dndCancelAll cancels the drag and the drops waiting for their finish: the
437+// session is protected.
438+func (s *Server) dndCancelAll() {
439+ if d := s.dnd; d != nil {
440+ s.dndEnd(d, true, false)
441+ }
442+ for _, t := range slices.Clone(s.dndDrops) {
443+ if src := t.d.src; src != nil && src.alive() {
444+ src.cancel()
445+ }
446+ s.dndDropDone(t)
447+ }
448+}
449+
450+// dndOfferGone runs when an offer of a drag is destroyed: a dropped target
451+// whose offers are all gone without finish cancels the source.
452+func (s *Server) dndOfferGone(o *dndOffer) {
453+ t := o.t
454+ t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })
455+ if t.state == targetDropped && !t.legacy && len(t.offers) == 0 {
456+ if src := t.d.src; src != nil && src.alive() {
457+ src.cancel()
458+ }
459+ s.dndDropDone(t)
460+ }
461+}
462+
463+// update recomputes the action after the target or the source changed
464+// something, and tells both when it changed.
465+func (o *dndOffer) update() {
466+ t, src := o.t, o.t.d.src
467+ if t.state == targetDone || src == nil || !src.alive() {
468+ return
469+ }
470+ action := chooseAction(o.actions&src.srcActions(), o.preferred)
471+ if action != t.action {
472+ t.action = action
473+ if src.res.Version() >= 3 {
474+ src.res.SendAction(action)
475+ }
476+ }
477+ if action != o.sent {
478+ o.sent = action
479+ if o.res.Version() >= 3 {
480+ o.res.SendAction(action)
481+ }
482+ }
483+}
484+
485+func (o *dndOffer) Accept(_ *wayland.DataOffer, _ uint32, mime string) {
486+ t := o.t
487+ if t.state != targetActive || !t.d.src.alive() {
488+ return
489+ }
490+ t.accepted = mime
491+ t.d.src.res.SendTarget(mime)
492+}
493+
494+func (o *dndOffer) Receive(_ *wayland.DataOffer, mime string, fd int) {
495+ defer unix.Close(fd)
496+ src := o.t.d.src
497+ if o.server.protected() || o.t.state == targetDone || !src.alive() {
498+ return
499+ }
500+ if slices.Contains(src.mimes, mime) {
501+ src.send(mime, fd)
502+ }
503+}
504+
505+func (*dndOffer) Destroy(*wayland.DataOffer) {}
506+
507+func (o *dndOffer) Finish(r *wayland.DataOffer) {
508+ t := o.t
509+ switch t.state {
510+ case targetDone:
511+ return
512+ case targetActive:
513+ r.PostError(uint32(wayland.DataOfferErrorInvalidFinish), "finish before the drop")
514+ return
515+ }
516+ if t.action == 0 || t.action == uint32(wayland.DataDeviceManagerDndActionAsk) {
517+ r.PostError(uint32(wayland.DataOfferErrorInvalidFinish), "finish without a final action")
518+ return
519+ }
520+ o.server.dndDropDone(t)
521+ if src := t.d.src; src.alive() && src.res.Version() >= 3 {
522+ src.res.SendDndFinished()
523+ }
524+}
525+
526+func (o *dndOffer) SetActions(r *wayland.DataOffer, actions, preferred uint32) {
527+ if actions&^dndAllActions != 0 {
528+ r.PostError(uint32(wayland.DataOfferErrorInvalidActionMask), "invalid action mask")
529+ return
530+ }
531+ if preferred != 0 && (preferred&(preferred-1) != 0 || preferred&actions == 0) {
532+ r.PostError(uint32(wayland.DataOfferErrorInvalidAction), "invalid preferred action")
533+ return
534+ }
535+ o.actions, o.preferred = actions, preferred
536+ o.update()
537+}
A internal/adapters/wayland/dnd_test.go +805 -0
@@ -0,0 +1,805 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ "github.com/bnema/neferwl/internal/adapters/logging"
11+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+const (
18+ actCopy = uint32(wayland.DataDeviceManagerDndActionCopy)
19+ actMove = uint32(wayland.DataDeviceManagerDndActionMove)
20+ actAsk = uint32(wayland.DataDeviceManagerDndActionAsk)
21+)
22+
23+// dndEvent is one data device, offer or source event a test client saw.
24+type dndEvent struct {
25+ name string
26+ mime string
27+ x, y float64
28+ n uint32
29+}
30+
31+type dndDeviceEvents struct {
32+ wlturbo.BaseProxy
33+ c *wlturbo.Display
34+ log chan dndEvent
35+}
36+
37+func (p *dndDeviceEvents) Dispatch(e *wlturbo.Event) {
38+ switch e.Opcode {
39+ case uint16(wayland.DataDeviceEventDataOffer):
40+ o := &dndOfferEvents{log: p.log}
41+ o.SetID(e.Uint32())
42+ registerWireProxy(p.c, o)
43+ case uint16(wayland.DataDeviceEventEnter):
44+ serial, _ := e.Uint32(), e.Uint32()
45+ x, y := e.Fixed().Float64(), e.Fixed().Float64()
46+ p.log <- dndEvent{name: "enter", x: x, y: y, n: e.Uint32() + serial*0}
47+ case uint16(wayland.DataDeviceEventLeave):
48+ p.log <- dndEvent{name: "leave"}
49+ case uint16(wayland.DataDeviceEventMotion):
50+ _ = e.Uint32()
51+ p.log <- dndEvent{name: "motion", x: e.Fixed().Float64(), y: e.Fixed().Float64()}
52+ case uint16(wayland.DataDeviceEventDrop):
53+ p.log <- dndEvent{name: "drop"}
54+ }
55+}
56+
57+type dndOfferEvents struct {
58+ wlturbo.BaseProxy
59+ log chan dndEvent
60+}
61+
62+func (p *dndOfferEvents) Dispatch(e *wlturbo.Event) {
63+ switch e.Opcode {
64+ case uint16(wayland.DataOfferEventOffer):
65+ p.log <- dndEvent{name: "offer", mime: e.String()}
66+ case uint16(wayland.DataOfferEventSourceActions):
67+ p.log <- dndEvent{name: "source_actions", n: e.Uint32()}
68+ case uint16(wayland.DataOfferEventAction):
69+ p.log <- dndEvent{name: "offer_action", n: e.Uint32()}
70+ }
71+}
72+
73+type dndSourceEvents struct {
74+ wlturbo.BaseProxy
75+ log chan dndEvent
76+}
77+
78+func (p *dndSourceEvents) Dispatch(e *wlturbo.Event) {
79+ switch e.Opcode {
80+ case uint16(wayland.DataSourceEventTarget):
81+ p.log <- dndEvent{name: "target", mime: e.String()}
82+ case uint16(wayland.DataSourceEventSend):
83+ mime := e.String()
84+ fd := int(e.Fd())
85+ _, _ = unix.Write(fd, []byte("dnd data"))
86+ _ = unix.Close(fd)
87+ p.log <- dndEvent{name: "send", mime: mime}
88+ case uint16(wayland.DataSourceEventCancelled):
89+ p.log <- dndEvent{name: "cancelled"}
90+ case uint16(wayland.DataSourceEventDndDropPerformed):
91+ p.log <- dndEvent{name: "drop_performed"}
92+ case uint16(wayland.DataSourceEventDndFinished):
93+ p.log <- dndEvent{name: "finished"}
94+ case uint16(wayland.DataSourceEventAction):
95+ p.log <- dndEvent{name: "action", n: e.Uint32()}
96+ }
97+}
98+
99+// dndClient is a client with a toplevel, a pointer and a data device.
100+type dndClient struct {
101+ c *wlturbo.Display
102+ manager uint32
103+ device *dndDeviceEvents
104+ serials chan uint32
105+ comp uint32
106+ shm uint32
107+ surf uint32
108+ window ports.WindowID
109+ log chan dndEvent
110+}
111+
112+func newDndClient(t *testing.T, s *Server, dir string, events <-chan ports.ClientEvent) *dndClient {
113+ t.Helper()
114+ c := protocolClient(t, s, dir)
115+ d := &dndClient{c: c, log: make(chan dndEvent, 64), serials: make(chan uint32, 8)}
116+ seat := bindVersion(t, c, "wl_seat", 8)
117+ registerProtocol(t, c, seat)
118+ ptr := &pressSerials{serials: d.serials}
119+ ptr.SetID(c.AllocateID())
120+ registerWireProxy(c, ptr)
121+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, ptr.ID())
122+ d.manager = bindVersion(t, c, "wl_data_device_manager", 3)
123+ d.device = &dndDeviceEvents{c: c, log: d.log}
124+ d.device.SetID(c.AllocateID())
125+ registerWireProxy(c, d.device)
126+ requestProtocol(t, c, d.manager, wayland.DataDeviceManagerRequestGetDataDevice, d.device.ID(), seat)
127+ d.comp = bindVersion(t, c, "wl_compositor", 5)
128+ d.shm = bindProtocol(t, c, "wl_shm")
129+ registerProtocol(t, c, d.shm)
130+ w, surf, _ := surfaceMapperAt(t, c, events, 5)()
131+ d.window, d.surf = w.ID, surf
132+ return d
133+}
134+
135+// next returns the next event the client sees.
136+func (d *dndClient) next(t *testing.T) dndEvent {
137+ t.Helper()
138+ deadline := time.After(2 * time.Second)
139+ for {
140+ if err := d.c.Roundtrip(); err != nil {
141+ t.Fatal(err)
142+ }
143+ select {
144+ case ev := <-d.log:
145+ return ev
146+ case <-deadline:
147+ t.Fatal("no event")
148+ case <-time.After(5 * time.Millisecond):
149+ }
150+ }
151+}
152+
153+// expect waits for the next event and checks its name.
154+func (d *dndClient) expect(t *testing.T, name string) dndEvent {
155+ t.Helper()
156+ ev := d.next(t)
157+ if ev.name != name {
158+ t.Fatalf("event %+v, want %s", ev, name)
159+ }
160+ return ev
161+}
162+
163+// none checks that no event arrives.
164+func (d *dndClient) none(t *testing.T) {
165+ t.Helper()
166+ for range 2 {
167+ if err := d.c.Roundtrip(); err != nil {
168+ t.Fatal(err)
169+ }
170+ time.Sleep(30 * time.Millisecond)
171+ }
172+ select {
173+ case ev := <-d.log:
174+ t.Fatalf("unexpected event %+v", ev)
175+ default:
176+ }
177+}
178+
179+func (d *dndClient) source(t *testing.T, actions uint32, mimes ...string) uint32 {
180+ t.Helper()
181+ src := &dndSourceEvents{log: d.log}
182+ src.SetID(d.c.AllocateID())
183+ registerWireProxy(d.c, src)
184+ requestProtocol(t, d.c, d.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID())
185+ for _, m := range mimes {
186+ requestProtocol(t, d.c, src.ID(), wayland.DataSourceRequestOffer, m)
187+ }
188+ if actions != 0 {
189+ requestProtocol(t, d.c, src.ID(), wayland.DataSourceRequestSetActions, actions)
190+ }
191+ return src.ID()
192+}
193+
194+// press makes the client's window press a button and returns the serial.
195+func (d *dndClient) press(t *testing.T, commands chan<- ports.ClientCommand) uint32 {
196+ t.Helper()
197+ commands <- ports.PointerFocus{ID: d.window, X: 1, Y: 1}
198+ commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Pressed: true, Time: time.Millisecond}
199+ deadline := time.After(2 * time.Second)
200+ for {
201+ if err := d.c.Roundtrip(); err != nil {
202+ t.Fatal(err)
203+ }
204+ select {
205+ case serial := <-d.serials:
206+ return serial
207+ case <-deadline:
208+ t.Fatal("no button")
209+ case <-time.After(5 * time.Millisecond):
210+ }
211+ }
212+}
213+
214+func (d *dndClient) startDrag(t *testing.T, src, icon, serial uint32) {
215+ t.Helper()
216+ requestProtocol(t, d.c, d.device.ID(), wayland.DataDeviceRequestStartDrag, src, d.surf, icon, serial)
217+ if err := d.c.Roundtrip(); err != nil {
218+ t.Fatal(err)
219+ }
220+}
221+
222+// buffer makes a w×h ARGB shm buffer.
223+func (d *dndClient) buffer(t *testing.T, w, h int) uint32 {
224+ t.Helper()
225+ fd, err := unix.MemfdCreate("dnd-icon", 0)
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ defer unix.Close(fd)
230+ if err := unix.Ftruncate(fd, int64(w*h*4)); err != nil {
231+ t.Fatal(err)
232+ }
233+ pool, buf := d.c.AllocateID(), d.c.AllocateID()
234+ registerProtocol(t, d.c, pool)
235+ registerProtocol(t, d.c, buf)
236+ if err := wireRequest(d.c, d.shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil {
237+ t.Fatal(err)
238+ }
239+ requestProtocol(t, d.c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0))
240+ return buf
241+}
242+
243+func (d *dndClient) surfaceNew(t *testing.T) uint32 {
244+ t.Helper()
245+ id := d.c.AllocateID()
246+ requestProtocol(t, d.c, d.comp, wayland.CompositorRequestCreateSurface, id)
247+ registerProtocol(t, d.c, id)
248+ return id
249+}
250+
251+// dndNextEvent returns the next client event of type T, skipping others.
252+func dndNextEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
253+ t.Helper()
254+ deadline := time.After(2 * time.Second)
255+ for {
256+ select {
257+ case ev := <-events:
258+ if v, ok := ev.(T); ok {
259+ return v
260+ }
261+ case <-deadline:
262+ t.Fatalf("no %T event", *new(T))
263+ }
264+ }
265+}
266+
267+// dndNoEvent checks that no client event of type T is pending.
268+func dndNoEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) {
269+ t.Helper()
270+ time.Sleep(50 * time.Millisecond)
271+ for {
272+ select {
273+ case ev := <-events:
274+ if _, ok := ev.(T); ok {
275+ t.Fatalf("unexpected %T: %+v", ev, ev)
276+ }
277+ default:
278+ return
279+ }
280+ }
281+}
282+
283+// dndPair is two clients with a drag started from a on a source.
284+type dndPair struct {
285+ s *Server
286+ events chan ports.ClientEvent
287+ commands chan ports.ClientCommand
288+ a, b *dndClient
289+}
290+
291+func newDndPair(t *testing.T) *dndPair {
292+ t.Helper()
293+ s, events, commands, dir := keyboardServer(t)
294+ p := &dndPair{s: s, events: events, commands: commands}
295+ p.a = newDndClient(t, s, dir, events)
296+ p.b = newDndClient(t, s, dir, events)
297+ return p
298+}
299+
300+// start begins a drag of a source offering text/plain, copy and move.
301+func (p *dndPair) start(t *testing.T, icon uint32) uint32 {
302+ t.Helper()
303+ src := p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list")
304+ p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
305+ return src
306+}
307+
308+// enter makes b the drag target and returns the offer id.
309+func (p *dndPair) enter(t *testing.T) uint32 {
310+ t.Helper()
311+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 5, Y: 6, Time: 10 * time.Millisecond}
312+ for _, want := range []dndEvent{{name: "offer", mime: "text/plain"}, {name: "offer", mime: "text/uri-list"}, {name: "source_actions", n: actCopy | actMove}} {
313+ if ev := p.b.next(t); ev != want {
314+ t.Fatalf("event %+v, want %+v", ev, want)
315+ }
316+ }
317+ ev := p.b.expect(t, "enter")
318+ if ev.x != 5 || ev.y != 6 || ev.n == 0 {
319+ t.Fatalf("enter %+v", ev)
320+ }
321+ return ev.n
322+}
323+
324+// A1: a drag from a to b delivers enter, motion and drop with the offer;
325+// the action is negotiated; the data reaches b; a is told every step.
326+func TestDataDragBetweenClients(t *testing.T) {
327+ p := newDndPair(t)
328+ p.start(t, 0)
329+ if v := dndNextEvent[ports.DataDragStarted](t, p.events); v.Icon != 0 {
330+ t.Fatalf("started %+v", v)
331+ }
332+ offer := p.enter(t)
333+
334+ p.commands <- ports.DataDragMotion{ID: p.b.window, X: 7, Y: 8, Time: 20 * time.Millisecond}
335+ if ev := p.b.expect(t, "motion"); ev.x != 7 || ev.y != 8 {
336+ t.Fatalf("motion %+v", ev)
337+ }
338+
339+ // b accepts and prefers move: both sides learn the action.
340+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
341+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
342+ if ev := p.a.expect(t, "target"); ev.mime != "text/plain" {
343+ t.Fatalf("target %+v", ev)
344+ }
345+ if ev := p.a.expect(t, "action"); ev.n != actMove {
346+ t.Fatalf("action %+v", ev)
347+ }
348+ if ev := p.b.expect(t, "offer_action"); ev.n != actMove {
349+ t.Fatalf("offer action %+v", ev)
350+ }
351+
352+ // Finishing before the drop is a protocol error, so only after it.
353+ p.commands <- ports.DataDragDrop{}
354+ p.b.expect(t, "drop")
355+ p.a.expect(t, "drop_performed")
356+
357+ var fds [2]int
358+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
359+ t.Fatal(err)
360+ }
361+ defer unix.Close(fds[0])
362+ if err := wireRequest(p.b.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
363+ t.Fatal(err)
364+ }
365+ _ = unix.Close(fds[1])
366+ if err := p.b.c.Roundtrip(); err != nil {
367+ t.Fatal(err)
368+ }
369+ if ev := p.a.expect(t, "send"); ev.mime != "text/plain" {
370+ t.Fatalf("send %+v", ev)
371+ }
372+ buf := make([]byte, 16)
373+ n, err := unix.Read(fds[0], buf)
374+ for errors.Is(err, unix.EINTR) {
375+ n, err = unix.Read(fds[0], buf)
376+ }
377+ if err != nil || string(buf[:n]) != "dnd data" {
378+ t.Fatalf("received %q, %v", buf[:max(n, 0)], err)
379+ }
380+
381+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
382+ p.a.expect(t, "finished")
383+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
384+ p.a.none(t)
385+ p.b.none(t)
386+ // core ended the drag: wayland does not echo it.
387+ dndNoEvent[ports.DataDragEnded](t, p.events)
388+}
389+
390+// The action follows wlroots: the preferred one if both allow it, else the
391+// first of copy, move, ask they share, else none.
392+func TestChooseAction(t *testing.T) {
393+ for _, tc := range []struct{ avail, preferred, want uint32 }{
394+ {actCopy | actMove, actMove, actMove},
395+ {actCopy | actMove, actAsk, actCopy},
396+ {actMove | actAsk, 0, actMove},
397+ {actAsk, 0, actAsk},
398+ {0, actCopy, 0},
399+ } {
400+ if got := chooseAction(tc.avail, tc.preferred); got != tc.want {
401+ t.Errorf("chooseAction(%d, %d) = %d, want %d", tc.avail, tc.preferred, got, tc.want)
402+ }
403+ }
404+}
405+
406+// A target and a source sharing no action leave the drop refused.
407+func TestDataDragNoCommonAction(t *testing.T) {
408+ p := newDndPair(t)
409+ src := p.a.source(t, actCopy, "text/plain")
410+ p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
411+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
412+ p.b.expect(t, "offer")
413+ p.b.expect(t, "source_actions")
414+ offer := p.b.expect(t, "enter").n
415+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
416+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actMove, actMove)
417+ p.a.expect(t, "target")
418+ p.commands <- ports.DataDragDrop{}
419+ p.b.expect(t, "leave")
420+ // The refused target no longer accepts.
421+ if ev := p.a.expect(t, "target"); ev.mime != "" {
422+ t.Fatalf("target %+v", ev)
423+ }
424+ p.a.expect(t, "cancelled")
425+}
426+
427+// A2: a release over nothing, or over a target that accepted nothing,
428+// cancels the source; the target gets leave and no drop.
429+func TestDataDragRefusedDrop(t *testing.T) {
430+ t.Run("nothing", func(t *testing.T) {
431+ p := newDndPair(t)
432+ p.start(t, 0)
433+ p.commands <- ports.DataDragDrop{}
434+ p.a.expect(t, "cancelled")
435+ p.b.none(t)
436+ })
437+ t.Run("not accepted", func(t *testing.T) {
438+ p := newDndPair(t)
439+ p.start(t, 0)
440+ p.enter(t)
441+ p.commands <- ports.DataDragDrop{}
442+ p.b.expect(t, "leave")
443+ p.a.expect(t, "cancelled")
444+ p.b.none(t)
445+ })
446+ t.Run("escape", func(t *testing.T) {
447+ p := newDndPair(t)
448+ p.start(t, 0)
449+ p.enter(t)
450+ p.commands <- ports.DataDragCancel{}
451+ p.b.expect(t, "leave")
452+ p.a.expect(t, "cancelled")
453+ dndNoEvent[ports.DataDragEnded](t, p.events)
454+ })
455+}
456+
457+// Moving to another surface leaves the first and enters the second; a
458+// surface of nobody (ID 0) leaves.
459+func TestDataDragLeavesOnFocusChange(t *testing.T) {
460+ p := newDndPair(t)
461+ p.start(t, 0)
462+ p.enter(t)
463+ p.commands <- ports.DataDragFocus{}
464+ p.b.expect(t, "leave")
465+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 2, Y: 3}
466+ for range 3 {
467+ p.b.next(t)
468+ }
469+ if ev := p.b.expect(t, "enter"); ev.x != 2 || ev.y != 3 {
470+ t.Fatalf("enter %+v", ev)
471+ }
472+}
473+
474+// A4: the source dying mid-drag ends it: the target gets leave and core a
475+// DataDragEnded; later commands change nothing.
476+func TestDataDragSourceDestroyed(t *testing.T) {
477+ p := newDndPair(t)
478+ src := p.start(t, 0)
479+ p.enter(t)
480+ requestProtocol(t, p.a.c, src, wayland.DataSourceRequestDestroy)
481+ if err := p.a.c.Roundtrip(); err != nil {
482+ t.Fatal(err)
483+ }
484+ p.b.expect(t, "leave")
485+ dndNextEvent[ports.DataDragEnded](t, p.events)
486+ p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}
487+ p.commands <- ports.DataDragDrop{}
488+ p.b.none(t)
489+}
490+
491+// A drag whose origin client dies ends the same way.
492+func TestDataDragOriginDies(t *testing.T) {
493+ p := newDndPair(t)
494+ p.start(t, 0)
495+ p.enter(t)
496+ _ = p.a.c.Close()
497+ p.b.expect(t, "leave")
498+ dndNextEvent[ports.DataDragEnded](t, p.events)
499+}
500+
501+// A6: a start_drag without the current press of the origin client is
502+// refused: the source is cancelled and core hears nothing.
503+func TestDataDragInvalidStart(t *testing.T) {
504+ t.Run("stale serial", func(t *testing.T) {
505+ p := newDndPair(t)
506+ serial := p.a.press(t, p.commands)
507+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)
508+ p.a.expect(t, "cancelled")
509+ dndNoEvent[ports.DataDragStarted](t, p.events)
510+ })
511+ t.Run("other client's press", func(t *testing.T) {
512+ p := newDndPair(t)
513+ p.a.press(t, p.commands)
514+ serial := p.b.press(t, p.commands)
515+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
516+ p.a.expect(t, "cancelled")
517+ dndNoEvent[ports.DataDragStarted](t, p.events)
518+ })
519+ t.Run("used source", func(t *testing.T) {
520+ p := newDndPair(t)
521+ src := p.start(t, 0)
522+ p.commands <- ports.DataDragCancel{}
523+ p.a.expect(t, "cancelled")
524+ p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
525+ p.a.expect(t, "cancelled")
526+ })
527+ t.Run("drag in progress", func(t *testing.T) {
528+ p := newDndPair(t)
529+ p.start(t, 0)
530+ dndNextEvent[ports.DataDragStarted](t, p.events)
531+ other := p.a.source(t, actCopy, "text/plain")
532+ p.a.startDrag(t, other, 0, p.a.press(t, p.commands))
533+ p.a.expect(t, "cancelled")
534+ dndNoEvent[ports.DataDragStarted](t, p.events)
535+ })
536+ t.Run("icon with another role", func(t *testing.T) {
537+ p := newDndPair(t)
538+ serial := p.a.press(t, p.commands)
539+ src := p.a.source(t, actCopy, "text/plain")
540+ // The origin toplevel is a role already.
541+ requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)
542+ expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))
543+ dndNoEvent[ports.DataDragStarted](t, p.events)
544+ })
545+}
546+
547+// A nil source is a drag internal to the origin client: only its own
548+// surfaces see it, without offer.
549+func TestDataDragWithoutSource(t *testing.T) {
550+ p := newDndPair(t)
551+ p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))
552+ dndNextEvent[ports.DataDragStarted](t, p.events)
553+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
554+ p.b.none(t)
555+ p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}
556+ if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {
557+ t.Fatalf("enter %+v", ev)
558+ }
559+ p.commands <- ports.DataDragDrop{}
560+ p.a.expect(t, "drop")
561+}
562+
563+// Offer and source requests are validated.
564+func TestDataDragProtocolErrors(t *testing.T) {
565+ t.Run("finish before drop", func(t *testing.T) {
566+ p := newDndPair(t)
567+ p.start(t, 0)
568+ offer := p.enter(t)
569+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
570+ expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidFinish))
571+ })
572+ t.Run("offer action mask", func(t *testing.T) {
573+ p := newDndPair(t)
574+ p.start(t, 0)
575+ offer := p.enter(t)
576+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, uint32(8), uint32(0))
577+ expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidActionMask))
578+ })
579+ t.Run("offer preferred action", func(t *testing.T) {
580+ p := newDndPair(t)
581+ p.start(t, 0)
582+ offer := p.enter(t)
583+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actMove)
584+ expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidAction))
585+ })
586+ t.Run("source action mask", func(t *testing.T) {
587+ p := newDndPair(t)
588+ src := p.a.source(t, 0, "text/plain")
589+ requestProtocol(t, p.a.c, src, wayland.DataSourceRequestSetActions, uint32(16))
590+ expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidActionMask))
591+ })
592+ t.Run("selection with actions", func(t *testing.T) {
593+ p := newDndPair(t)
594+ src := p.a.source(t, actCopy, "text/plain")
595+ requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestSetSelection, src, uint32(0))
596+ expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidSource))
597+ })
598+ t.Run("actions after the drag started", func(t *testing.T) {
599+ p := newDndPair(t)
600+ src := p.start(t, 0)
601+ requestProtocol(t, p.a.c, src, wayland.DataSourceRequestSetActions, actCopy)
602+ expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidSource))
603+ })
604+}
605+
606+// A destroyed offer before finish cancels the source.
607+func TestDataDragOfferDestroyedBeforeFinish(t *testing.T) {
608+ p := newDndPair(t)
609+ p.start(t, 0)
610+ offer := p.enter(t)
611+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
612+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
613+ p.a.expect(t, "target")
614+ p.a.expect(t, "action")
615+ p.commands <- ports.DataDragDrop{}
616+ p.b.expect(t, "offer_action")
617+ p.b.expect(t, "drop")
618+ p.a.expect(t, "drop_performed")
619+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
620+ p.a.expect(t, "cancelled")
621+}
622+
623+// iconContent reports whether the server still holds content for id.
624+func iconContent(t *testing.T, s *Server, id ports.WindowID) bool {
625+ t.Helper()
626+ var ok bool
627+ if !s.display.Do(func() { _, ok = s.latest[id] }) {
628+ t.Fatal("display stopped")
629+ }
630+ return ok
631+}
632+
633+// The icon is a surface with its own window ID: its size and the
634+// offsets of its commits reach core, its content is published, and both go
635+// when the drag ends.
636+func TestDataDragIcon(t *testing.T) {
637+ p := newDndPair(t)
638+ icon := p.a.surfaceNew(t)
639+ buf := p.a.buffer(t, 8, 6)
640+ // An offset on an ordinary surface is not used.
641+ requestProtocol(t, p.a.c, p.a.surf, wayland.SurfaceRequestOffset, int32(5), int32(5))
642+ requestProtocol(t, p.a.c, p.a.surf, wayland.SurfaceRequestCommit)
643+
644+ src := p.a.source(t, actCopy, "text/plain")
645+ p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
646+ started := dndNextEvent[ports.DataDragStarted](t, p.events)
647+ if started.Icon == 0 {
648+ t.Fatal("no icon ID")
649+ }
650+ id := started.Icon
651+ if iconContent(t, p.s, id) {
652+ t.Fatal("content before the first commit")
653+ }
654+
655+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
656+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-3), int32(-4))
657+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
658+ if err := p.a.c.Roundtrip(); err != nil {
659+ t.Fatal(err)
660+ }
661+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -3, Y: -4, W: 8, H: 6}) {
662+ t.Fatalf("icon %+v", got)
663+ }
664+ if !iconContent(t, p.s, id) {
665+ t.Fatal("no icon content")
666+ }
667+
668+ // Offsets add up from commit to commit.
669+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-1), int32(2))
670+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
671+ if err := p.a.c.Roundtrip(); err != nil {
672+ t.Fatal(err)
673+ }
674+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -4, Y: -2, W: 8, H: 6}) {
675+ t.Fatalf("icon %+v", got)
676+ }
677+ // The icon never takes input.
678+ dndNoEvent[ports.InputRegionChanged](t, p.events)
679+
680+ p.commands <- ports.DataDragCancel{}
681+ p.a.expect(t, "cancelled")
682+ if iconContent(t, p.s, id) {
683+ t.Fatal("icon content kept after the drag")
684+ }
685+}
686+
687+// An icon committed before start_drag shows at once.
688+func TestDataDragIconCommittedEarly(t *testing.T) {
689+ p := newDndPair(t)
690+ icon := p.a.surfaceNew(t)
691+ buf := p.a.buffer(t, 4, 4)
692+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
693+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
694+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), icon, p.a.press(t, p.commands))
695+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
696+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, W: 4, H: 4}) {
697+ t.Fatalf("icon %+v", got)
698+ }
699+ if !iconContent(t, p.s, id) {
700+ t.Fatal("no icon content")
701+ }
702+}
703+
704+// Before wl_surface v5 the icon offset rides on attach and adds up.
705+func TestDataDragIconAttachOffset(t *testing.T) {
706+ p := newDndPair(t)
707+ comp4 := bindVersion(t, p.a.c, "wl_compositor", 4)
708+ icon := p.a.c.AllocateID()
709+ requestProtocol(t, p.a.c, comp4, wayland.CompositorRequestCreateSurface, icon)
710+ registerProtocol(t, p.a.c, icon)
711+ buf := p.a.buffer(t, 4, 4)
712+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), icon, p.a.press(t, p.commands))
713+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
714+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(-2), int32(-1))
715+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
716+ if err := p.a.c.Roundtrip(); err != nil {
717+ t.Fatal(err)
718+ }
719+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -2, Y: -1, W: 4, H: 4}) {
720+ t.Fatalf("icon %+v", got)
721+ }
722+}
723+
724+// Destroying the icon mid-drag hides it; the drag goes on.
725+func TestDataDragIconDestroyed(t *testing.T) {
726+ p := newDndPair(t)
727+ icon := p.a.surfaceNew(t)
728+ buf := p.a.buffer(t, 8, 6)
729+ p.a.startDrag(t, p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list"), icon, p.a.press(t, p.commands))
730+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
731+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
732+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
733+ if err := p.a.c.Roundtrip(); err != nil {
734+ t.Fatal(err)
735+ }
736+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got.W != 8 {
737+ t.Fatalf("icon %+v", got)
738+ }
739+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestDestroy)
740+ if err := p.a.c.Roundtrip(); err != nil {
741+ t.Fatal(err)
742+ }
743+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id}) {
744+ t.Fatalf("icon %+v", got)
745+ }
746+ if iconContent(t, p.s, id) {
747+ t.Fatal("icon content kept")
748+ }
749+ p.enter(t)
750+}
751+
752+// A7: the session lock cancels a drag at once: target leave, source
753+// cancelled, icon gone.
754+func TestDataDragLockCancels(t *testing.T) {
755+ gate := &sessionsecurity.Gate{}
756+ events := make(chan ports.ClientEvent, 128)
757+ commands := make(chan ports.ClientCommand, 32)
758+ dir := t.TempDir()
759+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, Channels{Events: events, Commands: commands, SecurityChanges: make(chan ports.SecurityState, 16), SecurityEvents: make(chan ports.SecurityBackendEvent, 16)}, logging.For(context.Background(), "wayland"))
760+ if err != nil {
761+ t.Fatal(err)
762+ }
763+ ctx, cancel := context.WithCancel(context.Background())
764+ done := make(chan error, 1)
765+ go func() { done <- s.Run(ctx) }()
766+ t.Cleanup(func() {
767+ cancel()
768+ select {
769+ case err := <-done:
770+ if err != nil {
771+ t.Error(err)
772+ }
773+ case <-time.After(3 * time.Second):
774+ t.Error("server did not stop")
775+ }
776+ })
777+ p := &dndPair{s: s, events: events, commands: commands}
778+ p.a = newDndClient(t, s, dir, events)
779+ p.b = newDndClient(t, s, dir, events)
780+ icon := p.a.surfaceNew(t)
781+ buf := p.a.buffer(t, 8, 6)
782+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
783+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
784+ src := p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list")
785+ p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
786+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
787+ p.enter(t)
788+
789+ lc := protocolClient(t, s, dir)
790+ if _, err := bindLockManager(t, lc).Lock(); err != nil {
791+ t.Fatal(err)
792+ }
793+ if err := lc.Roundtrip(); err != nil {
794+ t.Fatal(err)
795+ }
796+ p.b.expect(t, "leave")
797+ p.a.expect(t, "cancelled")
798+ if iconContent(t, s, id) {
799+ t.Fatal("icon content kept")
800+ }
801+ // A new drag is refused while protected.
802+ src2 := p.a.source(t, actCopy, "text/plain")
803+ p.a.startDrag(t, src2, 0, uint32(1))
804+ p.a.expect(t, "cancelled")
805+}
@@ -0,0 +1,805 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
10+ "github.com/bnema/neferwl/internal/adapters/logging"
11+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+const (
18+ actCopy = uint32(wayland.DataDeviceManagerDndActionCopy)
19+ actMove = uint32(wayland.DataDeviceManagerDndActionMove)
20+ actAsk = uint32(wayland.DataDeviceManagerDndActionAsk)
21+)
22+
23+// dndEvent is one data device, offer or source event a test client saw.
24+type dndEvent struct {
25+ name string
26+ mime string
27+ x, y float64
28+ n uint32
29+}
30+
31+type dndDeviceEvents struct {
32+ wlturbo.BaseProxy
33+ c *wlturbo.Display
34+ log chan dndEvent
35+}
36+
37+func (p *dndDeviceEvents) Dispatch(e *wlturbo.Event) {
38+ switch e.Opcode {
39+ case uint16(wayland.DataDeviceEventDataOffer):
40+ o := &dndOfferEvents{log: p.log}
41+ o.SetID(e.Uint32())
42+ registerWireProxy(p.c, o)
43+ case uint16(wayland.DataDeviceEventEnter):
44+ serial, _ := e.Uint32(), e.Uint32()
45+ x, y := e.Fixed().Float64(), e.Fixed().Float64()
46+ p.log <- dndEvent{name: "enter", x: x, y: y, n: e.Uint32() + serial*0}
47+ case uint16(wayland.DataDeviceEventLeave):
48+ p.log <- dndEvent{name: "leave"}
49+ case uint16(wayland.DataDeviceEventMotion):
50+ _ = e.Uint32()
51+ p.log <- dndEvent{name: "motion", x: e.Fixed().Float64(), y: e.Fixed().Float64()}
52+ case uint16(wayland.DataDeviceEventDrop):
53+ p.log <- dndEvent{name: "drop"}
54+ }
55+}
56+
57+type dndOfferEvents struct {
58+ wlturbo.BaseProxy
59+ log chan dndEvent
60+}
61+
62+func (p *dndOfferEvents) Dispatch(e *wlturbo.Event) {
63+ switch e.Opcode {
64+ case uint16(wayland.DataOfferEventOffer):
65+ p.log <- dndEvent{name: "offer", mime: e.String()}
66+ case uint16(wayland.DataOfferEventSourceActions):
67+ p.log <- dndEvent{name: "source_actions", n: e.Uint32()}
68+ case uint16(wayland.DataOfferEventAction):
69+ p.log <- dndEvent{name: "offer_action", n: e.Uint32()}
70+ }
71+}
72+
73+type dndSourceEvents struct {
74+ wlturbo.BaseProxy
75+ log chan dndEvent
76+}
77+
78+func (p *dndSourceEvents) Dispatch(e *wlturbo.Event) {
79+ switch e.Opcode {
80+ case uint16(wayland.DataSourceEventTarget):
81+ p.log <- dndEvent{name: "target", mime: e.String()}
82+ case uint16(wayland.DataSourceEventSend):
83+ mime := e.String()
84+ fd := int(e.Fd())
85+ _, _ = unix.Write(fd, []byte("dnd data"))
86+ _ = unix.Close(fd)
87+ p.log <- dndEvent{name: "send", mime: mime}
88+ case uint16(wayland.DataSourceEventCancelled):
89+ p.log <- dndEvent{name: "cancelled"}
90+ case uint16(wayland.DataSourceEventDndDropPerformed):
91+ p.log <- dndEvent{name: "drop_performed"}
92+ case uint16(wayland.DataSourceEventDndFinished):
93+ p.log <- dndEvent{name: "finished"}
94+ case uint16(wayland.DataSourceEventAction):
95+ p.log <- dndEvent{name: "action", n: e.Uint32()}
96+ }
97+}
98+
99+// dndClient is a client with a toplevel, a pointer and a data device.
100+type dndClient struct {
101+ c *wlturbo.Display
102+ manager uint32
103+ device *dndDeviceEvents
104+ serials chan uint32
105+ comp uint32
106+ shm uint32
107+ surf uint32
108+ window ports.WindowID
109+ log chan dndEvent
110+}
111+
112+func newDndClient(t *testing.T, s *Server, dir string, events <-chan ports.ClientEvent) *dndClient {
113+ t.Helper()
114+ c := protocolClient(t, s, dir)
115+ d := &dndClient{c: c, log: make(chan dndEvent, 64), serials: make(chan uint32, 8)}
116+ seat := bindVersion(t, c, "wl_seat", 8)
117+ registerProtocol(t, c, seat)
118+ ptr := &pressSerials{serials: d.serials}
119+ ptr.SetID(c.AllocateID())
120+ registerWireProxy(c, ptr)
121+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, ptr.ID())
122+ d.manager = bindVersion(t, c, "wl_data_device_manager", 3)
123+ d.device = &dndDeviceEvents{c: c, log: d.log}
124+ d.device.SetID(c.AllocateID())
125+ registerWireProxy(c, d.device)
126+ requestProtocol(t, c, d.manager, wayland.DataDeviceManagerRequestGetDataDevice, d.device.ID(), seat)
127+ d.comp = bindVersion(t, c, "wl_compositor", 5)
128+ d.shm = bindProtocol(t, c, "wl_shm")
129+ registerProtocol(t, c, d.shm)
130+ w, surf, _ := surfaceMapperAt(t, c, events, 5)()
131+ d.window, d.surf = w.ID, surf
132+ return d
133+}
134+
135+// next returns the next event the client sees.
136+func (d *dndClient) next(t *testing.T) dndEvent {
137+ t.Helper()
138+ deadline := time.After(2 * time.Second)
139+ for {
140+ if err := d.c.Roundtrip(); err != nil {
141+ t.Fatal(err)
142+ }
143+ select {
144+ case ev := <-d.log:
145+ return ev
146+ case <-deadline:
147+ t.Fatal("no event")
148+ case <-time.After(5 * time.Millisecond):
149+ }
150+ }
151+}
152+
153+// expect waits for the next event and checks its name.
154+func (d *dndClient) expect(t *testing.T, name string) dndEvent {
155+ t.Helper()
156+ ev := d.next(t)
157+ if ev.name != name {
158+ t.Fatalf("event %+v, want %s", ev, name)
159+ }
160+ return ev
161+}
162+
163+// none checks that no event arrives.
164+func (d *dndClient) none(t *testing.T) {
165+ t.Helper()
166+ for range 2 {
167+ if err := d.c.Roundtrip(); err != nil {
168+ t.Fatal(err)
169+ }
170+ time.Sleep(30 * time.Millisecond)
171+ }
172+ select {
173+ case ev := <-d.log:
174+ t.Fatalf("unexpected event %+v", ev)
175+ default:
176+ }
177+}
178+
179+func (d *dndClient) source(t *testing.T, actions uint32, mimes ...string) uint32 {
180+ t.Helper()
181+ src := &dndSourceEvents{log: d.log}
182+ src.SetID(d.c.AllocateID())
183+ registerWireProxy(d.c, src)
184+ requestProtocol(t, d.c, d.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID())
185+ for _, m := range mimes {
186+ requestProtocol(t, d.c, src.ID(), wayland.DataSourceRequestOffer, m)
187+ }
188+ if actions != 0 {
189+ requestProtocol(t, d.c, src.ID(), wayland.DataSourceRequestSetActions, actions)
190+ }
191+ return src.ID()
192+}
193+
194+// press makes the client's window press a button and returns the serial.
195+func (d *dndClient) press(t *testing.T, commands chan<- ports.ClientCommand) uint32 {
196+ t.Helper()
197+ commands <- ports.PointerFocus{ID: d.window, X: 1, Y: 1}
198+ commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Pressed: true, Time: time.Millisecond}
199+ deadline := time.After(2 * time.Second)
200+ for {
201+ if err := d.c.Roundtrip(); err != nil {
202+ t.Fatal(err)
203+ }
204+ select {
205+ case serial := <-d.serials:
206+ return serial
207+ case <-deadline:
208+ t.Fatal("no button")
209+ case <-time.After(5 * time.Millisecond):
210+ }
211+ }
212+}
213+
214+func (d *dndClient) startDrag(t *testing.T, src, icon, serial uint32) {
215+ t.Helper()
216+ requestProtocol(t, d.c, d.device.ID(), wayland.DataDeviceRequestStartDrag, src, d.surf, icon, serial)
217+ if err := d.c.Roundtrip(); err != nil {
218+ t.Fatal(err)
219+ }
220+}
221+
222+// buffer makes a w×h ARGB shm buffer.
223+func (d *dndClient) buffer(t *testing.T, w, h int) uint32 {
224+ t.Helper()
225+ fd, err := unix.MemfdCreate("dnd-icon", 0)
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ defer unix.Close(fd)
230+ if err := unix.Ftruncate(fd, int64(w*h*4)); err != nil {
231+ t.Fatal(err)
232+ }
233+ pool, buf := d.c.AllocateID(), d.c.AllocateID()
234+ registerProtocol(t, d.c, pool)
235+ registerProtocol(t, d.c, buf)
236+ if err := wireRequest(d.c, d.shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil {
237+ t.Fatal(err)
238+ }
239+ requestProtocol(t, d.c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0))
240+ return buf
241+}
242+
243+func (d *dndClient) surfaceNew(t *testing.T) uint32 {
244+ t.Helper()
245+ id := d.c.AllocateID()
246+ requestProtocol(t, d.c, d.comp, wayland.CompositorRequestCreateSurface, id)
247+ registerProtocol(t, d.c, id)
248+ return id
249+}
250+
251+// dndNextEvent returns the next client event of type T, skipping others.
252+func dndNextEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
253+ t.Helper()
254+ deadline := time.After(2 * time.Second)
255+ for {
256+ select {
257+ case ev := <-events:
258+ if v, ok := ev.(T); ok {
259+ return v
260+ }
261+ case <-deadline:
262+ t.Fatalf("no %T event", *new(T))
263+ }
264+ }
265+}
266+
267+// dndNoEvent checks that no client event of type T is pending.
268+func dndNoEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) {
269+ t.Helper()
270+ time.Sleep(50 * time.Millisecond)
271+ for {
272+ select {
273+ case ev := <-events:
274+ if _, ok := ev.(T); ok {
275+ t.Fatalf("unexpected %T: %+v", ev, ev)
276+ }
277+ default:
278+ return
279+ }
280+ }
281+}
282+
283+// dndPair is two clients with a drag started from a on a source.
284+type dndPair struct {
285+ s *Server
286+ events chan ports.ClientEvent
287+ commands chan ports.ClientCommand
288+ a, b *dndClient
289+}
290+
291+func newDndPair(t *testing.T) *dndPair {
292+ t.Helper()
293+ s, events, commands, dir := keyboardServer(t)
294+ p := &dndPair{s: s, events: events, commands: commands}
295+ p.a = newDndClient(t, s, dir, events)
296+ p.b = newDndClient(t, s, dir, events)
297+ return p
298+}
299+
300+// start begins a drag of a source offering text/plain, copy and move.
301+func (p *dndPair) start(t *testing.T, icon uint32) uint32 {
302+ t.Helper()
303+ src := p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list")
304+ p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
305+ return src
306+}
307+
308+// enter makes b the drag target and returns the offer id.
309+func (p *dndPair) enter(t *testing.T) uint32 {
310+ t.Helper()
311+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 5, Y: 6, Time: 10 * time.Millisecond}
312+ for _, want := range []dndEvent{{name: "offer", mime: "text/plain"}, {name: "offer", mime: "text/uri-list"}, {name: "source_actions", n: actCopy | actMove}} {
313+ if ev := p.b.next(t); ev != want {
314+ t.Fatalf("event %+v, want %+v", ev, want)
315+ }
316+ }
317+ ev := p.b.expect(t, "enter")
318+ if ev.x != 5 || ev.y != 6 || ev.n == 0 {
319+ t.Fatalf("enter %+v", ev)
320+ }
321+ return ev.n
322+}
323+
324+// A1: a drag from a to b delivers enter, motion and drop with the offer;
325+// the action is negotiated; the data reaches b; a is told every step.
326+func TestDataDragBetweenClients(t *testing.T) {
327+ p := newDndPair(t)
328+ p.start(t, 0)
329+ if v := dndNextEvent[ports.DataDragStarted](t, p.events); v.Icon != 0 {
330+ t.Fatalf("started %+v", v)
331+ }
332+ offer := p.enter(t)
333+
334+ p.commands <- ports.DataDragMotion{ID: p.b.window, X: 7, Y: 8, Time: 20 * time.Millisecond}
335+ if ev := p.b.expect(t, "motion"); ev.x != 7 || ev.y != 8 {
336+ t.Fatalf("motion %+v", ev)
337+ }
338+
339+ // b accepts and prefers move: both sides learn the action.
340+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
341+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
342+ if ev := p.a.expect(t, "target"); ev.mime != "text/plain" {
343+ t.Fatalf("target %+v", ev)
344+ }
345+ if ev := p.a.expect(t, "action"); ev.n != actMove {
346+ t.Fatalf("action %+v", ev)
347+ }
348+ if ev := p.b.expect(t, "offer_action"); ev.n != actMove {
349+ t.Fatalf("offer action %+v", ev)
350+ }
351+
352+ // Finishing before the drop is a protocol error, so only after it.
353+ p.commands <- ports.DataDragDrop{}
354+ p.b.expect(t, "drop")
355+ p.a.expect(t, "drop_performed")
356+
357+ var fds [2]int
358+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
359+ t.Fatal(err)
360+ }
361+ defer unix.Close(fds[0])
362+ if err := wireRequest(p.b.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
363+ t.Fatal(err)
364+ }
365+ _ = unix.Close(fds[1])
366+ if err := p.b.c.Roundtrip(); err != nil {
367+ t.Fatal(err)
368+ }
369+ if ev := p.a.expect(t, "send"); ev.mime != "text/plain" {
370+ t.Fatalf("send %+v", ev)
371+ }
372+ buf := make([]byte, 16)
373+ n, err := unix.Read(fds[0], buf)
374+ for errors.Is(err, unix.EINTR) {
375+ n, err = unix.Read(fds[0], buf)
376+ }
377+ if err != nil || string(buf[:n]) != "dnd data" {
378+ t.Fatalf("received %q, %v", buf[:max(n, 0)], err)
379+ }
380+
381+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
382+ p.a.expect(t, "finished")
383+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
384+ p.a.none(t)
385+ p.b.none(t)
386+ // core ended the drag: wayland does not echo it.
387+ dndNoEvent[ports.DataDragEnded](t, p.events)
388+}
389+
390+// The action follows wlroots: the preferred one if both allow it, else the
391+// first of copy, move, ask they share, else none.
392+func TestChooseAction(t *testing.T) {
393+ for _, tc := range []struct{ avail, preferred, want uint32 }{
394+ {actCopy | actMove, actMove, actMove},
395+ {actCopy | actMove, actAsk, actCopy},
396+ {actMove | actAsk, 0, actMove},
397+ {actAsk, 0, actAsk},
398+ {0, actCopy, 0},
399+ } {
400+ if got := chooseAction(tc.avail, tc.preferred); got != tc.want {
401+ t.Errorf("chooseAction(%d, %d) = %d, want %d", tc.avail, tc.preferred, got, tc.want)
402+ }
403+ }
404+}
405+
406+// A target and a source sharing no action leave the drop refused.
407+func TestDataDragNoCommonAction(t *testing.T) {
408+ p := newDndPair(t)
409+ src := p.a.source(t, actCopy, "text/plain")
410+ p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
411+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
412+ p.b.expect(t, "offer")
413+ p.b.expect(t, "source_actions")
414+ offer := p.b.expect(t, "enter").n
415+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
416+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actMove, actMove)
417+ p.a.expect(t, "target")
418+ p.commands <- ports.DataDragDrop{}
419+ p.b.expect(t, "leave")
420+ // The refused target no longer accepts.
421+ if ev := p.a.expect(t, "target"); ev.mime != "" {
422+ t.Fatalf("target %+v", ev)
423+ }
424+ p.a.expect(t, "cancelled")
425+}
426+
427+// A2: a release over nothing, or over a target that accepted nothing,
428+// cancels the source; the target gets leave and no drop.
429+func TestDataDragRefusedDrop(t *testing.T) {
430+ t.Run("nothing", func(t *testing.T) {
431+ p := newDndPair(t)
432+ p.start(t, 0)
433+ p.commands <- ports.DataDragDrop{}
434+ p.a.expect(t, "cancelled")
435+ p.b.none(t)
436+ })
437+ t.Run("not accepted", func(t *testing.T) {
438+ p := newDndPair(t)
439+ p.start(t, 0)
440+ p.enter(t)
441+ p.commands <- ports.DataDragDrop{}
442+ p.b.expect(t, "leave")
443+ p.a.expect(t, "cancelled")
444+ p.b.none(t)
445+ })
446+ t.Run("escape", func(t *testing.T) {
447+ p := newDndPair(t)
448+ p.start(t, 0)
449+ p.enter(t)
450+ p.commands <- ports.DataDragCancel{}
451+ p.b.expect(t, "leave")
452+ p.a.expect(t, "cancelled")
453+ dndNoEvent[ports.DataDragEnded](t, p.events)
454+ })
455+}
456+
457+// Moving to another surface leaves the first and enters the second; a
458+// surface of nobody (ID 0) leaves.
459+func TestDataDragLeavesOnFocusChange(t *testing.T) {
460+ p := newDndPair(t)
461+ p.start(t, 0)
462+ p.enter(t)
463+ p.commands <- ports.DataDragFocus{}
464+ p.b.expect(t, "leave")
465+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 2, Y: 3}
466+ for range 3 {
467+ p.b.next(t)
468+ }
469+ if ev := p.b.expect(t, "enter"); ev.x != 2 || ev.y != 3 {
470+ t.Fatalf("enter %+v", ev)
471+ }
472+}
473+
474+// A4: the source dying mid-drag ends it: the target gets leave and core a
475+// DataDragEnded; later commands change nothing.
476+func TestDataDragSourceDestroyed(t *testing.T) {
477+ p := newDndPair(t)
478+ src := p.start(t, 0)
479+ p.enter(t)
480+ requestProtocol(t, p.a.c, src, wayland.DataSourceRequestDestroy)
481+ if err := p.a.c.Roundtrip(); err != nil {
482+ t.Fatal(err)
483+ }
484+ p.b.expect(t, "leave")
485+ dndNextEvent[ports.DataDragEnded](t, p.events)
486+ p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}
487+ p.commands <- ports.DataDragDrop{}
488+ p.b.none(t)
489+}
490+
491+// A drag whose origin client dies ends the same way.
492+func TestDataDragOriginDies(t *testing.T) {
493+ p := newDndPair(t)
494+ p.start(t, 0)
495+ p.enter(t)
496+ _ = p.a.c.Close()
497+ p.b.expect(t, "leave")
498+ dndNextEvent[ports.DataDragEnded](t, p.events)
499+}
500+
501+// A6: a start_drag without the current press of the origin client is
502+// refused: the source is cancelled and core hears nothing.
503+func TestDataDragInvalidStart(t *testing.T) {
504+ t.Run("stale serial", func(t *testing.T) {
505+ p := newDndPair(t)
506+ serial := p.a.press(t, p.commands)
507+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)
508+ p.a.expect(t, "cancelled")
509+ dndNoEvent[ports.DataDragStarted](t, p.events)
510+ })
511+ t.Run("other client's press", func(t *testing.T) {
512+ p := newDndPair(t)
513+ p.a.press(t, p.commands)
514+ serial := p.b.press(t, p.commands)
515+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
516+ p.a.expect(t, "cancelled")
517+ dndNoEvent[ports.DataDragStarted](t, p.events)
518+ })
519+ t.Run("used source", func(t *testing.T) {
520+ p := newDndPair(t)
521+ src := p.start(t, 0)
522+ p.commands <- ports.DataDragCancel{}
523+ p.a.expect(t, "cancelled")
524+ p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
525+ p.a.expect(t, "cancelled")
526+ })
527+ t.Run("drag in progress", func(t *testing.T) {
528+ p := newDndPair(t)
529+ p.start(t, 0)
530+ dndNextEvent[ports.DataDragStarted](t, p.events)
531+ other := p.a.source(t, actCopy, "text/plain")
532+ p.a.startDrag(t, other, 0, p.a.press(t, p.commands))
533+ p.a.expect(t, "cancelled")
534+ dndNoEvent[ports.DataDragStarted](t, p.events)
535+ })
536+ t.Run("icon with another role", func(t *testing.T) {
537+ p := newDndPair(t)
538+ serial := p.a.press(t, p.commands)
539+ src := p.a.source(t, actCopy, "text/plain")
540+ // The origin toplevel is a role already.
541+ requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)
542+ expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))
543+ dndNoEvent[ports.DataDragStarted](t, p.events)
544+ })
545+}
546+
547+// A nil source is a drag internal to the origin client: only its own
548+// surfaces see it, without offer.
549+func TestDataDragWithoutSource(t *testing.T) {
550+ p := newDndPair(t)
551+ p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))
552+ dndNextEvent[ports.DataDragStarted](t, p.events)
553+ p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
554+ p.b.none(t)
555+ p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}
556+ if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {
557+ t.Fatalf("enter %+v", ev)
558+ }
559+ p.commands <- ports.DataDragDrop{}
560+ p.a.expect(t, "drop")
561+}
562+
563+// Offer and source requests are validated.
564+func TestDataDragProtocolErrors(t *testing.T) {
565+ t.Run("finish before drop", func(t *testing.T) {
566+ p := newDndPair(t)
567+ p.start(t, 0)
568+ offer := p.enter(t)
569+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
570+ expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidFinish))
571+ })
572+ t.Run("offer action mask", func(t *testing.T) {
573+ p := newDndPair(t)
574+ p.start(t, 0)
575+ offer := p.enter(t)
576+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, uint32(8), uint32(0))
577+ expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidActionMask))
578+ })
579+ t.Run("offer preferred action", func(t *testing.T) {
580+ p := newDndPair(t)
581+ p.start(t, 0)
582+ offer := p.enter(t)
583+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actMove)
584+ expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidAction))
585+ })
586+ t.Run("source action mask", func(t *testing.T) {
587+ p := newDndPair(t)
588+ src := p.a.source(t, 0, "text/plain")
589+ requestProtocol(t, p.a.c, src, wayland.DataSourceRequestSetActions, uint32(16))
590+ expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidActionMask))
591+ })
592+ t.Run("selection with actions", func(t *testing.T) {
593+ p := newDndPair(t)
594+ src := p.a.source(t, actCopy, "text/plain")
595+ requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestSetSelection, src, uint32(0))
596+ expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidSource))
597+ })
598+ t.Run("actions after the drag started", func(t *testing.T) {
599+ p := newDndPair(t)
600+ src := p.start(t, 0)
601+ requestProtocol(t, p.a.c, src, wayland.DataSourceRequestSetActions, actCopy)
602+ expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidSource))
603+ })
604+}
605+
606+// A destroyed offer before finish cancels the source.
607+func TestDataDragOfferDestroyedBeforeFinish(t *testing.T) {
608+ p := newDndPair(t)
609+ p.start(t, 0)
610+ offer := p.enter(t)
611+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
612+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
613+ p.a.expect(t, "target")
614+ p.a.expect(t, "action")
615+ p.commands <- ports.DataDragDrop{}
616+ p.b.expect(t, "offer_action")
617+ p.b.expect(t, "drop")
618+ p.a.expect(t, "drop_performed")
619+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
620+ p.a.expect(t, "cancelled")
621+}
622+
623+// iconContent reports whether the server still holds content for id.
624+func iconContent(t *testing.T, s *Server, id ports.WindowID) bool {
625+ t.Helper()
626+ var ok bool
627+ if !s.display.Do(func() { _, ok = s.latest[id] }) {
628+ t.Fatal("display stopped")
629+ }
630+ return ok
631+}
632+
633+// The icon is a surface with its own window ID: its size and the
634+// offsets of its commits reach core, its content is published, and both go
635+// when the drag ends.
636+func TestDataDragIcon(t *testing.T) {
637+ p := newDndPair(t)
638+ icon := p.a.surfaceNew(t)
639+ buf := p.a.buffer(t, 8, 6)
640+ // An offset on an ordinary surface is not used.
641+ requestProtocol(t, p.a.c, p.a.surf, wayland.SurfaceRequestOffset, int32(5), int32(5))
642+ requestProtocol(t, p.a.c, p.a.surf, wayland.SurfaceRequestCommit)
643+
644+ src := p.a.source(t, actCopy, "text/plain")
645+ p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
646+ started := dndNextEvent[ports.DataDragStarted](t, p.events)
647+ if started.Icon == 0 {
648+ t.Fatal("no icon ID")
649+ }
650+ id := started.Icon
651+ if iconContent(t, p.s, id) {
652+ t.Fatal("content before the first commit")
653+ }
654+
655+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
656+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-3), int32(-4))
657+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
658+ if err := p.a.c.Roundtrip(); err != nil {
659+ t.Fatal(err)
660+ }
661+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -3, Y: -4, W: 8, H: 6}) {
662+ t.Fatalf("icon %+v", got)
663+ }
664+ if !iconContent(t, p.s, id) {
665+ t.Fatal("no icon content")
666+ }
667+
668+ // Offsets add up from commit to commit.
669+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-1), int32(2))
670+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
671+ if err := p.a.c.Roundtrip(); err != nil {
672+ t.Fatal(err)
673+ }
674+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -4, Y: -2, W: 8, H: 6}) {
675+ t.Fatalf("icon %+v", got)
676+ }
677+ // The icon never takes input.
678+ dndNoEvent[ports.InputRegionChanged](t, p.events)
679+
680+ p.commands <- ports.DataDragCancel{}
681+ p.a.expect(t, "cancelled")
682+ if iconContent(t, p.s, id) {
683+ t.Fatal("icon content kept after the drag")
684+ }
685+}
686+
687+// An icon committed before start_drag shows at once.
688+func TestDataDragIconCommittedEarly(t *testing.T) {
689+ p := newDndPair(t)
690+ icon := p.a.surfaceNew(t)
691+ buf := p.a.buffer(t, 4, 4)
692+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
693+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
694+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), icon, p.a.press(t, p.commands))
695+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
696+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, W: 4, H: 4}) {
697+ t.Fatalf("icon %+v", got)
698+ }
699+ if !iconContent(t, p.s, id) {
700+ t.Fatal("no icon content")
701+ }
702+}
703+
704+// Before wl_surface v5 the icon offset rides on attach and adds up.
705+func TestDataDragIconAttachOffset(t *testing.T) {
706+ p := newDndPair(t)
707+ comp4 := bindVersion(t, p.a.c, "wl_compositor", 4)
708+ icon := p.a.c.AllocateID()
709+ requestProtocol(t, p.a.c, comp4, wayland.CompositorRequestCreateSurface, icon)
710+ registerProtocol(t, p.a.c, icon)
711+ buf := p.a.buffer(t, 4, 4)
712+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), icon, p.a.press(t, p.commands))
713+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
714+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(-2), int32(-1))
715+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
716+ if err := p.a.c.Roundtrip(); err != nil {
717+ t.Fatal(err)
718+ }
719+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -2, Y: -1, W: 4, H: 4}) {
720+ t.Fatalf("icon %+v", got)
721+ }
722+}
723+
724+// Destroying the icon mid-drag hides it; the drag goes on.
725+func TestDataDragIconDestroyed(t *testing.T) {
726+ p := newDndPair(t)
727+ icon := p.a.surfaceNew(t)
728+ buf := p.a.buffer(t, 8, 6)
729+ p.a.startDrag(t, p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list"), icon, p.a.press(t, p.commands))
730+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
731+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
732+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
733+ if err := p.a.c.Roundtrip(); err != nil {
734+ t.Fatal(err)
735+ }
736+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got.W != 8 {
737+ t.Fatalf("icon %+v", got)
738+ }
739+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestDestroy)
740+ if err := p.a.c.Roundtrip(); err != nil {
741+ t.Fatal(err)
742+ }
743+ if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id}) {
744+ t.Fatalf("icon %+v", got)
745+ }
746+ if iconContent(t, p.s, id) {
747+ t.Fatal("icon content kept")
748+ }
749+ p.enter(t)
750+}
751+
752+// A7: the session lock cancels a drag at once: target leave, source
753+// cancelled, icon gone.
754+func TestDataDragLockCancels(t *testing.T) {
755+ gate := &sessionsecurity.Gate{}
756+ events := make(chan ports.ClientEvent, 128)
757+ commands := make(chan ports.ClientCommand, 32)
758+ dir := t.TempDir()
759+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, Channels{Events: events, Commands: commands, SecurityChanges: make(chan ports.SecurityState, 16), SecurityEvents: make(chan ports.SecurityBackendEvent, 16)}, logging.For(context.Background(), "wayland"))
760+ if err != nil {
761+ t.Fatal(err)
762+ }
763+ ctx, cancel := context.WithCancel(context.Background())
764+ done := make(chan error, 1)
765+ go func() { done <- s.Run(ctx) }()
766+ t.Cleanup(func() {
767+ cancel()
768+ select {
769+ case err := <-done:
770+ if err != nil {
771+ t.Error(err)
772+ }
773+ case <-time.After(3 * time.Second):
774+ t.Error("server did not stop")
775+ }
776+ })
777+ p := &dndPair{s: s, events: events, commands: commands}
778+ p.a = newDndClient(t, s, dir, events)
779+ p.b = newDndClient(t, s, dir, events)
780+ icon := p.a.surfaceNew(t)
781+ buf := p.a.buffer(t, 8, 6)
782+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
783+ requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
784+ src := p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list")
785+ p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
786+ id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
787+ p.enter(t)
788+
789+ lc := protocolClient(t, s, dir)
790+ if _, err := bindLockManager(t, lc).Lock(); err != nil {
791+ t.Fatal(err)
792+ }
793+ if err := lc.Roundtrip(); err != nil {
794+ t.Fatal(err)
795+ }
796+ p.b.expect(t, "leave")
797+ p.a.expect(t, "cancelled")
798+ if iconContent(t, s, id) {
799+ t.Fatal("icon content kept")
800+ }
801+ // A new drag is refused while protected.
802+ src2 := p.a.source(t, actCopy, "text/plain")
803+ p.a.startDrag(t, src2, 0, uint32(1))
804+ p.a.expect(t, "cancelled")
805+}
M internal/adapters/wayland/input_region.go +2 -1
@@ -82,7 +82,8 @@ func (s *surface) resetInputEmission() {
8282 func (s *surface) emitInput() {
8383 root := s.root()
8484 id := root.windowID()
85- if id == 0 {
85+ if id == 0 || root.kind == roleDragIcon {
86+ // A drag icon takes no input.
8687 return
8788 }
8889 all, rects := root.effectiveInput()
@@ -82,7 +82,8 @@ func (s *surface) resetInputEmission() {
82 func (s *surface) emitInput() {82 func (s *surface) emitInput() {
83 root := s.root()83 root := s.root()
84 id := root.windowID()84 id := root.windowID()
85- if id == 0 {85+ if id == 0 || root.kind == roleDragIcon {
86+ // A drag icon takes no input.
86 return87 return
87 }88 }
88 all, rects := root.effectiveInput()89 all, rects := root.effectiveInput()
M internal/adapters/wayland/server.go +10 -2
@@ -292,7 +292,12 @@ type Server struct {
292292 cursorQueued bool
293293 cursorReady chan struct{}
294294 // Clipboard and primary selection (clipboard.go).
295- selections [2]*clipSource
295+ selections [2]*clipSource
296+ // Drag and drop (dnd.go): the drag in progress, drops waiting for
297+ // their finish, and each client's data devices.
298+ dnd *dndState
299+ dndDrops []*dndTarget
300+ dataDevices map[server.Client][]*wayland.DataDevice
296301 clipDevices []*clipDevice
297302 dataSources, primarySources, controlSources map[*server.Resource]*clipSource
298303 // Pointer constraints (constraints.go).
@@ -786,13 +791,16 @@ func (s *Server) apply(cmd ports.ClientCommand) {
786791 // Only input/focus commands require an owner envelope. Config relay
787792 // also produces SetKeymap and other epoch-independent commands.
788793 switch cmd.(type) {
789- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey:
794+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey,
795+ ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
790796 return
791797 }
792798 }
793799 switch c := cmd.(type) {
794800 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
795801 s.applyInput(c)
802+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
803+ s.applyDrag(c)
796804 case ports.ConfigurePopup:
797805 if w := s.windows[c.ID]; w != nil && w.popup != nil {
798806 w.popup.configure(c)
@@ -292,7 +292,12 @@ type Server struct {
292 cursorQueued bool292 cursorQueued bool
293 cursorReady chan struct{}293 cursorReady chan struct{}
294 // Clipboard and primary selection (clipboard.go).294 // Clipboard and primary selection (clipboard.go).
295- selections [2]*clipSource295+ selections [2]*clipSource
296+ // Drag and drop (dnd.go): the drag in progress, drops waiting for
297+ // their finish, and each client's data devices.
298+ dnd *dndState
299+ dndDrops []*dndTarget
300+ dataDevices map[server.Client][]*wayland.DataDevice
296 clipDevices []*clipDevice301 clipDevices []*clipDevice
297 dataSources, primarySources, controlSources map[*server.Resource]*clipSource302 dataSources, primarySources, controlSources map[*server.Resource]*clipSource
298 // Pointer constraints (constraints.go).303 // Pointer constraints (constraints.go).
@@ -786,13 +791,16 @@ func (s *Server) apply(cmd ports.ClientCommand) {
786 // Only input/focus commands require an owner envelope. Config relay791 // Only input/focus commands require an owner envelope. Config relay
787 // also produces SetKeymap and other epoch-independent commands.792 // also produces SetKeymap and other epoch-independent commands.
788 switch cmd.(type) {793 switch cmd.(type) {
789- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey:794+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey,
795+ ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
790 return796 return
791 }797 }
792 }798 }
793 switch c := cmd.(type) {799 switch c := cmd.(type) {
794 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:800 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
795 s.applyInput(c)801 s.applyInput(c)
802+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
803+ s.applyDrag(c)
796 case ports.ConfigurePopup:804 case ports.ConfigurePopup:
797 if w := s.windows[c.ID]; w != nil && w.popup != nil {805 if w := s.windows[c.ID]; w != nil && w.popup != nil {
798 w.popup.configure(c)806 w.popup.configure(c)
M internal/adapters/wayland/session_lock.go +1 -0
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138138 }
139139
140140 func (s *Server) resetProtectionInput() {
141+ s.dndCancelAll()
141142 s.changeFocus(0)
142143 s.changePointerFocus(0, 0, 0)
143144 s.deactivateText()
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138 }138 }
139 139
140 func (s *Server) resetProtectionInput() {140 func (s *Server) resetProtectionInput() {
141+ s.dndCancelAll()
141 s.changeFocus(0)142 s.changeFocus(0)
142 s.changePointerFocus(0, 0, 0)143 s.changePointerFocus(0, 0, 0)
143 s.deactivateText()144 s.deactivateText()
M internal/adapters/wayland/surface.go +25 -3
@@ -26,6 +26,8 @@ const (
2626 roleCursor
2727 roleInputPopup
2828 roleSessionLock
29+ // roleDragIcon is the icon of a data drag (dnd.go).
30+ roleDragIcon
2931 )
3032
3133 type surface struct {
@@ -137,6 +139,9 @@ type pendingCommit struct {
137139 // barrier and wait are fifo requests; at the commit timestamp.
138140 barrier, wait bool
139141 at time.Time
142+ // dx, dy accumulate the attach offsets and wl_surface.offset requests;
143+ // only a drag icon uses them.
144+ dx, dy int32
140145 // damage is in surface (logical) pixels, bufDamage in buffer pixels.
141146 damage, bufDamage []ports.Rect
142147 // feedback are wp_presentation feedbacks for this commit.
@@ -192,6 +197,9 @@ func (s *surface) windowID() ports.WindowID {
192197 if s.lock != nil && s.lock.mapped {
193198 return s.lock.id
194199 }
200+ if s.kind == roleDragIcon && s.server.dnd != nil && s.server.dnd.icon == s {
201+ return s.server.dnd.iconID
202+ }
195203 return 0
196204 }
197205
@@ -343,10 +351,17 @@ func (s *surface) Destroy(*wayland.Surface) {
343351 if c := s.server.constraints[s]; c != nil {
344352 s.server.dropConstraint(c)
345353 }
354+ s.server.dndSurfaceGone(s)
346355 }
347-func (s *surface) Attach(_ *wayland.Surface, b *wayland.Buffer, _, _ int32) {
356+
357+// Attach: before wl_surface v5 the offset rides on attach; only a drag
358+// icon uses it.
359+func (s *surface) Attach(r *wayland.Surface, b *wayland.Buffer, dx, dy int32) {
348360 s.next.buffer = b
349361 s.next.attached = true
362+ if r.Version() < 5 {
363+ s.next.dx, s.next.dy = s.next.dx+dx, s.next.dy+dy
364+ }
350365 }
351366 func (s *surface) Frame(r *wayland.Surface, id uint32) {
352367 cb, err := wayland.NewCallback(r.Client(), 1, id, struct{}{})
@@ -544,6 +559,9 @@ func (s *surface) applyCommit(u *update) {
544559 }
545560 s.commitConstraint(u.cons, u.region, geometry)
546561 s.emitInput()
562+ if s.kind == roleDragIcon {
563+ s.server.dndIconCommit(s, u.dx, u.dy)
564+ }
547565 if s.xdg != nil && s.xdg.window != nil {
548566 s.xdg.window.afterCommit()
549567 }
@@ -757,5 +775,9 @@ func (s *surface) SetBufferScale(r *wayland.Surface, v int32) {
757775 }
758776 s.next.scale = int(v)
759777 }
760-func (*surface) Offset(*wayland.Surface, int32, int32) {}
761-func (*surface) GetRelease(*wayland.Surface, uint32) {}
778+
779+// Offset moves the next commit's surface (v5); only a drag icon uses it.
780+func (s *surface) Offset(_ *wayland.Surface, dx, dy int32) {
781+ s.next.dx, s.next.dy = s.next.dx+dx, s.next.dy+dy
782+}
783+func (*surface) GetRelease(*wayland.Surface, uint32) {}
@@ -26,6 +26,8 @@ const (
26 roleCursor26 roleCursor
27 roleInputPopup27 roleInputPopup
28 roleSessionLock28 roleSessionLock
29+ // roleDragIcon is the icon of a data drag (dnd.go).
30+ roleDragIcon
29 )31 )
30 32
31 type surface struct {33 type surface struct {
@@ -137,6 +139,9 @@ type pendingCommit struct {
137 // barrier and wait are fifo requests; at the commit timestamp.139 // barrier and wait are fifo requests; at the commit timestamp.
138 barrier, wait bool140 barrier, wait bool
139 at time.Time141 at time.Time
142+ // dx, dy accumulate the attach offsets and wl_surface.offset requests;
143+ // only a drag icon uses them.
144+ dx, dy int32
140 // damage is in surface (logical) pixels, bufDamage in buffer pixels.145 // damage is in surface (logical) pixels, bufDamage in buffer pixels.
141 damage, bufDamage []ports.Rect146 damage, bufDamage []ports.Rect
142 // feedback are wp_presentation feedbacks for this commit.147 // feedback are wp_presentation feedbacks for this commit.
@@ -192,6 +197,9 @@ func (s *surface) windowID() ports.WindowID {
192 if s.lock != nil && s.lock.mapped {197 if s.lock != nil && s.lock.mapped {
193 return s.lock.id198 return s.lock.id
194 }199 }
200+ if s.kind == roleDragIcon && s.server.dnd != nil && s.server.dnd.icon == s {
201+ return s.server.dnd.iconID
202+ }
195 return 0203 return 0
196 }204 }
197 205
@@ -343,10 +351,17 @@ func (s *surface) Destroy(*wayland.Surface) {
343 if c := s.server.constraints[s]; c != nil {351 if c := s.server.constraints[s]; c != nil {
344 s.server.dropConstraint(c)352 s.server.dropConstraint(c)
345 }353 }
354+ s.server.dndSurfaceGone(s)
346 }355 }
347-func (s *surface) Attach(_ *wayland.Surface, b *wayland.Buffer, _, _ int32) {356+
357+// Attach: before wl_surface v5 the offset rides on attach; only a drag
358+// icon uses it.
359+func (s *surface) Attach(r *wayland.Surface, b *wayland.Buffer, dx, dy int32) {
348 s.next.buffer = b360 s.next.buffer = b
349 s.next.attached = true361 s.next.attached = true
362+ if r.Version() < 5 {
363+ s.next.dx, s.next.dy = s.next.dx+dx, s.next.dy+dy
364+ }
350 }365 }
351 func (s *surface) Frame(r *wayland.Surface, id uint32) {366 func (s *surface) Frame(r *wayland.Surface, id uint32) {
352 cb, err := wayland.NewCallback(r.Client(), 1, id, struct{}{})367 cb, err := wayland.NewCallback(r.Client(), 1, id, struct{}{})
@@ -544,6 +559,9 @@ func (s *surface) applyCommit(u *update) {
544 }559 }
545 s.commitConstraint(u.cons, u.region, geometry)560 s.commitConstraint(u.cons, u.region, geometry)
546 s.emitInput()561 s.emitInput()
562+ if s.kind == roleDragIcon {
563+ s.server.dndIconCommit(s, u.dx, u.dy)
564+ }
547 if s.xdg != nil && s.xdg.window != nil {565 if s.xdg != nil && s.xdg.window != nil {
548 s.xdg.window.afterCommit()566 s.xdg.window.afterCommit()
549 }567 }
@@ -757,5 +775,9 @@ func (s *surface) SetBufferScale(r *wayland.Surface, v int32) {
757 }775 }
758 s.next.scale = int(v)776 s.next.scale = int(v)
759 }777 }
760-func (*surface) Offset(*wayland.Surface, int32, int32) {}778+
761-func (*surface) GetRelease(*wayland.Surface, uint32) {}779+// Offset moves the next commit's surface (v5); only a drag icon uses it.
780+func (s *surface) Offset(_ *wayland.Surface, dx, dy int32) {
781+ s.next.dx, s.next.dy = s.next.dx+dx, s.next.dy+dy
782+}
783+func (*surface) GetRelease(*wayland.Surface, uint32) {}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 24

Tags 10

↑↓ moveenter openesc close