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, Clock: r.clk.clock, Frames: r.frames})
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	s := r.end(t, false)
605	if id := selected(s); id != 2 {
606		t.Fatalf("selected %d after a left swipe, want 2", id)
607	}
608	before, _ := rectOf(opened, 2)
609	if after, _ := rectOf(s, 2); before != after {
610		t.Fatalf("preview moved: %+v then %+v", before, after)
611	}
612	// A small swipe does nothing: the l after it moves from 2 to 3.
613	r.begin()
614	r.at += 8 * time.Millisecond
615	r.input <- ports.SwipeUpdate{DX: -20, Time: r.at}
616	if id := selected(r.end(t, false)); id != 2 {
617		t.Fatalf("selected %d after a small swipe", id)
618	}
619	if id := selected(r.key(t, "l", 0)[0]); id != 3 {
620		t.Fatalf("selected %d after a small swipe and l, want 3", id)
621	}
622}
623
624// A four-finger swipe up opens the overview and down closes it on the
625// selection, whatever natural-scroll says (as niri). A short one or one
626// sideways does nothing.
627func TestFourFingerSwipeOverview(t *testing.T) {
628	for _, natural := range []bool{false, true} {
629		r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural })
630		threeColumns(t, r)
631		four := func(dx, dy float64, n int) ports.Scene {
632			r.at += time.Second
633			r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
634			for range n {
635				r.at += 8 * time.Millisecond
636				r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
637			}
638			return r.end(t, false)
639		}
640		inOverview := func(s ports.Scene) bool {
641			for _, w := range s.Windows {
642				if w.Preview > 0 {
643					return true
644				}
645			}
646			return false
647		}
648		if inOverview(four(0, -5, 4)) {
649			t.Fatalf("natural=%v: short swipe opened the overview", natural)
650		}
651		if inOverview(four(-40, 0, 10)) {
652			t.Fatalf("natural=%v: sideways swipe opened the overview", natural)
653		}
654		if !inOverview(four(0, -40, 10)) {
655			t.Fatalf("natural=%v: swipe up did not open the overview", natural)
656		}
657		if !inOverview(four(0, -40, 10)) {
658			t.Fatalf("natural=%v: a second swipe up closed it", natural)
659		}
660		r.key(t, "h", 0)
661		s := four(0, 40, 10)
662		if inOverview(s) {
663			t.Fatalf("natural=%v: swipe down did not close the overview", natural)
664		}
665		if id := r.focused(t); id != 2 {
666			t.Fatalf("natural=%v: closed on %d, want the selection 2", natural, id)
667		}
668	}
669}
670
671// four lifts a four-finger swipe of n updates of (dx, dy) after the
672// fingers went down, and returns the scene right after the lift.
673func (r *swipeRig) four(t *testing.T, dx, dy float64, n int) ports.Scene {
674	t.Helper()
675	r.at += time.Second
676	r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
677	for range n {
678		r.at += 8 * time.Millisecond
679		r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
680	}
681	return r.end(t, false)
682}
683
684// stashedThird maps three windows and stashes the third (shown, focused),
685// settled.
686func stashedThird(t *testing.T, r *swipeRig) ports.Scene {
687	t.Helper()
688	threeColumns(t, r)
689	s := r.key(t, "s", ports.ModAlt|ports.ModShift)[0]
690	if r.cfg.Animations.On {
691		s = r.settle(t)
692	}
693	return s
694}
695
696// A four-finger swipe down with the overview closed hides the stash, and
697// the next one shows it again with its windows fading in; the stash's
698// windows stay where the swipe found them otherwise.
699func TestFourFingerSwipeDownTogglesStash(t *testing.T) {
700	r := startSwipe(t, nil)
701	if !r.cfg.Animations.On {
702		t.Fatal("animations are off in the rig")
703	}
704	if w := windowOf(t, stashedThird(t, r), 3); w.Hidden {
705		t.Fatalf("setup: the stash is hidden: %+v", w)
706	}
707	r.four(t, 0, 40, 10)
708	if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
709		t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
710	}
711	if w := windowOf(t, r.settle(t), 3); !w.Hidden {
712		t.Fatalf("swipe down left the stash shown: %+v", w)
713	}
714	r.four(t, 0, 40, 10)
715	if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
716		t.Fatalf("showing: window 3 hidden %t, fade %v, want it appearing", w.Hidden, w.Fade)
717	}
718	if w := windowOf(t, r.settle(t), 3); w.Hidden || w.Fade != 0 {
719		t.Fatalf("second swipe down did not show the stash: %+v", w)
720	}
721}
722
723// A four-finger swipe up hides the shown stash, with the same fade as a
724// swipe down, instead of opening the overview.
725func TestFourFingerSwipeUpHidesShownStash(t *testing.T) {
726	r := startSwipe(t, nil)
727	stashedThird(t, r)
728	s := r.four(t, 0, -40, 10)
729	for _, w := range s.Windows {
730		if w.Preview > 0 {
731			t.Fatal("swipe up opened the overview with the stash shown")
732		}
733	}
734	if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
735		t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
736	}
737	s = r.settle(t)
738	if w := windowOf(t, s, 3); !w.Hidden {
739		t.Fatalf("swipe up left the stash shown: %+v", w)
740	}
741	for _, w := range s.Windows {
742		if w.Preview > 0 {
743			t.Fatal("overview open after the stash hid")
744		}
745	}
746}
747
748// With the stash hidden or empty a swipe up opens the overview.
749func TestFourFingerSwipeUpOpensOverviewWithoutShownStash(t *testing.T) {
750	inOverview := func(s ports.Scene) bool {
751		for _, w := range s.Windows {
752			if w.Preview > 0 {
753				return true
754			}
755		}
756		return false
757	}
758	for _, name := range []string{"hidden", "empty"} {
759		r := startSwipe(t, nil)
760		if name == "hidden" {
761			stashedThird(t, r)
762			r.key(t, "s", ports.ModAlt)
763			r.settle(t)
764		} else {
765			threeColumns(t, r)
766		}
767		r.four(t, 0, -40, 10)
768		if !inOverview(r.settle(t)) {
769			t.Fatalf("%s stash: swipe up did not open the overview", name)
770		}
771	}
772}
773
774// The stash shown over a covering fullscreen window is on screen: a swipe
775// up hides it and leaves the window fullscreen.
776func TestFourFingerSwipeUpHidesStashOverCover(t *testing.T) {
777	r := startSwipe(t, nil)
778	stashedThird(t, r)
779	r.key(t, "s", ports.ModAlt) // hide
780	r.settle(t)
781	r.key(t, "f", ports.ModAlt|ports.ModShift)
782	r.settleAll(t, r.outs...)
783	shown := r.key(t, "s", ports.ModAlt)[0] // show over the cover
784	sc, _ := r.settleAll(t, r.outs...)
785	if len(sc) == 0 {
786		sc = []ports.Scene{shown}
787	}
788	if w := windowOf(t, sc[0], 3); w.Hidden {
789		t.Fatalf("setup: the stash is hidden over the cover: %+v", w)
790	}
791	s := r.four(t, 0, -40, 10)
792	for _, w := range s.Windows {
793		if w.Preview > 0 {
794			t.Fatal("swipe up opened the overview with the stash over the cover")
795		}
796	}
797	// Over a cover the stash hides without a fade: the scene is final.
798	if w := windowOf(t, s, 3); !w.Hidden {
799		t.Fatalf("swipe up left the stash over the cover: %+v", w)
800	}
801	if w := windowOf(t, s, 2); !w.Fullscreen {
802		t.Fatalf("the covering window left fullscreen: %+v", w)
803	}
804}
805
806// With nothing in the stash a swipe down changes nothing: the scene is the
807// same one, with no new Seq and no animation.
808func TestFourFingerSwipeDownEmptyStash(t *testing.T) {
809	r := startSwipe(t, nil)
810	before := threeColumns(t, r)
811	after := r.four(t, 0, 40, 10)
812	if after.Seq != before.Seq || !after.SameAs(before) {
813		t.Fatalf("empty stash: scene changed, seq %d then %d", before.Seq, after.Seq)
814	}
815	if _, ok := r.settleAll(t, r.outs...); ok {
816		t.Fatal("empty stash: the swipe started an animation")
817	}
818}
819
820// With the overview open a swipe down closes it and does not touch the
821// stash, shown or hidden.
822func TestFourFingerSwipeDownInOverviewKeepsStash(t *testing.T) {
823	for _, hidden := range []bool{false, true} {
824		r := startSwipe(t, nil)
825		stashedThird(t, r)
826		if hidden {
827			r.key(t, "s", ports.ModAlt)
828			r.settle(t)
829		}
830		r.four(t, 0, -40, 10)
831		r.settle(t)
832		s := r.four(t, 0, 40, 10)
833		for _, w := range s.Windows {
834			if w.Preview > 0 {
835				t.Fatalf("hidden=%v: swipe down left the overview open", hidden)
836			}
837		}
838		if w := windowOf(t, r.settle(t), 3); w.Hidden != hidden {
839			t.Fatalf("hidden=%v: window 3 hidden %t after the overview closed", hidden, w.Hidden)
840		}
841	}
842}
843
844// Opening the overview during a column swipe drops the swipe: the rest
845// of it neither slides the columns under the overview nor lands them,
846// also once the overview closed again (escape before the rest).
847func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
848	for _, closeFirst := range []bool{false, true} {
849		r := startSwipe(t, nil)
850		settled := threeColumns(t, r)
851		r.begin()
852		r.move(t, -40, 0)
853		r.move(t, -40, 0)
854		r.key(t, "o", ports.ModAlt)
855		// s is the latest scene.
856		var s ports.Scene
857		if closeFirst {
858			s = r.key(t, "Escape", 0)[0]
859		}
860		for range 20 {
861			r.at += 8 * time.Millisecond
862			r.input <- ports.SwipeUpdate{DX: -80, Time: r.at}
863		}
864		// The updates publish nothing (the overview gesture, or a dropped
865		// swipe); the lift always publishes, so its scene is received
866		// before the next event: core is idle when the escape and the
867		// flips arrive, each waits for its own scene.
868		r.input <- ports.SwipeEnd{Time: r.at}
869		s = scene(t, r.scenes)
870		if !closeFirst {
871			s = r.key(t, "Escape", 0)[0]
872		}
873		// Past any spring: the escape's own springs (the cards zooming
874		// back) may chain after the swipe's landing, so settle until
875		// none runs.
876		for range 4 {
877			set, ok := r.settleAll(t, wide.Name)
878			if !ok {
879				break
880			}
881			s = set[0]
882		}
883		if len(s.Windows) == 0 || slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool {
884			// Neither a card nor a window still zooming back.
885			return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0
886		}) {
887			t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows)
888		}
889		for _, id := range []ports.WindowID{1, 2, 3} {
890			before, _ := rectOf(settled, id)
891			if after, _ := rectOf(s, id); before != after {
892				t.Fatalf("closeFirst=%v: window %d moved: %+v then %+v", closeFirst, id, before, after)
893			}
894		}
895	}
896}
897
898// A hard flick down from the first of four workspaces lands on the second:
899// one swipe never skips a workspace.
900func TestSwipeHardFlickMovesOneWorkspace(t *testing.T) {
901	r := startSwipe(t, nil)
902	for id := ports.WindowID(1); id <= 4; id++ {
903		r.mapWindow(t, id)
904		r.key(t, "Next", ports.ModAlt)
905	}
906	for range 4 {
907		r.key(t, "Prior", ports.ModAlt)
908	}
909	r.flick(t, 20, 0, 60)
910	s := r.settle(t)
911	if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
912		t.Fatalf("hard flick did not land on workspace 2: %v %t", got, ok)
913	}
914}
915
916// A hard flick across many columns moves the view one column edge.
917func TestSwipeHardFlickMovesOneColumn(t *testing.T) {
918	r := startSwipe(t, nil)
919	for id := ports.WindowID(1); id <= 5; id++ {
920		r.mapWindow(t, id)
921	}
922	// The view shows 4 and 5; a hard flick left shows 3 and 4, not 1.
923	r.flick(t, 20, -60, 0)
924	s := r.settle(t)
925	if got, ok := rectOf(s, 3); !ok || got.X != 0 {
926		t.Fatalf("column 3 at %v %t, want the left edge", got, ok)
927	}
928}
929
930// A two-finger scroll that steps in the overview and ends with the pointer
931// on another output does not hold the next scroll back.
932func TestOverviewScrollStopOnOtherOutput(t *testing.T) {
933	r := startSwipe(t, nil)
934	threeColumns(t, r)
935	r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
936	r.key(t, "o", ports.ModAlt)
937	selected := func(s ports.Scene) ports.WindowID {
938		for _, w := range s.Windows {
939			if w.Focused && w.Preview > 0 {
940				return w.ID
941			}
942		}
943		return 0
944	}
945	on := func(id ports.WindowID) func(ports.Scene) bool {
946		return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }
947	}
948	finger := func(dx float64) ports.PointerAxis {
949		return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}}
950	}
951	r.input <- ports.PointerMotion{X: 100, Y: 300}
952	r.input <- finger(-70)
953	sceneMatch(t, r.scenes, on(2))
954	// The pointer crosses to DP-2 and the fingers lift there.
955	r.input <- ports.PointerMotion{X: 1000, Y: 300}
956	r.input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
957	r.input <- ports.PointerMotion{X: 100, Y: 300}
958	r.input <- finger(-70)
959	sceneMatch(t, r.scenes, on(1))
960}
961
962// The output shrinks mid-swipe: the snap points no longer hold, so the
963// swipe lets go. The view shows what the resize alone shows, and the rest
964// of the swipe moves nothing.
965func TestSwipeOutputResizedMidSwipe(t *testing.T) {
966	smaller := ports.OutputInfo{Name: wide.Name, Width: 600, Height: 600, RefreshMilli: 60000}
967	four := func(r *swipeRig) {
968		for id := ports.WindowID(1); id <= 4; id++ {
969			r.mapWindow(t, id)
970		}
971	}
972	plain := startSwipe(t, nil)
973	four(plain)
974	plain.output <- ports.OutputAdded{Info: smaller}
975	want := scene(t, plain.scenes)
976
977	r := startSwipe(t, nil)
978	four(r)
979	r.begin()
980	for range 3 {
981		r.move(t, -40, 0)
982	}
983	r.output <- ports.OutputAdded{Info: smaller}
984	scene(t, r.scenes)
985	// The first update after the resize lets go: the slide stops.
986	s := r.move(t, -40, 0)
987	for _, id := range []ports.WindowID{1, 2, 3, 4} {
988		w, _ := rectOf(want, id)
989		if got, _ := rectOf(s, id); got != w {
990			t.Fatalf("window %d at %+v after the next update, want %+v", id, got, w)
991		}
992	}
993	for range 2 {
994		r.at += 8 * time.Millisecond
995		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
996	}
997	s = r.end(t, false)
998	for _, id := range []ports.WindowID{1, 2, 3, 4} {
999		w, _ := rectOf(want, id)
1000		if got, _ := rectOf(s, id); got != w {
1001			t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
1002		}
1003	}
1004}
1005
1006// A window maps mid-swipe: the column snap points moved, so the swipe lets
1007// go and the view shows what the map alone shows.
1008func TestSwipeWindowMappedMidSwipe(t *testing.T) {
1009	plain := startSwipe(t, nil)
1010	threeColumns(t, plain)
1011	want := plain.mapWindow(t, 4)[0]
1012
1013	r := startSwipe(t, nil)
1014	threeColumns(t, r)
1015	r.begin()
1016	for range 3 {
1017		r.move(t, -40, 0)
1018	}
1019	r.mapWindow(t, 4)
1020	r.move(t, -40, 0)
1021	r.at += 8 * time.Millisecond
1022	r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
1023	s := r.end(t, false)
1024	for _, id := range []ports.WindowID{1, 2, 3, 4} {
1025		w, _ := rectOf(want, id)
1026		if got, _ := rectOf(s, id); got != w {
1027			t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
1028		}
1029	}
1030}
1031
1032// A press that moves the view during a landing slide redirects the slide
1033// without stopping or reversing it: the view keeps its speed.
1034func TestSwipeRetargetKeepsVelocity(t *testing.T) {
1035	r := startSwipe(t, nil)
1036	for id := ports.WindowID(1); id <= 4; id++ {
1037		r.mapWindow(t, id)
1038	}
1039	// Focus on 4, the view shows 3 and 4. Go back to the first column.
1040	for range 3 {
1041		r.key(t, "Left", ports.ModAlt)
1042	}
1043	r.settle(t)
1044	// A flick toward the right lands on a later column: the view moves
1045	// right, so column 1 moves left.
1046	r.flick(t, 4, 40, 0)
1047	mid := r.frame(t, 16*time.Millisecond)
1048	prev, ok := rectOf(mid, 1)
1049	if !ok {
1050		t.Fatal("column 1 hidden during the landing")
1051	}
1052	if prev.X >= 0 {
1053		t.Fatalf("view did not move right: column 1 at %d", prev.X)
1054	}
1055	r.key(t, "Right", ports.ModAlt)
1056	// The retargeted spring keeps the speed of the landing: the view moves
1057	// on from the first frame and never goes back.
1058	for i := range 2 {
1059		s := r.frame(t, 16*time.Millisecond)
1060		got, _ := rectOf(s, 1)
1061		if got.X > prev.X || (i == 1 && got.X == prev.X) {
1062			t.Fatalf("frame %d: column 1 at %d after %d, the view reversed or stopped", i, got.X, prev.X)
1063		}
1064		prev = got
1065	}
1066}

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