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	// With a clock the test moves (animated rigs), frames carries the page
25	// flips and outputs lists the plugged ones: settle lands every spring
26	// before it reads the scene.
27	frames  chan ports.OutputFrame
28	clock   *stepClock
29	outputs []string
30	// lands counts the settles that found a spring running.
31	lands int
32}
33
34// startDrag runs core with Super as Cmd, no border, 10 px gaps, and
35// the given outputs. edit may change the config.
36func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
37	t.Helper()
38	return newDragRig(t, false, edit, outs...)
39}
40
41// startAnimatedDragRig is startDragRig with animations on and a clock the
42// test moves: settle moves it past every spring and sends the page flips,
43// so the scene it returns is the settled one, deterministically.
44func startAnimatedDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
45	t.Helper()
46	return newDragRig(t, true, func(c *ports.Config) {
47		c.Animations.On = true
48		if edit != nil {
49			edit(c)
50		}
51	}, outs...)
52}
53
54func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
55	t.Helper()
56	cfg := config.Defaults()
57	cfg.Border.Width = 0
58	cfg.Layout.Gaps = 10
59	cfg.Terminal.AutoOpen = "off"
60	if edit != nil {
61		edit(&cfg)
62	}
63	epoch := &atomic.Value{}
64	epoch.Store(ports.SecurityState{})
65	gate := portsmocks.NewMockSessionSecurity(t)
66	gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }).Maybe()
67	// Unbuffered: a send returns once core took the event, so it handled
68	// every event sent before (settle relies on it).
69	r := &dragRig{
70		client: make(chan ports.ClientEvent), input: make(chan ports.InputEvent),
71		output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
72		scenes: make(chan []ports.Scene, 1), epoch: epoch,
73	}
74	ch := core.Channels{Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
75	opts := core.Options{Security: gate}
76	if animated {
77		r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
78		ch.Frames, opts.Clock = r.frames, r.clock.clock
79		// An on variant that never saw a spring would be the off variant again.
80		t.Cleanup(func() {
81			if r.lands == 0 {
82				t.Error("no spring ever ran: the animations-on variant checked nothing")
83			}
84		})
85	}
86	c, err := core.New(cfg, ch, opts)
87	if err != nil {
88		t.Fatal(err)
89	}
90	ctx, cancel := context.WithCancel(context.Background())
91	done := make(chan error, 1)
92	go func() { done <- c.Run(ctx) }()
93	t.Cleanup(func() { cancel(); <-done })
94	for _, o := range outs {
95		r.output <- ports.OutputAdded{Info: o}
96		r.outputs = append(r.outputs, o.Name)
97		r.settle(t)
98	}
99	return r
100}
101
102// in sends a raw input event; the gate is on, so it is wrapped.
103func (r *dragRig) in(ev ports.InputEvent) {
104	send(r.input, ports.InputEvent(ports.SecurityInput{State: r.epoch.Load().(ports.SecurityState), Event: ev}))
105}
106
107// send fails the test binary fast instead of hanging when core is stuck.
108func send[T any](ch chan T, v T) {
109	select {
110	case ch <- v:
111	case <-time.After(5 * time.Second):
112		panic("core stuck")
113	}
114}
115
116// barrier is a window no test maps: a resize of it changes nothing but
117// makes core publish.
118const barrier ports.WindowID = 1 << 30
119
120// settle returns the scene set once core handled every event sent so
121// far. The second barrier is taken only after the first one published. An
122// animated rig first moves its clock past every spring and flips each
123// output, so the set is the settled one.
124func (r *dragRig) settle(t *testing.T) []ports.Scene {
125	t.Helper()
126	if r.frames != nil {
127		if _, ok := r.clock.settle(t, r.frames, r.scenes, r.outputs...); ok {
128			r.lands++
129		}
130	}
131	send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
132	send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
133	select {
134	case s := <-r.scenes:
135		r.last = s
136	case <-time.After(5 * time.Second):
137		t.Fatal("no scene")
138	}
139	return r.last
140}
141
142// drain returns the commands sent since the last drain.
143func (r *dragRig) drain() []ports.ClientCommand {
144	var out []ports.ClientCommand
145	for {
146		select {
147		case v := <-r.commands:
148			if s, ok := v.(ports.SecurityCommand); ok {
149				v = s.Command
150			}
151			out = append(out, v)
152		default:
153			return out
154		}
155	}
156}
157
158func pointerSent(cmds []ports.ClientCommand) []ports.ClientCommand {
159	var out []ports.ClientCommand
160	for _, v := range cmds {
161		switch v.(type) {
162		case ports.PointerButtonTo, ports.PointerMotionTo, ports.ForwardKey:
163			out = append(out, v)
164		}
165	}
166	return out
167}
168
169func dragRect(t *testing.T, set []ports.Scene, output string, id ports.WindowID) ports.Rect {
170	t.Helper()
171	w, ok := windowIn(set, output, id)
172	if !ok {
173		t.Fatalf("window %d not on %s", id, output)
174	}
175	return w.Rect
176}
177
178func dragScene(set []ports.Scene, output string) ports.Scene {
179	for _, s := range set {
180		if s.Output == output {
181			return s
182		}
183	}
184	return ports.Scene{}
185}
186
187// columns lists the tiles left to right by x, then top to bottom.
188func columnsOf(set []ports.Scene, output string) [][]ports.WindowID {
189	s := dragScene(set, output)
190	var wins []ports.SceneWindow
191	for _, w := range s.Windows {
192		if !w.Hidden && !w.Floating && !w.Popup {
193			wins = append(wins, w)
194		}
195	}
196	slices.SortFunc(wins, func(a, b ports.SceneWindow) int {
197		if a.Rect.X != b.Rect.X {
198			return a.Rect.X - b.Rect.X
199		}
200		return a.Rect.Y - b.Rect.Y
201	})
202	var out [][]ports.WindowID
203	lastX := -1 << 30
204	for _, w := range wins {
205		if w.Rect.X != lastX {
206			out = append(out, nil)
207			lastX = w.Rect.X
208		}
209		out[len(out)-1] = append(out[len(out)-1], w.ID)
210	}
211	return out
212}
213
214var dragOut = ports.OutputInfo{Name: "A", Width: 1000, Height: 600}
215
216func (r *dragRig) moveTo(x, y float64, msec uint32) {
217	r.in(ports.PointerMotion{X: x, Y: y, Time: time.Duration(msec) * time.Millisecond})
218}
219
220func (r *dragRig) press(t *testing.T, button uint32, mods ports.Mods) {
221	t.Helper()
222	if mods != 0 {
223		r.in(ports.KeyEvent{Keysym: "Super_L", Mods: mods, Pressed: true})
224	}
225	r.in(ports.PointerButton{Button: button, Pressed: true})
226}
227
228func TestCmdDragMovesFloatWithoutClientEvents(t *testing.T) {
229	r := startDragRig(t, nil, dragOut)
230	r.client <- ports.WindowMapped{ID: 1}
231	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
232	set := r.settle(t)
233	f := dragRect(t, set, "A", 2)
234	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
235	r.settle(t)
236	r.drain()
237	r.press(t, 0x110, ports.ModSuper)
238	r.moveTo(float64(f.X+60), float64(f.Y+40), 2)
239	set = r.settle(t)
240	got := dragRect(t, set, "A", 2)
241	if got.X != f.X+50 || got.Y != f.Y+30 || got.W != f.W {
242		t.Fatalf("moved to %+v from %+v", got, f)
243	}
244	if w, _ := windowIn(set, "A", 2); !w.Floating {
245		t.Fatal("not floating")
246	}
247	r.in(ports.PointerButton{Button: 0x110})
248	r.in(ports.KeyEvent{Keysym: "Super_L"})
249	r.settle(t)
250	cmds := r.drain()
251	for _, v := range pointerSent(cmds) {
252		if _, ok := v.(ports.PointerButtonTo); ok {
253			t.Fatalf("client saw %#v", v)
254		}
255		if _, ok := v.(ports.PointerMotionTo); ok {
256			t.Fatalf("client saw %#v", v)
257		}
258	}
259	// It stays there: a free float now.
260	r.moveTo(float64(f.X+61), float64(f.Y+41), 3)
261	if got2 := dragRect(t, r.settle(t), "A", 2); got2 != got {
262		t.Fatalf("moved after release: %+v", got2)
263	}
264	// A plain press goes to the client.
265	r.drain()
266	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
267	r.settle(t)
268	found := false
269	for _, v := range r.drain() {
270		if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 && b.Pressed {
271			found = true
272		}
273	}
274	if !found {
275		t.Fatal("plain press not forwarded")
276	}
277}
278
279func TestDragEscapeRestoresFloat(t *testing.T) {
280	r := startDragRig(t, nil, dragOut)
281	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
282	f := dragRect(t, r.settle(t), "A", 2)
283	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
284	r.settle(t)
285	r.press(t, 0x111, ports.ModSuper)
286	r.moveTo(float64(f.X+110), float64(f.Y+60), 2)
287	if got := dragRect(t, r.settle(t), "A", 2); got.W != f.W+100 || got.H != f.H+50 || got.X != f.X {
288		t.Fatalf("resize: %+v from %+v", got, f)
289	}
290	r.drain()
291	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
292	if got := dragRect(t, r.settle(t), "A", 2); got != f {
293		t.Fatalf("escape: %+v, want %+v", got, f)
294	}
295	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
296	r.in(ports.PointerButton{Button: 0x111})
297	r.settle(t)
298	for _, v := range pointerSent(r.drain()) {
299		t.Fatalf("client saw %#v", v)
300	}
301}
302
303func TestDragTileZonesAndHints(t *testing.T) {
304	both(t, func(t *testing.T, animated bool) {
305		dragTileZonesAndHints(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
306	})
307}
308
309func dragTileZonesAndHints(t *testing.T, r *dragRig) {
310	for id := ports.WindowID(1); id <= 3; id++ {
311		r.client <- ports.WindowMapped{ID: id}
312		r.settle(t)
313	}
314	set := r.settle(t)
315	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
316		t.Fatal(got)
317	}
318	a, c := dragRect(t, set, "A", 1), dragRect(t, set, "A", 3)
319	// Drag 1 onto the bottom band of 3: it stacks below 3.
320	r.moveTo(float64(a.X+a.W/2), float64(a.Y+a.H/2), 1)
321	r.settle(t)
322	r.press(t, 0x110, ports.ModSuper)
323	r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H-5), 2)
324	set = r.settle(t)
325	hints := dragScene(set, "A").DropHints
326	if len(hints) != 1 || hints[0].Y+hints[0].H != c.Y+c.H || hints[0].W != c.W {
327		t.Fatalf("hints %+v", hints)
328	}
329	// Tiles do not move until the drop.
330	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
331		t.Fatal(got)
332	}
333	// The drop itself must run a spring: the guard counts from here.
334	r.lands = 0
335	r.in(ports.PointerButton{Button: 0x110})
336	set = r.settle(t)
337	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
338		t.Fatal(got)
339	}
340	if len(dragScene(set, "A").DropHints) != 0 {
341		t.Fatal("hints left")
342	}
343	// Centre of 2 with stacked 1 dragged from its right: 1 becomes a
344	// column in 2's place, 2 moves toward where 1 came from.
345	b := dragRect(t, set, "A", 2)
346	one := dragRect(t, set, "A", 1)
347	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
348	r.settle(t)
349	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
350	r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 4)
351	if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
352		t.Fatalf("swap outline %+v", hints)
353	}
354	// The drop itself must run a spring: the guard counts from here.
355	r.lands = 0
356	r.in(ports.PointerButton{Button: 0x110})
357	set = r.settle(t)
358	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
359		t.Fatal(got)
360	}
361	// Lone 3 on the centre of 2: the columns swap.
362	b, c = dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
363	r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H/2), 5)
364	r.settle(t)
365	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
366	r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
367	r.settle(t)
368	// The drop itself must run a spring: the guard counts from here.
369	r.lands = 0
370	r.in(ports.PointerButton{Button: 0x110})
371	set = r.settle(t)
372	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
373		t.Fatal(got)
374	}
375	// Escape mid tile drag: nothing moves, no hints.
376	one = dragRect(t, set, "A", 1)
377	b = dragRect(t, set, "A", 2)
378	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 7)
379	r.settle(t)
380	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
381	r.moveTo(float64(b.X+b.W/2), float64(b.Y+5), 8)
382	if len(dragScene(r.settle(t), "A").DropHints) == 0 {
383		t.Fatal("no hint")
384	}
385	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
386	set = r.settle(t)
387	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) || len(dragScene(set, "A").DropHints) != 0 {
388		t.Fatal(got, dragScene(set, "A").DropHints)
389	}
390}
391
392// startDragMode is the drag rig with animations off, or on and landed by
393// settle.
394func startDragMode(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
395	t.Helper()
396	if animated {
397		return startAnimatedDragRig(t, edit, outs...)
398	}
399	return startDragRig(t, func(c *ports.Config) {
400		if edit != nil {
401			edit(c)
402		}
403		c.Animations.On = false
404	}, outs...)
405}
406
407func TestDragTileGapInsert(t *testing.T) {
408	both(t, func(t *testing.T, animated bool) {
409		dragTileGapInsert(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
410	})
411}
412
413func dragTileGapInsert(t *testing.T, r *dragRig) {
414	for id := ports.WindowID(1); id <= 3; id++ {
415		r.client <- ports.WindowMapped{ID: id}
416		r.settle(t)
417	}
418	r.client <- ports.WindowMapped{ID: 4}
419	r.settle(t)
420	// Stack 4 under 3 with the keyboard, then put 4 between 1 and 2.
421	r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper, Pressed: true})
422	r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper})
423	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
424	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
425	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
426	r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
427	set := r.settle(t)
428	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3, 4}}, slices.Equal) {
429		t.Fatal(got)
430	}
431	one, two, four := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 4)
432	gapX := (one.X + one.W + two.X) / 2
433	r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
434	r.settle(t)
435	r.press(t, 0x110, ports.ModSuper)
436	r.moveTo(float64(gapX), float64(one.Y+one.H/2), 2)
437	hints := dragScene(r.settle(t), "A").DropHints
438	if len(hints) != 1 || hints[0].H != one.H || hints[0].X+hints[0].W/2 != gapX {
439		t.Fatalf("gap hint %+v", hints)
440	}
441	r.in(ports.PointerButton{Button: 0x110})
442	if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got[:2], [][]ports.WindowID{{1}, {4}}, slices.Equal) {
443		t.Fatal(got)
444	}
445}
446
447// release sends the button release and returns the scene set core
448// publishes for it, without moving the clock (settle would).
449func (r *dragRig) release(t *testing.T) []ports.Scene {
450	t.Helper()
451	r.in(ports.PointerButton{Button: 0x110})
452	send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
453	send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
454	select {
455	case r.last = <-r.scenes:
456	case <-time.After(5 * time.Second):
457		t.Fatal("no scene")
458	}
459	return r.last
460}
461
462// A dropped tile slides from where the drag scene drew it (its own slot:
463// a tile does not follow the pointer) and its neighbours re-flow; the
464// client gets one configure, for the final size. With animations off the
465// drop is instant.
466func TestDragDropTileSlides(t *testing.T) {
467	both(t, func(t *testing.T, animated bool) {
468		r := startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
469		for id := ports.WindowID(1); id <= 3; id++ {
470			r.client <- ports.WindowMapped{ID: id}
471			r.settle(t)
472		}
473		set := r.settle(t)
474		one0, two0, three0 := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
475		r.moveTo(float64(one0.X+one0.W/2), float64(one0.Y+one0.H/2), 1)
476		r.settle(t)
477		r.press(t, 0x110, ports.ModSuper)
478		// The bottom band of 3: 1 stacks below it.
479		r.moveTo(float64(three0.X+three0.W/2), float64(three0.Y+three0.H-5), 2)
480		set = r.settle(t)
481		if got := dragRect(t, set, "A", 1); got != one0 {
482			t.Fatalf("tile left its slot during the drag: %+v, was %+v", got, one0)
483		}
484		r.drain()
485		set = r.release(t)
486		if len(dragScene(set, "A").DropHints) != 0 {
487			t.Fatal("hints left")
488		}
489		if !animated {
490			if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
491				t.Fatal(got)
492			}
493			// Instant: the scene right after the drop is the settled one.
494			if got := dragRect(t, set, "A", 1); got == one0 || got.X != dragRect(t, r.settle(t), "A", 1).X {
495				t.Fatalf("not dropped at once: %+v", got)
496			}
497			return
498		}
499		// Right after the release the windows are still where the drag
500		// scene drew them.
501		for id, want := range map[ports.WindowID]ports.Rect{1: one0, 2: two0, 3: three0} {
502			if got := dragRect(t, set, "A", id); got != want {
503				t.Fatalf("window %d at %+v right after the drop, want its old %+v", id, got, want)
504			}
505		}
506		mid, ok := r.clock.flip(t, r.frames, r.scenes, 30*time.Millisecond, "A")
507		if !ok {
508			t.Fatal("no frame after the drop: nothing animates")
509		}
510		r.lands++
511		// The dropped tile is between its old slot and its new one, and the
512		// column it joined re-flows too.
513		got := dragRect(t, mid, "A", 1)
514		if got == one0 || got.X <= one0.X && got.Y <= one0.Y {
515			t.Fatalf("window 1 at %+v on the first frame, still at its old %+v", got, one0)
516		}
517		if got2 := dragRect(t, mid, "A", 2); got2 == two0 {
518			t.Fatalf("neighbour 2 did not start moving: %+v", got2)
519		}
520		set = r.settle(t)
521		if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
522			t.Fatal(got)
523		}
524		want := map[ports.WindowID]ports.Rect{}
525		for id := ports.WindowID(1); id <= 3; id++ {
526			want[id] = dragRect(t, set, "A", id)
527		}
528		if want[1].Y <= want[3].Y || want[1].X != want[3].X {
529			t.Fatalf("1 is not stacked below 3 once settled: %+v", want)
530		}
531		// A window is configured at most once by the drop, to its settled
532		// size, never to a size along the slide; the dropped tile always.
533		sizes := map[ports.WindowID][][2]int{}
534		for _, v := range r.drain() {
535			if v, ok := v.(ports.ConfigureWindow); ok {
536				sizes[v.ID] = append(sizes[v.ID], [2]int{v.Width, v.Height})
537			}
538		}
539		for id, rect := range want {
540			got := sizes[id]
541			if len(got) > 1 || len(got) == 1 && got[0] != [2]int{rect.W, rect.H} || id == 1 && len(got) != 1 {
542				t.Fatalf("configures of window %d %v, want at most one at %dx%d (exactly one for window 1)", id, got, rect.W, rect.H)
543			}
544		}
545	})
546}
547
548func TestDragFixedNoGapZone(t *testing.T) {
549	both(t, func(t *testing.T, animated bool) {
550		dragFixedNoGapZone(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
551	})
552}
553
554func dragFixedNoGapZone(t *testing.T, r *dragRig) {
555	for id := ports.WindowID(1); id <= 2; id++ {
556		r.client <- ports.WindowMapped{ID: id}
557		r.settle(t)
558	}
559	set := r.settle(t)
560	one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
561	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
562	r.settle(t)
563	r.press(t, 0x110, ports.ModSuper)
564	r.moveTo(float64((one.X+one.W+two.X)/2), float64(one.Y+one.H/2), 2)
565	if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
566		t.Fatalf("fixed gap hint %+v", hints)
567	}
568	// Top band of 2 stacks 1 above it.
569	r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
570	r.settle(t)
571	// The drop itself must run a spring: the guard counts from here.
572	r.lands = 0
573	r.in(ports.PointerButton{Button: 0x110})
574	if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
575		t.Fatal(got)
576	}
577}
578
579func TestDragAcrossOutputs(t *testing.T) {
580	both(t, func(t *testing.T, animated bool) {
581		dragAcrossOutputs(t, startDragMode(t, animated, nil, dragOut, ports.OutputInfo{Name: "B", Width: 800, Height: 600}))
582	})
583}
584
585func dragAcrossOutputs(t *testing.T, r *dragRig) {
586	r.client <- ports.WindowMapped{ID: 1}
587	r.client <- ports.WindowMapped{ID: 2}
588	r.settle(t)
589	// Put 3 on B.
590	r.moveTo(1500, 300, 1)
591	r.settle(t)
592	r.client <- ports.WindowMapped{ID: 3}
593	set := r.settle(t)
594	if _, ok := windowIn(set, "B", 3); !ok {
595		t.Fatal("3 not on B")
596	}
597	one := dragRect(t, set, "A", 1)
598	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 2)
599	r.settle(t)
600	r.press(t, 0x110, ports.ModSuper)
601	r.moveTo(1400, 300, 3)
602	set = r.settle(t)
603	if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
604		t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
605	}
606	r.lands = 0
607	r.in(ports.PointerButton{Button: 0x110})
608	set = r.settle(t)
609	if _, ok := windowIn(set, "B", 1); !ok {
610		t.Fatal("1 not on B")
611	}
612	if w, _ := windowIn(set, "B", 1); !w.Focused {
613		t.Fatal("1 not focused")
614	}
615}
616
617func TestClientMoveRequest(t *testing.T) {
618	r := startDragRig(t, nil, dragOut)
619	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
620	f := dragRect(t, r.settle(t), "A", 2)
621	// Without a held button: ignored.
622	r.client <- ports.WindowMoveRequest{ID: 2}
623	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
624	r.settle(t)
625	r.moveTo(float64(f.X+20), float64(f.Y+10), 2)
626	if got := dragRect(t, r.settle(t), "A", 2); got != f {
627		t.Fatalf("moved without a button: %+v", got)
628	}
629	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
630	r.settle(t)
631	r.client <- ports.WindowMoveRequest{ID: 2}
632	r.settle(t)
633	r.drain()
634	r.moveTo(float64(f.X+60), float64(f.Y+10), 3)
635	if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+40 {
636		t.Fatalf("move request: %+v from %+v", got, f)
637	}
638	r.in(ports.PointerButton{Button: 0x110})
639	r.settle(t)
640	for _, v := range pointerSent(r.drain()) {
641		t.Fatalf("client saw %#v", v)
642	}
643}
644
645func TestLockClearsDrag(t *testing.T) {
646	r := startDragRig(t, nil, dragOut)
647	r.client <- ports.WindowMapped{ID: 1}
648	r.client <- ports.WindowMapped{ID: 2}
649	set := r.settle(t)
650	one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
651	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
652	r.settle(t)
653	r.press(t, 0x110, ports.ModSuper)
654	r.moveTo(float64(two.X+two.W/2), float64(two.Y+5), 2)
655	if len(dragScene(r.settle(t), "A").DropHints) == 0 {
656		t.Fatal("no hint")
657	}
658	locked := ports.SecurityState{Generation: 1, Protected: true}
659	r.epoch.Store(locked)
660	r.client <- ports.SessionLockChanged{State: locked}
661	set = r.settle(t)
662	if len(dragScene(set, "A").DropHints) != 0 {
663		t.Fatal("hints while locked")
664	}
665	unlocked := ports.SecurityState{Generation: 2}
666	r.epoch.Store(unlocked)
667	r.client <- ports.SessionLockChanged{State: unlocked}
668	r.settle(t)
669	r.in(ports.PointerButton{Button: 0x110})
670	set = r.settle(t)
671	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}}, slices.Equal) {
672		t.Fatal(got)
673	}
674}
675
676func TestDragEdgeScroll(t *testing.T) {
677	both(t, func(t *testing.T, animated bool) { dragEdgeScroll(t, startDragMode(t, animated, nil, dragOut)) })
678}
679
680func dragEdgeScroll(t *testing.T, r *dragRig) {
681	for id := ports.WindowID(1); id <= 4; id++ {
682		r.client <- ports.WindowMapped{ID: id}
683		r.settle(t)
684	}
685	// Back to the first column: 3 and 4 are off screen to the right.
686	for range 3 {
687		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
688		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
689	}
690	set := r.settle(t)
691	one := dragRect(t, set, "A", 1)
692	if one.X != 10 {
693		t.Fatalf("%+v", one)
694	}
695	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1000)
696	r.settle(t)
697	r.press(t, 0x110, ports.ModSuper)
698	r.moveTo(995, 300, 1001)
699	first := dragRect(t, r.settle(t), "A", 1)
700	if first.X >= one.X {
701		t.Fatalf("no scroll: %+v", first)
702	}
703	// Under 250 ms: no second step.
704	r.moveTo(996, 300, 1100)
705	if got := dragRect(t, r.settle(t), "A", 1); got != first {
706		t.Fatalf("scrolled too soon: %+v", got)
707	}
708	r.moveTo(997, 300, 1300)
709	second := dragRect(t, r.settle(t), "A", 1)
710	r.moveTo(998, 300, 1600)
711	r.moveTo(999, 300, 1900)
712	end := dragRect(t, r.settle(t), "A", 1)
713	r.moveTo(998, 300, 2200)
714	if got := dragRect(t, r.settle(t), "A", 1); second.X >= first.X || got != end {
715		t.Fatalf("first %+v second %+v end %+v got %+v", first, second, end, got)
716	}
717	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
718	r.settle(t)
719}
720
721func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
722	both(t, func(t *testing.T, animated bool) {
723		dragFullFixedStacksInOutlinedColumn(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
724	})
725}
726
727func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
728	for id := ports.WindowID(1); id <= 3; id++ {
729		r.client <- ports.WindowMapped{ID: id}
730		r.settle(t)
731	}
732	set := r.settle(t)
733	one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
734	// 4 joins 1's column from the top band of 1.
735	r.client <- ports.WindowMapped{ID: 4}
736	set = r.settle(t)
737	four := dragRect(t, set, "A", 4)
738	r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
739	r.settle(t)
740	r.press(t, 0x110, ports.ModSuper)
741	r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
742	r.settle(t)
743	// The drop itself must run a spring: the guard counts from here.
744	r.lands = 0
745	r.in(ports.PointerButton{Button: 0x110})
746	set = r.settle(t)
747	if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
748		t.Fatal(got)
749	}
750	one, two = dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
751	r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
752	r.settle(t)
753	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
754	r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
755	r.settle(t)
756	// The drop itself must run a spring: the guard counts from here.
757	r.lands = 0
758	r.in(ports.PointerButton{Button: 0x110})
759	if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
760		t.Fatal(got)
761	}
762}
763
764func TestPressAfterCancelGrabs(t *testing.T) {
765	r := startDragRig(t, nil, dragOut)
766	r.client <- ports.WindowMapped{ID: 1}
767	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
768	f := dragRect(t, r.settle(t), "A", 2)
769	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
770	r.settle(t)
771	r.press(t, 0x110, ports.ModSuper)
772	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
773	r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
774	r.in(ports.KeyEvent{Keysym: "Super_L"})
775	r.settle(t)
776	r.drain()
777	// Left is still held, swallowed. A right click on 2 goes to 2, and so
778	// does its release after the pointer left it.
779	r.in(ports.PointerButton{Button: 0x111, Pressed: true})
780	r.moveTo(5, 5, 2)
781	r.in(ports.PointerButton{Button: 0x111})
782	r.settle(t)
783	var got []ports.PointerButtonTo
784	for _, v := range r.drain() {
785		if b, ok := v.(ports.PointerButtonTo); ok {
786			got = append(got, b)
787		}
788	}
789	if len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 || got[1].Pressed {
790		t.Fatalf("%+v", got)
791	}
792}
793
794func TestClientResizeWithoutEdgesIgnored(t *testing.T) {
795	r := startDragRig(t, nil, dragOut)
796	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
797	f := dragRect(t, r.settle(t), "A", 2)
798	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
799	r.settle(t)
800	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
801	r.client <- ports.WindowMoveRequest{ID: 2, Resize: true}
802	r.moveTo(float64(f.X+60), float64(f.Y+60), 2)
803	if got := dragRect(t, r.settle(t), "A", 2); got != f {
804		t.Fatalf("%+v from %+v", got, f)
805	}
806}
807
808func TestClientDragUsesLastPress(t *testing.T) {
809	r := startDragRig(t, nil, dragOut)
810	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
811	f := dragRect(t, r.settle(t), "A", 2)
812	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
813	r.settle(t)
814	r.in(ports.PointerButton{Button: 0x111, Pressed: true})
815	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
816	r.client <- ports.WindowMoveRequest{ID: 2}
817	r.moveTo(float64(f.X+60), float64(f.Y+10), 2)
818	r.settle(t)
819	// Releasing left ends the drag: the window stays put afterwards.
820	r.in(ports.PointerButton{Button: 0x110})
821	r.moveTo(float64(f.X+90), float64(f.Y+10), 3)
822	if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+50 {
823		t.Fatalf("%+v from %+v", got, f)
824	}
825}
826
827func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
828	both(t, func(t *testing.T, animated bool) {
829		edgeScrollPublishesWithoutTarget(t, startDragMode(t, animated, nil, dragOut))
830	})
831}
832
833func edgeScrollPublishesWithoutTarget(t *testing.T, r *dragRig) {
834	for id := ports.WindowID(1); id <= 4; id++ {
835		r.client <- ports.WindowMapped{ID: id}
836		r.settle(t)
837	}
838	for range 3 {
839		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
840		r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
841	}
842	// Drag 2 into the top gap, then along it to the right edge: no drop
843	// target before or after, but the view scrolls.
844	two := dragRect(t, r.settle(t), "A", 2)
845	edge := float64(dragOut.Width - 30)
846	if float64(two.X+two.W) <= edge {
847		t.Fatalf("2 does not reach the edge: %+v", two)
848	}
849	r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 1000)
850	r.settle(t)
851	r.press(t, 0x110, ports.ModSuper)
852	r.moveTo(float64(two.X+two.W/2), 5, 1001)
853	if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
854		t.Fatalf("hint in the gap %+v", hints)
855	}
856	r.moveTo(edge, 5, 1300)
857	// An axis event publishes nothing mid-drag: once core took it, the
858	// motion's scene, if any, is in the channel.
859	r.in(ports.PointerAxis{})
860	select {
861	case set := <-r.scenes:
862		if got := dragRect(t, set, "A", 2); got.X >= two.X {
863			t.Fatalf("not scrolled: %+v", got)
864		}
865	default:
866		t.Fatal("edge scroll not published")
867	}
868}
869
870func TestModsFollowKeys(t *testing.T) {
871	r := startDragRig(t, nil, dragOut)
872	r.client <- ports.WindowMapped{ID: 1}
873	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
874	f := dragRect(t, r.settle(t), "A", 2)
875	r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
876	r.settle(t)
877	// Super pressed then released: a click is a plain click again.
878	r.in(ports.KeyEvent{Keysym: "Super_L", Mods: ports.ModSuper, Pressed: true})
879	r.in(ports.KeyEvent{Keysym: "Super_L"})
880	r.settle(t)
881	r.drain()
882	r.in(ports.PointerButton{Button: 0x110, Pressed: true})
883	r.settle(t)
884	found := false
885	for _, v := range r.drain() {
886		if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 {
887			found = true
888		}
889	}
890	if !found {
891		t.Fatal("click after cmd release not forwarded")
892	}
893}

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