v0.3.0 cf370a0

swipe_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"sync"
6	"testing"
7	"time"
8
9	"github.com/bnema/neferwl/internal/core"
10	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11	"github.com/bnema/neferwl/internal/ports"
12	"github.com/stretchr/testify/mock"
13)
14
15// swipeRig runs core on one 800x600 output with no gaps or border, a
16// clock the test moves, and page flips the test sends.
17type swipeRig struct {
18	*multiRig
19	frames chan ports.OutputFrame
20	mu     sync.Mutex
21	now    time.Time
22	// at is the device time of the next swipe event.
23	at time.Duration
24}
25
26var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}
27
28func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
29	t.Helper()
30	cfg := altCmdDefaults()
31	cfg.Border.Width = 0
32	cfg.Layout.Gaps = 0
33	cfg.Terminal.AutoOpen = "never"
34	if edit != nil {
35		edit(&cfg)
36	}
37	r := &swipeRig{frames: make(chan ports.OutputFrame), now: time.Unix(100, 0)}
38	r.multiRig = &multiRig{
39		client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),
40		output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
41		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,
42	}
43	clock := portsmocks.NewMockClock(t)
44	clock.EXPECT().Now().RunAndReturn(func() time.Time {
45		r.mu.Lock()
46		defer r.mu.Unlock()
47		return r.now
48	}).Maybe()
49	// The fallback timer never fires: frames come from the test.
50	clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
51		timer := portsmocks.NewMockTimer(t)
52		timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
53		timer.EXPECT().Stop().Return(true).Maybe()
54		return timer
55	}).Maybe()
56	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: clock, Frames: r.frames})
57	if err != nil {
58		t.Fatal(err)
59	}
60	ctx, cancel := context.WithCancel(context.Background())
61	done := make(chan error, 1)
62	go func() { done <- c.Run(ctx) }()
63	t.Cleanup(func() { cancel(); <-done })
64	r.plug(t, wide)
65	return r
66}
67
68func (r *swipeRig) advance(d time.Duration) {
69	r.mu.Lock()
70	r.now = r.now.Add(d)
71	r.mu.Unlock()
72}
73
74// begin starts a swipe; nothing moves until it picks an axis.
75func (r *swipeRig) begin() {
76	r.at += time.Second
77	r.input <- ports.SwipeBegin{Fingers: 3, Time: r.at}
78}
79
80// move sends one swipe update and returns the scene it produced.
81func (r *swipeRig) move(t *testing.T, dx, dy float64) ports.Scene {
82	t.Helper()
83	r.at += 8 * time.Millisecond
84	r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
85	return scene(t, r.scenes)
86}
87
88func (r *swipeRig) end(t *testing.T, cancelled bool) ports.Scene {
89	t.Helper()
90	r.input <- ports.SwipeEnd{Cancelled: cancelled, Time: r.at}
91	return scene(t, r.scenes)
92}
93
94// frame moves the clock by d, sends a flip and returns the next scene.
95func (r *swipeRig) frame(t *testing.T, d time.Duration) ports.Scene {
96	t.Helper()
97	r.advance(d)
98	r.frames <- ports.OutputFrame{Output: wide.Name}
99	return scene(t, r.scenes)
100}
101
102// settle moves the clock past any spring and returns the settled scene.
103func (r *swipeRig) settle(t *testing.T) ports.Scene {
104	t.Helper()
105	return r.frame(t, 5*time.Second)
106}
107
108// flick sends a quick swipe of n updates of (dx, dy) and lifts the
109// fingers; it returns the scene right after the lift.
110func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
111	t.Helper()
112	r.begin()
113	for range n {
114		r.move(t, dx, dy)
115	}
116	return r.end(t, false)
117}
118
119// workspaces puts window 1 on the first workspace and 2 on the second,
120// showing the first.
121func (r *swipeRig) workspaces(t *testing.T) {
122	t.Helper()
123	r.mapWindow(t, 1)
124	r.key(t, "Next", ports.ModAlt)
125	r.mapWindow(t, 2)
126	r.key(t, "Prior", ports.ModAlt)
127}
128
129func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {
130	for _, w := range s.Windows {
131		if w.ID == id && !w.Hidden {
132			return w.Rect, true
133		}
134	}
135	return ports.Rect{}, false
136}
137
138func (r *swipeRig) focused(t *testing.T) ports.WindowID {
139	t.Helper()
140	var id ports.WindowID
141	for len(r.commands) > 0 {
142		if v, ok := (<-r.commands).(ports.FocusWindow); ok {
143			id = v.ID
144		}
145	}
146	return id
147}
148
149// threeColumns maps three windows of half the width; the view shows 2
150// and 3, with 3 focused. It returns the last scene.
151func threeColumns(t *testing.T, r *swipeRig) ports.Scene {
152	t.Helper()
153	var s []ports.Scene
154	for id := ports.WindowID(1); id <= 3; id++ {
155		s = r.mapWindow(t, id)
156	}
157	return s[0]
158}
159
160func TestSwipeFollowsFingers(t *testing.T) {
161	r := startSwipe(t, nil)
162	before, _ := rectOf(threeColumns(t, r), 2)
163	r.begin()
164	// Under the 16 unit threshold the swipe has not picked its axis:
165	// that movement is not applied (as in GNOME Shell and niri).
166	r.input <- ports.SwipeUpdate{DX: -10, Time: r.at}
167	s := r.move(t, -10, 0)
168	after, _ := rectOf(s, 2)
169	// 10 units of 1200 per 800 px view are 7 px; near the resting view
170	// the detent holds the view back a little.
171	if d := after.X - before.X; d <= 0 || d >= 7 {
172		t.Fatalf("column moved %d px with the fingers", d)
173	}
174	// Halfway to the next column edge (400 px: 600 units) the view has
175	// caught up with the fingers; a full step shows whole.
176	s = r.move(t, -290, 0)
177	if got, _ := rectOf(s, 2); got.X-before.X != 200 {
178		t.Fatalf("column moved %d px halfway", got.X-before.X)
179	}
180	s = r.move(t, -300, 0)
181	if got, _ := rectOf(s, 2); got.X-before.X != 400 {
182		t.Fatalf("column moved %d px after a full step", got.X-before.X)
183	}
184}
185
186func TestSwipeFlickLandsOnColumnWithSpring(t *testing.T) {
187	r := startSwipe(t, nil)
188	threeColumns(t, r)
189	// Focus is on 3 (right); the view shows 2 and 3. Flick right to left
190	// fingers (content moves left in classic scroll): view goes left.
191	r.focused(t)
192	s := r.flick(t, 10, -40, 0)
193	mid, ok := rectOf(s, 1)
194	if !ok {
195		t.Fatal("column 1 not shown while the view slides")
196	}
197	if mid.X == 0 {
198		t.Fatal("view jumped: no slide")
199	}
200	s = r.frame(t, 16*time.Millisecond)
201	next, _ := rectOf(s, 1)
202	if next.X <= mid.X || next.X > 0 {
203		t.Fatalf("spring does not approach the target: %d then %d", mid.X, next.X)
204	}
205	s = r.settle(t)
206	if got, _ := rectOf(s, 1); got.X != 0 {
207		t.Fatalf("landed at %d, want column 1 at the left edge", got.X)
208	}
209	if id := r.focused(t); id != 1 && id != 2 {
210		t.Fatalf("focus %d after swiping left", id)
211	}
212}
213
214func TestSwipeNaturalScrollInverts(t *testing.T) {
215	for _, natural := range []bool{false, true} {
216		r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural })
217		threeColumns(t, r)
218		dx := -40.0
219		if natural {
220			dx = 40
221		}
222		r.flick(t, 10, dx, 0)
223		s := r.settle(t)
224		if got, _ := rectOf(s, 1); got.X != 0 {
225			t.Fatalf("natural=%t: column 1 at %d", natural, got.X)
226		}
227	}
228}
229
230func TestSwipeWorkspaceSlides(t *testing.T) {
231	r := startSwipe(t, nil)
232	r.workspaces(t)
233	r.begin()
234	s := r.move(t, 0, 30)
235	one, ok1 := rectOf(s, 1)
236	two, ok2 := rectOf(s, 2)
237	if !ok1 || !ok2 {
238		t.Fatalf("both workspaces show while sliding: %v %v", ok1, ok2)
239	}
240	if one.Y >= 0 || two.Y <= 0 || two.Y-one.Y != 600 {
241		t.Fatalf("slide positions %d %d", one.Y, two.Y)
242	}
243	for range 8 {
244		r.move(t, 0, 30)
245	}
246	s = r.end(t, false)
247	if _, ok := rectOf(s, 2); !ok {
248		t.Fatal("target workspace hidden while landing")
249	}
250	s = r.settle(t)
251	if _, ok := rectOf(s, 1); ok {
252		t.Fatal("old workspace still shown")
253	}
254	if got, _ := rectOf(s, 2); got.Y != 0 {
255		t.Fatalf("workspace 2 at %d", got.Y)
256	}
257}
258
259func TestSwipeWorkspaceCancelReturns(t *testing.T) {
260	r := startSwipe(t, nil)
261	r.workspaces(t)
262	r.begin()
263	for range 10 {
264		r.move(t, 0, 40)
265	}
266	r.end(t, true)
267	s := r.settle(t)
268	if got, ok := rectOf(s, 1); !ok || got.Y != 0 {
269		t.Fatalf("cancelled swipe left workspace 1 at %v %t", got, ok)
270	}
271}
272
273func TestSwipeFixedOverflowRunsFocusAction(t *testing.T) {
274	r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
275	r.mapWindow(t, 1)
276	r.mapWindow(t, 2)
277	r.focused(t)
278	// The view cannot slide: nothing moves until the fingers lift.
279	r.begin()
280	for range 10 {
281		r.at += 8 * time.Millisecond
282		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
283	}
284	r.end(t, false)
285	if id := r.focused(t); id != 1 {
286		t.Fatalf("fixed overflow swipe left focuses %d", id)
287	}
288}
289
290func TestSwipeShortSwipeDoesNothingInFixedOverflow(t *testing.T) {
291	r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
292	r.mapWindow(t, 1)
293	r.mapWindow(t, 2)
294	r.focused(t)
295	r.begin()
296	r.input <- ports.SwipeUpdate{DX: -20, Time: r.at + time.Second}
297	r.input <- ports.SwipeEnd{Time: r.at + 2*time.Second}
298	r.key(t, "Return", 0)
299	if id := r.focused(t); id != 0 {
300		t.Fatalf("slow short swipe moved focus to %d", id)
301	}
302}
303
304func TestKeyDuringSlideRetargets(t *testing.T) {
305	r := startSwipe(t, nil)
306	threeColumns(t, r)
307	s := r.flick(t, 10, -40, 0)
308	landing, _ := rectOf(s, 2)
309	// Back to column 3: the view heads there from where it is, no jump.
310	r.key(t, "Right", ports.ModAlt)
311	s = r.key(t, "Right", ports.ModAlt)[0]
312	if got, _ := rectOf(s, 2); got.X != landing.X {
313		t.Fatalf("view jumped from %d to %d", landing.X, got.X)
314	}
315	// The retargeted spring starts on the next frame.
316	r.frame(t, 16*time.Millisecond)
317	s = r.settle(t)
318	if got, _ := rectOf(s, 3); got.X != 400 {
319		t.Fatalf("column 3 landed at %d", got.X)
320	}
321}
322
323func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) {
324	r := startSwipe(t, nil)
325	r.workspaces(t)
326	// Workspace 3 is the trailing empty one; start there and swipe up to 2.
327	r.key(t, "Next", ports.ModAlt)
328	r.key(t, "Next", ports.ModAlt)
329	r.begin()
330	var s ports.Scene
331	for range 6 {
332		s = r.move(t, 0, -40)
333	}
334	before, ok := rectOf(s, 2)
335	if !ok {
336		t.Fatal("workspace 2 not sliding in")
337	}
338	// Landing drops no numbered workspace here (the trailing one stays),
339	// but the view must not jump either way.
340	s = r.end(t, false)
341	after, _ := rectOf(s, 2)
342	if after.Y != before.Y {
343		t.Fatalf("landing jumped from %d to %d", before.Y, after.Y)
344	}
345	if got, _ := rectOf(r.settle(t), 2); got.Y != 0 {
346		t.Fatalf("workspace 2 landed at %d", got.Y)
347	}
348}
349
350func TestSwipeLandingKeepsViewWhenEmptyWorkspaceDrops(t *testing.T) {
351	r := startSwipe(t, nil)
352	// 1 on the first workspace, 2 on the third; the second is empty and
353	// active, so leaving it drops it and renumbers the third.
354	r.mapWindow(t, 1)
355	r.key(t, "Next", ports.ModAlt)
356	r.key(t, "Next", ports.ModAlt)
357	r.mapWindow(t, 2)
358	r.key(t, "Prior", ports.ModAlt)
359	r.begin()
360	var s ports.Scene
361	for range 4 {
362		s = r.move(t, 0, 20)
363	}
364	before, ok := rectOf(s, 2)
365	if !ok {
366		t.Fatal("workspace 3 not sliding in")
367	}
368	s = r.end(t, false)
369	after, ok := rectOf(s, 2)
370	if !ok || after.Y != before.Y {
371		t.Fatalf("landing moved workspace 3 from %d to %d (shown %t)", before.Y, after.Y, ok)
372	}
373	if got, _ := rectOf(r.settle(t), 2); got.Y != 0 {
374		t.Fatalf("landed at %d", got.Y)
375	}
376}
377
378func TestSwipeDropsWhenWorkspacesChange(t *testing.T) {
379	r := startSwipe(t, nil)
380	r.workspaces(t)
381	r.begin()
382	r.move(t, 0, 30)
383	// A key switches workspace mid-swipe: the swipe lets go at once.
384	s := r.key(t, "Next", ports.ModAlt)[0]
385	if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
386		t.Fatalf("workspace 2 at %v %t after the key", got, ok)
387	}
388	if _, ok := rectOf(s, 1); ok {
389		t.Fatal("workspace 1 still sliding")
390	}
391	// The rest of the swipe moves nothing: its end leaves the view as is.
392	r.at += 8 * time.Millisecond
393	r.input <- ports.SwipeUpdate{DY: 30, Time: r.at}
394	if got, ok := rectOf(r.end(t, false), 2); !ok || got.Y != 0 {
395		t.Fatalf("workspace 2 at %v %t after the swipe", got, ok)
396	}
397}
398
399func TestSlideMovesPointerFocus(t *testing.T) {
400	r := startSwipe(t, nil)
401	threeColumns(t, r)
402	// The pointer rests over column 2 (x 100: the view shows 2 and 3).
403	r.input <- ports.PointerMotion{X: 100, Y: 300}
404	r.drain()
405	r.flick(t, 10, -40, 0)
406	r.settle(t)
407	var focus ports.PointerFocus
408	var motion ports.PointerMotionTo
409	// Core sends the pointer update right after the scene: wait for it.
410	for {
411		var v ports.ClientCommand
412		select {
413		case v = <-r.commands:
414		case <-time.After(50 * time.Millisecond):
415		}
416		if v == nil {
417			break
418		}
419		switch v := v.(type) {
420		case ports.PointerFocus:
421			focus = v
422		case ports.PointerMotionTo:
423			motion = v
424		}
425	}
426	// Column 1 slid under the still pointer; it follows the window.
427	if focus.ID != 1 {
428		t.Fatalf("pointer focus %+v", focus)
429	}
430	if motion.ID != 1 || motion.X != 100 || motion.DX != 0 {
431		t.Fatalf("last motion %+v", motion)
432	}
433}
434
435func TestSwipeBeginWithoutEndSettlesTheFirst(t *testing.T) {
436	r := startSwipe(t, nil)
437	threeColumns(t, r)
438	r.begin()
439	s := r.move(t, -40, 0)
440	moved, _ := rectOf(s, 2)
441	// A begin without the previous end: the first swipe springs back.
442	r.begin()
443	scene(t, r.scenes)
444	s = r.settle(t)
445	if got, _ := rectOf(s, 2); got.X == moved.X || got.X != 0 {
446		t.Fatalf("first swipe left column 2 at %d", got.X)
447	}
448}
449
450func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) {
451	r := startSwipe(t, nil)
452	before, _ := rectOf(threeColumns(t, r), 2)
453	r.begin()
454	r.move(t, -30, 0)
455	r.key(t, "Next", ports.ModAlt)
456	r.key(t, "Prior", ports.ModAlt)
457	// The same workspace is back, but the swipe was let go.
458	r.at += 8 * time.Millisecond
459	r.input <- ports.SwipeUpdate{DX: -30, Time: r.at}
460	if got, _ := rectOf(r.end(t, false), 2); got != before {
461		t.Fatalf("column 2 at %v, want %v", got, before)
462	}
463}
464
465func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
466	r := startSwipe(t, nil)
467	before, _ := rectOf(threeColumns(t, r), 2)
468	r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
469	// Window 4 on DP-2, so it has a workspace below to switch to.
470	r.input <- ports.PointerMotion{X: 1000, Y: 300}
471	scene(t, r.scenes)
472	r.mapWindow(t, 4)
473	r.input <- ports.PointerMotion{X: 100, Y: 300}
474	scene(t, r.scenes)
475	r.drain()
476	r.begin()
477	r.move(t, -30, 0)
478	// The pointer goes to DP-2 and a bind switches its workspace.
479	r.input <- ports.PointerMotion{X: 1000, Y: 300}
480	scene(t, r.scenes)
481	for _, sc := range r.key(t, "Next", ports.ModAlt) {
482		if _, ok := rectOf(sc, 4); ok {
483			t.Fatal("DP-2 did not switch workspace")
484		}
485	}
486	// The swipe on DP-1 still follows the fingers.
487	var got ports.Rect
488	for _, sc := range receiveMove(t, r, -570) {
489		if sc.Output == wide.Name {
490			got, _ = rectOf(sc, 2)
491		}
492	}
493	// 600 units at 2/3 px each: a full step of 400 px.
494	if got.X-before.X != 400 {
495		t.Fatalf("column 2 moved %d px", got.X-before.X)
496	}
497}
498
499// receiveMove sends one swipe update and returns every output's scene.
500func receiveMove(t *testing.T, r *swipeRig, dx float64) []ports.Scene {
501	t.Helper()
502	r.at += 8 * time.Millisecond
503	r.input <- ports.SwipeUpdate{DX: dx, Time: r.at}
504	return receive(t, r.scenes)
505}
506
507func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) {
508	r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
509	r.mapWindow(t, 1)
510	r.mapWindow(t, 2)
511	r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
512	// Windows 3 and 4 on DP-2, 4 focused there.
513	r.input <- ports.PointerMotion{X: 1000, Y: 300}
514	scene(t, r.scenes)
515	r.mapWindow(t, 3)
516	r.mapWindow(t, 4)
517	// The swipe starts on DP-1 ...
518	r.input <- ports.PointerMotion{X: 100, Y: 300}
519	scene(t, r.scenes)
520	r.begin()
521	for range 10 {
522		r.at += 8 * time.Millisecond
523		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
524	}
525	// ... and the pointer takes the focus to DP-2 before the fingers lift.
526	r.input <- ports.PointerMotion{X: 1000, Y: 300}
527	scene(t, r.scenes)
528	r.drain()
529	r.input <- ports.SwipeEnd{Time: r.at}
530	for {
531		var v ports.ClientCommand
532		select {
533		case v = <-r.commands:
534		case <-time.After(50 * time.Millisecond):
535		}
536		if v == nil {
537			break
538		}
539		if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 {
540			t.Fatal("swipe from DP-1 moved the focus on DP-2")
541		}
542	}
543}
544
545// drain waits for core to handle what was sent, then empties commands.
546func (r *swipeRig) drain() {
547	for {
548		select {
549		case <-r.commands:
550		case <-time.After(50 * time.Millisecond):
551			return
552		}
553	}
554}
555
556func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {
557	r := startSwipe(t, nil)
558	threeColumns(t, r)
559	r.key(t, "Left", ports.ModAlt)
560	s := r.key(t, "Left", ports.ModAlt)[0]
561	if got, _ := rectOf(s, 1); got.X != 0 {
562		t.Fatalf("column 1 at %d", got.X)
563	}
564}
565
566// In the overview a three-finger swipe does not slide the view: nothing
567// is drawn while the fingers move, and when they lift the selection moves
568// one column, the previews where they were.
569func TestSwipeInOverview(t *testing.T) {
570	r := startSwipe(t, nil)
571	threeColumns(t, r)
572	opened := r.key(t, "o", ports.ModAlt)[0]
573	selected := func(s ports.Scene) ports.WindowID {
574		for _, w := range s.Windows {
575			if w.Focused && w.Preview > 0 {
576				return w.ID
577			}
578		}
579		return 0
580	}
581	r.begin()
582	for range 3 {
583		r.at += 8 * time.Millisecond
584		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
585	}
586	s := r.end(t, false)
587	if id := selected(s); id != 2 {
588		t.Fatalf("selected %d after a left swipe, want 2", id)
589	}
590	before, _ := rectOf(opened, 2)
591	if after, _ := rectOf(s, 2); before != after {
592		t.Fatalf("preview moved: %+v then %+v", before, after)
593	}
594	// A small swipe does nothing: the l after it moves from 2 to 3.
595	r.begin()
596	r.at += 8 * time.Millisecond
597	r.input <- ports.SwipeUpdate{DX: -20, Time: r.at}
598	if id := selected(r.end(t, false)); id != 2 {
599		t.Fatalf("selected %d after a small swipe", id)
600	}
601	if id := selected(r.key(t, "l", 0)[0]); id != 3 {
602		t.Fatalf("selected %d after a small swipe and l, want 3", id)
603	}
604}
605
606// A four-finger swipe up opens the overview and down closes it on the
607// selection, whatever natural-scroll says (as niri). A short one or one
608// sideways does nothing.
609func TestFourFingerSwipeOverview(t *testing.T) {
610	for _, natural := range []bool{false, true} {
611		r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural })
612		threeColumns(t, r)
613		four := func(dx, dy float64, n int) ports.Scene {
614			r.at += time.Second
615			r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
616			for range n {
617				r.at += 8 * time.Millisecond
618				r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
619			}
620			return r.end(t, false)
621		}
622		inOverview := func(s ports.Scene) bool {
623			for _, w := range s.Windows {
624				if w.Preview > 0 {
625					return true
626				}
627			}
628			return false
629		}
630		if inOverview(four(0, -5, 4)) {
631			t.Fatalf("natural=%v: short swipe opened the overview", natural)
632		}
633		if inOverview(four(-40, 0, 10)) {
634			t.Fatalf("natural=%v: sideways swipe opened the overview", natural)
635		}
636		if !inOverview(four(0, -40, 10)) {
637			t.Fatalf("natural=%v: swipe up did not open the overview", natural)
638		}
639		if !inOverview(four(0, -40, 10)) {
640			t.Fatalf("natural=%v: a second swipe up closed it", natural)
641		}
642		r.key(t, "h", 0)
643		s := four(0, 40, 10)
644		if inOverview(s) {
645			t.Fatalf("natural=%v: swipe down did not close the overview", natural)
646		}
647		if id := r.focused(t); id != 2 {
648			t.Fatalf("natural=%v: closed on %d, want the selection 2", natural, id)
649		}
650	}
651}
652
653// Opening the overview during a column swipe drops the swipe: the rest
654// of it neither slides the columns under the overview nor lands them,
655// also once the overview closed again (escape before the rest).
656func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
657	for _, closeFirst := range []bool{false, true} {
658		r := startSwipe(t, nil)
659		settled := threeColumns(t, r)
660		r.begin()
661		r.move(t, -40, 0)
662		r.move(t, -40, 0)
663		r.key(t, "o", ports.ModAlt)
664		if closeFirst {
665			r.key(t, "Escape", 0)
666		}
667		for range 20 {
668			r.at += 8 * time.Millisecond
669			r.input <- ports.SwipeUpdate{DX: -80, Time: r.at}
670		}
671		r.input <- ports.SwipeEnd{Time: r.at}
672		if !closeFirst {
673			r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true}
674		}
675		// Past any spring: the view shows where it settled.
676		r.advance(5 * time.Second)
677		r.frames <- ports.OutputFrame{Output: wide.Name}
678		s := sceneMatch(t, r.scenes, func(s ports.Scene) bool {
679			for _, w := range s.Windows {
680				if w.Preview > 0 {
681					return false
682				}
683			}
684			return true
685		})
686		for _, id := range []ports.WindowID{1, 2, 3} {
687			before, _ := rectOf(settled, id)
688			if after, _ := rectOf(s, id); before != after {
689				t.Fatalf("closeFirst=%v: window %d moved: %+v then %+v", closeFirst, id, before, after)
690			}
691		}
692	}
693}
694
695// A hard flick down from the first of four workspaces lands on the second:
696// one swipe never skips a workspace.
697func TestSwipeHardFlickMovesOneWorkspace(t *testing.T) {
698	r := startSwipe(t, nil)
699	for id := ports.WindowID(1); id <= 4; id++ {
700		r.mapWindow(t, id)
701		r.key(t, "Next", ports.ModAlt)
702	}
703	for range 4 {
704		r.key(t, "Prior", ports.ModAlt)
705	}
706	r.flick(t, 20, 0, 60)
707	s := r.settle(t)
708	if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
709		t.Fatalf("hard flick did not land on workspace 2: %v %t", got, ok)
710	}
711}
712
713// A hard flick across many columns moves the view one column edge.
714func TestSwipeHardFlickMovesOneColumn(t *testing.T) {
715	r := startSwipe(t, nil)
716	for id := ports.WindowID(1); id <= 5; id++ {
717		r.mapWindow(t, id)
718	}
719	// The view shows 4 and 5; a hard flick left shows 3 and 4, not 1.
720	r.flick(t, 20, -60, 0)
721	s := r.settle(t)
722	if got, ok := rectOf(s, 3); !ok || got.X != 0 {
723		t.Fatalf("column 3 at %v %t, want the left edge", got, ok)
724	}
725}
726
727// A two-finger scroll that steps in the overview and ends with the pointer
728// on another output does not hold the next scroll back.
729func TestOverviewScrollStopOnOtherOutput(t *testing.T) {
730	r := startSwipe(t, nil)
731	threeColumns(t, r)
732	r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
733	r.key(t, "o", ports.ModAlt)
734	selected := func(s ports.Scene) ports.WindowID {
735		for _, w := range s.Windows {
736			if w.Focused && w.Preview > 0 {
737				return w.ID
738			}
739		}
740		return 0
741	}
742	on := func(id ports.WindowID) func(ports.Scene) bool {
743		return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }
744	}
745	finger := func(dx float64) ports.PointerAxis {
746		return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}}
747	}
748	r.input <- ports.PointerMotion{X: 100, Y: 300}
749	r.input <- finger(-70)
750	sceneMatch(t, r.scenes, on(2))
751	// The pointer crosses to DP-2 and the fingers lift there.
752	r.input <- ports.PointerMotion{X: 1000, Y: 300}
753	r.input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
754	r.input <- ports.PointerMotion{X: 100, Y: 300}
755	r.input <- finger(-70)
756	sceneMatch(t, r.scenes, on(1))
757}
758
759// The output shrinks mid-swipe: the snap points no longer hold, so the
760// swipe lets go. The view shows what the resize alone shows, and the rest
761// of the swipe moves nothing.
762func TestSwipeOutputResizedMidSwipe(t *testing.T) {
763	smaller := ports.OutputInfo{Name: wide.Name, Width: 600, Height: 600, RefreshMilli: 60000}
764	four := func(r *swipeRig) {
765		for id := ports.WindowID(1); id <= 4; id++ {
766			r.mapWindow(t, id)
767		}
768	}
769	plain := startSwipe(t, nil)
770	four(plain)
771	plain.output <- ports.OutputAdded{Info: smaller}
772	want := scene(t, plain.scenes)
773
774	r := startSwipe(t, nil)
775	four(r)
776	r.begin()
777	for range 3 {
778		r.move(t, -40, 0)
779	}
780	r.output <- ports.OutputAdded{Info: smaller}
781	scene(t, r.scenes)
782	// The first update after the resize lets go: the slide stops.
783	s := r.move(t, -40, 0)
784	for _, id := range []ports.WindowID{1, 2, 3, 4} {
785		w, _ := rectOf(want, id)
786		if got, _ := rectOf(s, id); got != w {
787			t.Fatalf("window %d at %+v after the next update, want %+v", id, got, w)
788		}
789	}
790	for range 2 {
791		r.at += 8 * time.Millisecond
792		r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
793	}
794	s = r.end(t, false)
795	for _, id := range []ports.WindowID{1, 2, 3, 4} {
796		w, _ := rectOf(want, id)
797		if got, _ := rectOf(s, id); got != w {
798			t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
799		}
800	}
801}
802
803// A window maps mid-swipe: the column snap points moved, so the swipe lets
804// go and the view shows what the map alone shows.
805func TestSwipeWindowMappedMidSwipe(t *testing.T) {
806	plain := startSwipe(t, nil)
807	threeColumns(t, plain)
808	want := plain.mapWindow(t, 4)[0]
809
810	r := startSwipe(t, nil)
811	threeColumns(t, r)
812	r.begin()
813	for range 3 {
814		r.move(t, -40, 0)
815	}
816	r.mapWindow(t, 4)
817	r.move(t, -40, 0)
818	r.at += 8 * time.Millisecond
819	r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
820	s := r.end(t, false)
821	for _, id := range []ports.WindowID{1, 2, 3, 4} {
822		w, _ := rectOf(want, id)
823		if got, _ := rectOf(s, id); got != w {
824			t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
825		}
826	}
827}

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