refactor/kvconf 3462c76

motion_test.go RawHistoryFolder

1package core
2
3import (
4	"math"
5	"slices"
6	"testing"
7	"time"
8)
9
10func TestSwipeTrackerVelocity(t *testing.T) {
11	var tr swipeTracker
12	for i := range 11 {
13		tr.push(10, time.Duration(i)*10*time.Millisecond)
14	}
15	// 150 ms of history: 16 pushes would fit, 11 span 100 ms and 110 units.
16	if got := tr.velocity(); math.Abs(got-1100) > 1e-9 {
17		t.Fatalf("velocity %v, want 1100", got)
18	}
19	if tr.pos != 110 {
20		t.Fatalf("pos %v", tr.pos)
21	}
22	// A flick lands further than where the fingers left it.
23	if end := tr.projectedEnd(); end <= tr.pos {
24		t.Fatalf("projected %v not past %v", end, tr.pos)
25	}
26}
27
28func TestSwipeTrackerForgetsOldMovement(t *testing.T) {
29	var tr swipeTracker
30	tr.push(500, 0)
31	tr.push(1, 10*time.Millisecond)
32	// The fingers rested before lifting: the old flick no longer counts.
33	tr.push(0, 400*time.Millisecond)
34	if v := tr.velocity(); v != 0 {
35		t.Fatalf("velocity %v after a pause", v)
36	}
37	if tr.projectedEnd() != tr.pos {
38		t.Fatal("a still swipe lands where it is")
39	}
40}
41
42func TestSwipeTrackerIgnoresEarlierEvents(t *testing.T) {
43	var tr swipeTracker
44	tr.push(5, 20*time.Millisecond)
45	tr.push(5, 10*time.Millisecond)
46	if tr.pos != 5 || len(tr.history) != 1 {
47		t.Fatalf("pos %v history %d", tr.pos, len(tr.history))
48	}
49}
50
51func TestSpringSettles(t *testing.T) {
52	s := viewSpring(-300, 0)
53	if v := s.valueAt(0); math.Abs(v+300) > 1e-9 {
54		t.Fatalf("start %v", v)
55	}
56	d := s.duration()
57	if d <= 0 || d > 2*time.Second {
58		t.Fatalf("duration %v", d)
59	}
60	if v := s.valueAt(d); math.Abs(v) > 0.5 {
61		t.Fatalf("end %v", v)
62	}
63	// Critically damped: it never passes its target.
64	for at := time.Duration(0); at < d; at += time.Millisecond {
65		if v := s.valueAt(at); v > 1e-9 {
66			t.Fatalf("overshoot %v at %v", v, at)
67		}
68	}
69}
70
71func TestSpringKeepsVelocity(t *testing.T) {
72	// A spring launched toward its target moves faster at first.
73	still, flung := viewSpring(-300, 0), viewSpring(-300, 3000)
74	at := 10 * time.Millisecond
75	if still.valueAt(at) >= flung.valueAt(at) {
76		t.Fatal("initial velocity ignored")
77	}
78}
79
80func TestSpringOverdamped(t *testing.T) {
81	s := spring{From: 0, To: 1, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001}
82	d := s.duration()
83	if v := s.valueAt(d); math.IsNaN(v) || math.Abs(v-1) > 0.01 {
84		t.Fatalf("value %v at %v", v, d)
85	}
86	if (spring{From: 0, To: 0, DampingRatio: 1.15, Stiffness: 850, Epsilon: 0.0001}).duration() != 0 {
87		t.Fatal("a spring at rest takes time")
88	}
89}
90
91func TestMotionEndsOnTarget(t *testing.T) {
92	start := time.Unix(10, 0)
93	m := newMotion(workspaceSpring(0.4, 0), start, 1)
94	if v, done := m.at(start); done || v != 0.4 {
95		t.Fatalf("start %v %t", v, done)
96	}
97	if v, done := m.at(start.Add(10 * time.Second)); !done || v != 0 {
98		t.Fatalf("end %v %t", v, done)
99	}
100}
101
102func TestRubberBand(t *testing.T) {
103	r := workspaceBand
104	if r.clamp(0, 2, 1.5) != 1.5 {
105		t.Fatal("inside moves freely")
106	}
107	past := r.clamp(0, 2, 3)
108	if past <= 2 || past >= 2+r.limit {
109		t.Fatalf("past the end %v", past)
110	}
111	if before := r.clamp(0, 2, -10); before >= 0 || before <= -r.limit {
112		t.Fatalf("before the start %v", before)
113	}
114	if r.clampDerivative(0, 2, 1) != 1 || r.clampDerivative(0, 2, 3) >= 1 {
115		t.Fatal("derivative")
116	}
117}
118
119func TestSnap(t *testing.T) {
120	// Four columns of 400 on an 800 wide view, no gaps: views align on
121	// column edges between 0 and 800.
122	w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, MaxColumns: 2}
123	for i := range 4 {
124		w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
125	}
126	if got := w.snapPoints(); !slices.Equal(got, []float64{0, 400, 800}) {
127		t.Fatalf("snap points %v", got)
128	}
129	for _, tc := range []struct {
130		view    int
131		forward bool
132		focus   int
133	}{
134		{view: 0, forward: true, focus: 0},
135		{view: 400, forward: true, focus: 2},
136		{view: 400, forward: false, focus: 1},
137		{view: 800, forward: true, focus: 3},
138	} {
139		if focus := w.snapFocus(tc.view, tc.forward); focus != tc.focus {
140			t.Errorf("snapFocus(%d, %t) = %d; want %d", tc.view, tc.forward, focus, tc.focus)
141		}
142	}
143}
144
145// Column edges closer than snapSpacing merge: a step always shows. The
146// last column's end stays, dropping a point close before it.
147func TestSnapPointsMergeClose(t *testing.T) {
148	for _, tc := range []struct {
149		widths []int
150		want   []float64
151	}{
152		// Views aligning a column: 0, 600, 640 (merged into 600), 1000.
153		{[]int{600, 400, 640, 360}, []float64{0, 600, 1000}},
154		// 0, 360, 600, 960 and the end 1000: 960 goes, 1000 stays.
155		{[]int{600, 360, 400, 640}, []float64{0, 360, 600, 1000}},
156		// Columns narrower than the view: one point.
157		{[]int{300, 300}, []float64{0}},
158	} {
159		w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}}
160		for _, px := range tc.widths {
161			w.Columns = append(w.Columns, Column{Width: Width{Pixels: px}, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162		}
163		if got := w.snapPoints(); !slices.Equal(got, tc.want) {
164			t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
165		}
166	}
167}
168
169// swipe pushes n updates of delta, 8 ms apart, from time 0.
170func swipe(s *snapSwipe, n int, delta float64) time.Duration {
171	at := time.Duration(0)
172	for range n {
173		at += 8 * time.Millisecond
174		s.push(delta, at)
175	}
176	return at
177}
178
179// slowSwipe pushes dist over two seconds and rests before the lift.
180func slowSwipe(s *snapSwipe, dist float64) time.Duration {
181	at := time.Duration(0)
182	for range 40 {
183		at += 50 * time.Millisecond
184		s.push(dist/40, at)
185	}
186	return at + 200*time.Millisecond
187}
188
189func workspaceSnap(start float64) snapSwipe {
190	return newSnapSwipe(start, math.Round(start), 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
191}
192
193// One swipe reaches the next workspace at most, however fast or far: a
194// slow one settles on the closest, back unless past halfway; a cancelled
195// one returns.
196func TestSnapSwipeOneStep(t *testing.T) {
197	for _, tc := range []struct {
198		name      string
199		push      func(*snapSwipe) time.Duration
200		cancelled bool
201		want      float64
202	}{
203		{"quick flick down", func(s *snapSwipe) time.Duration { return swipe(s, 6, 30) }, false, 3},
204		{"quick flick up", func(s *snapSwipe) time.Duration { return swipe(s, 6, -30) }, false, 1},
205		{"hard long flick", func(s *snapSwipe) time.Duration { return swipe(s, 30, 60) }, false, 3},
206		{"short quick flick", func(s *snapSwipe) time.Duration { return swipe(s, 3, 20) }, false, 3},
207		{"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, 100) }, false, 2},
208		{"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, 200) }, false, 3},
209		{"slow far up", func(s *snapSwipe) time.Duration { return slowSwipe(s, -900) }, false, 1},
210		{"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 10, 30) }, true, 2},
211		// Most of the way down, then a hard flick back: one step up.
212		{"forward then hard back", func(s *snapSwipe) time.Duration {
213			at := slowSwipe(s, 250)
214			for range 4 {
215				at += 8 * time.Millisecond
216				s.push(-30, at)
217			}
218			return at
219		}, false, 1},
220	} {
221		s := workspaceSnap(2)
222		at := tc.push(&s)
223		if p := s.pos(); p < 1-workspaceBand.limit || p > 3+workspaceBand.limit {
224			t.Errorf("%s: view %v went past the neighbors", tc.name, p)
225		}
226		if got, _ := s.end(tc.cancelled, at); got != tc.want {
227			t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
228		}
229	}
230}
231
232// A swipe caught during a landing steps from the landing: one more.
233func TestSnapSwipeDuringLanding(t *testing.T) {
234	s := newSnapSwipe(2.6, 3, 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
235	at := swipe(&s, 6, 30)
236	if p := s.pos(); p > 4+workspaceBand.limit {
237		t.Fatalf("view %v went past 4", p)
238	}
239	if got, _ := s.end(false, at); got != 4 {
240		t.Fatalf("landed on %v, want 4", got)
241	}
242}
243
244// From a view between points (a merged column edge) a slow swipe never
245// settles behind where it started; a quick one reaches the points around.
246func TestSnapSwipeRestBetweenPoints(t *testing.T) {
247	for _, tc := range []struct {
248		name string
249		push func(*snapSwipe) time.Duration
250		want float64
251	}{
252		{"slow", func(s *snapSwipe) time.Duration { return slowSwipe(s, 5) }, 640},
253		{"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 4, -40) }, 600},
254		{"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 4, 40) }, 1000},
255	} {
256		s := newSnapSwipe(640, 640, 1, []float64{0, 600, 1000}, workspaceBand.scaled(1000))
257		at := tc.push(&s)
258		if got, _ := s.end(false, at); got != tc.want {
259			t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
260		}
261	}
262}
263
264// A swipe caught while sliding to a far workspace lands on that one or
265// its neighbors, never back where the slide shows.
266func TestSnapSwipeCaughtFarFromRest(t *testing.T) {
267	for _, delta := range []float64{-60, 60} {
268		s := newSnapSwipe(0.5, 4, 1/workspaceSwipeMovement, indexPoints(6), workspaceBand)
269		at := swipe(&s, 20, delta)
270		if got, _ := s.end(false, at); got < 3 || got > 5 {
271			t.Errorf("delta %v: landed on %v, want 3 to 5", delta, got)
272		}
273	}
274}
275
276// The view follows the fingers in their direction, without jumps, from a
277// start on a point, between points, or caught past the edge (a spring back
278// from the rubber band); near a point it sticks, a full step shows whole.
279func TestSnapSwipeDetents(t *testing.T) {
280	for _, start := range []float64{2, 2.4, -0.03, 4.03} {
281		s := workspaceSnap(start)
282		if p := s.pos(); math.Abs(p-start) > 1e-9 {
283			t.Fatalf("start %v shows %v", start, p)
284		}
285		for _, dir := range []float64{1, -1} {
286			s := workspaceSnap(start)
287			prev := s.pos()
288			for i := range 400 {
289				s.push(dir, time.Duration(i)*time.Millisecond)
290				p := s.pos()
291				if (p-prev)*dir < 0 || math.Abs(p-prev) > 0.01 {
292					t.Fatalf("start %v dir %v: view %v then %v at %d", start, dir, prev, p, i)
293				}
294				prev = p
295			}
296		}
297	}
298	s := workspaceSnap(2)
299	s.push(300, 0)
300	if p := s.pos(); math.Abs(p-3) > 1e-9 {
301		t.Fatalf("a full step shows %v", p)
302	}
303	s = workspaceSnap(2)
304	s.push(30, 0)
305	if p := s.pos(); p >= 2.1 || p <= 2 {
306		t.Fatalf("near a point the view moved to %v for 0.1", p)
307	}
308}
309
310// One workspace: nowhere to go, the view resists and stays.
311func TestSnapSwipeSinglePoint(t *testing.T) {
312	s := newSnapSwipe(0, 0, 1/workspaceSwipeMovement, indexPoints(1), workspaceBand)
313	at := swipe(&s, 10, 40)
314	if p := s.pos(); p <= 0 || p > workspaceBand.limit {
315		t.Fatalf("view %v", p)
316	}
317	target, velocity := s.end(false, at)
318	if target != 0 || math.IsNaN(velocity) {
319		t.Fatalf("landed on %v at %v", target, velocity)
320	}
321}
322
323func TestStepSwipe(t *testing.T) {
324	for _, tc := range []struct {
325		name      string
326		push      func(*snapSwipe) time.Duration
327		cancelled bool
328		want      int
329	}{
330		{"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 3, -40) }, false, -1},
331		{"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, false, 1},
332		{"hard", func(s *snapSwipe) time.Duration { return swipe(s, 30, 80) }, false, 1},
333		{"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, -100) }, false, 0},
334		{"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, -200) }, false, -1},
335		{"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, true, 0},
336	} {
337		s := newStepSwipe()
338		at := tc.push(&s)
339		target, v := s.end(tc.cancelled, at)
340		if got := int(math.Round(target)); got != tc.want {
341			t.Errorf("%s: step %d, want %d", tc.name, got, tc.want)
342		}
343		if math.IsNaN(v) {
344			t.Errorf("%s: velocity NaN", tc.name)
345		}
346	}
347}
348
349// With no column fully shown (a column wider than the space between the
350// gaps), the focus goes to the column at the left edge.
351func TestSnapFocusWideColumn(t *testing.T) {
352	w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, Gaps: 10}
353	for i := range 3 {
354		w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
355	}
356	// Full width with gaps: 780 wide, between 10 and 790. The view
357	// columnX(1)+1 shifts it 1 px left, so it is not fully shown.
358	w.Columns[1].FullWidth = true
359	if !w.slidable() {
360		t.Fatal("the workspace does not scroll")
361	}
362	view := w.columnX(1) - w.Usable.X - w.gap() + 1
363	for _, forward := range []bool{true, false} {
364		if focus := w.snapFocus(view, forward); focus != 1 {
365			t.Fatalf("forward=%t: focus %d, want the wide column 1", forward, focus)
366		}
367	}
368}
369
370func TestSpringVelocityAt(t *testing.T) {
371	springs := map[string]spring{
372		"critical": {From: -300, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001},
373		"under":    {From: -300, DampingRatio: 0.5, Stiffness: 800, Epsilon: 0.0001},
374		"over":     {From: -300, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001},
375	}
376	const h = time.Microsecond
377	for name, s := range springs {
378		for _, v0 := range []float64{0, 3000} {
379			s := s
380			s.Velocity = v0
381			if got := s.velocityAt(0); math.Abs(got-v0) > 1e-9*max(1, math.Abs(v0)) {
382				t.Errorf("%s v0=%v: velocityAt(0) = %v", name, v0, got)
383			}
384			for _, at := range []time.Duration{5, 50, 200} {
385				at *= time.Millisecond
386				want := (s.valueAt(at+h) - s.valueAt(at-h)) / (2 * h.Seconds())
387				got := s.velocityAt(at)
388				if math.Abs(got-want) > 1e-3*max(1, math.Abs(want)) {
389					t.Errorf("%s v0=%v at %v: velocityAt = %v, derivative %v", name, v0, at, got, want)
390				}
391			}
392		}
393	}
394}
395
396func TestMotionSlowdownScalesTime(t *testing.T) {
397	start := time.Unix(10, 0)
398	s := viewSpring(-300, 0)
399	one, two := newMotion(s, start, 1), newMotion(s, start, 2)
400	if two.end != 2*one.end {
401		t.Fatalf("end %v, want %v", two.end, 2*one.end)
402	}
403	for _, d := range []time.Duration{5 * time.Millisecond, 40 * time.Millisecond, 150 * time.Millisecond} {
404		a, _ := one.at(start.Add(d))
405		b, _ := two.at(start.Add(2 * d))
406		if math.Abs(a-b) > 1e-9 {
407			t.Errorf("at %v: %v vs %v", d, a, b)
408		}
409	}
410	if m := newMotion(s, start, 0); m.slow != 1 || m.end != one.end {
411		t.Fatalf("slowdown 0 is not 1: %+v", m)
412	}
413}
414
415func TestMotionVelocity(t *testing.T) {
416	start := time.Unix(10, 0)
417	for _, slow := range []float64{1, 2.5} {
418		m := newMotion(viewSpring(-300, 3000), start, slow)
419		if v := m.velocity(); v != 3000 {
420			t.Fatalf("slow %v: initial velocity %v", slow, v)
421		}
422		d := 30 * time.Millisecond
423		m.at(start.Add(d))
424		tau := time.Duration(float64(d) / slow)
425		if want := m.spring.velocityAt(tau) / slow; m.velocity() != want {
426			t.Fatalf("slow %v: velocity %v, want %v", slow, m.velocity(), want)
427		}
428		if _, done := m.at(start.Add(time.Hour)); !done || m.velocity() != 0 {
429			t.Fatalf("slow %v: settled velocity %v", slow, m.velocity())
430		}
431	}
432}
433
434func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
435	start := time.Unix(10, 0)
436	w := &Workspace{View: 100, view: slide{off: -300}}
437	w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2)
438	w.view.motion.at(start.Add(20 * time.Millisecond))
439	v := w.view.motion.velocity()
440	if v <= 0 {
441		t.Fatalf("velocity %v", v)
442	}
443	w.View = 40
444	w.retarget(100)
445	if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 {
446		t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion)
447	}
448	if got := w.view.motion.velocity(); got != v {
449		t.Fatalf("velocity %v after retarget, want %v", got, v)
450	}
451	if got := w.view.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 {
452		t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v)
453	}
454	var idle Workspace
455	idle.View = 5
456	idle.retarget(0)
457	if idle.view.motion.on || idle.view.off != 0 {
458		t.Fatal("retarget without a slide must not start one")
459	}
460}
461
462// A pixel spring settles once its rounded position can no longer differ
463// from the target: well before its epsilon tail, and from then on the
464// spring itself (run to its epsilon) never leaves half a pixel of the
465// target. A level spring (fade, veil) runs to its epsilon.
466func TestMotionPixelSnapCutsTail(t *testing.T) {
467	start := time.Unix(10, 0)
468	for _, tc := range []struct {
469		name     string
470		from, v0 float64
471	}{{"from rest", 100, 0}, {"flung past", 100, -3000}, {"flung back", 100, 3000}, {"short", 3, 0}, {"stopped near", 0.4, 60}} {
472		m := newMotion(viewSpring(tc.from, tc.v0), start, 1)
473		var settledAt time.Duration
474		for d := time.Duration(0); d <= m.end; d += time.Millisecond {
475			if _, done := m.at(start.Add(d)); done {
476				settledAt = d
477				break
478			}
479		}
480		if settledAt == 0 || settledAt >= m.end {
481			t.Fatalf("%s: settled at %v of %v", tc.name, settledAt, m.end)
482		}
483		if v, done := m.at(start.Add(settledAt)); !done || v != 0 {
484			t.Fatalf("%s: settled value %v %v", tc.name, v, done)
485		}
486		for d := settledAt; d <= m.end+time.Second; d += 100 * time.Microsecond {
487			if x := m.spring.valueAt(d); math.Abs(x) >= pixelSnap {
488				t.Fatalf("%s: %v past the snap at %v (settled at %v)", tc.name, x, d, settledAt)
489			}
490		}
491		t.Logf("%s: settled at %v, epsilon end %v", tc.name, settledAt, m.end)
492	}
493	level := newMotion(levelSpring(1, 0), start, 1)
494	if v, done := level.at(start.Add(level.end - time.Millisecond)); done || v == 0 {
495		t.Fatalf("a level spring snapped: %v %v", v, done)
496	}
497	if _, done := level.at(start.Add(level.end)); !done {
498		t.Fatal("a level spring never ends")
499	}
500}
501
502// A workspace slide snaps in output pixels: at a 600 px output half a pixel
503// is 1/1200 of a workspace.
504func TestSwitchSpringSnapsAtOutputPixels(t *testing.T) {
505	m := newMonitorWithIDs("", "", new(uint64))
506	m.template.Output = Rect{W: 800, H: 600}
507	s := m.switchSpring(0.5, 0)
508	if s.Snap != pixelSnap/600 {
509		t.Fatalf("snap %v", s.Snap)
510	}
511	if s.settled(0.5/600, 0) || !s.settled(0.2/600, 0) {
512		t.Fatal("snap threshold")
513	}
514}

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