main f2d166b

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

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