feat/data-dnd e05cb15

dnd_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"errors"
6	"sync/atomic"
7	"testing"
8	"time"
9
10	"github.com/bnema/go-wayland-bindings/server/wayland"
11	"github.com/bnema/neferwl/internal/adapters/logging"
12	"github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13	"github.com/bnema/neferwl/internal/ports"
14	"github.com/bnema/wlturbo"
15	"golang.org/x/sys/unix"
16)
17
18const (
19	actCopy = uint32(wayland.DataDeviceManagerDndActionCopy)
20	actMove = uint32(wayland.DataDeviceManagerDndActionMove)
21	actAsk  = uint32(wayland.DataDeviceManagerDndActionAsk)
22)
23
24// dndEvent is one data device, offer or source event a test client saw.
25type dndEvent struct {
26	name string
27	mime string
28	x, y float64
29	n    uint32
30}
31
32type dndDeviceEvents struct {
33	wlturbo.BaseProxy
34	c   *wlturbo.Display
35	log chan dndEvent
36	// offer is the id of the latest data_offer.
37	offer atomic.Uint32
38}
39
40func (p *dndDeviceEvents) Dispatch(e *wlturbo.Event) {
41	switch e.Opcode {
42	case uint16(wayland.DataDeviceEventDataOffer):
43		o := &dndOfferEvents{log: p.log}
44		o.SetID(e.Uint32())
45		p.offer.Store(o.ID())
46		registerWireProxy(p.c, o)
47	case uint16(wayland.DataDeviceEventEnter):
48		serial, _ := e.Uint32(), e.Uint32()
49		x, y := e.Fixed().Float64(), e.Fixed().Float64()
50		p.log <- dndEvent{name: "enter", x: x, y: y, n: e.Uint32() + serial*0}
51	case uint16(wayland.DataDeviceEventLeave):
52		p.log <- dndEvent{name: "leave"}
53	case uint16(wayland.DataDeviceEventMotion):
54		_ = e.Uint32()
55		p.log <- dndEvent{name: "motion", x: e.Fixed().Float64(), y: e.Fixed().Float64()}
56	case uint16(wayland.DataDeviceEventDrop):
57		p.log <- dndEvent{name: "drop"}
58	}
59}
60
61type dndOfferEvents struct {
62	wlturbo.BaseProxy
63	log chan dndEvent
64}
65
66func (p *dndOfferEvents) Dispatch(e *wlturbo.Event) {
67	switch e.Opcode {
68	case uint16(wayland.DataOfferEventOffer):
69		p.log <- dndEvent{name: "offer", mime: e.String()}
70	case uint16(wayland.DataOfferEventSourceActions):
71		p.log <- dndEvent{name: "source_actions", n: e.Uint32()}
72	case uint16(wayland.DataOfferEventAction):
73		p.log <- dndEvent{name: "offer_action", n: e.Uint32()}
74	}
75}
76
77type dndSourceEvents struct {
78	wlturbo.BaseProxy
79	log chan dndEvent
80}
81
82func (p *dndSourceEvents) Dispatch(e *wlturbo.Event) {
83	switch e.Opcode {
84	case uint16(wayland.DataSourceEventTarget):
85		p.log <- dndEvent{name: "target", mime: e.String()}
86	case uint16(wayland.DataSourceEventSend):
87		mime := e.String()
88		fd := int(e.Fd())
89		_, _ = unix.Write(fd, []byte("dnd data"))
90		_ = unix.Close(fd)
91		p.log <- dndEvent{name: "send", mime: mime}
92	case uint16(wayland.DataSourceEventCancelled):
93		p.log <- dndEvent{name: "cancelled"}
94	case uint16(wayland.DataSourceEventDndDropPerformed):
95		p.log <- dndEvent{name: "drop_performed"}
96	case uint16(wayland.DataSourceEventDndFinished):
97		p.log <- dndEvent{name: "finished"}
98	case uint16(wayland.DataSourceEventAction):
99		p.log <- dndEvent{name: "action", n: e.Uint32()}
100	}
101}
102
103// dndClient is a client with a toplevel, a pointer and a data device.
104type dndClient struct {
105	keys    chan uint32
106	c       *wlturbo.Display
107	manager uint32
108	device  *dndDeviceEvents
109	serials chan uint32
110	comp    uint32
111	shm     uint32
112	surf    uint32
113	window  ports.WindowID
114	log     chan dndEvent
115}
116
117// keySerials records the serial of each key event.
118type keySerials struct {
119	wlturbo.BaseProxy
120	serials chan uint32
121}
122
123func (p *keySerials) Dispatch(e *wlturbo.Event) {
124	if e.Opcode == uint16(wayland.KeyboardEventKey) {
125		p.serials <- e.Uint32()
126	}
127}
128
129func newDndClient(t *testing.T, s *Server, dir string, events <-chan ports.ClientEvent) *dndClient {
130	t.Helper()
131	return newDndClientAt(t, s, dir, events, 3)
132}
133
134// newDndClientAt is newDndClient with wl_data_device_manager at version.
135func newDndClientAt(t *testing.T, s *Server, dir string, events <-chan ports.ClientEvent, version uint32) *dndClient {
136	t.Helper()
137	c := protocolClient(t, s, dir)
138	d := &dndClient{c: c, log: make(chan dndEvent, 64), serials: make(chan uint32, 8), keys: make(chan uint32, 8)}
139	kb := &keySerials{serials: d.keys}
140	seat := bindVersion(t, c, "wl_seat", 8)
141	registerProtocol(t, c, seat)
142	ptr := &pressSerials{serials: d.serials}
143	ptr.SetID(c.AllocateID())
144	registerWireProxy(c, ptr)
145	requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, ptr.ID())
146	kb.SetID(c.AllocateID())
147	registerWireProxy(c, kb)
148	requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, kb.ID())
149	d.manager = bindVersion(t, c, "wl_data_device_manager", version)
150	d.device = &dndDeviceEvents{c: c, log: d.log}
151	d.device.SetID(c.AllocateID())
152	registerWireProxy(c, d.device)
153	requestProtocol(t, c, d.manager, wayland.DataDeviceManagerRequestGetDataDevice, d.device.ID(), seat)
154	d.comp = bindVersion(t, c, "wl_compositor", 5)
155	d.shm = bindProtocol(t, c, "wl_shm")
156	registerProtocol(t, c, d.shm)
157	w, surf, _ := surfaceMapperAt(t, c, events, 5)()
158	d.window, d.surf = w.ID, surf
159	return d
160}
161
162// lastOffer is the id of the latest offer the client got.
163func (d *dndClient) lastOffer(t *testing.T) uint32 {
164	t.Helper()
165	id := d.device.offer.Load()
166	if id == 0 {
167		t.Fatal("no offer")
168	}
169	return id
170}
171
172// next returns the next event the client sees.
173func (d *dndClient) next(t *testing.T) dndEvent {
174	t.Helper()
175	deadline := time.After(2 * time.Second)
176	for {
177		if err := d.c.Roundtrip(); err != nil {
178			t.Fatal(err)
179		}
180		select {
181		case ev := <-d.log:
182			return ev
183		case <-deadline:
184			t.Fatal("no event")
185		case <-time.After(5 * time.Millisecond):
186		}
187	}
188}
189
190// expect waits for the next event and checks its name.
191func (d *dndClient) expect(t *testing.T, name string) dndEvent {
192	t.Helper()
193	ev := d.next(t)
194	if ev.name != name {
195		t.Fatalf("event %+v, want %s", ev, name)
196	}
197	return ev
198}
199
200// none checks that no event arrives.
201func (d *dndClient) none(t *testing.T) {
202	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)
208	}
209	select {
210	case ev := <-d.log:
211		t.Fatalf("unexpected event %+v", ev)
212	default:
213	}
214}
215
216func (d *dndClient) source(t *testing.T, actions uint32, mimes ...string) uint32 {
217	t.Helper()
218	src := &dndSourceEvents{log: d.log}
219	src.SetID(d.c.AllocateID())
220	registerWireProxy(d.c, src)
221	requestProtocol(t, d.c, d.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID())
222	for _, m := range mimes {
223		requestProtocol(t, d.c, src.ID(), wayland.DataSourceRequestOffer, m)
224	}
225	if actions != 0 {
226		requestProtocol(t, d.c, src.ID(), wayland.DataSourceRequestSetActions, actions)
227	}
228	return src.ID()
229}
230
231// press makes the client's window press a button and returns the serial.
232func (d *dndClient) press(t *testing.T, commands chan<- ports.ClientCommand) uint32 {
233	t.Helper()
234	commands <- ports.PointerFocus{ID: d.window, X: 1, Y: 1}
235	commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Pressed: true, Grab: true, Time: time.Millisecond}
236	deadline := time.After(2 * time.Second)
237	for {
238		if err := d.c.Roundtrip(); err != nil {
239			t.Fatal(err)
240		}
241		select {
242		case serial := <-d.serials:
243			return serial
244		case <-deadline:
245			t.Fatal("no button")
246		case <-time.After(5 * time.Millisecond):
247		}
248	}
249}
250
251// press2 returns the next pointer button serial.
252func (d *dndClient) press2(t *testing.T) uint32 {
253	t.Helper()
254	deadline := time.After(2 * time.Second)
255	for {
256		if err := d.c.Roundtrip(); err != nil {
257			t.Fatal(err)
258		}
259		select {
260		case serial := <-d.serials:
261			return serial
262		case <-deadline:
263			t.Fatal("no button")
264		case <-time.After(5 * time.Millisecond):
265		}
266	}
267}
268
269// release releases the button press took and waits for the client to see it.
270func (d *dndClient) release(t *testing.T, commands chan<- ports.ClientCommand) {
271	t.Helper()
272	commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Time: 2 * time.Millisecond}
273	d.press2(t)
274}
275
276// key presses a key on the client's focused window and returns the serial.
277func (d *dndClient) key(t *testing.T, commands chan<- ports.ClientCommand) uint32 {
278	t.Helper()
279	commands <- ports.FocusWindow{ID: d.window}
280	commands <- ports.ForwardKey{ID: d.window, Key: ports.KeyEvent{Keycode: 30, Pressed: true, Time: 3 * time.Millisecond}}
281	deadline := time.After(2 * time.Second)
282	for {
283		if err := d.c.Roundtrip(); err != nil {
284			t.Fatal(err)
285		}
286		select {
287		case serial := <-d.keys:
288			return serial
289		case <-deadline:
290			t.Fatal("no key")
291		case <-time.After(5 * time.Millisecond):
292		}
293	}
294}
295
296// events returns what the client saw up to and including name.
297func (d *dndClient) until(t *testing.T, name string) []dndEvent {
298	t.Helper()
299	var got []dndEvent
300	for {
301		ev := d.next(t)
302		got = append(got, ev)
303		if ev.name == name {
304			return got
305		}
306	}
307}
308
309func (d *dndClient) startDrag(t *testing.T, src, icon, serial uint32) {
310	t.Helper()
311	d.startDragFrom(t, src, d.surf, icon, serial)
312}
313
314// startDragFrom is startDrag with an origin surface.
315func (d *dndClient) startDragFrom(t *testing.T, src, origin, icon, serial uint32) {
316	t.Helper()
317	requestProtocol(t, d.c, d.device.ID(), wayland.DataDeviceRequestStartDrag, src, origin, icon, serial)
318	if err := d.c.Roundtrip(); err != nil {
319		t.Fatal(err)
320	}
321}
322
323// buffer makes a w×h ARGB shm buffer.
324func (d *dndClient) buffer(t *testing.T, w, h int) uint32 {
325	t.Helper()
326	fd, err := unix.MemfdCreate("dnd-icon", 0)
327	if err != nil {
328		t.Fatal(err)
329	}
330	defer unix.Close(fd)
331	if err := unix.Ftruncate(fd, int64(w*h*4)); err != nil {
332		t.Fatal(err)
333	}
334	pool, buf := d.c.AllocateID(), d.c.AllocateID()
335	registerProtocol(t, d.c, pool)
336	registerProtocol(t, d.c, buf)
337	if err := wireRequest(d.c, d.shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil {
338		t.Fatal(err)
339	}
340	requestProtocol(t, d.c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0))
341	return buf
342}
343
344func (d *dndClient) surfaceNew(t *testing.T) uint32 {
345	t.Helper()
346	id := d.c.AllocateID()
347	requestProtocol(t, d.c, d.comp, wayland.CompositorRequestCreateSurface, id)
348	registerProtocol(t, d.c, id)
349	return id
350}
351
352// dndNextEvent returns the next client event of type T, skipping others.
353func dndNextEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
354	t.Helper()
355	deadline := time.After(2 * time.Second)
356	for {
357		select {
358		case ev := <-events:
359			if v, ok := ev.(T); ok {
360				return v
361			}
362		case <-deadline:
363			t.Fatalf("no %T event", *new(T))
364		}
365	}
366}
367
368// dndNoEvent checks that no client event of type T is pending.
369func dndNoEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) {
370	t.Helper()
371	time.Sleep(50 * time.Millisecond)
372	for {
373		select {
374		case ev := <-events:
375			if _, ok := ev.(T); ok {
376				t.Fatalf("unexpected %T: %+v", ev, ev)
377			}
378		default:
379			return
380		}
381	}
382}
383
384// dndPair is two clients with a drag started from a on a source.
385type dndPair struct {
386	s        *Server
387	events   chan ports.ClientEvent
388	commands chan ports.ClientCommand
389	a, b     *dndClient
390}
391
392func newDndPair(t *testing.T) *dndPair {
393	t.Helper()
394	s, events, commands, dir := keyboardServer(t)
395	p := &dndPair{s: s, events: events, commands: commands}
396	p.a = newDndClient(t, s, dir, events)
397	p.b = newDndClient(t, s, dir, events)
398	return p
399}
400
401// start begins a drag of a source offering text/plain, copy and move.
402func (p *dndPair) start(t *testing.T, icon uint32) uint32 {
403	t.Helper()
404	src := p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list")
405	p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
406	return src
407}
408
409// enter makes b the drag target and returns the offer id.
410func (p *dndPair) enter(t *testing.T) uint32 {
411	t.Helper()
412	p.commands <- ports.DataDragFocus{ID: p.b.window, X: 5, Y: 6, Time: 10 * time.Millisecond}
413	for _, want := range []dndEvent{{name: "offer", mime: "text/plain"}, {name: "offer", mime: "text/uri-list"}, {name: "source_actions", n: actCopy | actMove}} {
414		if ev := p.b.next(t); ev != want {
415			t.Fatalf("event %+v, want %+v", ev, want)
416		}
417	}
418	ev := p.b.expect(t, "enter")
419	if ev.x != 5 || ev.y != 6 || ev.n == 0 {
420		t.Fatalf("enter %+v", ev)
421	}
422	return ev.n
423}
424
425// A1: a drag from a to b delivers enter, motion and drop with the offer;
426// the action is negotiated; the data reaches b; a is told every step.
427func TestDataDragBetweenClients(t *testing.T) {
428	p := newDndPair(t)
429	p.start(t, 0)
430	if v := dndNextEvent[ports.DataDragStarted](t, p.events); v.Icon != 0 {
431		t.Fatalf("started %+v", v)
432	}
433	offer := p.enter(t)
434
435	p.commands <- ports.DataDragMotion{ID: p.b.window, X: 7, Y: 8, Time: 20 * time.Millisecond}
436	if ev := p.b.expect(t, "motion"); ev.x != 7 || ev.y != 8 {
437		t.Fatalf("motion %+v", ev)
438	}
439
440	// b accepts and prefers move: both sides learn the action.
441	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
442	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
443	if ev := p.a.expect(t, "target"); ev.mime != "text/plain" {
444		t.Fatalf("target %+v", ev)
445	}
446	if ev := p.a.expect(t, "action"); ev.n != actMove {
447		t.Fatalf("action %+v", ev)
448	}
449	if ev := p.b.expect(t, "offer_action"); ev.n != actMove {
450		t.Fatalf("offer action %+v", ev)
451	}
452
453	// Finishing before the drop is a protocol error, so only after it.
454	p.commands <- ports.DataDragDrop{}
455	p.b.expect(t, "drop")
456	p.b.expect(t, "leave")
457	p.a.expect(t, "drop_performed")
458
459	var fds [2]int
460	if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
461		t.Fatal(err)
462	}
463	defer unix.Close(fds[0])
464	if err := wireRequest(p.b.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
465		t.Fatal(err)
466	}
467	_ = unix.Close(fds[1])
468	if err := p.b.c.Roundtrip(); err != nil {
469		t.Fatal(err)
470	}
471	if ev := p.a.expect(t, "send"); ev.mime != "text/plain" {
472		t.Fatalf("send %+v", ev)
473	}
474	buf := make([]byte, 16)
475	n, err := unix.Read(fds[0], buf)
476	for errors.Is(err, unix.EINTR) {
477		n, err = unix.Read(fds[0], buf)
478	}
479	if err != nil || string(buf[:n]) != "dnd data" {
480		t.Fatalf("received %q, %v", buf[:max(n, 0)], err)
481	}
482
483	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
484	p.a.expect(t, "finished")
485	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
486	p.a.none(t)
487	p.b.none(t)
488	// core ended the drag: wayland does not echo it.
489	dndNoEvent[ports.DataDragEnded](t, p.events)
490}
491
492// The action follows wlroots: the preferred one if both allow it, else the
493// first of copy, move, ask they share, else none.
494func TestChooseAction(t *testing.T) {
495	for _, tc := range []struct{ avail, preferred, want uint32 }{
496		{actCopy | actMove, actMove, actMove},
497		{actCopy | actMove, actAsk, actCopy},
498		{actMove | actAsk, 0, actMove},
499		{actAsk, 0, actAsk},
500		{0, actCopy, 0},
501	} {
502		if got := chooseAction(tc.avail, tc.preferred); got != tc.want {
503			t.Errorf("chooseAction(%d, %d) = %d, want %d", tc.avail, tc.preferred, got, tc.want)
504		}
505	}
506}
507
508// A target and a source sharing no action leave the drop refused.
509func TestDataDragNoCommonAction(t *testing.T) {
510	p := newDndPair(t)
511	src := p.a.source(t, actCopy, "text/plain")
512	p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
513	p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
514	p.b.expect(t, "offer")
515	p.b.expect(t, "source_actions")
516	offer := p.b.expect(t, "enter").n
517	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
518	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actMove, actMove)
519	p.a.expect(t, "target")
520	p.commands <- ports.DataDragDrop{}
521	p.b.expect(t, "leave")
522	// The refused target no longer accepts.
523	if ev := p.a.expect(t, "target"); ev.mime != "" {
524		t.Fatalf("target %+v", ev)
525	}
526	p.a.expect(t, "cancelled")
527}
528
529// A2: a release over nothing, or over a target that accepted nothing,
530// cancels the source; the target gets leave and no drop.
531func TestDataDragRefusedDrop(t *testing.T) {
532	t.Run("nothing", func(t *testing.T) {
533		p := newDndPair(t)
534		p.start(t, 0)
535		p.commands <- ports.DataDragDrop{}
536		p.a.expect(t, "cancelled")
537		p.b.none(t)
538	})
539	t.Run("not accepted", func(t *testing.T) {
540		p := newDndPair(t)
541		p.start(t, 0)
542		p.enter(t)
543		p.commands <- ports.DataDragDrop{}
544		p.b.expect(t, "leave")
545		p.a.expect(t, "cancelled")
546		p.b.none(t)
547	})
548	t.Run("escape", func(t *testing.T) {
549		p := newDndPair(t)
550		p.start(t, 0)
551		p.enter(t)
552		p.commands <- ports.DataDragCancel{}
553		p.b.expect(t, "leave")
554		p.a.expect(t, "cancelled")
555		dndNoEvent[ports.DataDragEnded](t, p.events)
556	})
557}
558
559// Moving to another surface leaves the first and enters the second; a
560// surface of nobody (ID 0) leaves.
561func TestDataDragLeavesOnFocusChange(t *testing.T) {
562	p := newDndPair(t)
563	p.start(t, 0)
564	p.enter(t)
565	p.commands <- ports.DataDragFocus{}
566	p.b.expect(t, "leave")
567	p.commands <- ports.DataDragFocus{ID: p.b.window, X: 2, Y: 3}
568	for range 3 {
569		p.b.next(t)
570	}
571	if ev := p.b.expect(t, "enter"); ev.x != 2 || ev.y != 3 {
572		t.Fatalf("enter %+v", ev)
573	}
574}
575
576// A4: the source dying mid-drag ends it: the target gets leave and core a
577// DataDragEnded; later commands change nothing.
578func TestDataDragSourceDestroyed(t *testing.T) {
579	p := newDndPair(t)
580	src := p.start(t, 0)
581	p.enter(t)
582	requestProtocol(t, p.a.c, src, wayland.DataSourceRequestDestroy)
583	if err := p.a.c.Roundtrip(); err != nil {
584		t.Fatal(err)
585	}
586	p.b.expect(t, "leave")
587	dndNextEvent[ports.DataDragEnded](t, p.events)
588	p.commands <- ports.DataDragMotion{ID: p.b.window, X: 9, Y: 9}
589	p.commands <- ports.DataDragDrop{}
590	p.b.none(t)
591}
592
593// A drag whose origin client dies ends the same way.
594func TestDataDragOriginDies(t *testing.T) {
595	p := newDndPair(t)
596	p.start(t, 0)
597	p.enter(t)
598	_ = p.a.c.Close()
599	p.b.expect(t, "leave")
600	dndNextEvent[ports.DataDragEnded](t, p.events)
601}
602
603// A6: a start_drag without the current press of the origin client is
604// refused: the source is cancelled and core hears nothing.
605func TestDataDragInvalidStart(t *testing.T) {
606	t.Run("stale serial", func(t *testing.T) {
607		p := newDndPair(t)
608		serial := p.a.press(t, p.commands)
609		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial-1)
610		p.a.expect(t, "cancelled")
611		dndNoEvent[ports.DataDragStarted](t, p.events)
612	})
613	t.Run("other client's press", func(t *testing.T) {
614		p := newDndPair(t)
615		p.a.press(t, p.commands)
616		serial := p.b.press(t, p.commands)
617		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
618		p.a.expect(t, "cancelled")
619		dndNoEvent[ports.DataDragStarted](t, p.events)
620	})
621	t.Run("key serial", func(t *testing.T) {
622		p := newDndPair(t)
623		p.a.press(t, p.commands)
624		p.a.release(t, p.commands)
625		serial := p.a.key(t, p.commands)
626		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
627		p.a.expect(t, "cancelled")
628		dndNoEvent[ports.DataDragStarted](t, p.events)
629	})
630	t.Run("serial after the release", func(t *testing.T) {
631		p := newDndPair(t)
632		serial := p.a.press(t, p.commands)
633		p.a.release(t, p.commands)
634		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
635		p.a.expect(t, "cancelled")
636		dndNoEvent[ports.DataDragStarted](t, p.events)
637	})
638	t.Run("other surface of the client", func(t *testing.T) {
639		p := newDndPair(t)
640		serial := p.a.press(t, p.commands)
641		other := p.a.surfaceNew(t)
642		p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), other, 0, serial)
643		p.a.expect(t, "cancelled")
644		dndNoEvent[ports.DataDragStarted](t, p.events)
645	})
646	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.
648		p := newDndPair(t)
649		first := p.a.press(t, p.commands)
650		p.commands <- ports.PointerButtonTo{ID: p.a.window, Button: 0x111, Pressed: true, Time: 2 * time.Millisecond}
651		second := p.a.press2(t)
652		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, second)
653		p.a.expect(t, "cancelled")
654		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, first)
655		// Core is told the grab's button: its release drops.
656		if ev := dndNextEvent[ports.DataDragStarted](t, p.events); ev.Button != 0x110 {
657			t.Fatalf("drag button %#x, want the grab's", ev.Button)
658		}
659	})
660	t.Run("grab release consumed by core", func(t *testing.T) {
661		// A window drag consumes the release: the next grab press
662		// still makes the serial a drag can name.
663		p := newDndPair(t)
664		stale := p.a.press(t, p.commands)
665		serial := p.a.press(t, p.commands)
666		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, stale)
667		p.a.expect(t, "cancelled")
668		p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
669		dndNextEvent[ports.DataDragStarted](t, p.events)
670	})
671	t.Run("grab release of a gone window", func(t *testing.T) {
672		p := newDndPair(t)
673		serial := p.a.press(t, p.commands)
674		p.commands <- ports.PointerButtonTo{ID: 9999, Button: 0x110, Time: 2 * time.Millisecond}
675		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)
677		p.a.expect(t, "cancelled")
678		dndNoEvent[ports.DataDragStarted](t, p.events)
679	})
680	t.Run("used source", func(t *testing.T) {
681		p := newDndPair(t)
682		src := p.start(t, 0)
683		p.commands <- ports.DataDragCancel{}
684		p.a.expect(t, "cancelled")
685		p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
686		p.a.expect(t, "cancelled")
687	})
688	t.Run("drag in progress", func(t *testing.T) {
689		p := newDndPair(t)
690		p.start(t, 0)
691		dndNextEvent[ports.DataDragStarted](t, p.events)
692		other := p.a.source(t, actCopy, "text/plain")
693		p.a.startDrag(t, other, 0, p.a.press(t, p.commands))
694		p.a.expect(t, "cancelled")
695		dndNoEvent[ports.DataDragStarted](t, p.events)
696	})
697	t.Run("icon with another role", func(t *testing.T) {
698		p := newDndPair(t)
699		serial := p.a.press(t, p.commands)
700		src := p.a.source(t, actCopy, "text/plain")
701		// 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)
703		expectProtocolError(t, p.a.c, p.a.device.ID(), uint32(wayland.DataDeviceErrorRole))
704		dndNoEvent[ports.DataDragStarted](t, p.events)
705	})
706}
707
708// A nil source is a drag internal to the origin client: only its own
709// surfaces see it, without offer.
710func TestDataDragWithoutSource(t *testing.T) {
711	p := newDndPair(t)
712	p.a.startDrag(t, 0, 0, p.a.press(t, p.commands))
713	dndNextEvent[ports.DataDragStarted](t, p.events)
714	p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
715	p.b.none(t)
716	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 {
718		t.Fatalf("enter %+v", ev)
719	}
720	p.commands <- ports.DataDragDrop{}
721	p.a.expect(t, "drop")
722}
723
724// Offer and source requests are validated.
725func TestDataDragProtocolErrors(t *testing.T) {
726	t.Run("finish before drop", func(t *testing.T) {
727		p := newDndPair(t)
728		p.start(t, 0)
729		offer := p.enter(t)
730		requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestFinish)
731		expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidFinish))
732	})
733	t.Run("offer action mask", func(t *testing.T) {
734		p := newDndPair(t)
735		p.start(t, 0)
736		offer := p.enter(t)
737		requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, uint32(8), uint32(0))
738		expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidActionMask))
739	})
740	t.Run("offer preferred action", func(t *testing.T) {
741		p := newDndPair(t)
742		p.start(t, 0)
743		offer := p.enter(t)
744		requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actMove)
745		expectProtocolError(t, p.b.c, offer, uint32(wayland.DataOfferErrorInvalidAction))
746	})
747	t.Run("source action mask", func(t *testing.T) {
748		p := newDndPair(t)
749		src := p.a.source(t, 0, "text/plain")
750		requestProtocol(t, p.a.c, src, wayland.DataSourceRequestSetActions, uint32(16))
751		expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidActionMask))
752	})
753	t.Run("selection with actions", func(t *testing.T) {
754		p := newDndPair(t)
755		src := p.a.source(t, actCopy, "text/plain")
756		requestProtocol(t, p.a.c, p.a.device.ID(), wayland.DataDeviceRequestSetSelection, src, uint32(0))
757		expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidSource))
758	})
759	t.Run("actions after the drag started", func(t *testing.T) {
760		p := newDndPair(t)
761		src := p.start(t, 0)
762		requestProtocol(t, p.a.c, src, wayland.DataSourceRequestSetActions, actCopy)
763		expectProtocolError(t, p.a.c, src, uint32(wayland.DataSourceErrorInvalidSource))
764	})
765}
766
767// A destroyed offer before finish cancels the source.
768func TestDataDragOfferDestroyedBeforeFinish(t *testing.T) {
769	p := newDndPair(t)
770	p.start(t, 0)
771	offer := p.enter(t)
772	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
773	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
774	p.a.expect(t, "target")
775	p.a.expect(t, "action")
776	p.commands <- ports.DataDragDrop{}
777	p.b.expect(t, "offer_action")
778	p.b.expect(t, "drop")
779	p.b.expect(t, "leave")
780	p.a.expect(t, "drop_performed")
781	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
782	p.a.expect(t, "cancelled")
783}
784
785// iconContent reports whether the server still holds content for id.
786func iconContent(t *testing.T, s *Server, id ports.WindowID) bool {
787	t.Helper()
788	var ok bool
789	if !s.display.Do(func() { _, ok = s.latest[id] }) {
790		t.Fatal("display stopped")
791	}
792	return ok
793}
794
795// The icon is a surface with its own window ID: its size and the
796// offsets of its commits reach core, its content is published, and both go
797// when the drag ends.
798func TestDataDragIcon(t *testing.T) {
799	p := newDndPair(t)
800	icon := p.a.surfaceNew(t)
801	buf := p.a.buffer(t, 8, 6)
802	// An offset on an ordinary surface is not used.
803	requestProtocol(t, p.a.c, p.a.surf, wayland.SurfaceRequestOffset, int32(5), int32(5))
804	requestProtocol(t, p.a.c, p.a.surf, wayland.SurfaceRequestCommit)
805
806	src := p.a.source(t, actCopy, "text/plain")
807	p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
808	started := dndNextEvent[ports.DataDragStarted](t, p.events)
809	if started.Icon == 0 {
810		t.Fatal("no icon ID")
811	}
812	id := started.Icon
813	if iconContent(t, p.s, id) {
814		t.Fatal("content before the first commit")
815	}
816
817	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
818	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-3), int32(-4))
819	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
820	if err := p.a.c.Roundtrip(); err != nil {
821		t.Fatal(err)
822	}
823	if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -3, Y: -4, W: 8, H: 6}) {
824		t.Fatalf("icon %+v", got)
825	}
826	if !iconContent(t, p.s, id) {
827		t.Fatal("no icon content")
828	}
829
830	// Offsets add up from commit to commit.
831	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-1), int32(2))
832	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
833	if err := p.a.c.Roundtrip(); err != nil {
834		t.Fatal(err)
835	}
836	if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -4, Y: -2, W: 8, H: 6}) {
837		t.Fatalf("icon %+v", got)
838	}
839	// The icon never takes input.
840	dndNoEvent[ports.InputRegionChanged](t, p.events)
841
842	p.commands <- ports.DataDragCancel{}
843	p.a.expect(t, "cancelled")
844	if iconContent(t, p.s, id) {
845		t.Fatal("icon content kept after the drag")
846	}
847}
848
849// An icon committed before start_drag shows at once.
850func TestDataDragIconCommittedEarly(t *testing.T) {
851	p := newDndPair(t)
852	icon := p.a.surfaceNew(t)
853	buf := p.a.buffer(t, 4, 4)
854	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
855	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
856	p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), icon, p.a.press(t, p.commands))
857	id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
858	if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, W: 4, H: 4}) {
859		t.Fatalf("icon %+v", got)
860	}
861	if !iconContent(t, p.s, id) {
862		t.Fatal("no icon content")
863	}
864}
865
866// Before wl_surface v5 the icon offset rides on attach and adds up.
867func TestDataDragIconAttachOffset(t *testing.T) {
868	p := newDndPair(t)
869	comp4 := bindVersion(t, p.a.c, "wl_compositor", 4)
870	icon := p.a.c.AllocateID()
871	requestProtocol(t, p.a.c, comp4, wayland.CompositorRequestCreateSurface, icon)
872	registerProtocol(t, p.a.c, icon)
873	buf := p.a.buffer(t, 4, 4)
874	p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), icon, p.a.press(t, p.commands))
875	id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
876	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(-2), int32(-1))
877	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
878	if err := p.a.c.Roundtrip(); err != nil {
879		t.Fatal(err)
880	}
881	if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -2, Y: -1, W: 4, H: 4}) {
882		t.Fatalf("icon %+v", got)
883	}
884}
885
886// Destroying the icon mid-drag hides it; the drag goes on.
887func TestDataDragIconDestroyed(t *testing.T) {
888	p := newDndPair(t)
889	icon := p.a.surfaceNew(t)
890	buf := p.a.buffer(t, 8, 6)
891	p.a.startDrag(t, p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list"), icon, p.a.press(t, p.commands))
892	id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
893	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
894	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
895	if err := p.a.c.Roundtrip(); err != nil {
896		t.Fatal(err)
897	}
898	if got := dndNextEvent[ports.DataDragIcon](t, p.events); got.W != 8 {
899		t.Fatalf("icon %+v", got)
900	}
901	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestDestroy)
902	if err := p.a.c.Roundtrip(); err != nil {
903		t.Fatal(err)
904	}
905	if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id}) {
906		t.Fatalf("icon %+v", got)
907	}
908	if iconContent(t, p.s, id) {
909		t.Fatal("icon content kept")
910	}
911	p.enter(t)
912}
913
914// lockedPair is a pair of clients on a server that can lock the session.
915func lockedPair(t *testing.T) (*dndPair, string) {
916	t.Helper()
917	gate := &sessionsecurity.Gate{}
918	events := make(chan ports.ClientEvent, 128)
919	commands := make(chan ports.ClientCommand, 32)
920	dir := t.TempDir()
921	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"))
922	if err != nil {
923		t.Fatal(err)
924	}
925	ctx, cancel := context.WithCancel(context.Background())
926	done := make(chan error, 1)
927	go func() { done <- s.Run(ctx) }()
928	t.Cleanup(func() {
929		cancel()
930		select {
931		case err := <-done:
932			if err != nil {
933				t.Error(err)
934			}
935		case <-time.After(3 * time.Second):
936			t.Error("server did not stop")
937		}
938	})
939	p := &dndPair{s: s, events: events, commands: commands}
940	p.a = newDndClient(t, s, dir, events)
941	p.b = newDndClient(t, s, dir, events)
942	return p, dir
943}
944
945func lockSession(t *testing.T, s *Server, dir string) {
946	t.Helper()
947	lc := protocolClient(t, s, dir)
948	if _, err := bindLockManager(t, lc).Lock(); err != nil {
949		t.Fatal(err)
950	}
951	if err := lc.Roundtrip(); err != nil {
952		t.Fatal(err)
953	}
954}
955
956// A7: the session lock cancels a drag at once: target leave, source
957// cancelled, icon gone.
958func TestDataDragLockCancels(t *testing.T) {
959	p, dir := lockedPair(t)
960	icon := p.a.surfaceNew(t)
961	buf := p.a.buffer(t, 8, 6)
962	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
963	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
964	src := p.a.source(t, actCopy|actMove, "text/plain", "text/uri-list")
965	p.a.startDrag(t, src, icon, p.a.press(t, p.commands))
966	id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
967	p.enter(t)
968
969	lockSession(t, p.s, dir)
970	p.b.expect(t, "leave")
971	p.a.expect(t, "cancelled")
972	if iconContent(t, p.s, id) {
973		t.Fatal("icon content kept")
974	}
975	// A new drag is refused while protected.
976	src2 := p.a.source(t, actCopy, "text/plain")
977	p.a.startDrag(t, src2, 0, uint32(1))
978	p.a.expect(t, "cancelled")
979}
980
981// GO-006: a dropped transfer waiting for its finish dies with the lock, for
982// a v3 and a pre-v3 target alike.
983func TestDataDropLockCancelsPending(t *testing.T) {
984	for _, version := range []uint32{3, 2} {
985		p, dir := lockedPair(t)
986		p.b = newDndClientAt(t, p.s, dir, p.events, version)
987		p.start(t, 0)
988		p.commands <- ports.DataDragFocus{ID: p.b.window, X: 5, Y: 6}
989		p.b.until(t, "enter")
990		offer := p.b.lastOffer(t)
991		requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
992		if version >= 3 {
993			requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
994		}
995		p.a.until(t, "target")
996		p.commands <- ports.DataDragDrop{}
997		p.a.until(t, "drop_performed")
998		lockSession(t, p.s, dir)
999		p.a.expect(t, "cancelled")
1000	}
1001}
1002
1003// A subsurface of the focused tree is a valid origin.
1004func TestDataDragFromSubsurface(t *testing.T) {
1005	p := newDndPair(t)
1006	serial := p.a.press(t, p.commands)
1007	child := p.a.surfaceNew(t)
1008	subc := bindProtocol(t, p.a.c, "wl_subcompositor")
1009	requestProtocol(t, p.a.c, subc, wayland.SubcompositorRequestGetSubsurface, p.a.c.AllocateID(), child, p.a.surf)
1010	p.a.startDragFrom(t, p.a.source(t, actCopy, "text/plain"), child, 0, serial)
1011	dndNextEvent[ports.DataDragStarted](t, p.events)
1012}
1013
1014// GO-003: destroying the offers of the target withdraws the acceptance: the
1015// release then cancels, with no drop.
1016func TestDataDragOfferDestroyedBeforeRelease(t *testing.T) {
1017	p := newDndPair(t)
1018	p.start(t, 0)
1019	offer := p.enter(t)
1020	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1021	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestSetActions, actCopy, actCopy)
1022	p.a.expect(t, "target")
1023	p.a.expect(t, "action")
1024	requestProtocol(t, p.b.c, offer, wayland.DataOfferRequestDestroy)
1025	if err := p.b.c.Roundtrip(); err != nil {
1026		t.Fatal(err)
1027	}
1028	if ev := p.a.expect(t, "target"); ev.mime != "" {
1029		t.Fatalf("target %+v", ev)
1030	}
1031	if ev := p.a.expect(t, "action"); ev.n != 0 {
1032		t.Fatalf("action %+v", ev)
1033	}
1034	p.commands <- ports.DataDragDrop{}
1035	got := p.b.until(t, "leave")
1036	for _, ev := range got {
1037		if ev.name == "drop" {
1038			t.Fatalf("drop after the offer was destroyed: %+v", got)
1039		}
1040	}
1041	p.a.expect(t, "cancelled")
1042}
1043
1044// GO-004: with ask, the target picks the final action after the drop; the
1045// source hears it just before dnd_finished.
1046func TestDataDragAsk(t *testing.T) {
1047	p := newDndPair(t)
1048	src := p.a.source(t, actCopy|actMove|actAsk, "text/plain")
1049	p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
1050	p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
1051	p.b.until(t, "enter")
1052	id := p.b.lastOffer(t)
1053	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1054	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove|actAsk, actAsk)
1055	p.a.expect(t, "target")
1056	if ev := p.a.expect(t, "action"); ev.n != actAsk {
1057		t.Fatalf("action %+v", ev)
1058	}
1059	p.commands <- ports.DataDragDrop{}
1060	p.b.until(t, "leave")
1061	p.a.expect(t, "drop_performed")
1062
1063	// Finishing while the action is ask is an error; the choice comes first.
1064	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(2), "text/plain")
1065	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove, actMove)
1066	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
1069	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestFinish)
1070	if ev := p.a.expect(t, "action"); ev.n != actMove {
1071		t.Fatalf("action %+v", ev)
1072	}
1073	p.a.expect(t, "finished")
1074}
1075
1076// A final preferred action outside the source's is an error.
1077func TestDataDragAskFinalActionNotInSource(t *testing.T) {
1078	p := newDndPair(t)
1079	src := p.a.source(t, actCopy|actAsk, "text/plain")
1080	p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
1081	p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
1082	p.b.until(t, "enter")
1083	id := p.b.lastOffer(t)
1084	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1085	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actCopy|actMove|actAsk, actAsk)
1086	p.a.expect(t, "target")
1087	p.a.expect(t, "action")
1088	p.commands <- ports.DataDragDrop{}
1089	p.b.until(t, "leave")
1090	requestProtocol(t, p.b.c, id, wayland.DataOfferRequestSetActions, actMove, actMove)
1091	expectProtocolError(t, p.b.c, id, uint32(wayland.DataOfferErrorInvalidAction))
1092}
1093
1094// GO-005: a pre-v3 target copies: a source that does not allow copy is
1095// cancelled, one that does gets it.
1096func TestDataDragLegacyTarget(t *testing.T) {
1097	setup := func(t *testing.T, actions uint32) (*dndPair, uint32) {
1098		s, events, commands, dir := keyboardServer(t)
1099		p := &dndPair{s: s, events: events, commands: commands}
1100		p.a = newDndClient(t, s, dir, events)
1101		p.b = newDndClientAt(t, s, dir, events, 2)
1102		src := p.a.source(t, actions, "text/plain")
1103		p.a.startDrag(t, src, 0, p.a.press(t, p.commands))
1104		p.commands <- ports.DataDragFocus{ID: p.b.window, X: 1, Y: 1}
1105		p.b.until(t, "enter")
1106		id := p.b.lastOffer(t)
1107		requestProtocol(t, p.b.c, id, wayland.DataOfferRequestAccept, uint32(1), "text/plain")
1108		p.a.expect(t, "target")
1109		return p, id
1110	}
1111	t.Run("move only", func(t *testing.T) {
1112		p, _ := setup(t, actMove)
1113		p.commands <- ports.DataDragDrop{}
1114		got := p.b.until(t, "leave")
1115		for _, ev := range got {
1116			if ev.name == "drop" {
1117				t.Fatalf("drop for a source without copy: %+v", got)
1118			}
1119		}
1120		p.a.until(t, "cancelled")
1121	})
1122	t.Run("copy", func(t *testing.T) {
1123		p, id := setup(t, actCopy|actMove)
1124		p.commands <- ports.DataDragDrop{}
1125		p.b.expect(t, "drop")
1126		p.b.expect(t, "leave")
1127		if ev := p.a.expect(t, "action"); ev.n != actCopy {
1128			t.Fatalf("action %+v", ev)
1129		}
1130		p.a.expect(t, "drop_performed")
1131		// GO-006: destroying the last offer finishes the transfer.
1132		requestProtocol(t, p.b.c, id, wayland.DataOfferRequestDestroy)
1133		p.a.expect(t, "finished")
1134	})
1135}
1136
1137// GO-007: of several offset requests before one commit, the last counts.
1138func TestDataDragIconOffsetReplacedBeforeCommit(t *testing.T) {
1139	p := newDndPair(t)
1140	icon := p.a.surfaceNew(t)
1141	buf := p.a.buffer(t, 8, 6)
1142	p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), icon, p.a.press(t, p.commands))
1143	id := dndNextEvent[ports.DataDragStarted](t, p.events).Icon
1144	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
1145	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-1), int32(-1))
1146	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestOffset, int32(-5), int32(-7))
1147	requestProtocol(t, p.a.c, icon, wayland.SurfaceRequestCommit)
1148	if err := p.a.c.Roundtrip(); err != nil {
1149		t.Fatal(err)
1150	}
1151	if got := dndNextEvent[ports.DataDragIcon](t, p.events); got != (ports.DataDragIcon{ID: id, X: -5, Y: -7, W: 8, H: 6}) {
1152		t.Fatalf("icon %+v", got)
1153	}
1154}

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 23

Tags 10

↑↓ moveenter openesc close