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}