detached 91f7d4a

Merge branch 'fix/review-dnd' into fix/release-review

commit
91f7d4af6e173f50022cbc2d8e09b60f1d63aa70
author
brice · · 2026-10-19 18:49 UTC
merge of
353b4e3 + ef7b51c · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +232 -40
M internal/adapters/wayland/dnd.go +27 -7
@@ -58,9 +58,11 @@ type dndTarget struct {
5858 surface *wayland.Surface
5959 devices []*wayland.DataDevice
6060 offers []*dndOffer
61- // accepted is the MIME type the target accepted; action the action
62- // chosen for the source.
61+ // accepted is the MIME type the target accepted, owned by acceptor.
62+ // action is the shared result of the latest set_actions request,
63+ // broadcast to every offer so drop and finish use the same result.
6364 accepted string
65+ acceptor *dndOffer
6466 action uint32
6567 state int
6668 // legacy: no device speaks wl_data_offer v3: the target copies and
@@ -118,6 +120,9 @@ func (s *Server) addDataDevice(c server.Client, dev *wayland.DataDevice) {
118120 list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })
119121 if len(list) == 0 {
120122 delete(s.dataDevices, c)
123+ if d := s.dnd; d != nil && d.target != nil && d.target.surface.Client() == c {
124+ s.dndLeave(d)
125+ }
121126 // An internal drag has nobody left to tell.
122127 if d := s.dnd; d != nil && d.client == c && d.src == nil {
123128 s.dndEnd(d, false, true)
@@ -391,7 +396,7 @@ func (s *Server) dndDrop(d *dndState) {
391396 }
392397 }
393398 }
394- if t.accepted == "" || t.action == 0 {
399+ if !t.legacy && t.accepted == "" || t.action == 0 {
395400 s.dndEnd(d, true, false)
396401 return
397402 }
@@ -487,6 +492,16 @@ func (s *Server) dndCancelAll() {
487492 func (s *Server) dndOfferGone(o *dndOffer) {
488493 t := o.t
489494 t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })
495+ if t.state == targetActive && t.acceptor == o {
496+ t.acceptor = nil
497+ s.dndResetSource(t)
498+ for _, offer := range t.offers {
499+ if offer.sent != 0 && offer.res.Version() >= 3 {
500+ offer.sent = 0
501+ offer.res.SendAction(0)
502+ }
503+ }
504+ }
490505 if len(t.offers) != 0 {
491506 return
492507 }
@@ -520,10 +535,14 @@ func (o *dndOffer) update() {
520535 src.res.SendAction(action)
521536 }
522537 }
523- if t.state == targetActive && action != o.sent {
524- o.sent = action
525- if o.res.Version() >= 3 {
526- o.res.SendAction(action)
538+ if t.state == targetActive {
539+ for _, offer := range t.offers {
540+ if action != offer.sent {
541+ offer.sent = action
542+ if offer.res.Version() >= 3 {
543+ offer.res.SendAction(action)
544+ }
545+ }
527546 }
528547 }
529548 }
@@ -534,6 +553,7 @@ func (o *dndOffer) Accept(_ *wayland.DataOffer, _ uint32, mime string) {
534553 return
535554 }
536555 t.accepted = mime
556+ t.acceptor = o
537557 t.d.src.res.SendTarget(mime)
538558 }
539559
@@ -58,9 +58,11 @@ type dndTarget struct {
58 surface *wayland.Surface58 surface *wayland.Surface
59 devices []*wayland.DataDevice59 devices []*wayland.DataDevice
60 offers []*dndOffer60 offers []*dndOffer
61- // accepted is the MIME type the target accepted; action the action61+ // accepted is the MIME type the target accepted, owned by acceptor.
62- // chosen for the source.62+ // action is the shared result of the latest set_actions request,
63+ // broadcast to every offer so drop and finish use the same result.
63 accepted string64 accepted string
65+ acceptor *dndOffer
64 action uint3266 action uint32
65 state int67 state int
66 // legacy: no device speaks wl_data_offer v3: the target copies and68 // legacy: no device speaks wl_data_offer v3: the target copies and
@@ -118,6 +120,9 @@ func (s *Server) addDataDevice(c server.Client, dev *wayland.DataDevice) {
118 list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })120 list := slices.DeleteFunc(slices.Clone(s.dataDevices[c]), func(x *wayland.DataDevice) bool { return x == dev })
119 if len(list) == 0 {121 if len(list) == 0 {
120 delete(s.dataDevices, c)122 delete(s.dataDevices, c)
123+ if d := s.dnd; d != nil && d.target != nil && d.target.surface.Client() == c {
124+ s.dndLeave(d)
125+ }
121 // An internal drag has nobody left to tell.126 // An internal drag has nobody left to tell.
122 if d := s.dnd; d != nil && d.client == c && d.src == nil {127 if d := s.dnd; d != nil && d.client == c && d.src == nil {
123 s.dndEnd(d, false, true)128 s.dndEnd(d, false, true)
@@ -391,7 +396,7 @@ func (s *Server) dndDrop(d *dndState) {
391 }396 }
392 }397 }
393 }398 }
394- if t.accepted == "" || t.action == 0 {399+ if !t.legacy && t.accepted == "" || t.action == 0 {
395 s.dndEnd(d, true, false)400 s.dndEnd(d, true, false)
396 return401 return
397 }402 }
@@ -487,6 +492,16 @@ func (s *Server) dndCancelAll() {
487 func (s *Server) dndOfferGone(o *dndOffer) {492 func (s *Server) dndOfferGone(o *dndOffer) {
488 t := o.t493 t := o.t
489 t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })494 t.offers = slices.DeleteFunc(t.offers, func(x *dndOffer) bool { return x == o })
495+ if t.state == targetActive && t.acceptor == o {
496+ t.acceptor = nil
497+ s.dndResetSource(t)
498+ for _, offer := range t.offers {
499+ if offer.sent != 0 && offer.res.Version() >= 3 {
500+ offer.sent = 0
501+ offer.res.SendAction(0)
502+ }
503+ }
504+ }
490 if len(t.offers) != 0 {505 if len(t.offers) != 0 {
491 return506 return
492 }507 }
@@ -520,10 +535,14 @@ func (o *dndOffer) update() {
520 src.res.SendAction(action)535 src.res.SendAction(action)
521 }536 }
522 }537 }
523- if t.state == targetActive && action != o.sent {538+ if t.state == targetActive {
524- o.sent = action539+ for _, offer := range t.offers {
525- if o.res.Version() >= 3 {540+ if action != offer.sent {
526- o.res.SendAction(action)541+ offer.sent = action
542+ if offer.res.Version() >= 3 {
543+ offer.res.SendAction(action)
544+ }
545+ }
527 }546 }
528 }547 }
529 }548 }
@@ -534,6 +553,7 @@ func (o *dndOffer) Accept(_ *wayland.DataOffer, _ uint32, mime string) {
534 return553 return
535 }554 }
536 t.accepted = mime555 t.accepted = mime
556+ t.acceptor = o
537 t.d.src.res.SendTarget(mime)557 t.d.src.res.SendTarget(mime)
538 }558 }
539 559
M internal/adapters/wayland/dnd_test.go +137 -31
@@ -198,13 +198,13 @@ func (d *dndClient) expect(t *testing.T, name string) dndEvent {
198198 }
199199
200200 // none checks that no event arrives.
201-func (d *dndClient) none(t *testing.T) {
201+func (d *dndClient) none(t *testing.T, commands chan<- ports.ClientCommand) {
202202 t.Helper()
203- for range 2 {
204- if err := d.c.Roundtrip(); err != nil {
205- t.Fatal(err)
206- }
207- time.Sleep(30 * time.Millisecond)
203+ // Observing a key sent through the command queue proves earlier
204+ // compositor commands ran. The roundtrip then drains wire events.
205+ d.key(t, commands)
206+ if err := d.c.Roundtrip(); err != nil {
207+ t.Fatal(err)
208208 }
209209 select {
210210 case ev := <-d.log:
@@ -366,17 +366,26 @@ func dndNextEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientE
366366 }
367367
368368 // dndNoEvent checks that no client event of type T is pending.
369-func dndNoEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) {
369+func dndNoEvent[T ports.ClientEvent](t *testing.T, s *Server, events <-chan ports.ClientEvent) {
370370 t.Helper()
371- time.Sleep(50 * time.Millisecond)
371+ // Callers have observed completion of the triggering request/command.
372+ // Put a marker behind its emitted events, including the forwarding queue.
373+ marker := ports.WindowResized{ID: ^ports.WindowID(0)}
374+ if !s.display.Do(func() { s.emit(marker) }) {
375+ t.Fatal("display stopped")
376+ }
377+ deadline := time.After(2 * time.Second)
372378 for {
373379 select {
374380 case ev := <-events:
381+ if v, ok := ev.(ports.WindowResized); ok && v.ID == marker.ID {
382+ return
383+ }
375384 if _, ok := ev.(T); ok {
376385 t.Fatalf("unexpected %T: %+v", ev, ev)
377386 }
378- default:
379- return
387+ case <-deadline:
388+ t.Fatal("no event barrier")
380389 }
381390 }
382391 }
@@ -483,10 +492,10 @@ func TestDataDragBetweenClients(t *testing.T) {
483492 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
484493 p.a.expect(t, "finished")
485494 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
486- p.a.none(t)
487- p.b.none(t)
495+ p.a.none(t, p.commands)
496+ p.b.none(t, p.commands)
488497 // core ended the drag: wayland does not echo it.
489- dndNoEvent[ports.DataDragEnded](t, p.events)
498+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
490499 }
491500
492501 // The action follows wlroots: the preferred one if both allow it, else the
@@ -534,7 +543,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
534543 p.start(t, 0)
535544 p.commands <- ports.DataDragDrop{}
536545 p.a.expect(t, "cancelled")
537- p.b.none(t)
546+ p.b.none(t, p.commands)
538547 })
539548 t.Run("not accepted", func(t *testing.T) {
540549 p := newDndPair(t)
@@ -543,7 +552,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
543552 p.commands <- ports.DataDragDrop{}
544553 p.b.expect(t, "leave")
545554 p.a.expect(t, "cancelled")
546- p.b.none(t)
555+ p.b.none(t, p.commands)
547556 })
548557 t.Run("escape", func(t *testing.T) {
549558 p := newDndPair(t)
@@ -552,7 +561,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
552561 p.commands <- ports.DataDragCancel{}
553562 p.b.expect(t, "leave")
554563 p.a.expect(t, "cancelled")
555- dndNoEvent[ports.DataDragEnded](t, p.events)
564+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
556565 })
557566 }
558567
@@ -587,7 +596,7 @@ func TestDataDragSourceDestroyed(t *testing.T) {
587596 dndNextEvent[ports.DataDragEnded](t, p.events)
588597 p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}
589598 p.commands <- ports.DataDragDrop{}
590- p.b.none(t)
599+ p.b.none(t, p.commands)
591600 }
592601
593602 // A drag whose origin client dies ends the same way.
@@ -608,7 +617,7 @@ func TestDataDragInvalidStart(t *testing.T) {
608617 serial := p.a.press(t, p.commands)
609618 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)
610619 p.a.expect(t, "cancelled")
611- dndNoEvent[ports.DataDragStarted](t, p.events)
620+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
612621 })
613622 t.Run("other client's press", func(t *testing.T) {
614623 p := newDndPair(t)
@@ -616,7 +625,7 @@ func TestDataDragInvalidStart(t *testing.T) {
616625 serial := p.b.press(t, p.commands)
617626 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
618627 p.a.expect(t, "cancelled")
619- dndNoEvent[ports.DataDragStarted](t, p.events)
628+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
620629 })
621630 t.Run("key serial", func(t *testing.T) {
622631 p := newDndPair(t)
@@ -625,7 +634,7 @@ func TestDataDragInvalidStart(t *testing.T) {
625634 serial := p.a.key(t, p.commands)
626635 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
627636 p.a.expect(t, "cancelled")
628- dndNoEvent[ports.DataDragStarted](t, p.events)
637+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
629638 })
630639 t.Run("serial after the release", func(t *testing.T) {
631640 p := newDndPair(t)
@@ -633,7 +642,7 @@ func TestDataDragInvalidStart(t *testing.T) {
633642 p.a.release(t, p.commands)
634643 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
635644 p.a.expect(t, "cancelled")
636- dndNoEvent[ports.DataDragStarted](t, p.events)
645+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
637646 })
638647 t.Run("other surface of the client", func(t *testing.T) {
639648 p := newDndPair(t)
@@ -641,7 +650,7 @@ func TestDataDragInvalidStart(t *testing.T) {
641650 other := p.a.surfaceNew(t)
642651 p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial)
643652 p.a.expect(t, "cancelled")
644- dndNoEvent[ports.DataDragStarted](t, p.events)
653+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
645654 })
646655 t.Run("press of the first button", func(t *testing.T) {
647656 // A second button does not make a new grab: only the first serial.
@@ -675,7 +684,7 @@ func TestDataDragInvalidStart(t *testing.T) {
675684 p.a.key(t, p.commands) // the release is applied once the key is seen
676685 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
677686 p.a.expect(t, "cancelled")
678- dndNoEvent[ports.DataDragStarted](t, p.events)
687+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
679688 })
680689 t.Run("used source", func(t *testing.T) {
681690 p := newDndPair(t)
@@ -692,7 +701,7 @@ func TestDataDragInvalidStart(t *testing.T) {
692701 other := p.a.source(t, actCopy, "text/plain")
693702 p.a.startDrag(t, other, 0, p.a.press(t, p.commands))
694703 p.a.expect(t, "cancelled")
695- dndNoEvent[ports.DataDragStarted](t, p.events)
704+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
696705 })
697706 t.Run("icon with another role", func(t *testing.T) {
698707 p := newDndPair(t)
@@ -701,7 +710,7 @@ func TestDataDragInvalidStart(t *testing.T) {
701710 // The origin toplevel is a role already.
702711 requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)
703712 expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))
704- dndNoEvent[ports.DataDragStarted](t, p.events)
713+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
705714 })
706715 }
707716
@@ -712,7 +721,7 @@ func TestDataDragWithoutSource(t *testing.T) {
712721 p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))
713722 dndNextEvent[ports.DataDragStarted](t, p.events)
714723 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
715- p.b.none(t)
724+ p.b.none(t, p.commands)
716725 p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}
717726 if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {
718727 t.Fatalf("enter %+v", ev)
@@ -837,7 +846,7 @@ func TestDataDragIcon(t *testing.T) {
837846 t.Fatalf("icon %+v", got)
838847 }
839848 // The icon never takes input.
840- dndNoEvent[ports.InputRegionChanged](t, p.events)
849+ dndNoEvent[ports.InputRegionChanged](t, p.s, p.events)
841850
842851 p.commands <- ports.DataDragCancel{}
843852 p.a.expect(t, "cancelled")
@@ -1064,8 +1073,8 @@ func TestDataDragAsk(t *testing.T) {
10641073 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain")
10651074 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
10661075 p.a.expect(t, "target")
1067- p.b.none(t) // no offer.action after the drop
1068- p.a.none(t) // the final action waits for the finish
1076+ p.b.none(t, p.commands) // no offer.action after the drop
1077+ p.a.none(t, p.commands) // the final action waits for the finish
10691078 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish)
10701079 if ev := p.a.expect(t, "action"); ev.n != actMove {
10711080 t.Fatalf("action %+v", ev)
@@ -1104,8 +1113,7 @@ func TestDataDragLegacyTarget(t *testing.T) {
11041113 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
11051114 p.b.until(t, "enter")
11061115 id := p.b.lastOffer(t)
1107- requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1108- p.a.expect(t, "target")
1116+ // Legacy accept is feedback only: deliberately never send it.
11091117 return p, id
11101118 }
11111119 t.Run("move only", func(t *testing.T) {
@@ -1152,3 +1160,101 @@ func TestDataDragIconOffsetReplacedBeforeCommit(t *testing.T) {
11521160 t.Fatalf("icon %+v", got)
11531161 }
11541162 }
1163+
1164+func TestDataDragLastTargetDeviceReleased(t *testing.T) {
1165+ p := newDndPair(t)
1166+ p.start(t, 0)
1167+ offer := p.enter(t)
1168+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1169+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
1170+ p.a.expect(t, "target")
1171+ p.a.expect(t, "action")
1172+ p.b.expect(t, "offer_action")
1173+ requestProtocol(t, p.b.c, p.b.device.ID(), wayland.DataDeviceRequestRelease)
1174+ if err := p.b.c.Roundtrip(); err != nil {
1175+ t.Fatal(err)
1176+ }
1177+ p.commands <- ports.DataDragDrop{}
1178+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1179+ t.Fatalf("target %+v", ev)
1180+ }
1181+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1182+ t.Fatalf("action %+v", ev)
1183+ }
1184+ p.a.expect(t, "cancelled")
1185+ // The offer was deliberately kept alive; it must not keep a pending drop.
1186+ if !p.s.display.Do(func() {
1187+ if len(p.s.dndDrops) != 0 {
1188+ t.Error("pending drop without a device")
1189+ }
1190+ }) {
1191+ t.Fatal("display stopped")
1192+ }
1193+}
1194+
1195+func TestDataDragMultipleOffersShareAction(t *testing.T) {
1196+ for _, name := range []string{"finish", "acceptor destroyed"} {
1197+ t.Run(name, func(t *testing.T) {
1198+ p := newDndPair(t)
1199+ seat := bindVersion(t, p.b.c, "wl_seat", 8)
1200+ second := &dndDeviceEvents{c: p.b.c, log: make(chan dndEvent, 64)}
1201+ second.SetID(p.b.c.AllocateID())
1202+ registerWireProxy(p.b.c, second)
1203+ requestProtocol(t, p.b.c, p.b.manager, wayland.DataDeviceManagerRequestGetDataDevice, second.ID(), seat)
1204+ if err := p.b.c.Roundtrip(); err != nil {
1205+ t.Fatal(err)
1206+ }
1207+ p.start(t, 0)
1208+ first := p.enter(t)
1209+ other := &dndClient{c: p.b.c, device: second, log: second.log}
1210+ other.until(t, "enter")
1211+ two := other.lastOffer(t)
1212+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1213+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestSetActions, actCopy|actMove, actCopy)
1214+ p.a.expect(t, "target")
1215+ p.a.expect(t, "action")
1216+ for _, d := range []*dndClient{p.b, other} {
1217+ if ev := d.expect(t, "offer_action"); ev.n != actCopy {
1218+ t.Fatalf("copy: %+v", ev)
1219+ }
1220+ }
1221+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
1222+ if ev := p.a.expect(t, "action"); ev.n != actMove {
1223+ t.Fatalf("source action %+v", ev)
1224+ }
1225+ for _, d := range []*dndClient{p.b, other} {
1226+ if ev := d.expect(t, "offer_action"); ev.n != actMove {
1227+ t.Fatalf("move: %+v", ev)
1228+ }
1229+ }
1230+ if name == "finish" {
1231+ p.commands <- ports.DataDragDrop{}
1232+ for _, d := range []*dndClient{p.b, other} {
1233+ d.expect(t, "drop")
1234+ d.expect(t, "leave")
1235+ }
1236+ p.a.expect(t, "drop_performed")
1237+ // Either offer finishes the same negotiated move transfer.
1238+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestFinish)
1239+ p.a.expect(t, "finished")
1240+ return
1241+ }
1242+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestDestroy)
1243+ if err := p.b.c.Roundtrip(); err != nil {
1244+ t.Fatal(err)
1245+ }
1246+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1247+ t.Fatalf("stale acceptance %+v", ev)
1248+ }
1249+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1250+ t.Fatalf("stale action %+v", ev)
1251+ }
1252+ if ev := other.expect(t, "offer_action"); ev.n != 0 {
1253+ t.Fatalf("remaining offer %+v", ev)
1254+ }
1255+ p.commands <- ports.DataDragDrop{}
1256+ p.a.expect(t, "cancelled")
1257+ other.expect(t, "leave")
1258+ })
1259+ }
1260+}
@@ -198,13 +198,13 @@ func (d *dndClient) expect(t *testing.T, name string) dndEvent {
198 }198 }
199 199
200 // none checks that no event arrives.200 // none checks that no event arrives.
201-func (d *dndClient) none(t *testing.T) {201+func (d *dndClient) none(t *testing.T, commands chan<- ports.ClientCommand) {
202 t.Helper()202 t.Helper()
203- for range 2 {203+ // Observing a key sent through the command queue proves earlier
204- if err := d.c.Roundtrip(); err != nil {204+ // compositor commands ran. The roundtrip then drains wire events.
205- t.Fatal(err)205+ d.key(t, commands)
206- }206+ if err := d.c.Roundtrip(); err != nil {
207- time.Sleep(30 * time.Millisecond)207+ t.Fatal(err)
208 }208 }
209 select {209 select {
210 case ev := <-d.log:210 case ev := <-d.log:
@@ -366,17 +366,26 @@ func dndNextEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientE
366 }366 }
367 367
368 // dndNoEvent checks that no client event of type T is pending.368 // dndNoEvent checks that no client event of type T is pending.
369-func dndNoEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) {369+func dndNoEvent[T ports.ClientEvent](t *testing.T, s *Server, events <-chan ports.ClientEvent) {
370 t.Helper()370 t.Helper()
371- time.Sleep(50 * time.Millisecond)371+ // Callers have observed completion of the triggering request/command.
372+ // Put a marker behind its emitted events, including the forwarding queue.
373+ marker := ports.WindowResized{ID: ^ports.WindowID(0)}
374+ if !s.display.Do(func() { s.emit(marker) }) {
375+ t.Fatal("display stopped")
376+ }
377+ deadline := time.After(2 * time.Second)
372 for {378 for {
373 select {379 select {
374 case ev := <-events:380 case ev := <-events:
381+ if v, ok := ev.(ports.WindowResized); ok && v.ID == marker.ID {
382+ return
383+ }
375 if _, ok := ev.(T); ok {384 if _, ok := ev.(T); ok {
376 t.Fatalf("unexpected %T: %+v", ev, ev)385 t.Fatalf("unexpected %T: %+v", ev, ev)
377 }386 }
378- default:387+ case <-deadline:
379- return388+ t.Fatal("no event barrier")
380 }389 }
381 }390 }
382 }391 }
@@ -483,10 +492,10 @@ func TestDataDragBetweenClients(t *testing.T) {
483 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)492 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
484 p.a.expect(t, "finished")493 p.a.expect(t, "finished")
485 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)494 requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
486- p.a.none(t)495+ p.a.none(t, p.commands)
487- p.b.none(t)496+ p.b.none(t, p.commands)
488 // core ended the drag: wayland does not echo it.497 // core ended the drag: wayland does not echo it.
489- dndNoEvent[ports.DataDragEnded](t, p.events)498+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
490 }499 }
491 500
492 // The action follows wlroots: the preferred one if both allow it, else the501 // The action follows wlroots: the preferred one if both allow it, else the
@@ -534,7 +543,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
534 p.start(t, 0)543 p.start(t, 0)
535 p.commands <- ports.DataDragDrop{}544 p.commands <- ports.DataDragDrop{}
536 p.a.expect(t, "cancelled")545 p.a.expect(t, "cancelled")
537- p.b.none(t)546+ p.b.none(t, p.commands)
538 })547 })
539 t.Run("not accepted", func(t *testing.T) {548 t.Run("not accepted", func(t *testing.T) {
540 p := newDndPair(t)549 p := newDndPair(t)
@@ -543,7 +552,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
543 p.commands <- ports.DataDragDrop{}552 p.commands <- ports.DataDragDrop{}
544 p.b.expect(t, "leave")553 p.b.expect(t, "leave")
545 p.a.expect(t, "cancelled")554 p.a.expect(t, "cancelled")
546- p.b.none(t)555+ p.b.none(t, p.commands)
547 })556 })
548 t.Run("escape", func(t *testing.T) {557 t.Run("escape", func(t *testing.T) {
549 p := newDndPair(t)558 p := newDndPair(t)
@@ -552,7 +561,7 @@ func TestDataDragRefusedDrop(t *testing.T) {
552 p.commands <- ports.DataDragCancel{}561 p.commands <- ports.DataDragCancel{}
553 p.b.expect(t, "leave")562 p.b.expect(t, "leave")
554 p.a.expect(t, "cancelled")563 p.a.expect(t, "cancelled")
555- dndNoEvent[ports.DataDragEnded](t, p.events)564+ dndNoEvent[ports.DataDragEnded](t, p.s, p.events)
556 })565 })
557 }566 }
558 567
@@ -587,7 +596,7 @@ func TestDataDragSourceDestroyed(t *testing.T) {
587 dndNextEvent[ports.DataDragEnded](t, p.events)596 dndNextEvent[ports.DataDragEnded](t, p.events)
588 p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}597 p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}
589 p.commands <- ports.DataDragDrop{}598 p.commands <- ports.DataDragDrop{}
590- p.b.none(t)599+ p.b.none(t, p.commands)
591 }600 }
592 601
593 // A drag whose origin client dies ends the same way.602 // A drag whose origin client dies ends the same way.
@@ -608,7 +617,7 @@ func TestDataDragInvalidStart(t *testing.T) {
608 serial := p.a.press(t, p.commands)617 serial := p.a.press(t, p.commands)
609 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)618 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)
610 p.a.expect(t, "cancelled")619 p.a.expect(t, "cancelled")
611- dndNoEvent[ports.DataDragStarted](t, p.events)620+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
612 })621 })
613 t.Run("other client's press", func(t *testing.T) {622 t.Run("other client's press", func(t *testing.T) {
614 p := newDndPair(t)623 p := newDndPair(t)
@@ -616,7 +625,7 @@ func TestDataDragInvalidStart(t *testing.T) {
616 serial := p.b.press(t, p.commands)625 serial := p.b.press(t, p.commands)
617 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)626 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
618 p.a.expect(t, "cancelled")627 p.a.expect(t, "cancelled")
619- dndNoEvent[ports.DataDragStarted](t, p.events)628+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
620 })629 })
621 t.Run("key serial", func(t *testing.T) {630 t.Run("key serial", func(t *testing.T) {
622 p := newDndPair(t)631 p := newDndPair(t)
@@ -625,7 +634,7 @@ func TestDataDragInvalidStart(t *testing.T) {
625 serial := p.a.key(t, p.commands)634 serial := p.a.key(t, p.commands)
626 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)635 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
627 p.a.expect(t, "cancelled")636 p.a.expect(t, "cancelled")
628- dndNoEvent[ports.DataDragStarted](t, p.events)637+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
629 })638 })
630 t.Run("serial after the release", func(t *testing.T) {639 t.Run("serial after the release", func(t *testing.T) {
631 p := newDndPair(t)640 p := newDndPair(t)
@@ -633,7 +642,7 @@ func TestDataDragInvalidStart(t *testing.T) {
633 p.a.release(t, p.commands)642 p.a.release(t, p.commands)
634 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)643 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
635 p.a.expect(t, "cancelled")644 p.a.expect(t, "cancelled")
636- dndNoEvent[ports.DataDragStarted](t, p.events)645+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
637 })646 })
638 t.Run("other surface of the client", func(t *testing.T) {647 t.Run("other surface of the client", func(t *testing.T) {
639 p := newDndPair(t)648 p := newDndPair(t)
@@ -641,7 +650,7 @@ func TestDataDragInvalidStart(t *testing.T) {
641 other := p.a.surfaceNew(t)650 other := p.a.surfaceNew(t)
642 p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial)651 p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial)
643 p.a.expect(t, "cancelled")652 p.a.expect(t, "cancelled")
644- dndNoEvent[ports.DataDragStarted](t, p.events)653+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
645 })654 })
646 t.Run("press of the first button", func(t *testing.T) {655 t.Run("press of the first button", func(t *testing.T) {
647 // A second button does not make a new grab: only the first serial.656 // A second button does not make a new grab: only the first serial.
@@ -675,7 +684,7 @@ func TestDataDragInvalidStart(t *testing.T) {
675 p.a.key(t, p.commands) // the release is applied once the key is seen684 p.a.key(t, p.commands) // the release is applied once the key is seen
676 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)685 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
677 p.a.expect(t, "cancelled")686 p.a.expect(t, "cancelled")
678- dndNoEvent[ports.DataDragStarted](t, p.events)687+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
679 })688 })
680 t.Run("used source", func(t *testing.T) {689 t.Run("used source", func(t *testing.T) {
681 p := newDndPair(t)690 p := newDndPair(t)
@@ -692,7 +701,7 @@ func TestDataDragInvalidStart(t *testing.T) {
692 other := p.a.source(t, actCopy, "text/plain")701 other := p.a.source(t, actCopy, "text/plain")
693 p.a.startDrag(t, other, 0, p.a.press(t, p.commands))702 p.a.startDrag(t, other, 0, p.a.press(t, p.commands))
694 p.a.expect(t, "cancelled")703 p.a.expect(t, "cancelled")
695- dndNoEvent[ports.DataDragStarted](t, p.events)704+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
696 })705 })
697 t.Run("icon with another role", func(t *testing.T) {706 t.Run("icon with another role", func(t *testing.T) {
698 p := newDndPair(t)707 p := newDndPair(t)
@@ -701,7 +710,7 @@ func TestDataDragInvalidStart(t *testing.T) {
701 // The origin toplevel is a role already.710 // The origin toplevel is a role already.
702 requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)711 requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestStartDrag, src, p.a.surf, p.a.surf, serial)
703 expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))712 expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))
704- dndNoEvent[ports.DataDragStarted](t, p.events)713+ dndNoEvent[ports.DataDragStarted](t, p.s, p.events)
705 })714 })
706 }715 }
707 716
@@ -712,7 +721,7 @@ func TestDataDragWithoutSource(t *testing.T) {
712 p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))721 p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))
713 dndNextEvent[ports.DataDragStarted](t, p.events)722 dndNextEvent[ports.DataDragStarted](t, p.events)
714 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}723 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
715- p.b.none(t)724+ p.b.none(t, p.commands)
716 p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}725 p.commands <- ports.DataDragFocus{ID: p.a.window, X: 4, Y: 5}
717 if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {726 if ev := p.a.expect(t, "enter"); ev.n != 0 || ev.x != 4 || ev.y != 5 {
718 t.Fatalf("enter %+v", ev)727 t.Fatalf("enter %+v", ev)
@@ -837,7 +846,7 @@ func TestDataDragIcon(t *testing.T) {
837 t.Fatalf("icon %+v", got)846 t.Fatalf("icon %+v", got)
838 }847 }
839 // The icon never takes input.848 // The icon never takes input.
840- dndNoEvent[ports.InputRegionChanged](t, p.events)849+ dndNoEvent[ports.InputRegionChanged](t, p.s, p.events)
841 850
842 p.commands <- ports.DataDragCancel{}851 p.commands <- ports.DataDragCancel{}
843 p.a.expect(t, "cancelled")852 p.a.expect(t, "cancelled")
@@ -1064,8 +1073,8 @@ func TestDataDragAsk(t *testing.T) {
1064 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain")1073 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain")
1065 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)1074 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
1066 p.a.expect(t, "target")1075 p.a.expect(t, "target")
1067- p.b.none(t) // no offer.action after the drop1076+ p.b.none(t, p.commands) // no offer.action after the drop
1068- p.a.none(t) // the final action waits for the finish1077+ p.a.none(t, p.commands) // the final action waits for the finish
1069 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish)1078 requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish)
1070 if ev := p.a.expect(t, "action"); ev.n != actMove {1079 if ev := p.a.expect(t, "action"); ev.n != actMove {
1071 t.Fatalf("action %+v", ev)1080 t.Fatalf("action %+v", ev)
@@ -1104,8 +1113,7 @@ func TestDataDragLegacyTarget(t *testing.T) {
1104 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}1113 p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
1105 p.b.until(t, "enter")1114 p.b.until(t, "enter")
1106 id := p.b.lastOffer(t)1115 id := p.b.lastOffer(t)
1107- requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(1), "text/plain")1116+ // Legacy accept is feedback only: deliberately never send it.
1108- p.a.expect(t, "target")
1109 return p, id1117 return p, id
1110 }1118 }
1111 t.Run("move only", func(t *testing.T) {1119 t.Run("move only", func(t *testing.T) {
@@ -1152,3 +1160,101 @@ func TestDataDragIconOffsetReplacedBeforeCommit(t *testing.T) {
1152 t.Fatalf("icon %+v", got)1160 t.Fatalf("icon %+v", got)
1153 }1161 }
1154 }1162 }
1163+
1164+func TestDataDragLastTargetDeviceReleased(t *testing.T) {
1165+ p := newDndPair(t)
1166+ p.start(t, 0)
1167+ offer := p.enter(t)
1168+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1169+ requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
1170+ p.a.expect(t, "target")
1171+ p.a.expect(t, "action")
1172+ p.b.expect(t, "offer_action")
1173+ requestProtocol(t, p.b.c, p.b.device.ID(), wayland.DataDeviceRequestRelease)
1174+ if err := p.b.c.Roundtrip(); err != nil {
1175+ t.Fatal(err)
1176+ }
1177+ p.commands <- ports.DataDragDrop{}
1178+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1179+ t.Fatalf("target %+v", ev)
1180+ }
1181+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1182+ t.Fatalf("action %+v", ev)
1183+ }
1184+ p.a.expect(t, "cancelled")
1185+ // The offer was deliberately kept alive; it must not keep a pending drop.
1186+ if !p.s.display.Do(func() {
1187+ if len(p.s.dndDrops) != 0 {
1188+ t.Error("pending drop without a device")
1189+ }
1190+ }) {
1191+ t.Fatal("display stopped")
1192+ }
1193+}
1194+
1195+func TestDataDragMultipleOffersShareAction(t *testing.T) {
1196+ for _, name := range []string{"finish", "acceptor destroyed"} {
1197+ t.Run(name, func(t *testing.T) {
1198+ p := newDndPair(t)
1199+ seat := bindVersion(t, p.b.c, "wl_seat", 8)
1200+ second := &dndDeviceEvents{c: p.b.c, log: make(chan dndEvent, 64)}
1201+ second.SetID(p.b.c.AllocateID())
1202+ registerWireProxy(p.b.c, second)
1203+ requestProtocol(t, p.b.c, p.b.manager, wayland.DataDeviceManagerRequestGetDataDevice, second.ID(), seat)
1204+ if err := p.b.c.Roundtrip(); err != nil {
1205+ t.Fatal(err)
1206+ }
1207+ p.start(t, 0)
1208+ first := p.enter(t)
1209+ other := &dndClient{c: p.b.c, device: second, log: second.log}
1210+ other.until(t, "enter")
1211+ two := other.lastOffer(t)
1212+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1213+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestSetActions, actCopy|actMove, actCopy)
1214+ p.a.expect(t, "target")
1215+ p.a.expect(t, "action")
1216+ for _, d := range []*dndClient{p.b, other} {
1217+ if ev := d.expect(t, "offer_action"); ev.n != actCopy {
1218+ t.Fatalf("copy: %+v", ev)
1219+ }
1220+ }
1221+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
1222+ if ev := p.a.expect(t, "action"); ev.n != actMove {
1223+ t.Fatalf("source action %+v", ev)
1224+ }
1225+ for _, d := range []*dndClient{p.b, other} {
1226+ if ev := d.expect(t, "offer_action"); ev.n != actMove {
1227+ t.Fatalf("move: %+v", ev)
1228+ }
1229+ }
1230+ if name == "finish" {
1231+ p.commands <- ports.DataDragDrop{}
1232+ for _, d := range []*dndClient{p.b, other} {
1233+ d.expect(t, "drop")
1234+ d.expect(t, "leave")
1235+ }
1236+ p.a.expect(t, "drop_performed")
1237+ // Either offer finishes the same negotiated move transfer.
1238+ requestProtocol(t, p.b.c, two, wayland.DataOfferRequestFinish)
1239+ p.a.expect(t, "finished")
1240+ return
1241+ }
1242+ requestProtocol(t, p.b.c, first, wayland.DataOfferRequestDestroy)
1243+ if err := p.b.c.Roundtrip(); err != nil {
1244+ t.Fatal(err)
1245+ }
1246+ if ev := p.a.expect(t, "target"); ev.mime != "" {
1247+ t.Fatalf("stale acceptance %+v", ev)
1248+ }
1249+ if ev := p.a.expect(t, "action"); ev.n != 0 {
1250+ t.Fatalf("stale action %+v", ev)
1251+ }
1252+ if ev := other.expect(t, "offer_action"); ev.n != 0 {
1253+ t.Fatalf("remaining offer %+v", ev)
1254+ }
1255+ p.commands <- ports.DataDragDrop{}
1256+ p.a.expect(t, "cancelled")
1257+ other.expect(t, "leave")
1258+ })
1259+ }
1260+}
M internal/core/core.go +7 -2
@@ -1153,8 +1153,13 @@ func (c *Core) Run(ctx context.Context) error {
11531153 if stop {
11541154 return nil
11551155 }
1156- if publish && c.publish(ctx) != nil {
1157- return nil
1156+ if publish {
1157+ if c.publish(ctx) != nil {
1158+ return nil
1159+ }
1160+ if c.dataDrag != nil && dataDragHitChanged(ev) && c.dataDragRetarget(ctx, c.motionTime) != nil {
1161+ return nil
1162+ }
11581163 }
11591164 case f, ok := <-c.ch.Frames:
11601165 if c.securityCheckpoint(ctx) != nil {
@@ -1153,8 +1153,13 @@ func (c *Core) Run(ctx context.Context) error {
1153 if stop {1153 if stop {
1154 return nil1154 return nil
1155 }1155 }
1156- if publish && c.publish(ctx) != nil {1156+ if publish {
1157- return nil1157+ if c.publish(ctx) != nil {
1158+ return nil
1159+ }
1160+ if c.dataDrag != nil && dataDragHitChanged(ev) && c.dataDragRetarget(ctx, c.motionTime) != nil {
1161+ return nil
1162+ }
1158 }1163 }
1159 case f, ok := <-c.ch.Frames:1164 case f, ok := <-c.ch.Frames:
1160 if c.securityCheckpoint(ctx) != nil {1165 if c.securityCheckpoint(ctx) != nil {
M internal/core/datadrag.go +15 -0
@@ -94,6 +94,21 @@ func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
9494 return nil
9595 }
9696
97+// dataDragHitChanged excludes notifications that only change metadata,
98+// capture state or the drag icon. The type switch allocates nothing.
99+func dataDragHitChanged(ev ports.ClientEvent) bool {
100+ switch ev.(type) {
101+ case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,
102+ ports.WindowResized, ports.PopupRequest, ports.PopupMapped,
103+ ports.OutputPower, ports.WindowParent, ports.WindowModal,
104+ ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest,
105+ ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:
106+ return true
107+ default:
108+ return false
109+ }
110+}
111+
97112 // dataDragRetarget re-routes a still cursor: the target is told only when
98113 // it or the point in it changed.
99114 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {
@@ -94,6 +94,21 @@ func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
94 return nil94 return nil
95 }95 }
96 96
97+// dataDragHitChanged excludes notifications that only change metadata,
98+// capture state or the drag icon. The type switch allocates nothing.
99+func dataDragHitChanged(ev ports.ClientEvent) bool {
100+ switch ev.(type) {
101+ case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,
102+ ports.WindowResized, ports.PopupRequest, ports.PopupMapped,
103+ ports.OutputPower, ports.WindowParent, ports.WindowModal,
104+ ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest,
105+ ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:
106+ return true
107+ default:
108+ return false
109+ }
110+}
111+
97 // dataDragRetarget re-routes a still cursor: the target is told only when112 // dataDragRetarget re-routes a still cursor: the target is told only when
98 // it or the point in it changed.113 // it or the point in it changed.
99 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {114 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {
A internal/core/datadrag_alloc_test.go +34 -0
@@ -0,0 +1,34 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestDataDragUnchangedRetargetAllocations(t *testing.T) {
11+ c := publishRig(t, 1)
12+ c.cursorX, c.cursorY = 20, 30
13+ id, x, y := c.hit(c.cursorX, c.cursorY)
14+ if id == 0 {
15+ t.Fatal("no target")
16+ }
17+ c.dataDrag = &dataDragState{target: id, at: [2]float64{x, y}}
18+ ctx := context.Background()
19+ var ev ports.ClientEvent = ports.InputRegionChanged{ID: id, All: true}
20+ if n := testing.AllocsPerRun(1000, func() {
21+ if dataDragHitChanged(ev) {
22+ if err := c.dataDragRetarget(ctx, 0); err != nil {
23+ panic(err)
24+ }
25+ }
26+ }); n != 0 {
27+ t.Fatalf("unchanged retarget: %g allocations, want 0", n)
28+ }
29+ for _, ev := range []ports.ClientEvent{ports.DataDragIcon{}, ports.WindowAppID{}, ports.CaptureSessionOpen{}, ports.PointerConstrained{}} {
30+ if dataDragHitChanged(ev) {
31+ t.Fatalf("rehit for irrelevant event %T", ev)
32+ }
33+ }
34+}
@@ -0,0 +1,34 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestDataDragUnchangedRetargetAllocations(t *testing.T) {
11+ c := publishRig(t, 1)
12+ c.cursorX, c.cursorY = 20, 30
13+ id, x, y := c.hit(c.cursorX, c.cursorY)
14+ if id == 0 {
15+ t.Fatal("no target")
16+ }
17+ c.dataDrag = &dataDragState{target: id, at: [2]float64{x, y}}
18+ ctx := context.Background()
19+ var ev ports.ClientEvent = ports.InputRegionChanged{ID: id, All: true}
20+ if n := testing.AllocsPerRun(1000, func() {
21+ if dataDragHitChanged(ev) {
22+ if err := c.dataDragRetarget(ctx, 0); err != nil {
23+ panic(err)
24+ }
25+ }
26+ }); n != 0 {
27+ t.Fatalf("unchanged retarget: %g allocations, want 0", n)
28+ }
29+ for _, ev := range []ports.ClientEvent{ports.DataDragIcon{}, ports.WindowAppID{}, ports.CaptureSessionOpen{}, ports.PointerConstrained{}} {
30+ if dataDragHitChanged(ev) {
31+ t.Fatalf("rehit for irrelevant event %T", ev)
32+ }
33+ }
34+}
M internal/core/datadrag_test.go +12 -0
@@ -575,3 +575,15 @@ func TestDataDragIgnoresCompositorSwipes(t *testing.T) {
575575 t.Fatalf("a swipe moved windows during the drag:\n%#v\n%#v", before[0].Windows, after[0].Windows)
576576 }
577577 }
578+
579+func TestDataDragRehitsAfterInputRegionChange(t *testing.T) {
580+ d := startDnd(t)
581+ d.start(t, 0)
582+ // The scene is settled and the cursor never moves after drag start.
583+ d.client <- ports.InputRegionChanged{ID: 1}
584+ d.settle(t)
585+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{Time: time.Millisecond})
586+ d.client <- ports.InputRegionChanged{ID: 1, All: true}
587+ d.settle(t)
588+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{ID: 1, X: 20, Y: 30, Time: time.Millisecond})
589+}
@@ -575,3 +575,15 @@ func TestDataDragIgnoresCompositorSwipes(t *testing.T) {
575 t.Fatalf("a swipe moved windows during the drag:\n%#v\n%#v", before[0].Windows, after[0].Windows)575 t.Fatalf("a swipe moved windows during the drag:\n%#v\n%#v", before[0].Windows, after[0].Windows)
576 }576 }
577 }577 }
578+
579+func TestDataDragRehitsAfterInputRegionChange(t *testing.T) {
580+ d := startDnd(t)
581+ d.start(t, 0)
582+ // The scene is settled and the cursor never moves after drag start.
583+ d.client <- ports.InputRegionChanged{ID: 1}
584+ d.settle(t)
585+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{Time: time.Millisecond})
586+ d.client <- ports.InputRegionChanged{ID: 1, All: true}
587+ d.settle(t)
588+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{ID: 1, X: 20, Y: 30, Time: time.Millisecond})
589+}

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 25

Tags 10

↑↓ moveenter openesc close