feat/data-dnd e05cb15

swipe_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"slices"
6	"testing"
7	"time"
8
9	"github.com/bnema/neferwl/internal/core"
10	"github.com/bnema/neferwl/internal/ports"
11)
12
13// swipeRig runs core on one 800x600 output with no gaps or border, a
14// clock the test moves, and page flips the test sends.
15type swipeRig struct {
16	*multiRig
17	frames chan ports.OutputFrame
18	clk    *stepClock
19	// at is the device time of the next swipe event.
20	at time.Duration
21	// outs are the plugged outputs, for landed.
22	outs []string
23}
24
25var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}
26
27func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
28	t.Helper()
29	return startSwipeOn(t, edit, wide)
30}
31
32// startSwipeOn is startSwipe with the given outputs plugged in order.
33func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *swipeRig {
34	t.Helper()
35	cfg := altCmdDefaults()
36	cfg.Border.Width = 0
37	cfg.Layout.Gaps = 0
38	cfg.Terminal.AutoOpen = "never"
39	// The rig steps animations frame by frame: they are on unless edit
40	// turns them off.
41	cfg.Animations.On = true
42	if edit != nil {
43		edit(&cfg)
44	}
45	r := &swipeRig{frames: make(chan ports.OutputFrame), clk: newStepClock(t)}
46	r.multiRig = &multiRig{
47		client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),
48		output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
49		commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
50	}
51	c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Frames: r.frames}, core.Options{Clock: r.clk.clock})
52	if err != nil {
53		t.Fatal(err)
54	}
55	ctx, cancel := context.WithCancel(context.Background())
56	done := make(chan error, 1)
57	go func() { done <- c.Run(ctx) }()
58	t.Cleanup(func() { cancel(); <-done })
59	for _, o := range outs {
60		r.plug(t, o)
61		r.outs = append(r.outs, o.Name)
62	}
63	return r
64}
65
66func (r *swipeRig) advance(d time.Duration) {
67	r.clk.advance(d)
68}
69
70// mapWindow maps a window and, with animations on, lands its entrance (and
71// the neighbours' re-flow) so the scene is the settled one: the tests of a
72// swipe rig look at what the map leaves. The entrance itself is checked on
73// the raw multiRig.mapWindow (TestMap*).
74func (r *swipeRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
75	t.Helper()
76	set := r.multiRig.mapWindow(t, id)
77	if landed, ok := r.settleAll(t, r.outs...); ok {
78		return landed
79	}
80	return set
81}
82
83// begin starts a swipe; nothing moves until it picks an axis.
84func (r *swipeRig) begin() {
85	r.at += time.Second
86	r.input <- ports.SwipeBegin{Fingers: 3, Time: r.at}
87}
88
89// move sends one swipe update and returns the scene it produced.
90func (r *swipeRig) move(t *testing.T, dx, dy float64) ports.Scene {
91	t.Helper()
92	r.at += 8 * time.Millisecond
93	r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
94	return scene(t, r.scenes)
95}
96
97func (r *swipeRig) end(t *testing.T, cancelled bool) ports.Scene {
98	t.Helper()
99	r.input <- ports.SwipeEnd{Cancelled: cancelled, Time: r.at}
100	return scene(t, r.scenes)
101}
102
103// frame moves the clock by d, sends a flip and returns the next scene.
104func (r *swipeRig) frame(t *testing.T, d time.Duration) ports.Scene {
105	t.Helper()
106	r.advance(d)
107	r.frames <- ports.OutputFrame{Output: wide.Name}
108	return scene(t, r.scenes)
109}
110
111// settle moves the clock past any spring and returns the settled scene.
112func (r *swipeRig) settle(t *testing.T) ports.Scene {
113	t.Helper()
114	return r.frame(t, 5*time.Second)
115}
116
117// settleAll moves the clock past every spring, flips the outputs and
118// returns the scene set that results (ok false: nothing was animating).
119func (r *swipeRig) settleAll(t *testing.T, outputs ...string) ([]ports.Scene, bool) {
120	t.Helper()
121	return r.clk.settle(t, r.frames, r.scenes, outputs...)
122}
123
124// flick sends a quick swipe of n updates of (dx, dy) and lifts the
125// fingers; it returns the scene right after the lift.
126func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
127	t.Helper()
128	r.begin()
129	for range n {
130		r.move(t, dx, dy)
131	}
132	return r.end(t, false)
133}
134
135// workspaces puts window 1 on the first workspace and 2 on the second,
136// showing the first.
137func (r *swipeRig) workspaces(t *testing.T) {
138	t.Helper()
139	r.mapWindow(t, 1)
140	r.keySettled(t, "Next")
141	r.mapWindow(t, 2)
142	r.keySettled(t, "Prior")
143}
144
145// keySettled presses a workspace key and waits for the slide it starts, so
146// the next step begins from the settled view.
147func (r *swipeRig) keySettled(t *testing.T, sym string) {
148	t.Helper()
149	r.key(t, sym, ports.ModAlt)
150	if r.cfg.Animations.On {
151		r.settle(t)
152	}
153}
154
155func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {
156	for _, w := range s.Windows {
157		if w.ID == id && !w.Hidden {
158			return w.Rect, true
159		}
160	}
161	return ports.Rect{}, false
162}
163
164func (r *swipeRig) focused(t *testing.T) ports.WindowID {
165	t.Helper()
166	var id ports.WindowID
167	for len(r.commands) > 0 {
168		if v, ok := (<-r.commands).(ports.FocusWindow); ok {
169			id = v.ID
170		}
171	}
172	return id
173}
174
175// threeColumns maps three windows of half the width; the view shows 2
176// and 3, with 3 focused. It returns the last scene.
177func threeColumns(t *testing.T, r *swipeRig) ports.Scene {
178	t.Helper()
179	var s []ports.Scene
180	for id := ports.WindowID(1); id <= 3; id++ {
181		s = r.mapWindow(t, id)
182	}
183	return s[0]
184}
185
186func TestSwipeFollowsFingers(t *testing.T) {
187	r := startSwipe(t, nil)
188	before, _ := rectOf(threeColumns(t, r), 2)
189	r.begin()
190	// Under the 16 unit threshold the swipe has not picked its axis:
191	// that movement is not applied (as in GNOME Shell and niri).
192	r.input <- ports.SwipeUpdate{DX: -10, Time: r.at}
193	s := r.move(t, -10, 0)
194	after, _ := rectOf(s, 2)
195	// 10 units of 1200 per 800 px view are 7 px; near the resting view
196	// the detent holds the view back a little.
197	if d := after.X - before.X; d <= 0 || d >= 7 {
198		t.Fatalf("column moved %d px with the fingers", d)
199	}
200	// Halfway to the next column edge (400 px: 600 units) the view has
201	// caught up with the fingers; a full step shows whole.
202	s = r.move(t, -290, 0)
203	if got, _ := rectOf(s, 2); got.X-before.X != 200 {
204		t.Fatalf("column moved %d px halfway", got.X-before.X)
205	}
206	s = r.move(t, -300, 0)
207	if got, _ := rectOf(s, 2); got.X-before.X != 400 {
208		t.Fatalf("column moved %d px after a full step", got.X-before.X)
209	}
210}
211
212func TestSwipeFlickLandsOnColumnWithSpring(t *testing.T) {
213	r := startSwipe(t, nil)
214	threeColumns(t, r)
215	// Focus is on 3 (right); the view shows 2 and 3. Flick right to left
216	// fingers (content moves left in classic scroll): view goes left.
217	r.focused(t)
218	s := r.flick(t, 10, -40, 0)
219	mid, ok := rectOf(s, 1)
220	if !ok {
221		t.Fatal("column 1 not shown while the view slides")
222	}
223	if mid.X == 0 {
224		t.Fatal("view jumped: no slide")
225	}
226	s = r.frame(t, 16*time.Millisecond)
227	next, _ := rectOf(s, 1)
228	if next.X <= mid.X || next.X > 0 {
229		t.Fatalf("spring does not approach the target: %d then %d", mid.X, next.X)
230	}
231	s = r.settle(t)
232	if got, _ := rectOf(s, 1); got.X != 0 {
233		t.Fatalf("landed at %d, want column 1 at the left edge", got.X)
234	}
235	if id := r.focused(t); id != 1 && id != 2 {
236		t.Fatalf("focus %d after swiping left", id)
237	}
238}
239
240func TestSwipeNaturalScrollInverts(t *testing.T) {
241	for _, natural := range []bool{false, true} {
242		r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural })
243		threeColumns(t, r)
244		dx := -40.0
245		if natural {
246			dx = 40
247		}
248		r.flick(t, 10, dx, 0)
249		s := r.settle(t)
250		if got, _ := rectOf(s, 1); got.X != 0 {
251			t.Fatalf("natural=%t: column 1 at %d", natural, got.X)
252		}
253	}
254}
255
256func TestSwipeWorkspaceSlides(t *testing.T) {
257	r := startSwipe(t, nil)
258	r.workspaces(t)
259	r.begin()
260	s := r.move(t, 0, 30)
261	one, ok1 := rectOf(s, 1)
262	two, ok2 := rectOf(s, 2)
263	if !ok1 || !ok2 {
264		t.Fatalf("both workspaces show while sliding: %v %v", ok1, ok2)
265	}
266	if one.Y >= 0 || two.Y <= 0 || two.Y-one.Y != 600 {
267		t.Fatalf("slide positions %d %d", one.Y, two.Y)
268	}
269	for range 8 {
270		r.move(t, 0, 30)
271	}
272	s = r.end(t, false)
273	if _, ok := rectOf(s, 2); !ok {
274		t.Fatal("target workspace hidden while landing")
275	}
276	s = r.settle(t)
277	if _, ok := rectOf(s, 1); ok {
278		t.Fatal("old workspace still shown")
279	}
280	if got, _ := rectOf(s, 2); got.Y != 0 {
281		t.Fatalf("workspace 2 at %d", got.Y)
282	}
283}
284
285func TestSwipeWorkspaceCancelReturns(t *testing.T) {
286	r := startSwipe(t, nil)
287	r.workspaces(t)
288	r.begin()
289	for range 10 {
290		r.move(t, 0, 40)
291	}
292	r.end(t, true)
293	s := r.settle(t)
294	if got, ok := rectOf(s, 1); !ok || got.Y != 0 {
295		t.Fatalf("cancelled swipe left workspace 1 at %v %t", got, ok)
296	}
297}
298
299func TestSwipeFixedOverflowRunsFocusAction(t *testing.T) {
300	r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
301	r.mapWindow(t, 1)
302	r.mapWindow(t, 2)
303	r.focused(t)
304	// The view cannot slide: nothing moves until the fingers lift.
305	r.begin()
306	for range 10 {
307		r.at += 8 * time.Millisecond
308		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
309	}
310	r.end(t, false)
311	if id := r.focused(t); id != 1 {
312		t.Fatalf("fixed overflow swipe left focuses %d", id)
313	}
314}
315
316func TestSwipeShortSwipeDoesNothingInFixedOverflow(t *testing.T) {
317	r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
318	r.mapWindow(t, 1)
319	r.mapWindow(t, 2)
320	r.focused(t)
321	r.begin()
322	r.input <- ports.SwipeUpdate{DX: -20, Time: r.at + time.Second}
323	r.input <- ports.SwipeEnd{Time: r.at + 2*time.Second}
324	r.key(t, "Return", 0)
325	if id := r.focused(t); id != 0 {
326		t.Fatalf("slow short swipe moved focus to %d", id)
327	}
328}
329
330func TestKeyDuringSlideRetargets(t *testing.T) {
331	r := startSwipe(t, nil)
332	threeColumns(t, r)
333	s := r.flick(t, 10, -40, 0)
334	landing, _ := rectOf(s, 2)
335	// Back to column 3: the view heads there from where it is, no jump.
336	r.key(t, "Right", ports.ModAlt)
337	s = r.key(t, "Right", ports.ModAlt)[0]
338	if got, _ := rectOf(s, 2); got.X != landing.X {
339		t.Fatalf("view jumped from %d to %d", landing.X, got.X)
340	}
341	// The retargeted spring starts on the next frame.
342	r.frame(t, 16*time.Millisecond)
343	s = r.settle(t)
344	if got, _ := rectOf(s, 3); got.X != 400 {
345		t.Fatalf("column 3 landed at %d", got.X)
346	}
347}
348
349func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) {
350	r := startSwipe(t, nil)
351	r.workspaces(t)
352	// Workspace 3 is the trailing empty one; start there and swipe up to 2.
353	r.keySettled(t, "Next")
354	r.keySettled(t, "Next")
355	r.begin()
356	var s ports.Scene
357	for range 6 {
358		s = r.move(t, 0, -40)
359	}
360	before, ok := rectOf(s, 2)
361	if !ok {
362		t.Fatal("workspace 2 not sliding in")
363	}
364	// Landing drops no numbered workspace here (the trailing one stays),
365	// but the view must not jump either way.
366	s = r.end(t, false)
367	after, _ := rectOf(s, 2)
368	if after.Y != before.Y {
369		t.Fatalf("landing jumped from %d to %d", before.Y, after.Y)
370	}
371	if got, _ := rectOf(r.settle(t), 2); got.Y != 0 {
372		t.Fatalf("workspace 2 landed at %d", got.Y)
373	}
374}
375
376func TestSwipeLandingKeepsViewWhenEmptyWorkspaceDrops(t *testing.T) {
377	r := startSwipe(t, nil)
378	// 1 on the first workspace, 2 on the third; the second is empty and
379	// active, so leaving it drops it and renumbers the third.
380	r.mapWindow(t, 1)
381	r.key(t, "Next", ports.ModAlt)
382	r.key(t, "Next", ports.ModAlt)
383	r.mapWindow(t, 2)
384	r.key(t, "Prior", ports.ModAlt)
385	r.begin()
386	var s ports.Scene
387	for range 4 {
388		s = r.move(t, 0, 20)
389	}
390	before, ok := rectOf(s, 2)
391	if !ok {
392		t.Fatal("workspace 3 not sliding in")
393	}
394	s = r.end(t, false)
395	after, ok := rectOf(s, 2)
396	if !ok || after.Y != before.Y {
397		t.Fatalf("landing moved workspace 3 from %d to %d (shown %t)", before.Y, after.Y, ok)
398	}
399	if got, _ := rectOf(r.settle(t), 2); got.Y != 0 {
400		t.Fatalf("landed at %d", got.Y)
401	}
402}
403
404func TestSwipeDropsWhenWorkspacesChange(t *testing.T) {
405	r := startSwipe(t, nil)
406	r.workspaces(t)
407	r.begin()
408	r.move(t, 0, 30)
409	// A key switches workspace mid-swipe: the swipe lets go at once.
410	s := r.key(t, "Next", ports.ModAlt)[0]
411	if got, ok := rectOf(s, 2); !ok || got.Y == 0 {
412		t.Fatalf("workspace 2 at %v %t after the key, want the old position", got, ok)
413	}
414	s = r.settle(t)
415	if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
416		t.Fatalf("workspace 2 at %v %t after the key", got, ok)
417	}
418	if _, ok := rectOf(s, 1); ok {
419		t.Fatal("workspace 1 still sliding")
420	}
421	// The rest of the swipe moves nothing: its end leaves the view as is.
422	r.at += 8 * time.Millisecond
423	r.input <- ports.SwipeUpdate{DY: 30, Time: r.at}
424	if got, ok := rectOf(r.end(t, false), 2); !ok || got.Y != 0 {
425		t.Fatalf("workspace 2 at %v %t after the swipe", got, ok)
426	}
427}
428
429func TestSlideMovesPointerFocus(t *testing.T) {
430	r := startSwipe(t, nil)
431	threeColumns(t, r)
432	// The pointer rests over column 2 (x 100: the view shows 2 and 3).
433	r.input <- ports.PointerMotion{X: 100, Y: 300}
434	r.drain()
435	r.flick(t, 10, -40, 0)
436	r.settle(t)
437	var focus ports.PointerFocus
438	var motion ports.PointerMotionTo
439	// Core sends the pointer update right after the scene.
440	for _, v := range r.sent() {
441		switch v := v.(type) {
442		case ports.PointerFocus:
443			focus = v
444		case ports.PointerMotionTo:
445			motion = v
446		}
447	}
448	// Column 1 slid under the still pointer; it follows the window.
449	if focus.ID != 1 {
450		t.Fatalf("pointer focus %+v", focus)
451	}
452	if motion.ID != 1 || motion.X != 100 || motion.DX != 0 {
453		t.Fatalf("last motion %+v", motion)
454	}
455}
456
457func TestSwipeBeginWithoutEndSettlesTheFirst(t *testing.T) {
458	r := startSwipe(t, nil)
459	threeColumns(t, r)
460	r.begin()
461	s := r.move(t, -40, 0)
462	moved, _ := rectOf(s, 2)
463	// A begin without the previous end: the first swipe springs back.
464	r.begin()
465	scene(t, r.scenes)
466	s = r.settle(t)
467	if got, _ := rectOf(s, 2); got.X == moved.X || got.X != 0 {
468		t.Fatalf("first swipe left column 2 at %d", got.X)
469	}
470}
471
472func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) {
473	r := startSwipe(t, nil)
474	before, _ := rectOf(threeColumns(t, r), 2)
475	r.begin()
476	r.move(t, -30, 0)
477	r.key(t, "Next", ports.ModAlt)
478	r.key(t, "Prior", ports.ModAlt)
479	// The same workspace is back, but the swipe was let go.
480	r.at += 8 * time.Millisecond
481	r.input <- ports.SwipeUpdate{DX: -30, Time: r.at}
482	if got, _ := rectOf(r.end(t, false), 2); got != before {
483		t.Fatalf("column 2 at %v, want %v", got, before)
484	}
485}
486
487func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
488	r := startSwipe(t, nil)
489	before, _ := rectOf(threeColumns(t, r), 2)
490	r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
491	// Window 4 on DP-2, so it has a workspace below to switch to.
492	r.input <- ports.PointerMotion{X: 1000, Y: 300}
493	scene(t, r.scenes)
494	r.mapWindow(t, 4)
495	r.input <- ports.PointerMotion{X: 100, Y: 300}
496	scene(t, r.scenes)
497	r.drain()
498	r.begin()
499	r.move(t, -30, 0)
500	// The pointer goes to DP-2 and a bind switches its workspace.
501	r.input <- ports.PointerMotion{X: 1000, Y: 300}
502	scene(t, r.scenes)
503	r.key(t, "Next", ports.ModAlt)
504	// DP-2 slides to its other workspace on its own flips.
505	r.advance(5 * time.Second)
506	r.frames <- ports.OutputFrame{Output: "DP-2"}
507	for _, sc := range receive(t, r.scenes) {
508		if _, ok := rectOf(sc, 4); ok {
509			t.Fatal("DP-2 did not switch workspace")
510		}
511	}
512	// The swipe on DP-1 still follows the fingers.
513	var got ports.Rect
514	for _, sc := range receiveMove(t, r, -570) {
515		if sc.Output == wide.Name {
516			got, _ = rectOf(sc, 2)
517		}
518	}
519	// 600 units at 2/3 px each: a full step of 400 px.
520	if got.X-before.X != 400 {
521		t.Fatalf("column 2 moved %d px", got.X-before.X)
522	}
523}
524
525// receiveMove sends one swipe update and returns every output's scene.
526func receiveMove(t *testing.T, r *swipeRig, dx float64) []ports.Scene {
527	t.Helper()
528	r.at += 8 * time.Millisecond
529	r.input <- ports.SwipeUpdate{DX: dx, Time: r.at}
530	return receive(t, r.scenes)
531}
532
533func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) {
534	r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
535	r.mapWindow(t, 1)
536	r.mapWindow(t, 2)
537	r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
538	// Windows 3 and 4 on DP-2, 4 focused there.
539	r.input <- ports.PointerMotion{X: 1000, Y: 300}
540	scene(t, r.scenes)
541	r.mapWindow(t, 3)
542	r.mapWindow(t, 4)
543	// The swipe starts on DP-1 ...
544	r.input <- ports.PointerMotion{X: 100, Y: 300}
545	scene(t, r.scenes)
546	r.begin()
547	for range 10 {
548		r.at += 8 * time.Millisecond
549		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
550	}
551	// ... and the pointer takes the focus to DP-2 before the fingers lift.
552	r.input <- ports.PointerMotion{X: 1000, Y: 300}
553	scene(t, r.scenes)
554	r.drain()
555	r.input <- ports.SwipeEnd{Time: r.at}
556	for _, v := range r.sent() {
557		if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 {
558			t.Fatal("swipe from DP-1 moved the focus on DP-2")
559		}
560	}
561}
562
563// drain waits for core to handle what was sent, then empties commands.
564func (r *swipeRig) drain() {
565	for range r.sent() {
566	}
567}
568
569// sent waits for core to handle what was sent and returns the commands it
570// sent meanwhile (the sync flip is a barrier, see published).
571func (r *swipeRig) sent() []ports.ClientCommand {
572	send(r.frames, ports.OutputFrame{Output: "sync"})
573	var out []ports.ClientCommand
574	for {
575		select {
576		case v := <-r.commands:
577			out = append(out, v)
578		default:
579			return out
580		}
581	}
582}
583
584// In the overview a three-finger swipe does not slide the view: nothing
585// is drawn while the fingers move, and when they lift the selection moves
586// one column, the previews where they were.
587func TestSwipeInOverview(t *testing.T) {
588	r := startSwipe(t, nil)
589	threeColumns(t, r)
590	opened := r.key(t, "o", ports.ModAlt)[0]
591	selected := func(s ports.Scene) ports.WindowID {
592		for _, w := range s.Windows {
593			if w.Focused && w.Preview > 0 {
594				return w.ID
595			}
596		}
597		return 0
598	}
599	r.begin()
600	for range 3 {
601		r.at += 8 * time.Millisecond
602		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
603	}
604	sceneMatch(t, r.scenes, func(s ports.Scene) bool { return selected(s) == 2 })
605	s := r.end(t, false)
606	if id := selected(s); id != 2 {
607		t.Fatalf("selected %d after a left swipe, want 2", id)
608	}
609	before, _ := rectOf(opened, 2)
610	if after, _ := rectOf(s, 2); before != after {
611		t.Fatalf("preview moved: %+v then %+v", before, after)
612	}
613	// A small swipe does nothing: the l after it moves from 2 to 3.
614	r.begin()
615	r.at += 8 * time.Millisecond
616	r.input <- ports.SwipeUpdate{DX: -20, Time: r.at}
617	if id := selected(r.end(t, false)); id != 2 {
618		t.Fatalf("selected %d after a small swipe", id)
619	}
620	if id := selected(r.key(t, "l", 0)[0]); id != 3 {
621		t.Fatalf("selected %d after a small swipe and l, want 3", id)
622	}
623}
624
625// A four-finger swipe up opens the overview and down closes it on the
626// selection, whatever natural-scroll says (as niri). A short one or one
627// sideways does nothing.
628func TestFourFingerSwipeOverview(t *testing.T) {
629	for _, natural := range []bool{false, true} {
630		r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural })
631		threeColumns(t, r)
632		four := func(dx, dy float64, n int) ports.Scene {
633			r.at += time.Second
634			r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
635			for range n {
636				r.at += 8 * time.Millisecond
637				r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
638			}
639			return r.end(t, false)
640		}
641		inOverview := func(s ports.Scene) bool {
642			for _, w := range s.Windows {
643				if w.Preview > 0 {
644					return true
645				}
646			}
647			return false
648		}
649		if inOverview(four(0, -5, 4)) {
650			t.Fatalf("natural=%v: short swipe opened the overview", natural)
651		}
652		if inOverview(four(-40, 0, 10)) {
653			t.Fatalf("natural=%v: sideways swipe opened the overview", natural)
654		}
655		if !inOverview(four(0, -40, 10)) {
656			t.Fatalf("natural=%v: swipe up did not open the overview", natural)
657		}
658		if !inOverview(four(0, -40, 10)) {
659			t.Fatalf("natural=%v: a second swipe up closed it", natural)
660		}
661		r.key(t, "h", 0)
662		s := four(0, 40, 10)
663		if inOverview(s) {
664			t.Fatalf("natural=%v: swipe down did not close the overview", natural)
665		}
666		if id := r.focused(t); id != 2 {
667			t.Fatalf("natural=%v: closed on %d, want the selection 2", natural, id)
668		}
669	}
670}
671
672// four lifts a four-finger swipe of n updates of (dx, dy) after the
673// fingers went down, and returns the scene right after the lift.
674func (r *swipeRig) four(t *testing.T, dx, dy float64, n int) ports.Scene {
675	t.Helper()
676	r.at += time.Second
677	r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
678	for range n {
679		r.at += 8 * time.Millisecond
680		r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
681	}
682	return r.end(t, false)
683}
684
685// stashedThird maps three windows and stashes the third (shown, focused),
686// settled.
687func stashedThird(t *testing.T, r *swipeRig) ports.Scene {
688	t.Helper()
689	threeColumns(t, r)
690	s := r.key(t, "s", ports.ModAlt|ports.ModShift)[0]
691	if r.cfg.Animations.On {
692		s = r.settle(t)
693	}
694	return s
695}
696
697// A four-finger swipe down with the overview closed hides the stash, and
698// the next one shows it again with its windows fading in; the stash's
699// windows stay where the swipe found them otherwise.
700func TestFourFingerSwipeDownTogglesStash(t *testing.T) {
701	r := startSwipe(t, nil)
702	if !r.cfg.Animations.On {
703		t.Fatal("animations are off in the rig")
704	}
705	if w := windowOf(t, stashedThird(t, r), 3); w.Hidden {
706		t.Fatalf("setup: the stash is hidden: %+v", w)
707	}
708	r.four(t, 0, 40, 10)
709	if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
710		t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
711	}
712	if w := windowOf(t, r.settle(t), 3); !w.Hidden {
713		t.Fatalf("swipe down left the stash shown: %+v", w)
714	}
715	r.four(t, 0, 40, 10)
716	if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
717		t.Fatalf("showing: window 3 hidden %t, fade %v, want it appearing", w.Hidden, w.Fade)
718	}
719	if w := windowOf(t, r.settle(t), 3); w.Hidden || w.Fade != 0 {
720		t.Fatalf("second swipe down did not show the stash: %+v", w)
721	}
722}
723
724// A four-finger swipe up hides the shown stash, with the same fade as a
725// swipe down, instead of opening the overview.
726func TestFourFingerSwipeUpHidesShownStash(t *testing.T) {
727	r := startSwipe(t, nil)
728	stashedThird(t, r)
729	s := r.four(t, 0, -40, 10)
730	for _, w := range s.Windows {
731		if w.Preview > 0 {
732			t.Fatal("swipe up opened the overview with the stash shown")
733		}
734	}
735	if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
736		t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
737	}
738	s = r.settle(t)
739	if w := windowOf(t, s, 3); !w.Hidden {
740		t.Fatalf("swipe up left the stash shown: %+v", w)
741	}
742	for _, w := range s.Windows {
743		if w.Preview > 0 {
744			t.Fatal("overview open after the stash hid")
745		}
746	}
747}
748
749// With the stash hidden or empty a swipe up opens the overview.
750func TestFourFingerSwipeUpOpensOverviewWithoutShownStash(t *testing.T) {
751	inOverview := func(s ports.Scene) bool {
752		for _, w := range s.Windows {
753			if w.Preview > 0 {
754				return true
755			}
756		}
757		return false
758	}
759	for _, name := range []string{"hidden", "empty"} {
760		r := startSwipe(t, nil)
761		if name == "hidden" {
762			stashedThird(t, r)
763			r.key(t, "s", ports.ModAlt)
764			r.settle(t)
765		} else {
766			threeColumns(t, r)
767		}
768		r.four(t, 0, -40, 10)
769		if !inOverview(r.settle(t)) {
770			t.Fatalf("%s stash: swipe up did not open the overview", name)
771		}
772	}
773}
774
775// The stash shown over a covering fullscreen window is on screen: a swipe
776// up hides it and leaves the window fullscreen.
777func TestFourFingerSwipeUpHidesStashOverCover(t *testing.T) {
778	r := startSwipe(t, nil)
779	stashedThird(t, r)
780	r.key(t, "s", ports.ModAlt) // hide
781	r.settle(t)
782	r.key(t, "f", ports.ModAlt|ports.ModShift)
783	r.settleAll(t, r.outs...)
784	shown := r.key(t, "s", ports.ModAlt)[0] // show over the cover
785	sc, _ := r.settleAll(t, r.outs...)
786	if len(sc) == 0 {
787		sc = []ports.Scene{shown}
788	}
789	if w := windowOf(t, sc[0], 3); w.Hidden {
790		t.Fatalf("setup: the stash is hidden over the cover: %+v", w)
791	}
792	s := r.four(t, 0, -40, 10)
793	for _, w := range s.Windows {
794		if w.Preview > 0 {
795			t.Fatal("swipe up opened the overview with the stash over the cover")
796		}
797	}
798	// Over a cover the stash hides without a fade: the scene is final.
799	if w := windowOf(t, s, 3); !w.Hidden {
800		t.Fatalf("swipe up left the stash over the cover: %+v", w)
801	}
802	if w := windowOf(t, s, 2); !w.Fullscreen {
803		t.Fatalf("the covering window left fullscreen: %+v", w)
804	}
805}
806
807// With nothing in the stash a swipe down changes nothing: the scene is the
808// same one, with no new Seq and no animation.
809func TestFourFingerSwipeDownEmptyStash(t *testing.T) {
810	r := startSwipe(t, nil)
811	before := threeColumns(t, r)
812	after := r.four(t, 0, 40, 10)
813	if after.Seq != before.Seq || !after.SameAs(before) {
814		t.Fatalf("empty stash: scene changed, seq %d then %d", before.Seq, after.Seq)
815	}
816	if _, ok := r.settleAll(t, r.outs...); ok {
817		t.Fatal("empty stash: the swipe started an animation")
818	}
819}
820
821// With the overview open a swipe down closes it and does not touch the
822// stash, shown or hidden.
823func TestFourFingerSwipeDownInOverviewKeepsStash(t *testing.T) {
824	for _, hidden := range []bool{false, true} {
825		r := startSwipe(t, nil)
826		stashedThird(t, r)
827		if hidden {
828			r.key(t, "s", ports.ModAlt)
829			r.settle(t)
830		}
831		r.four(t, 0, -40, 10)
832		r.settle(t)
833		s := r.four(t, 0, 40, 10)
834		for _, w := range s.Windows {
835			if w.Preview > 0 {
836				t.Fatalf("hidden=%v: swipe down left the overview open", hidden)
837			}
838		}
839		if w := windowOf(t, r.settle(t), 3); w.Hidden != hidden {
840			t.Fatalf("hidden=%v: window 3 hidden %t after the overview closed", hidden, w.Hidden)
841		}
842	}
843}
844
845// Opening the overview during a column swipe drops the swipe: the rest
846// of it neither slides the columns under the overview nor lands them,
847// also once the overview closed again (escape before the rest).
848func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
849	for _, closeFirst := range []bool{false, true} {
850		r := startSwipe(t, nil)
851		settled := threeColumns(t, r)
852		r.begin()
853		r.move(t, -40, 0)
854		r.move(t, -40, 0)
855		r.key(t, "o", ports.ModAlt)
856		// s is the latest scene.
857		var s ports.Scene
858		if closeFirst {
859			s = r.key(t, "Escape", 0)[0]
860		}
861		for range 20 {
862			r.at += 8 * time.Millisecond
863			r.input <- ports.SwipeUpdate{DX: -80, Time: r.at}
864		}
865		// The updates publish nothing (the overview gesture, or a dropped
866		// swipe); the lift always publishes, so its scene is received
867		// before the next event: core is idle when the escape and the
868		// flips arrive, each waits for its own scene.
869		r.input <- ports.SwipeEnd{Time: r.at}
870		s = scene(t, r.scenes)
871		if !closeFirst {
872			s = r.key(t, "Escape", 0)[0]
873		}
874		// Past any spring: the escape's own springs (the cards zooming
875		// back) may chain after the swipe's landing, so settle until
876		// none runs.
877		for range 4 {
878			set, ok := r.settleAll(t, wide.Name)
879			if !ok {
880				break
881			}
882			s = set[0]
883		}
884		if len(s.Windows) == 0 || slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool {
885			// Neither a card nor a window still zooming back.
886			return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0
887		}) {
888			t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows)
889		}
890		for _, id := range []ports.WindowID{1, 2, 3} {
891			before, _ := rectOf(settled, id)
892			if after, _ := rectOf(s, id); before != after {
893				t.Fatalf("closeFirst=%v: window %d moved: %+v then %+v", closeFirst, id, before, after)
894			}
895		}
896	}
897}
898
899// A hard flick down from the first of four workspaces lands on the second:
900// one swipe never skips a workspace.
901func TestSwipeHardFlickMovesOneWorkspace(t *testing.T) {
902	r := startSwipe(t, nil)
903	for id := ports.WindowID(1); id <= 4; id++ {
904		r.mapWindow(t, id)
905		r.key(t, "Next", ports.ModAlt)
906	}
907	for range 4 {
908		r.key(t, "Prior", ports.ModAlt)
909	}
910	r.flick(t, 20, 0, 60)
911	s := r.settle(t)
912	if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
913		t.Fatalf("hard flick did not land on workspace 2: %v %t", got, ok)
914	}
915}
916
917// A hard flick across many columns moves the view one column edge.
918func TestSwipeHardFlickMovesOneColumn(t *testing.T) {
919	r := startSwipe(t, nil)
920	for id := ports.WindowID(1); id <= 5; id++ {
921		r.mapWindow(t, id)
922	}
923	// The view shows 4 and 5; a hard flick left shows 3 and 4, not 1.
924	r.flick(t, 20, -60, 0)
925	s := r.settle(t)
926	if got, ok := rectOf(s, 3); !ok || got.X != 0 {
927		t.Fatalf("column 3 at %v %t, want the left edge", got, ok)
928	}
929}
930
931// A two-finger scroll that steps in the overview and ends with the pointer
932// on another output does not hold the next scroll back.
933func TestOverviewScrollStopOnOtherOutput(t *testing.T) {
934	r := startSwipe(t, nil)
935	threeColumns(t, r)
936	r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
937	r.key(t, "o", ports.ModAlt)
938	selected := func(s ports.Scene) ports.WindowID {
939		for _, w := range s.Windows {
940			if w.Focused && w.Preview > 0 {
941				return w.ID
942			}
943		}
944		return 0
945	}
946	on := func(id ports.WindowID) func(ports.Scene) bool {
947		return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }
948	}
949	finger := func(dx float64) ports.SwipeUpdate {
950		return ports.SwipeUpdate{DX: dx}
951	}
952	r.input <- ports.PointerMotion{X: 100, Y: 300}
953	r.input <- ports.SwipeBegin{Fingers: 3}
954	r.input <- finger(-70)
955	sceneMatch(t, r.scenes, on(2))
956	// The pointer crosses to DP-2 and the fingers lift there.
957	r.input <- ports.PointerMotion{X: 1000, Y: 300}
958	r.input <- ports.SwipeEnd{}
959	r.input <- ports.PointerMotion{X: 100, Y: 300}
960	r.input <- ports.SwipeBegin{Fingers: 3}
961	r.input <- finger(-70)
962	sceneMatch(t, r.scenes, on(1))
963}
964
965// The output shrinks mid-swipe: the snap points no longer hold, so the
966// swipe lets go. The view shows what the resize alone shows, and the rest
967// of the swipe moves nothing.
968func TestSwipeOutputResizedMidSwipe(t *testing.T) {
969	smaller := ports.OutputInfo{Name: wide.Name, Width: 600, Height: 600, RefreshMilli: 60000}
970	four := func(r *swipeRig) {
971		for id := ports.WindowID(1); id <= 4; id++ {
972			r.mapWindow(t, id)
973		}
974	}
975	plain := startSwipe(t, nil)
976	four(plain)
977	plain.output <- ports.OutputAdded{Info: smaller}
978	want := scene(t, plain.scenes)
979
980	r := startSwipe(t, nil)
981	four(r)
982	r.begin()
983	for range 3 {
984		r.move(t, -40, 0)
985	}
986	r.output <- ports.OutputAdded{Info: smaller}
987	scene(t, r.scenes)
988	// The first update after the resize lets go: the slide stops.
989	s := r.move(t, -40, 0)
990	for _, id := range []ports.WindowID{1, 2, 3, 4} {
991		w, _ := rectOf(want, id)
992		if got, _ := rectOf(s, id); got != w {
993			t.Fatalf("window %d at %+v after the next update, want %+v", id, got, w)
994		}
995	}
996	for range 2 {
997		r.at += 8 * time.Millisecond
998		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
999	}
1000	s = r.end(t, false)
1001	for _, id := range []ports.WindowID{1, 2, 3, 4} {
1002		w, _ := rectOf(want, id)
1003		if got, _ := rectOf(s, id); got != w {
1004			t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
1005		}
1006	}
1007}
1008
1009// A window maps mid-swipe: the column snap points moved, so the swipe lets
1010// go and the view shows what the map alone shows.
1011func TestSwipeWindowMappedMidSwipe(t *testing.T) {
1012	plain := startSwipe(t, nil)
1013	threeColumns(t, plain)
1014	want := plain.mapWindow(t, 4)[0]
1015
1016	r := startSwipe(t, nil)
1017	threeColumns(t, r)
1018	r.begin()
1019	for range 3 {
1020		r.move(t, -40, 0)
1021	}
1022	r.mapWindow(t, 4)
1023	r.move(t, -40, 0)
1024	r.at += 8 * time.Millisecond
1025	r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
1026	s := r.end(t, false)
1027	for _, id := range []ports.WindowID{1, 2, 3, 4} {
1028		w, _ := rectOf(want, id)
1029		if got, _ := rectOf(s, id); got != w {
1030			t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
1031		}
1032	}
1033}
1034
1035// A press that moves the view during a landing slide redirects the slide
1036// without stopping or reversing it: the view keeps its speed.
1037func TestSwipeRetargetKeepsVelocity(t *testing.T) {
1038	r := startSwipe(t, nil)
1039	for id := ports.WindowID(1); id <= 4; id++ {
1040		r.mapWindow(t, id)
1041	}
1042	// Focus on 4, the view shows 3 and 4. Go back to the first column.
1043	for range 3 {
1044		r.key(t, "Left", ports.ModAlt)
1045	}
1046	r.settle(t)
1047	// A flick toward the right lands on a later column: the view moves
1048	// right, so column 1 moves left.
1049	r.flick(t, 4, 40, 0)
1050	mid := r.frame(t, 16*time.Millisecond)
1051	prev, ok := rectOf(mid, 1)
1052	if !ok {
1053		t.Fatal("column 1 hidden during the landing")
1054	}
1055	if prev.X >= 0 {
1056		t.Fatalf("view did not move right: column 1 at %d", prev.X)
1057	}
1058	r.key(t, "Right", ports.ModAlt)
1059	// The retargeted spring keeps the speed of the landing: the view moves
1060	// on from the first frame and never goes back.
1061	for i := range 2 {
1062		s := r.frame(t, 16*time.Millisecond)
1063		got, _ := rectOf(s, 1)
1064		if got.X > prev.X || (i == 1 && got.X == prev.X) {
1065			t.Fatalf("frame %d: column 1 at %d after %d, the view reversed or stopped", i, got.X, prev.X)
1066		}
1067		prev = got
1068	}
1069}

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