feat/hints a471fa4

datadrag_test.go RawHistoryFolder

1package core_test
2
3import (
4	"slices"
5	"testing"
6	"time"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11const dndIcon ports.WindowID = 900
12
13// dndRig is a drag rig with tiles 1 and 2 side by side and the pointer
14// on 1 with its button held, as a client that is about to start_drag.
15type dndRig struct {
16	*dragRig
17	one, two ports.Rect
18}
19
20func startDnd(t *testing.T, outs ...ports.OutputInfo) *dndRig {
21	t.Helper()
22	if len(outs) == 0 {
23		outs = []ports.OutputInfo{dragOut}
24	}
25	r := startDragRig(t, nil, outs...)
26	r.client <- ports.WindowMapped{ID: 1}
27	r.client <- ports.WindowMapped{ID: 2}
28	set := r.settle(t)
29	d := &dndRig{dragRig: r, one: dragRect(t, set, outs[0].Name, 1), two: dragRect(t, set, outs[0].Name, 2)}
30	r.moveTo(float64(d.one.X+20), float64(d.one.Y+30), 1)
31	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
32	r.settle(t)
33	r.drain()
34	return d
35}
36
37// dataDragCommands keeps what the data drag sends and the pointer events
38// no client may see during it.
39func dataDragCommands(cmds []ports.ClientCommand) []ports.ClientCommand {
40	var out []ports.ClientCommand
41	for _, v := range cmds {
42		switch v.(type) {
43		case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel,
44			ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo,
45			ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
46			out = append(out, v)
47		}
48	}
49	return out
50}
51
52func (d *dndRig) start(t *testing.T, icon ports.WindowID) []ports.ClientCommand {
53	t.Helper()
54	d.client <- ports.DataDragStarted{Button: 0x110, Icon: icon}
55	d.settle(t)
56	return dataDragCommands(d.drain())
57}
58
59func wantCommands(t *testing.T, got []ports.ClientCommand, want ...ports.ClientCommand) {
60	t.Helper()
61	if !slices.Equal(got, want) {
62		t.Fatalf("commands %#v, want %#v", got, want)
63	}
64}
65
66func TestDataDragRoutesToSurfaceUnderPointer(t *testing.T) {
67	d := startDnd(t)
68	// The origin loses the pointer and is the first target.
69	wantCommands(t, d.start(t, 0),
70		ports.PointerFocus{},
71		ports.DataDragFocus{ID: 1, X: 20, Y: 30, Time: time.Millisecond})
72	// Motion over the same surface.
73	d.moveTo(float64(d.one.X+25), float64(d.one.Y+35), 2)
74	d.settle(t)
75	wantCommands(t, dataDragCommands(d.drain()), ports.DataDragMotion{ID: 1, X: 25, Y: 35, Time: 2 * time.Millisecond})
76	// Over the other window: it is the new target.
77	d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
78	d.settle(t)
79	wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{ID: 2, X: 5, Y: 6, Time: 3 * time.Millisecond})
80	d.moveTo(float64(d.two.X+7), float64(d.two.Y+8), 4)
81	d.settle(t)
82	wantCommands(t, dataDragCommands(d.drain()), ports.DataDragMotion{ID: 2, X: 7, Y: 8, Time: 4 * time.Millisecond})
83	// Over nothing: no target.
84	d.moveTo(float64(d.two.X+d.two.W+5), float64(d.two.Y+8), 5)
85	d.settle(t)
86	wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{Time: 5 * time.Millisecond})
87	d.moveTo(float64(d.two.X+d.two.W+6), float64(d.two.Y+8), 6)
88	d.settle(t)
89	wantCommands(t, dataDragCommands(d.drain()))
90}
91
92func TestDataDragDropsOnRelease(t *testing.T) {
93	d := startDnd(t)
94	d.start(t, 0)
95	d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
96	d.in(ports.PointerButton{Button: 0x110, Time: 4 * time.Millisecond})
97	d.settle(t)
98	// The release is the drop; the pointer then points at the window under
99	// the cursor, not at the origin that grabbed it.
100	wantCommands(t, dataDragCommands(d.drain()),
101		ports.DataDragFocus{ID: 2, X: 5, Y: 6, Time: 3 * time.Millisecond},
102		ports.DataDragDrop{},
103		ports.PointerFocus{ID: 2, X: 5, Y: 6})
104	// The drag is over: motion is plain pointer motion again.
105	d.moveTo(float64(d.two.X+9), float64(d.two.Y+9), 5)
106	d.settle(t)
107	wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 2, X: 9, Y: 9, Time: 5 * time.Millisecond})
108	// The next press is an ordinary click.
109	d.in(ports.PointerButton{Button: 0x110, Pressed: true})
110	d.settle(t)
111	wantCommands(t, dataDragCommands(d.drain()), ports.PointerButtonTo{ID: 2, Button: 0x110, Pressed: true, Grab: true})
112}
113
114func TestDataDragSendsNothingToClients(t *testing.T) {
115	d := startDnd(t)
116	d.start(t, 0)
117	d.in(ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}})
118	d.in(ports.PinchBegin{Fingers: 2, Time: time.Second})
119	d.in(ports.PinchUpdate{Scale: 2, Time: time.Second})
120	d.in(ports.PinchEnd{Time: time.Second})
121	d.in(ports.HoldBegin{Fingers: 2, Time: time.Second})
122	d.in(ports.HoldEnd{Time: time.Second})
123	d.in(ports.SwipeBegin{Fingers: 2, Time: time.Second})
124	d.in(ports.SwipeEnd{Time: time.Second})
125	// Other buttons neither press nor release anywhere.
126	d.in(ports.PointerButton{Button: 0x111, Pressed: true})
127	d.in(ports.PointerButton{Button: 0x111})
128	d.settle(t)
129	for _, v := range dataDragCommands(d.drain()) {
130		switch v.(type) {
131		case ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.PointerMotionTo, ports.DataDragDrop, ports.DataDragCancel:
132			t.Fatalf("sent %#v", v)
133		}
134	}
135	// Still dragging: the drag button's release drops.
136	d.in(ports.PointerButton{Button: 0x110})
137	d.settle(t)
138	if got := dataDragCommands(d.drain()); len(got) == 0 || got[0] != (ports.DataDragDrop{}) {
139		t.Fatalf("commands %#v", got)
140	}
141}
142
143func TestDataDragKeyboardKeepsForwarding(t *testing.T) {
144	d := startDnd(t)
145	d.start(t, 0)
146	d.in(ports.KeyEvent{Keysym: "Shift_L", Keycode: 42, Pressed: true, Mods: ports.ModShift})
147	d.in(ports.KeyEvent{Keysym: "Shift_L", Keycode: 42})
148	d.settle(t)
149	n := 0
150	for _, v := range d.drain() {
151		if _, ok := v.(ports.ForwardKey); ok {
152			n++
153		}
154	}
155	if n != 2 {
156		t.Fatalf("%d keys forwarded, want 2", n)
157	}
158}
159
160func TestDataDragEscapeCancels(t *testing.T) {
161	d := startDnd(t)
162	d.start(t, 0)
163	d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
164	d.settle(t)
165	d.drain()
166	d.in(ports.KeyEvent{Keysym: "Escape", Keycode: 1, Pressed: true})
167	d.in(ports.KeyEvent{Keysym: "Escape", Keycode: 1})
168	d.in(ports.PointerButton{Button: 0x110})
169	d.settle(t)
170	cmds := d.drain()
171	wantCommands(t, dataDragCommands(cmds),
172		ports.DataDragCancel{},
173		ports.PointerFocus{ID: 2, X: 5, Y: 6})
174	for _, v := range cmds {
175		if k, ok := v.(ports.ForwardKey); ok {
176			t.Fatalf("client saw %#v", k)
177		}
178	}
179	// The press that started the drag is over.
180	d.moveTo(float64(d.two.X+9), float64(d.two.Y+9), 5)
181	d.settle(t)
182	wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 2, X: 9, Y: 9, Time: 5 * time.Millisecond})
183}
184
185func TestDataDragEndedByWayland(t *testing.T) {
186	d := startDnd(t)
187	d.start(t, dndIcon)
188	d.client <- ports.DataDragIcon{ID: dndIcon, X: 1, Y: 2, W: 16, H: 16}
189	d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
190	d.settle(t)
191	d.drain()
192	// The source died: no command, the icon goes and the pointer is
193	// pointed at the window under the cursor.
194	d.client <- ports.DataDragEnded{}
195	set := d.settle(t)
196	if _, ok := windowIn(set, "A", dndIcon); ok {
197		t.Fatal("icon still drawn")
198	}
199	wantCommands(t, dataDragCommands(d.drain()),
200		ports.PointerFocus{ID: 2, X: 5, Y: 6})
201	// The held button's release reaches nobody.
202	d.in(ports.PointerButton{Button: 0x110})
203	d.settle(t)
204	wantCommands(t, dataDragCommands(d.drain()))
205	// An end with no drag is ignored.
206	d.client <- ports.DataDragEnded{}
207	d.settle(t)
208	wantCommands(t, dataDragCommands(d.drain()))
209}
210
211func TestDataDragRefused(t *testing.T) {
212	t.Run("no press", func(t *testing.T) {
213		d := startDnd(t)
214		d.in(ports.PointerButton{Button: 0x110})
215		d.settle(t)
216		d.drain()
217		wantCommands(t, d.start(t, 0), ports.DataDragCancel{})
218		// No drag started: motion is plain.
219		d.moveTo(float64(d.one.X+21), float64(d.one.Y+30), 2)
220		d.settle(t)
221		wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 1, X: 21, Y: 30, Time: 2 * time.Millisecond})
222	})
223	t.Run("window drag", func(t *testing.T) {
224		r := startDragRig(t, nil, dragOut)
225		r.client <- ports.WindowMapped{ID: 1}
226		r.client <- ports.WindowMapped{ID: 2}
227		set := r.settle(t)
228		one := dragRect(t, set, "A", 1)
229		r.moveTo(float64(one.X+20), float64(one.Y+30), 1)
230		r.settle(t)
231		r.press(t, 0x110, ports.ModSuper)
232		r.settle(t)
233		r.drain()
234		r.client <- ports.DataDragStarted{Button: 0x110}
235		r.settle(t)
236		wantCommands(t, r.drain(), ports.DataDragCancel{})
237		// The window drag goes on.
238		r.moveTo(float64(one.X+200), float64(one.Y+30), 2)
239		r.in(ports.PointerButton{Button: 0x110})
240		r.settle(t)
241		wantCommands(t, dataDragCommands(r.drain()), ports.PointerFocus{ID: 1, X: 200, Y: 30})
242	})
243	t.Run("twice", func(t *testing.T) {
244		d := startDnd(t)
245		d.start(t, 0)
246		wantCommands(t, d.start(t, 0), ports.DataDragCancel{})
247	})
248	t.Run("locked", func(t *testing.T) {
249		d := startDnd(t)
250		locked := ports.SecurityState{Generation: 1, Protected: true}
251		d.epoch.Store(locked)
252		d.client <- ports.SessionLockChanged{State: locked}
253		d.settle(t)
254		d.drain()
255		d.client <- ports.DataDragStarted{Button: 0x110}
256		d.settle(t)
257		var got []ports.ClientCommand
258		for _, v := range d.drain() {
259			switch v.(type) {
260			case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
261				got = append(got, v)
262			}
263		}
264		wantCommands(t, got, ports.DataDragCancel{})
265	})
266}
267
268func TestDataDragCommandsAreSecurityWrapped(t *testing.T) {
269	d := startDnd(t)
270	d.client <- ports.DataDragStarted{Button: 0x110}
271	d.settle(t)
272	n := 0
273	for {
274		select {
275		case v := <-d.commands:
276			s, ok := v.(ports.SecurityCommand)
277			if !ok {
278				t.Fatalf("unwrapped %#v", v)
279			}
280			if _, ok := s.Command.(ports.DataDragFocus); ok {
281				n++
282			}
283			continue
284		default:
285		}
286		break
287	}
288	if n != 1 {
289		t.Fatalf("%d DataDragFocus", n)
290	}
291}
292
293func TestDataDragLockCancelsIt(t *testing.T) {
294	d := startDnd(t)
295	d.start(t, dndIcon)
296	d.client <- ports.DataDragIcon{ID: dndIcon, W: 16, H: 16}
297	d.settle(t)
298	locked := ports.SecurityState{Generation: 1, Protected: true}
299	d.epoch.Store(locked)
300	d.client <- ports.SessionLockChanged{State: locked, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 1000, Height: 600}}}
301	set := d.settle(t)
302	if _, ok := windowIn(set, "A", dndIcon); ok {
303		t.Fatal("icon drawn while locked")
304	}
305	d.drain()
306	d.in(ports.PointerMotion{X: 100, Y: 100, Time: time.Second})
307	d.settle(t)
308	for _, v := range d.drain() {
309		switch v.(type) {
310		case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop:
311			t.Fatalf("data drag alive while locked: %#v", v)
312		}
313	}
314	// After the unlock, the old drag is gone: plain pointer events.
315	unlocked := ports.SecurityState{Generation: 2}
316	d.epoch.Store(unlocked)
317	d.client <- ports.SessionLockChanged{State: unlocked}
318	d.settle(t)
319	d.drain()
320	d.moveTo(float64(d.one.X+21), float64(d.one.Y+30), 9)
321	d.settle(t)
322	got := dataDragCommands(d.drain())
323	wantCommands(t, got, ports.PointerFocus{ID: 1, X: 21, Y: 30}, ports.PointerMotionTo{ID: 1, X: 21, Y: 30, Time: 9 * time.Millisecond})
324}
325
326func TestDataDragTargetUnmapped(t *testing.T) {
327	d := startDnd(t)
328	d.start(t, 0)
329	d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
330	d.settle(t)
331	d.drain()
332	d.client <- ports.WindowUnmapped{ID: 2}
333	d.settle(t)
334	cmds := dataDragCommands(d.drain())
335	if len(cmds) == 0 || cmds[0] != (ports.DataDragFocus{Time: 3 * time.Millisecond}) {
336		t.Fatalf("commands %#v", cmds)
337	}
338}
339
340func TestDataDragIconIsLastWindowAtCursor(t *testing.T) {
341	d := startDnd(t)
342	d.start(t, dndIcon)
343	// Not drawn until wayland measured it.
344	if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
345		t.Fatal("icon drawn before its size")
346	}
347	cx, cy := d.one.X+20, d.one.Y+30
348	d.client <- ports.DataDragIcon{ID: dndIcon, X: -4, Y: -6, W: 32, H: 24}
349	set := d.settle(t)
350	s := dragScene(set, "A")
351	last := s.Windows[len(s.Windows)-1]
352	want := ports.SceneWindow{ID: dndIcon, Rect: ports.Rect{X: cx - 4, Y: cy - 6, W: 32, H: 24}, Popup: true, OverLayers: true}
353	if last != want {
354		t.Fatalf("last window %+v, want %+v", last, want)
355	}
356	// It follows the cursor; the scene changes.
357	d.moveTo(float64(cx+100), float64(cy+50), 2)
358	moved := dragScene(d.settle(t), "A")
359	if moved.SameAs(s) || moved.Seq == s.Seq {
360		t.Fatal("scene did not change with the icon")
361	}
362	if got := moved.Windows[len(moved.Windows)-1]; got.Rect.X != cx+96 || got.Rect.Y != cy+44 || got.ID != dndIcon {
363		t.Fatalf("moved icon %+v", got)
364	}
365	// Under the icon, the surface below is still the target.
366	d.drain()
367	d.moveTo(float64(cx+101), float64(cy+50), 3)
368	d.settle(t)
369	for _, v := range d.drain() {
370		if f, ok := v.(ports.DataDragFocus); ok && f.ID == dndIcon {
371			t.Fatalf("icon was targeted: %#v", f)
372		}
373	}
374	// A commit that changes the offset moves it; a zero size hides it.
375	d.client <- ports.DataDragIcon{ID: dndIcon, X: 0, Y: 0, W: 32, H: 24}
376	if got := dragScene(d.settle(t), "A"); got.Windows[len(got.Windows)-1].Rect.X != cx+101 {
377		t.Fatalf("offset ignored: %+v", got.Windows[len(got.Windows)-1])
378	}
379	d.client <- ports.DataDragIcon{ID: dndIcon}
380	if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
381		t.Fatal("hidden icon drawn")
382	}
383	// Geometry of another surface is not the icon's.
384	d.client <- ports.DataDragIcon{ID: dndIcon + 1, W: 10, H: 10}
385	if _, ok := windowIn(d.settle(t), "A", dndIcon+1); ok {
386		t.Fatal("foreign icon drawn")
387	}
388	// The drop removes it.
389	d.client <- ports.DataDragIcon{ID: dndIcon, W: 32, H: 24}
390	d.settle(t)
391	d.in(ports.PointerButton{Button: 0x110})
392	set = d.settle(t)
393	if _, ok := windowIn(set, "A", dndIcon); ok {
394		t.Fatal("icon drawn after the drop")
395	}
396}
397
398func TestDataDragIconOnScreenUnderCursor(t *testing.T) {
399	other := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
400	d := startDnd(t, dragOut, other)
401	d.start(t, dndIcon)
402	d.client <- ports.DataDragIcon{ID: dndIcon, X: 2, Y: 3, W: 10, H: 10}
403	d.moveTo(1000+100, 50, 2)
404	set := d.settle(t)
405	if _, ok := windowIn(set, "A", dndIcon); ok {
406		t.Fatal("icon on the other screen")
407	}
408	w, ok := windowIn(set, "B", dndIcon)
409	if !ok {
410		t.Fatal("no icon on B")
411	}
412	if w.Rect != (ports.Rect{X: 102, Y: 53, W: 10, H: 10}) {
413		t.Fatalf("icon %+v", w.Rect)
414	}
415}
416
417func TestDataDragNoIconIgnoresIconEvents(t *testing.T) {
418	d := startDnd(t)
419	d.start(t, 0)
420	d.client <- ports.DataDragIcon{ID: dndIcon, W: 10, H: 10}
421	if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
422		t.Fatal("icon drawn for a drag without one")
423	}
424	// Nor with no drag at all.
425	d.in(ports.PointerButton{Button: 0x110})
426	d.settle(t)
427	d.client <- ports.DataDragIcon{ID: dndIcon, W: 10, H: 10}
428	if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
429		t.Fatal("icon drawn without a drag")
430	}
431}
432
433func TestDataDragFocusScreenFollowsPointer(t *testing.T) {
434	r := startDragRig(t, nil, dragOut, ports.OutputInfo{Name: "B", Width: 1000, Height: 600})
435	r.client <- ports.WindowMapped{ID: 1}
436	r.settle(t)
437	r.moveTo(1500, 300, 1)
438	r.settle(t)
439	r.client <- ports.WindowMapped{ID: 3}
440	set := r.settle(t)
441	one := dragRect(t, set, "A", 1)
442	r.moveTo(float64(one.X+20), float64(one.Y+30), 2)
443	set = r.settle(t)
444	if w, _ := windowIn(set, "A", 1); !w.Focused {
445		t.Fatal("1 not focused")
446	}
447	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
448	r.settle(t)
449	r.client <- ports.DataDragStarted{Button: 0x110}
450	r.moveTo(1500, 300, 3)
451	set = r.settle(t)
452	if w, _ := windowIn(set, "B", 3); !w.Focused {
453		t.Fatal("the screen under the drag did not take the focus")
454	}
455	three := dragRect(t, set, "B", 3)
456	var focus []ports.DataDragFocus
457	for _, v := range r.drain() {
458		if f, ok := v.(ports.DataDragFocus); ok {
459			focus = append(focus, f)
460		}
461	}
462	if n := len(focus); n != 2 || focus[1].ID != 3 || focus[1].X != float64(1500-1000-three.X) {
463		t.Fatalf("focus %+v", focus)
464	}
465}
466
467func TestDataDragFollowsWorkspaceSwitchUnderStillCursor(t *testing.T) {
468	d := startDnd(t)
469	d.start(t, 0)
470	d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
471	d.settle(t)
472	d.drain()
473	// cmd+2 shows an empty workspace under the still cursor: the target
474	// leaves, and the release drops on nothing.
475	d.in(ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true})
476	d.in(ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper})
477	d.settle(t)
478	got := dataDragCommands(d.drain())
479	if !slices.Contains(got, ports.ClientCommand(ports.DataDragFocus{Time: 3 * time.Millisecond})) {
480		t.Fatalf("no leave after the switch: %#v", got)
481	}
482	d.in(ports.PointerButton{Button: 0x110, Time: 4 * time.Millisecond})
483	d.settle(t)
484	got = dataDragCommands(d.drain())
485	if len(got) == 0 || got[0] != (ports.DataDragDrop{}) {
486		t.Fatalf("commands %#v", got)
487	}
488	for _, v := range got {
489		if f, ok := v.(ports.DataDragFocus); ok && f.ID != 0 {
490			t.Fatalf("hidden window targeted: %#v", got)
491		}
492	}
493}
494
495func TestDataDragTargetMovedUnderStillCursor(t *testing.T) {
496	d := startDnd(t)
497	d.start(t, 0)
498	// Over 2, focused by a click-free keyboard move, then cmd+f maximizes
499	// its column: still the target under the cursor, at another point.
500	d.moveTo(float64(d.two.X+d.two.W-20), float64(d.two.Y+30), 2)
501	d.in(ports.KeyEvent{Keysym: "l", Keycode: 38, Mods: ports.ModSuper, Pressed: true})
502	d.in(ports.KeyEvent{Keysym: "l", Keycode: 38, Mods: ports.ModSuper})
503	d.settle(t)
504	d.drain()
505	d.in(ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
506	d.in(ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper})
507	set := d.settle(t)
508	wide := dragRect(t, set, dragOut.Name, 2)
509	if wide.X == d.two.X {
510		t.Fatalf("the column did not move: %v", wide)
511	}
512	x, y := float64(d.two.X+d.two.W-20-wide.X), float64(d.two.Y+30-wide.Y)
513	got := dataDragCommands(d.drain())
514	if !slices.Contains(got, ports.ClientCommand(ports.DataDragMotion{ID: 2, X: x, Y: y, Time: 2 * time.Millisecond})) {
515		t.Fatalf("no motion to %v,%v: %#v", x, y, got)
516	}
517}
518
519func TestDataDragDropsOnItsOwnButton(t *testing.T) {
520	d := startDnd(t)
521	// Right pressed after left: the drag is left's, the grab's.
522	d.in(ports.PointerButton{Button: 0x111, Pressed: true})
523	d.settle(t)
524	d.drain()
525	d.start(t, 0)
526	d.in(ports.PointerButton{Button: 0x111})
527	d.settle(t)
528	if got := dataDragCommands(d.drain()); slices.Contains(got, ports.ClientCommand(ports.DataDragDrop{})) {
529		t.Fatalf("right's release dropped: %#v", got)
530	}
531	d.in(ports.PointerButton{Button: 0x110})
532	d.settle(t)
533	if got := dataDragCommands(d.drain()); len(got) == 0 || got[0] != (ports.DataDragDrop{}) {
534		t.Fatalf("left's release did not drop: %#v", got)
535	}
536}
537
538func TestDataDragEscapeCancelsOverSwitcher(t *testing.T) {
539	d := startDnd(t)
540	d.start(t, 0)
541	d.in(ports.KeyEvent{Keysym: "Super_L", Keycode: 125, Mods: ports.ModSuper, Pressed: true})
542	d.in(ports.KeyEvent{Keysym: "Tab", Keycode: 15, Mods: ports.ModSuper, Pressed: true})
543	d.in(ports.KeyEvent{Keysym: "Tab", Keycode: 15, Mods: ports.ModSuper})
544	d.settle(t)
545	d.drain()
546	d.in(ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true})
547	d.settle(t)
548	got := dataDragCommands(d.drain())
549	if len(got) == 0 || got[0] != (ports.DataDragCancel{}) {
550		t.Fatalf("Escape did not cancel the drag: %#v", got)
551	}
552	d.in(ports.PointerButton{Button: 0x110})
553	d.settle(t)
554	if slices.Contains(dataDragCommands(d.drain()), ports.ClientCommand(ports.DataDragDrop{})) {
555		t.Fatal("dropped after Escape")
556	}
557}
558
559func TestDataDragIgnoresCompositorSwipes(t *testing.T) {
560	d := startDnd(t)
561	d.start(t, 0)
562	before := d.settle(t)
563	// Flicks that page columns and workspaces, as navigation would.
564	for _, fingers := range []int{3, 4} {
565		at := time.Second
566		d.in(ports.SwipeBegin{Fingers: fingers, Time: at})
567		for range 10 {
568			at += 8 * time.Millisecond
569			d.in(ports.SwipeUpdate{DX: 60, AccelDX: 60, DY: 40, AccelDY: 40, Time: at})
570		}
571		d.in(ports.SwipeEnd{Time: at})
572	}
573	after := d.settle(t)
574	if !slices.Equal(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	}
577}

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