feat/hints a471fa4

transition_chain_internal_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"math"
6	"slices"
7	"testing"
8	"time"
9
10	"github.com/bnema/neferwl/internal/ports"
11)
12
13// A bind that stops a spring while it acts (Monitor.Focus) must not lose its
14// speed: the transition that follows starts from what the snapshot recorded.
15func TestChainedActionsKeepVelocity(t *testing.T) {
16	for _, tt := range chainCases() {
17		t.Run(tt.name, func(t *testing.T) {
18			c, ic := indicatorCore(t)
19			c.cfg.Animations.On = true
20			tt.setup(c)
21			settleShown(t, c)
22			act := func(actions []Action) {
23				before := c.snapshot(ic.now)
24				for _, a := range actions {
25					c.applyAction(a)
26				}
27				c.transition(before, ic.now)
28				settleShown(t, c)
29			}
30			act(tt.first)
31			if !c.animating() {
32				t.Fatal("the first action started no motion")
33			}
34			ic.now = ic.now.Add(30 * time.Millisecond)
35			c.animate(ic.now, nil)
36			settleShown(t, c)
37			want := tt.velocity(c)
38			if want == 0 {
39				t.Fatal("no speed to carry over")
40			}
41			act(tt.second)
42			if got := tt.velocity(c); got != want {
43				t.Fatalf("velocity %v after the second action, want the carried %v", got, want)
44			}
45		})
46	}
47}
48
49// An action between two frames chains from the speed its motion has at the
50// action's time (1 ms after the last frame), within 1e-6, not the last
51// frame's.
52func TestChainedActionsSampleVelocityAtActionTime(t *testing.T) {
53	for _, tt := range chainCases() {
54		t.Run(tt.name, func(t *testing.T) {
55			c, ic := indicatorCore(t)
56			c.cfg.Animations.On = true
57			tt.setup(c)
58			settleShown(t, c)
59			before := c.snapshot(ic.now)
60			for _, a := range tt.first {
61				c.applyAction(a)
62			}
63			c.transition(before, ic.now)
64			settleShown(t, c)
65			ic.now = ic.now.Add(30 * time.Millisecond)
66			c.animate(ic.now, nil)
67			settleShown(t, c)
68			m := tt.motion(c)
69			last := m.velocity()
70			ic.now = ic.now.Add(time.Millisecond)
71			// The spring's own speed at now, computed without motion.at.
72			tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow)
73			want := m.spring.velocityAt(tau) / m.slow
74			if math.Abs(want-last) < 1e-3 {
75				t.Fatalf("speed %v did not change in 1 ms (%v): the case checks nothing", last, want)
76			}
77			before = c.snapshot(ic.now)
78			for _, a := range tt.second {
79				c.applyAction(a)
80			}
81			c.transition(before, ic.now)
82			if got := tt.velocity(c); math.Abs(got-want) > 1e-6 {
83				t.Fatalf("velocity %v after the second action, want the sampled %v (last frame's %v)", got, want, last)
84			}
85		})
86	}
87}
88
89type chainCase struct {
90	name   string
91	setup  func(*Core)
92	first  []Action
93	second []Action
94	// motion is the motion the first action started.
95	motion func(*Core) motion
96}
97
98// velocity is the speed of the motion the first action started.
99func (tt chainCase) velocity(c *Core) float64 { return tt.motion(c).velocity() }
100
101func chainCases() []chainCase {
102	return []chainCase{
103		{"camera", func(c *Core) {
104			m := c.cur().mon
105			for id := WindowID(1); id <= 4; id++ {
106				m.AddWindow(id)
107			}
108		}, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft},
109			[]Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight},
110			func(c *Core) motion { return c.cur().mon.Current().view.motion }},
111		{"workspace switch", func(c *Core) {
112			m := c.cur().mon
113			m.AddWindow(1)
114			m.Focus(1)
115			m.AddWindow(2)
116			m.Focus(0)
117		}, []Action{ActionFocusWorkspaceNext}, []Action{ActionFocusWorkspaceNext},
118			func(c *Core) motion { return c.cur().mon.switchView.motion }},
119		{"rect", func(c *Core) {
120			m := c.cur().mon
121			m.AddWindow(1)
122			m.AddWindow(2)
123		}, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight},
124			func(c *Core) motion { return c.cur().rects[1].x }},
125	}
126}
127
128// The fallback timer guards every animating output: a page flip of one
129// output (step with only set) keeps it while another output still animates.
130func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
131	c, ic := indicatorCore(t)
132	c.cfg.Animations.On = true
133	a := c.cur()
134	c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
135	b := c.screens[1]
136	a.mon.AddWindow(1)
137	b.mon.AddWindow(2)
138	settleShown(t, c)
139	for _, sc := range []*screen{a, b} {
140		w := sc.mon.Current()
141		w.view.off, w.view.motion = 100, c.spring(viewSpring(100, 0), ic.now)
142	}
143	// The one frame timer is made on the first arm and reset afterwards:
144	// a re-arm shows as a Reset on the clock.
145	c.armFrame()
146	resets := len(ic.resets)
147	ic.now = ic.now.Add(16 * time.Millisecond)
148	if err := c.step(context.Background(), a); err != nil {
149		t.Fatal(err)
150	}
151	<-c.ch.Scenes
152	if c.frameC == nil || len(ic.resets) != resets {
153		t.Fatalf("a flip of A re-armed or dropped the fallback timer while B animates: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
154	}
155
156	// With nothing else animating the flip's own publish decides.
157	b.mon.Current().view.stop()
158	if err := c.step(context.Background(), a); err != nil {
159		t.Fatal(err)
160	}
161	<-c.ch.Scenes
162	if c.frameC == nil || len(ic.resets) != resets+1 {
163		t.Fatalf("A still animates but its timer was not re-armed: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
164	}
165	ic.now = ic.now.Add(5 * time.Second)
166	if err := c.step(context.Background(), nil); err != nil {
167		t.Fatal(err)
168	}
169	<-c.ch.Scenes
170	if c.animating() || c.frameC != nil {
171		t.Fatalf("settled but the timer runs (%v)", c.frameC != nil)
172	}
173}
174
175// A window that left the layout while its rect motion ran drops the motion:
176// it would ask for frames with nothing to move.
177func TestRefreshShownDropsRectsOfWindowsOutOfLayout(t *testing.T) {
178	c, ic := indicatorCore(t)
179	c.cfg.Animations.On = true
180	sc := c.cur()
181	sc.mon.AddWindow(1)
182	sc.mon.AddWindow(2)
183	settleShown(t, c)
184	sp := viewSpring(40, 0)
185	sc.rects = map[WindowID]rectMotion{
186		1:  {x: c.spring(sp, ic.now), dx: 40},
187		99: {x: c.spring(sp, ic.now), dx: 40},
188	}
189	sc.rectsWS = sc.mon.Current()
190	settleShown(t, c)
191	if _, ok := sc.rects[99]; ok {
192		t.Fatal("a window outside the layout keeps its rect motion")
193	}
194	if _, ok := sc.rects[1]; !ok {
195		t.Fatal("a window in the layout lost its rect motion")
196	}
197}
198
199// A swipe that follows the fingers owns the view: a focus bind pressed
200// meanwhile starts no camera motion.
201func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
202	c, ic := indicatorCore(t)
203	c.cfg.Animations.On = true
204	sc := c.cur()
205	for id := WindowID(1); id <= 4; id++ {
206		sc.mon.AddWindow(id)
207	}
208	settleShown(t, c)
209	w := sc.mon.Current()
210	w.view.off = 25
211	c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
212	before := c.snapshot(ic.now)
213	viewX := w.View
214	for range 3 {
215		c.applyAction(ActionFocusColumnLeft)
216	}
217	if w.View == viewX {
218		t.Fatal("the focus did not move the view: the case checks nothing")
219	}
220	c.transition(before, ic.now)
221	if w.view.motion.on || w.view.off != 25 {
222		t.Fatalf("camera motion %v, shift %v: the transition took over the fingers' view", w.view.motion.on, w.view.off)
223	}
224}
225
226// A switch to or from a workspace with its own size stays settled: no
227// monitor-wide slide would be cropped to it. The same switch between
228// workspaces of the monitor's size slides.
229func TestNoSwitchMotionForSizedWorkspace(t *testing.T) {
230	for _, tc := range []struct {
231		name  string
232		sized bool
233	}{{"sized", true}, {"unsized", false}} {
234		t.Run(tc.name, func(t *testing.T) {
235			c, ic := indicatorCore(t)
236			c.cfg.Animations.On = true
237			m := c.cur().mon
238			m.AddWindow(1)
239			if tc.sized {
240				m.Workspaces[1].SetSize(100, 100)
241			}
242			settleShown(t, c)
243			before := c.snapshot(ic.now)
244			m.Focus(1)
245			c.transition(before, ic.now)
246			if got := m.switchView.motion.on; got == tc.sized {
247				t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized)
248			}
249			if tc.sized && m.switchView.off != 0 {
250				t.Fatalf("switchOff %v left for a framed switch", m.switchView.off)
251			}
252		})
253	}
254}
255
256// chainedRects is a core with two windows whose first action moves them,
257// leaving rect motions running one frame in: the clock moved 30 ms and
258// animate sampled them.
259func chainedRects(t *testing.T, first Action) (*Core, *indicatorClock) {
260	t.Helper()
261	c, ic := indicatorCore(t)
262	c.cfg.Animations.On = true
263	m := c.cur().mon
264	m.AddWindow(1)
265	m.AddWindow(2)
266	settleShown(t, c)
267	before := c.snapshot(ic.now)
268	c.applyAction(first)
269	c.transition(before, ic.now)
270	settleShown(t, c)
271	ic.now = ic.now.Add(30 * time.Millisecond)
272	c.animate(ic.now, nil)
273	settleShown(t, c)
274	return c, ic
275}
276
277// A bind pressed between two frames chains from the speed the springs have
278// at the bind's time, not the one of the last frame: a second change 1 ms
279// after a frame keeps the sampled speed (within 1e-6) of the component the
280// first one started, for a width change and for a move.
281func TestChainedRectVelocityIsSampledAtActionTime(t *testing.T) {
282	for _, tc := range []struct {
283		name          string
284		first, second Action
285		comp          func(*rectMotion) *motion
286		sampled       func(rectShot) float64
287	}{
288		{"width", ActionMaximizeColumn, ActionMaximizeColumn,
289			func(m *rectMotion) *motion { return &m.w }, func(r rectShot) float64 { return r.vel.w }},
290		{"position", ActionMoveColumnLeft, ActionMoveColumnRight,
291			func(m *rectMotion) *motion { return &m.x }, func(r rectShot) float64 { return r.vel.x }},
292	} {
293		t.Run(tc.name, func(t *testing.T) {
294			c, ic := chainedRects(t, tc.first)
295			sc := c.cur()
296			id := WindowID(0)
297			for k, rm := range sc.rects {
298				if tc.comp(&rm).on {
299					id = k
300				}
301			}
302			if id == 0 {
303				t.Fatalf("no window with a %s motion in %v", tc.name, sc.rects)
304			}
305			rm := sc.rects[id]
306			m := tc.comp(&rm)
307			last := m.velocity()
308			ic.now = ic.now.Add(time.Millisecond)
309
310			// The expected speed is the spring's own at now, sampled independently.
311			tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow)
312			want := m.spring.velocityAt(tau) / m.slow
313			if math.Abs(want-last) < 1e-3 {
314				t.Fatalf("the speed did not change in 1 ms (%v to %v): the case checks nothing", last, want)
315			}
316
317			before := c.snapshot(ic.now)
318			var shot rectShot
319			for _, r := range before[0].rects {
320				if r.id == id {
321					shot = r
322				}
323			}
324			if got := tc.sampled(shot); math.Abs(got-want) > 1e-6 {
325				t.Fatalf("sampled speed %v, want %v", got, want)
326			}
327			// Sampling left the motion alone.
328			cur := sc.rects[id]
329			if got := tc.comp(&cur).velocity(); got != last {
330				t.Fatalf("snapshot moved the motion: speed %v, was %v", got, last)
331			}
332
333			c.applyAction(tc.second)
334			c.transition(before, ic.now)
335			after := sc.rects[id]
336			n := tc.comp(&after)
337			if !n.on {
338				t.Fatalf("the second action left no %s motion", tc.name)
339			}
340			if v := n.velocity(); math.Abs(v-want) > 1e-6 {
341				t.Fatalf("speed %v after the second action, want the sampled %v", v, want)
342			}
343		})
344	}
345}
346
347// A retarget keeps the sub-pixel offsets: the old rect is the settled one
348// plus the float offsets, not the rounded rect that was drawn.
349func TestRetargetKeepsSubPixelOffsets(t *testing.T) {
350	c, ic := chainedRects(t, ActionMoveColumnLeft)
351	sc := c.cur()
352	rm := sc.rects[1]
353	if rm.dx == math.Round(rm.dx) {
354		t.Fatalf("dx = %v is whole: the case checks nothing", rm.dx)
355	}
356	settledOld := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 1 })].Rect
357
358	before := c.snapshot(ic.now)
359	var shot rectShot
360	for _, r := range before[0].rects {
361		if r.id == 1 {
362			shot = r
363		}
364	}
365	if shot.rect != settledOld {
366		t.Fatalf("shot rect %+v, want the settled %+v", shot.rect, settledOld)
367	}
368	if shot.off.x != rm.dx || shot.off.w != rm.dw {
369		t.Fatalf("shot offsets %+v, want the motion's x %v w %v", shot.off, rm.dx, rm.dw)
370	}
371
372	c.applyAction(ActionMoveColumnRight)
373	c.transition(before, ic.now)
374	settledNew := c.cur().mon.Layout()[slices.IndexFunc(c.cur().mon.Layout(), func(p Placement) bool { return p.ID == 1 })].Rect
375	if settledNew == settledOld {
376		t.Fatal("the second action did not move window 1")
377	}
378	got := sc.rects[1]
379	// Old rect = settled old + float offset: the new offset from the new
380	// settled rect carries the fraction of the old one.
381	want := float64(settledOld.X-settledNew.X) + rm.dx
382	if math.Abs(got.dx-want) > 1e-9 {
383		t.Fatalf("dx = %v after the retarget, want %v (settled %d to %d plus the offset %v)", got.dx, want, settledOld.X, settledNew.X, rm.dx)
384	}
385	rounded := float64(settledOld.X-settledNew.X) + math.Round(rm.dx)
386	if math.Abs(got.dx-rounded) < 1e-6 {
387		t.Fatalf("dx = %v equals the rounded rect's: the fraction was lost", got.dx)
388	}
389}
390
391// An action that moves other windows leaves a running rect motion alone: it
392// is not restarted from the rounded rect nor resampled (same start, speed
393// and offsets), while the windows the action moved animate.
394func TestRectMotionOfUnmovedWindowIsUntouched(t *testing.T) {
395	c, ic := indicatorCore(t)
396	c.cfg.Animations.On = true
397	m := c.cur().mon
398	for id := WindowID(1); id <= 3; id++ {
399		m.AddWindow(id)
400	}
401	// 2 and 3 share a column; 1 is a column of its own.
402	c.applyAction(ActionConsumeOrExpelLeft)
403	settleShown(t, c)
404	sc := c.cur()
405
406	// A runs a rect motion, one frame in.
407	sp := viewSpring(60.5, 0)
408	sc.rects = map[WindowID]rectMotion{1: {x: c.spring(sp, ic.now), dx: 60.5}}
409	sc.rectsWS = m.Current()
410	ic.now = ic.now.Add(30 * time.Millisecond)
411	c.animate(ic.now, nil)
412	settleShown(t, c)
413	a := sc.rects[1]
414	if !a.x.on || a.dx == 0 || a.dx == 60.5 || a.x.velocity() == 0 {
415		t.Fatalf("setup: A's motion %+v is not mid-flight", a)
416	}
417
418	// B: swapping the stacked 2 and 3 moves those two only.
419	ic.now = ic.now.Add(time.Millisecond)
420	before := c.snapshot(ic.now)
421	c.applyAction(ActionMoveWindowUp)
422	c.transition(before, ic.now)
423	if got, ok := sc.rects[1]; !ok || got != a {
424		t.Fatalf("A's motion changed: %+v, was %+v", got, a)
425	}
426	for _, id := range []WindowID{2, 3} {
427		b, ok := sc.rects[id]
428		if !ok || !b.y.on || b.dy == 0 {
429			t.Fatalf("window %d does not animate: %+v", id, b)
430		}
431		if b.x.on {
432			t.Fatalf("window %d moved sideways: %+v", id, b)
433		}
434	}
435}

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