v0.2.0 13c6a90

drag_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"slices"
6	"sync/atomic"
7	"testing"
8	"time"
9
10	"github.com/bnema/neferwl/internal/adapters/config"
11	"github.com/bnema/neferwl/internal/core"
12	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13	"github.com/bnema/neferwl/internal/ports"
14)
15
16type dragRig struct {
17	client   chan ports.ClientEvent
18	input    chan ports.InputEvent
19	output   chan ports.OutputEvent
20	commands chan ports.ClientCommand
21	scenes   chan []ports.Scene
22	last     []ports.Scene
23	epoch    *atomic.Value
24}
25
26// startDrag runs core with Super as Cmd, no border, 10 px gaps, and
27// the given outputs. edit may change the config.
28func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
29	t.Helper()
30	cfg := config.Defaults()
31	cfg.Border.Width = 0
32	cfg.Layout.Gaps = 10
33	cfg.Terminal.AutoOpen = "off"
34	if edit != nil {
35		edit(&cfg)
36	}
37	epoch := &atomic.Value{}
38	epoch.Store(ports.SecurityState{})
39	gate := portsmocks.NewMockSessionSecurity(t)
40	gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }).Maybe()
41	// Unbuffered: a send returns once core took the event, so it handled
42	// every event sent before (settle relies on it).
43	r := &dragRig{
44		client: make(chan ports.ClientEvent), input: make(chan ports.InputEvent),
45		output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
46		scenes: make(chan []ports.Scene, 1), epoch: epoch,
47	}
48	c, err := core.New(cfg, core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes})
49	if err != nil {
50		t.Fatal(err)
51	}
52	ctx, cancel := context.WithCancel(context.Background())
53	done := make(chan error, 1)
54	go func() { done <- c.Run(ctx) }()
55	t.Cleanup(func() { cancel(); <-done })
56	for _, o := range outs {
57		r.output <- ports.OutputAdded{Info: o}
58		r.settle(t)
59	}
60	return r
61}
62
63// in sends a raw input event; the gate is on, so it is wrapped.
64func (r *dragRig) in(ev ports.InputEvent) {
65	send(r.input, ports.InputEvent(ports.SecurityInput{State: r.epoch.Load().(ports.SecurityState), Event: ev}))
66}
67
68// send fails the test binary fast instead of hanging when core is stuck.
69func send[T any](ch chan T, v T) {
70	select {
71	case ch <- v:
72	case <-time.After(5 * time.Second):
73		panic("core stuck")
74	}
75}
76
77// barrier is a window no test maps: a resize of it changes nothing but
78// makes core publish.
79const barrier ports.WindowID = 1 << 30
80
81// settle returns the scene set once core handled every event sent so
82// far. The second barrier is taken only after the first one published.
83func (r *dragRig) settle(t *testing.T) []ports.Scene {
84	t.Helper()
85	send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
86	send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
87	select {
88	case s := <-r.scenes:
89		r.last = s
90	case <-time.After(5 * time.Second):
91		t.Fatal("no scene")
92	}
93	return r.last
94}
95
96// drain returns the commands sent since the last drain.
97func (r *dragRig) drain() []ports.ClientCommand {
98	var out []ports.ClientCommand
99	for {
100		select {
101		case v := <-r.commands:
102			if s, ok := v.(ports.SecurityCommand); ok {
103				v = s.Command
104			}
105			out = append(out, v)
106		default:
107			return out
108		}
109	}
110}
111
112func pointerSent(cmds []ports.ClientCommand) []ports.ClientCommand {
113	var out []ports.ClientCommand
114	for _, v := range cmds {
115		switch v.(type) {
116		case ports.PointerButtonTo, ports.PointerMotionTo, ports.ForwardKey:
117			out = append(out, v)
118		}
119	}
120	return out
121}
122
123func dragRect(t *testing.T, set []ports.Scene, output string, id ports.WindowID) ports.Rect {
124	t.Helper()
125	w, ok := windowIn(set, output, id)
126	if !ok {
127		t.Fatalf("window %d not on %s", id, output)
128	}
129	return w.Rect
130}
131
132func dragScene(set []ports.Scene, output string) ports.Scene {
133	for _, s := range set {
134		if s.Output == output {
135			return s
136		}
137	}
138	return ports.Scene{}
139}
140
141// columns lists the tiles left to right by x, then top to bottom.
142func columnsOf(set []ports.Scene, output string) [][]ports.WindowID {
143	s := dragScene(set, output)
144	var wins []ports.SceneWindow
145	for _, w := range s.Windows {
146		if !w.Hidden && !w.Floating && !w.Popup {
147			wins = append(wins, w)
148		}
149	}
150	slices.SortFunc(wins, func(a, b ports.SceneWindow) int {
151		if a.Rect.X != b.Rect.X {
152			return a.Rect.X - b.Rect.X
153		}
154		return a.Rect.Y - b.Rect.Y
155	})
156	var out [][]ports.WindowID
157	lastX := -1 << 30
158	for _, w := range wins {
159		if w.Rect.X != lastX {
160			out = append(out, nil)
161			lastX = w.Rect.X
162		}
163		out[len(out)-1] = append(out[len(out)-1], w.ID)
164	}
165	return out
166}
167
168var dragOut = ports.OutputInfo{Name: "A", Width: 1000, Height: 600}
169
170func (r *dragRig) moveTo(x, y float64, msec uint32) {
171	r.in(ports.PointerMotion{X: x, Y: y, TimeMsec: msec})
172}
173
174func (r *dragRig) press(t *testing.T, button uint32, mods ports.Mods) {
175	t.Helper()
176	if mods != 0 {
177		r.in(ports.KeyEvent{Keysym: "Super_L", Mods: mods, Pressed: true})
178	}
179	r.in(ports.PointerButton{Button: button, Pressed: true})
180}
181
182func TestCmdDragMovesFloatWithoutClientEvents(t *testing.T) {
183	r := startDragRig(t, nil, dragOut)
184	r.client <- ports.WindowMapped{ID: 1}
185	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
186	set := r.settle(t)
187	f := dragRect(t, set, "A", 2)
188	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
189	r.settle(t)
190	r.drain()
191	r.press(t, 0x110, ports.ModSuper)
192	r.moveTo(float64(f.X+60), float64(f.Y+40), 2)
193	set = r.settle(t)
194	got := dragRect(t, set, "A", 2)
195	if got.X != f.X+50 || got.Y != f.Y+30 || got.W != f.W {
196		t.Fatalf("moved to %+v from %+v", got, f)
197	}
198	if w, _ := windowIn(set, "A", 2); !w.Floating {
199		t.Fatal("not floating")
200	}
201	r.in(ports.PointerButton{Button: 0x110})
202	r.in(ports.KeyEvent{Keysym: "Super_L"})
203	r.settle(t)
204	cmds := r.drain()
205	for _, v := range pointerSent(cmds) {
206		if _, ok := v.(ports.PointerButtonTo); ok {
207			t.Fatalf("client saw %#v", v)
208		}
209		if _, ok := v.(ports.PointerMotionTo); ok {
210			t.Fatalf("client saw %#v", v)
211		}
212	}
213	// It stays there: a free float now.
214	r.moveTo(float64(f.X+61), float64(f.Y+41), 3)
215	if got2 := dragRect(t, r.settle(t), "A", 2); got2 != got {
216		t.Fatalf("moved after release: %+v", got2)
217	}
218	// A plain press goes to the client.
219	r.drain()
220	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
221	r.settle(t)
222	found := false
223	for _, v := range r.drain() {
224		if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 && b.Pressed {
225			found = true
226		}
227	}
228	if !found {
229		t.Fatal("plain press not forwarded")
230	}
231}
232
233func TestDragEscapeRestoresFloat(t *testing.T) {
234	r := startDragRig(t, nil, dragOut)
235	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
236	f := dragRect(t, r.settle(t), "A", 2)
237	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
238	r.settle(t)
239	r.press(t, 0x111, ports.ModSuper)
240	r.moveTo(float64(f.X+110), float64(f.Y+60), 2)
241	if got := dragRect(t, r.settle(t), "A", 2); got.W != f.W+100 || got.H != f.H+50 || got.X != f.X {
242		t.Fatalf("resize: %+v from %+v", got, f)
243	}
244	r.drain()
245	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
246	if got := dragRect(t, r.settle(t), "A", 2); got != f {
247		t.Fatalf("escape: %+v, want %+v", got, f)
248	}
249	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
250	r.in(ports.PointerButton{Button: 0x111})
251	r.settle(t)
252	for _, v := range pointerSent(r.drain()) {
253		t.Fatalf("client saw %#v", v)
254	}
255}
256
257func TestDragTileZonesAndHints(t *testing.T) {
258	r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
259	for id := ports.WindowID(1); id <= 3; id++ {
260		r.client <- ports.WindowMapped{ID: id}
261		r.settle(t)
262	}
263	set := r.settle(t)
264	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
265		t.Fatal(got)
266	}
267	a, c := dragRect(t, set, "A", 1), dragRect(t, set, "A", 3)
268	// Drag 1 onto the bottom band of 3: it stacks below 3.
269	r.moveTo(float64(a.X+a.W/2), float64(a.Y+a.H/2), 1)
270	r.settle(t)
271	r.press(t, 0x110, ports.ModSuper)
272	r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H-5), 2)
273	set = r.settle(t)
274	hints := dragScene(set, "A").DropHints
275	if len(hints) != 1 || hints[0].Y+hints[0].H != c.Y+c.H || hints[0].W != c.W {
276		t.Fatalf("hints %+v", hints)
277	}
278	// Tiles do not move until the drop.
279	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
280		t.Fatal(got)
281	}
282	r.in(ports.PointerButton{Button: 0x110})
283	set = r.settle(t)
284	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
285		t.Fatal(got)
286	}
287	if len(dragScene(set, "A").DropHints) != 0 {
288		t.Fatal("hints left")
289	}
290	// Centre of 2 with stacked 1 dragged from its right: 1 becomes a
291	// column in 2's place, 2 moves toward where 1 came from.
292	b := dragRect(t, set, "A", 2)
293	one := dragRect(t, set, "A", 1)
294	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
295	r.settle(t)
296	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
297	r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 4)
298	if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
299		t.Fatalf("swap outline %+v", hints)
300	}
301	r.in(ports.PointerButton{Button: 0x110})
302	set = r.settle(t)
303	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
304		t.Fatal(got)
305	}
306	// Lone 3 on the centre of 2: the columns swap.
307	b, c = dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
308	r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H/2), 5)
309	r.settle(t)
310	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
311	r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
312	r.settle(t)
313	r.in(ports.PointerButton{Button: 0x110})
314	set = r.settle(t)
315	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
316		t.Fatal(got)
317	}
318	// Escape mid tile drag: nothing moves, no hints.
319	one = dragRect(t, set, "A", 1)
320	b = dragRect(t, set, "A", 2)
321	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 7)
322	r.settle(t)
323	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
324	r.moveTo(float64(b.X+b.W/2), float64(b.Y+5), 8)
325	if len(dragScene(r.settle(t), "A").DropHints) == 0 {
326		t.Fatal("no hint")
327	}
328	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
329	set = r.settle(t)
330	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) || len(dragScene(set, "A").DropHints) != 0 {
331		t.Fatal(got, dragScene(set, "A").DropHints)
332	}
333}
334
335func TestDragTileGapInsert(t *testing.T) {
336	r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
337	for id := ports.WindowID(1); id <= 3; id++ {
338		r.client <- ports.WindowMapped{ID: id}
339		r.settle(t)
340	}
341	r.client <- ports.WindowMapped{ID: 4}
342	r.settle(t)
343	// Stack 4 under 3 with the keyboard, then put 4 between 1 and 2.
344	r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper, Pressed: true})
345	r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper})
346	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
347	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
348	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
349	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
350	set := r.settle(t)
351	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3, 4}}, slices.Equal) {
352		t.Fatal(got)
353	}
354	one, two, four := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 4)
355	gapX := (one.X + one.W + two.X) / 2
356	r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
357	r.settle(t)
358	r.press(t, 0x110, ports.ModSuper)
359	r.moveTo(float64(gapX), float64(one.Y+one.H/2), 2)
360	hints := dragScene(r.settle(t), "A").DropHints
361	if len(hints) != 1 || hints[0].H != one.H || hints[0].X+hints[0].W/2 != gapX {
362		t.Fatalf("gap hint %+v", hints)
363	}
364	r.in(ports.PointerButton{Button: 0x110})
365	if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got[:2], [][]ports.WindowID{{1}, {4}}, slices.Equal) {
366		t.Fatal(got)
367	}
368}
369
370func TestDragFixedNoGapZone(t *testing.T) {
371	r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
372	for id := ports.WindowID(1); id <= 2; id++ {
373		r.client <- ports.WindowMapped{ID: id}
374		r.settle(t)
375	}
376	set := r.settle(t)
377	one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
378	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
379	r.settle(t)
380	r.press(t, 0x110, ports.ModSuper)
381	r.moveTo(float64((one.X+one.W+two.X)/2), float64(one.Y+one.H/2), 2)
382	if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
383		t.Fatalf("fixed gap hint %+v", hints)
384	}
385	// Top band of 2 stacks 1 above it.
386	r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
387	r.settle(t)
388	r.in(ports.PointerButton{Button: 0x110})
389	if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
390		t.Fatal(got)
391	}
392}
393
394func TestDragAcrossOutputs(t *testing.T) {
395	b := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
396	r := startDragRig(t, nil, dragOut, b)
397	r.client <- ports.WindowMapped{ID: 1}
398	r.client <- ports.WindowMapped{ID: 2}
399	r.settle(t)
400	// Put 3 on B.
401	r.moveTo(1500, 300, 1)
402	r.settle(t)
403	r.client <- ports.WindowMapped{ID: 3}
404	set := r.settle(t)
405	if _, ok := windowIn(set, "B", 3); !ok {
406		t.Fatal("3 not on B")
407	}
408	one := dragRect(t, set, "A", 1)
409	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 2)
410	r.settle(t)
411	r.press(t, 0x110, ports.ModSuper)
412	r.moveTo(1400, 300, 3)
413	set = r.settle(t)
414	if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
415		t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
416	}
417	r.in(ports.PointerButton{Button: 0x110})
418	set = r.settle(t)
419	if _, ok := windowIn(set, "B", 1); !ok {
420		t.Fatal("1 not on B")
421	}
422	if w, _ := windowIn(set, "B", 1); !w.Focused {
423		t.Fatal("1 not focused")
424	}
425}
426
427func TestClientMoveRequest(t *testing.T) {
428	r := startDragRig(t, nil, dragOut)
429	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
430	f := dragRect(t, r.settle(t), "A", 2)
431	// Without a held button: ignored.
432	r.client <- ports.WindowMoveRequest{ID: 2}
433	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
434	r.settle(t)
435	r.moveTo(float64(f.X+20), float64(f.Y+10), 2)
436	if got := dragRect(t, r.settle(t), "A", 2); got != f {
437		t.Fatalf("moved without a button: %+v", got)
438	}
439	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
440	r.settle(t)
441	r.client <- ports.WindowMoveRequest{ID: 2}
442	r.settle(t)
443	r.drain()
444	r.moveTo(float64(f.X+60), float64(f.Y+10), 3)
445	if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+40 {
446		t.Fatalf("move request: %+v from %+v", got, f)
447	}
448	r.in(ports.PointerButton{Button: 0x110})
449	r.settle(t)
450	for _, v := range pointerSent(r.drain()) {
451		t.Fatalf("client saw %#v", v)
452	}
453}
454
455func TestLockClearsDrag(t *testing.T) {
456	r := startDragRig(t, nil, dragOut)
457	r.client <- ports.WindowMapped{ID: 1}
458	r.client <- ports.WindowMapped{ID: 2}
459	set := r.settle(t)
460	one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
461	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
462	r.settle(t)
463	r.press(t, 0x110, ports.ModSuper)
464	r.moveTo(float64(two.X+two.W/2), float64(two.Y+5), 2)
465	if len(dragScene(r.settle(t), "A").DropHints) == 0 {
466		t.Fatal("no hint")
467	}
468	locked := ports.SecurityState{Generation: 1, Protected: true}
469	r.epoch.Store(locked)
470	r.client <- ports.SessionLockChanged{State: locked}
471	set = r.settle(t)
472	if len(dragScene(set, "A").DropHints) != 0 {
473		t.Fatal("hints while locked")
474	}
475	unlocked := ports.SecurityState{Generation: 2}
476	r.epoch.Store(unlocked)
477	r.client <- ports.SessionLockChanged{State: unlocked}
478	r.settle(t)
479	r.in(ports.PointerButton{Button: 0x110})
480	set = r.settle(t)
481	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}}, slices.Equal) {
482		t.Fatal(got)
483	}
484}
485
486func TestDragEdgeScroll(t *testing.T) {
487	r := startDragRig(t, nil, dragOut)
488	for id := ports.WindowID(1); id <= 4; id++ {
489		r.client <- ports.WindowMapped{ID: id}
490		r.settle(t)
491	}
492	// Back to the first column: 3 and 4 are off screen to the right.
493	for range 3 {
494		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
495		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
496	}
497	set := r.settle(t)
498	one := dragRect(t, set, "A", 1)
499	if one.X != 10 {
500		t.Fatalf("%+v", one)
501	}
502	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1000)
503	r.settle(t)
504	r.press(t, 0x110, ports.ModSuper)
505	r.moveTo(995, 300, 1001)
506	first := dragRect(t, r.settle(t), "A", 1)
507	if first.X >= one.X {
508		t.Fatalf("no scroll: %+v", first)
509	}
510	// Under 250 ms: no second step.
511	r.moveTo(996, 300, 1100)
512	if got := dragRect(t, r.settle(t), "A", 1); got != first {
513		t.Fatalf("scrolled too soon: %+v", got)
514	}
515	r.moveTo(997, 300, 1300)
516	second := dragRect(t, r.settle(t), "A", 1)
517	r.moveTo(998, 300, 1600)
518	r.moveTo(999, 300, 1900)
519	end := dragRect(t, r.settle(t), "A", 1)
520	r.moveTo(998, 300, 2200)
521	if got := dragRect(t, r.settle(t), "A", 1); second.X >= first.X || got != end {
522		t.Fatalf("first %+v second %+v end %+v got %+v", first, second, end, got)
523	}
524	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
525	r.settle(t)
526}
527
528func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
529	r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
530	for id := ports.WindowID(1); id <= 3; id++ {
531		r.client <- ports.WindowMapped{ID: id}
532		r.settle(t)
533	}
534	set := r.settle(t)
535	one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
536	// 4 joins 1's column from the top band of 1.
537	r.client <- ports.WindowMapped{ID: 4}
538	set = r.settle(t)
539	four := dragRect(t, set, "A", 4)
540	r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
541	r.settle(t)
542	r.press(t, 0x110, ports.ModSuper)
543	r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
544	r.settle(t)
545	r.in(ports.PointerButton{Button: 0x110})
546	set = r.settle(t)
547	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
548		t.Fatal(got)
549	}
550	one, two = dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
551	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
552	r.settle(t)
553	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
554	r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
555	r.settle(t)
556	r.in(ports.PointerButton{Button: 0x110})
557	if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
558		t.Fatal(got)
559	}
560}
561
562func TestPressAfterCancelGrabs(t *testing.T) {
563	r := startDragRig(t, nil, dragOut)
564	r.client <- ports.WindowMapped{ID: 1}
565	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
566	f := dragRect(t, r.settle(t), "A", 2)
567	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
568	r.settle(t)
569	r.press(t, 0x110, ports.ModSuper)
570	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
571	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
572	r.in(ports.KeyEvent{Keysym: "Super_L"})
573	r.settle(t)
574	r.drain()
575	// Left is still held, swallowed. A right click on 2 goes to 2, and so
576	// does its release after the pointer left it.
577	r.in(ports.PointerButton{Button: 0x111, Pressed: true})
578	r.moveTo(5, 5, 2)
579	r.in(ports.PointerButton{Button: 0x111})
580	r.settle(t)
581	var got []ports.PointerButtonTo
582	for _, v := range r.drain() {
583		if b, ok := v.(ports.PointerButtonTo); ok {
584			got = append(got, b)
585		}
586	}
587	if len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 || got[1].Pressed {
588		t.Fatalf("%+v", got)
589	}
590}
591
592func TestClientResizeWithoutEdgesIgnored(t *testing.T) {
593	r := startDragRig(t, nil, dragOut)
594	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
595	f := dragRect(t, r.settle(t), "A", 2)
596	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
597	r.settle(t)
598	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
599	r.client <- ports.WindowMoveRequest{ID: 2, Resize: true}
600	r.moveTo(float64(f.X+60), float64(f.Y+60), 2)
601	if got := dragRect(t, r.settle(t), "A", 2); got != f {
602		t.Fatalf("%+v from %+v", got, f)
603	}
604}
605
606func TestClientDragUsesLastPress(t *testing.T) {
607	r := startDragRig(t, nil, dragOut)
608	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
609	f := dragRect(t, r.settle(t), "A", 2)
610	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
611	r.settle(t)
612	r.in(ports.PointerButton{Button: 0x111, Pressed: true})
613	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
614	r.client <- ports.WindowMoveRequest{ID: 2}
615	r.moveTo(float64(f.X+60), float64(f.Y+10), 2)
616	r.settle(t)
617	// Releasing left ends the drag: the window stays put afterwards.
618	r.in(ports.PointerButton{Button: 0x110})
619	r.moveTo(float64(f.X+90), float64(f.Y+10), 3)
620	if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+50 {
621		t.Fatalf("%+v from %+v", got, f)
622	}
623}
624
625func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626	r := startDragRig(t, nil, dragOut)
627	for id := ports.WindowID(1); id <= 4; id++ {
628		r.client <- ports.WindowMapped{ID: id}
629		r.settle(t)
630	}
631	for range 3 {
632		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
633		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
634	}
635	// Drag 2 into the top gap, then along it to the right edge: no drop
636	// target before or after, but the view scrolls.
637	two := dragRect(t, r.settle(t), "A", 2)
638	edge := float64(dragOut.Width - 30)
639	if float64(two.X+two.W) <= edge {
640		t.Fatalf("2 does not reach the edge: %+v", two)
641	}
642	r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 1000)
643	r.settle(t)
644	r.press(t, 0x110, ports.ModSuper)
645	r.moveTo(float64(two.X+two.W/2), 5, 1001)
646	if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
647		t.Fatalf("hint in the gap %+v", hints)
648	}
649	r.moveTo(edge, 5, 1300)
650	// An axis event publishes nothing mid-drag: once core took it, the
651	// motion's scene, if any, is in the channel.
652	r.in(ports.PointerAxis{})
653	select {
654	case set := <-r.scenes:
655		if got := dragRect(t, set, "A", 2); got.X >= two.X {
656			t.Fatalf("not scrolled: %+v", got)
657		}
658	default:
659		t.Fatal("edge scroll not published")
660	}
661}
662
663func TestModsFollowKeys(t *testing.T) {
664	r := startDragRig(t, nil, dragOut)
665	r.client <- ports.WindowMapped{ID: 1}
666	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
667	f := dragRect(t, r.settle(t), "A", 2)
668	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
669	r.settle(t)
670	// Super pressed then released: a click is a plain click again.
671	r.in(ports.KeyEvent{Keysym: "Super_L", Mods: ports.ModSuper, Pressed: true})
672	r.in(ports.KeyEvent{Keysym: "Super_L"})
673	r.settle(t)
674	r.drain()
675	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
676	r.settle(t)
677	found := false
678	for _, v := range r.drain() {
679		if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 {
680			found = true
681		}
682	}
683	if !found {
684		t.Fatal("click after cmd release not forwarded")
685	}
686}

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