transition_stash_internal_test.go RawHistoryFolder
1package core
2
3import (
4 "math"
5 "slices"
6 "testing"
7 "time"
8
9 "github.com/bnema/neferwl/internal/ports"
10)
11
12// stashRig is indicatorCore with animations on and a stash of windows 4, 3
13// and 2 (2 selected, 3 peeking on its left) over the tile 1, settled. The
14// peek veil is 0.5.
15func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan ports.ClientCommand) {
16 t.Helper()
17 c, ic, cmds := fadeCore(t)
18 c.cfg.Animations.On = on
19 c.cfg.Stash.Dim = 0.5
20 sc := c.cur()
21 for id := WindowID(1); id <= 4; id++ {
22 sc.mon.AddWindow(id)
23 }
24 for _, id := range []WindowID{4, 3, 2} {
25 sc.mon.Current().FocusID(id)
26 c.applyAction(ActionToggleWindowStash)
27 }
28 settleShown(t, c)
29 drainConfigures(cmds)
30 return c, ic, sc, cmds
31}
32
33// act is a bind: snapshot, action, transition.
34func act(c *Core, ic *indicatorClock, a Action) {
35 shots := c.snapshot(ic.now)
36 c.applyAction(a)
37 c.transition(shots, ic.now)
38}
39
40// Showing the stash fades in and grows every window it brings on screen;
41// the windows it keeps off screen stay out; the settled layout is the
42// final one at once, with no extra configure.
43func TestStashShowAppears(t *testing.T) {
44 c, ic, sc, cmds := stashRig(t, true)
45 act(c, ic, ActionToggleStashVisible)
46 settleShown(t, c)
47 if !sc.mon.Current().stashHidden {
48 t.Fatal("setup: the stash is shown")
49 }
50 ic.now = ic.now.Add(5 * time.Second)
51 c.animate(ic.now, nil)
52 settleShown(t, c)
53 drainConfigures(cmds)
54
55 act(c, ic, ActionToggleStashVisible)
56 s := indicatorScene(t, c)
57 for _, id := range []WindowID{2, 3} {
58 p := settledOf(t, sc, id)
59 w := sceneWindow(t, s, id)
60 if w.Hidden || w.Fade != 1 || w.Rect != scaledRect(p.Rect, appearScale) || w.Zoom < 0.85 || w.Zoom > 0.95 {
61 t.Fatalf("window %d first frame %+v, want invisible at 90 %% of %+v", id, w, p.Rect)
62 }
63 }
64 if w := sceneWindow(t, s, 4); !w.Hidden || w.Fade != 0 {
65 t.Fatalf("window 4 stays off screen: %+v", w)
66 }
67 // The action configures what it changes once, at the settled size.
68 cfgs := configuresOf(cmds)
69 for _, id := range []WindowID{2, 3} {
70 if len(cfgs[id]) != 1 {
71 t.Fatalf("window %d configured %d times, want once: %+v", id, len(cfgs[id]), cfgs[id])
72 }
73 want := c.clientRect(settledOf(t, sc, id))
74 if v := cfgs[id][0]; !v.Visible || v.Width != want.W || v.Height != want.H {
75 t.Fatalf("window %d configure %+v, want %dx%d (the settled client rect)", id, v, want.W, want.H)
76 }
77 }
78
79 s = frame(t, c, ic, 40*time.Millisecond)
80 for _, id := range []WindowID{2, 3} {
81 w := sceneWindow(t, s, id)
82 if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
83 t.Fatalf("window %d mid-way fade %v zoom %v, want strictly between", id, w.Fade, w.Zoom)
84 }
85 }
86 s = frame(t, c, ic, 5*time.Second)
87 for _, id := range []WindowID{2, 3} {
88 p := settledOf(t, sc, id)
89 if w := sceneWindow(t, s, id); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect {
90 t.Fatalf("window %d settled %+v, want %+v", id, w, p.Rect)
91 }
92 }
93 if c.animating() || len(sc.rects) != 0 {
94 t.Fatalf("still animating: %d rects", len(sc.rects))
95 }
96 if ids := drainConfigures(cmds); len(ids) != 0 {
97 t.Fatalf("the animation reconfigured %v", ids)
98 }
99}
100
101// Hiding the stash keeps its windows drawn while they fade out and shrink,
102// out of reach of input and focus, until they settle.
103func TestStashHideLeaves(t *testing.T) {
104 c, ic, sc, cmds := stashRig(t, true)
105 left := map[WindowID]Placement{}
106 for _, id := range []WindowID{2, 3} {
107 left[id] = settledOf(t, sc, id)
108 }
109 act(c, ic, ActionToggleStashVisible)
110 s := indicatorScene(t, c)
111 if len(sc.settledLayout) != len(sc.shown) {
112 t.Fatalf("settled %d shown %d", len(sc.settledLayout), len(sc.shown))
113 }
114 for _, id := range []WindowID{2, 3} {
115 w := sceneWindow(t, s, id)
116 if w.Hidden || w.Fade != 0 || w.Rect != left[id].Rect {
117 t.Fatalf("window %d first frame %+v, want drawn opaque at %+v", id, w, left[id].Rect)
118 }
119 if _, _, ok := c.windowRect(id); ok {
120 t.Fatalf("window %d is still on screen for input", id)
121 }
122 }
123 // The selected window is under the tile: its centre hits the tile.
124 r := left[2].Rect
125 if id, _, _ := c.hit(float64(r.X+r.W/2), float64(r.Y+r.H/2)); id == 2 || id == 3 {
126 t.Fatalf("hit %d on a leaving window", id)
127 }
128 if id, _ := sc.mon.Focused(); id == 2 || id == 3 {
129 t.Fatalf("focused %d", id)
130 }
131 if ids := drainConfigures(cmds); slices.Contains(ids, 2) || slices.Contains(ids, 3) {
132 t.Fatalf("a leaving window was configured: %v", ids)
133 }
134
135 s = frame(t, c, ic, 40*time.Millisecond)
136 for _, id := range []WindowID{2, 3} {
137 if w := sceneWindow(t, s, id); !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left[id].Rect.W) {
138 t.Fatalf("window %d mid-way %+v", id, w)
139 }
140 }
141 s = frame(t, c, ic, 5*time.Second)
142 for _, id := range []WindowID{2, 3} {
143 if w := sceneWindow(t, s, id); !w.Hidden {
144 t.Fatalf("window %d still drawn after the settle: %+v", id, w)
145 }
146 }
147 if c.animating() || len(sc.rects) != 0 {
148 t.Fatalf("still animating: %d rects", len(sc.rects))
149 }
150 // The hide's own configure comes once the fade is over, from the size
151 // the window had (not from nothing).
152 var hidden []ports.ConfigureWindow
153 for {
154 select {
155 case cmd := <-cmds:
156 if v, ok := cmd.(ports.ConfigureWindow); ok && v.ID == 2 {
157 hidden = append(hidden, v)
158 }
159 continue
160 default:
161 }
162 break
163 }
164 if len(hidden) != 1 || hidden[0].Visible || hidden[0].Activated {
165 t.Fatalf("configures of window 2 after the hide: %+v", hidden)
166 }
167}
168
169// With animations off both are instant.
170func TestStashToggleOffIsInstant(t *testing.T) {
171 c, ic, sc, _ := stashRig(t, false)
172 act(c, ic, ActionToggleStashVisible)
173 s := indicatorScene(t, c)
174 if w := sceneWindow(t, s, 2); !w.Hidden || len(sc.rects) != 0 || c.animating() {
175 t.Fatalf("hide with animations off: %+v, %d rects", w, len(sc.rects))
176 }
177 act(c, ic, ActionToggleStashVisible)
178 s = indicatorScene(t, c)
179 if w := sceneWindow(t, s, 2); w.Hidden || w.Fade != 0 || w.Zoom != 0 || len(sc.rects) != 0 || c.animating() {
180 t.Fatalf("show with animations off: %+v, %d rects", w, len(sc.rects))
181 }
182}
183
184// Navigating in the stash animates the peek veil: the window that leaves the
185// centre darkens from 0, the one that reaches it clears from the veil.
186func TestStashNavigationAnimatesPeekDim(t *testing.T) {
187 c, ic, sc, _ := stashRig(t, true)
188 if w := sceneWindow(t, indicatorScene(t, c), 3); w.Dim != 0.5 {
189 t.Fatalf("setup: peek dim %v", w.Dim)
190 }
191 act(c, ic, ActionFocusColumnLeft) // 3 selected, 2 peeks on its right
192 s := indicatorScene(t, c)
193 if w := sceneWindow(t, s, 3); w.Dim != 0.5 || w.Focused != true {
194 t.Fatalf("window 3 right after: %+v, want the veil it had", w)
195 }
196 if w := sceneWindow(t, s, 2); w.Dim != 0 {
197 t.Fatalf("window 2 right after: dim %v, want 0 (it was selected)", w.Dim)
198 }
199 s = frame(t, c, ic, 40*time.Millisecond)
200 if w := sceneWindow(t, s, 3); !(w.Dim > 0 && w.Dim < 0.5) {
201 t.Fatalf("window 3 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
202 }
203 if w := sceneWindow(t, s, 2); !(w.Dim > 0 && w.Dim < 0.5) {
204 t.Fatalf("window 2 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
205 }
206 s = frame(t, c, ic, 5*time.Second)
207 if w := sceneWindow(t, s, 3); w.Dim != 0 {
208 t.Fatalf("window 3 settled dim %v, want 0", w.Dim)
209 }
210 if w := sceneWindow(t, s, 2); w.Dim != 0.5 {
211 t.Fatalf("window 2 settled dim %v, want 0.5", w.Dim)
212 }
213 if c.animating() || len(sc.rects) != 0 {
214 t.Fatalf("still animating: %d rects", len(sc.rects))
215 }
216}
217
218// A navigation that chains on a running one starts the veil where it was.
219func TestStashNavigationChainsPeekDim(t *testing.T) {
220 c, ic, _, _ := stashRig(t, true)
221 act(c, ic, ActionFocusColumnLeft)
222 indicatorScene(t, c)
223 ic.now = ic.now.Add(30 * time.Millisecond)
224 c.animate(ic.now, nil)
225 mid := sceneWindow(t, indicatorScene(t, c), 3).Dim
226 act(c, ic, ActionFocusColumnRight) // 3 goes back to a peek
227 if got := sceneWindow(t, indicatorScene(t, c), 3).Dim; got < mid-0.02 || got > mid+0.02 {
228 t.Fatalf("window 3 veil %v after the chained action, want the %v it had", got, mid)
229 }
230}
231
232// The overview pile: the stash cards shrink from the stash windows' rects
233// and settle on the pile layout (C1's rules).
234func TestOverviewPileScalesFromStash(t *testing.T) {
235 c, ic, sc, _ := stashRig(t, true)
236 from := map[WindowID]ports.Rect{}
237 s := indicatorScene(t, c)
238 for _, id := range []WindowID{2, 3} {
239 from[id] = sceneWindow(t, s, id).Rect
240 }
241 toggleOverview(c, sc, ic.now)
242 s = indicatorScene(t, c)
243 for _, id := range []WindowID{2, 3} {
244 w := sceneWindow(t, s, id)
245 p := settledOf(t, sc, id)
246 if w.Hidden || w.Preview != overviewCardZoom || w.Rect != from[id] || w.Zoom != 1 {
247 t.Fatalf("window %d first frame %+v, want its stash rect %+v at content scale 1 (card %+v)", id, w, from[id], p.Rect)
248 }
249 checkContent(t, w, from[id], "first frame")
250 }
251 s = frame(t, c, ic, 40*time.Millisecond)
252 for _, id := range []WindowID{2, 3} {
253 w := sceneWindow(t, s, id)
254 p := settledOf(t, sc, id)
255 if !(w.Rect.W < from[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > overviewCardZoom && w.Zoom < 1) {
256 t.Fatalf("window %d mid-way %+v, want between %+v and %+v", id, w, from[id], p.Rect)
257 }
258 checkContent(t, w, from[id], "mid-way")
259 }
260 s = frame(t, c, ic, 5*time.Second)
261 for _, id := range []WindowID{2, 3} {
262 w := sceneWindow(t, s, id)
263 p := settledOf(t, sc, id)
264 if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != overviewCardZoom {
265 t.Fatalf("window %d settled %+v, want the pile card %+v", id, w, p)
266 }
267 }
268}
269
270// A navigation slides the stash view: the windows keep the places they
271// were drawn at and move with one spring, with no rect motion of their own.
272// What the view brings in is off screen until it reaches the margin; what
273// it sends off goes once the slide ends.
274func TestStashNavigationSlides(t *testing.T) {
275 c, ic, sc, cmds := stashRig(t, true)
276 w := sc.mon.Current()
277 w.AddWindow(5)
278 w.stashFocus = false // the new tile has the focus, not the stash
279 c.applyAction(ActionToggleWindowStash) // stash 4 3 2 5: 5 selected, 2 peeks
280 if len(w.Stash) != 4 || w.stashAt != 3 {
281 t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt)
282 }
283 settleShown(t, c)
284 centre := sceneWindow(t, indicatorScene(t, c), 5).Rect
285 act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 becomes a peek
286 s := indicatorScene(t, c)
287 if got := sceneWindow(t, s, 5); got.Hidden || got.Rect != centre || got.Dim != 0 {
288 t.Fatalf("window 5 jumped: %+v, want %+v unveiled", got, centre)
289 }
290 if got := sceneWindow(t, s, 3); !got.Hidden {
291 t.Fatalf("window 3 is on screen before the slide: %+v", got)
292 }
293 if len(sc.rects) != 0 || !w.stashView.motion.on {
294 t.Fatalf("%d rect motions, slide %v: the view slides alone", len(sc.rects), w.stashView.motion.on)
295 }
296 if got := frame(t, c, ic, 40*time.Millisecond); !(sceneWindow(t, got, 5).Dim > 0) {
297 t.Fatal("window 5 has no veil mid-slide")
298 }
299 settledAll := frame(t, c, ic, 5*time.Second)
300 if got := sceneWindow(t, settledAll, 3); got.Hidden || got.Dim != 0.5 {
301 t.Fatalf("window 3 settled %+v", got)
302 }
303 drainConfigures(cmds)
304 act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes
305 frame(t, c, ic, 5*time.Second)
306 s = indicatorScene(t, c)
307 if got := sceneWindow(t, s, 5); !got.Hidden {
308 t.Fatalf("window 5 still drawn after the settle: %+v", got)
309 }
310 if got := sceneWindow(t, s, 4); got.Hidden {
311 t.Fatalf("window 4 not at the margin: %+v", got)
312 }
313 if c.animating() || w.stashView.off != 0 {
314 t.Fatal("still animating")
315 }
316}
317
318// near fails the test unless a and b differ by less than eps.
319func near(t *testing.T, what string, a, b, eps float64) {
320 t.Helper()
321 if math.Abs(a-b) > eps {
322 t.Fatalf("%s jumped: %v then %v", what, a, b)
323 }
324}
325
326// A hide during the show's fade-in continues from what was drawn: no jump
327// in fade or rect, then it settles gone.
328func TestStashHideDuringShowDoesNotJump(t *testing.T) {
329 c, ic, sc, _ := stashRig(t, true)
330 act(c, ic, ActionToggleStashVisible)
331 indicatorScene(t, c)
332 ic.now = ic.now.Add(5 * time.Second)
333 c.animate(ic.now, nil)
334 indicatorScene(t, c)
335 act(c, ic, ActionToggleStashVisible) // show
336 indicatorScene(t, c)
337 s := frame(t, c, ic, 30*time.Millisecond)
338 last := sceneWindow(t, s, 2)
339 if !(last.Fade > 0 && last.Fade < 1) {
340 t.Fatalf("setup: fade %v", last.Fade)
341 }
342 act(c, ic, ActionToggleStashVisible) // hide, same instant
343 w := sceneWindow(t, indicatorScene(t, c), 2)
344 if w.Hidden {
345 t.Fatalf("window 2 vanished: %+v", w)
346 }
347 near(t, "fade", last.Fade, w.Fade, 0.02)
348 near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
349 near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
350 near(t, "zoom", contentScale(last), contentScale(w), 0.03)
351 s = frame(t, c, ic, 5*time.Second)
352 if w := sceneWindow(t, s, 2); !w.Hidden || c.animating() || len(sc.rects) != 0 {
353 t.Fatalf("after the settle: %+v, %d rects", w, len(sc.rects))
354 }
355}
356
357// A show during the hide's fade-out continues from what was drawn.
358func TestStashShowDuringHideDoesNotJump(t *testing.T) {
359 c, ic, sc, _ := stashRig(t, true)
360 act(c, ic, ActionToggleStashVisible) // hide
361 indicatorScene(t, c)
362 s := frame(t, c, ic, 30*time.Millisecond)
363 last := sceneWindow(t, s, 2)
364 if !(last.Fade > 0 && last.Fade < 1) {
365 t.Fatalf("setup: fade %v", last.Fade)
366 }
367 act(c, ic, ActionToggleStashVisible) // show
368 w := sceneWindow(t, indicatorScene(t, c), 2)
369 if w.Hidden {
370 t.Fatalf("window 2 hidden: %+v", w)
371 }
372 near(t, "fade", last.Fade, w.Fade, 0.02)
373 near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
374 near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
375 near(t, "zoom", contentScale(last), contentScale(w), 0.03)
376 // The entrance now runs back to opaque (the fade's speed carries over:
377 // it may still rise a few frames before it turns).
378 s = frame(t, c, ic, 150*time.Millisecond)
379 if n := sceneWindow(t, s, 2); !(n.Fade < w.Fade) {
380 t.Fatalf("fade %v then %v, want it to clear", w.Fade, n.Fade)
381 }
382 s = frame(t, c, ic, 5*time.Second)
383 p := settledOf(t, sc, 2)
384 if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect || c.animating() {
385 t.Fatalf("settled %+v, want %+v", w, p.Rect)
386 }
387}
388
389// A second navigation during the slide goes on from the view as drawn: no
390// window jumps, and the one sent off goes once the slide ends.
391func TestStashNavigationChainsFromSlide(t *testing.T) {
392 c, ic, _, _ := stashRig(t, true)
393 // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters.
394 act(c, ic, ActionFocusColumnLeft)
395 indicatorScene(t, c)
396 s := frame(t, c, ic, 30*time.Millisecond)
397 last := sceneWindow(t, s, 2)
398 if !c.cur().mon.Current().stashView.motion.on {
399 t.Fatal("setup: the slide already ended")
400 }
401 // Left again: 4 centred, 3 peeks right, 2 goes off.
402 act(c, ic, ActionFocusColumnLeft)
403 w := sceneWindow(t, indicatorScene(t, c), 2)
404 if w.Hidden {
405 t.Fatalf("window 2 vanished: %+v", w)
406 }
407 near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
408 near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
409 near(t, "dim", last.Dim, w.Dim, 0.03)
410 if s = frame(t, c, ic, 5*time.Second); !sceneWindow(t, s, 2).Hidden {
411 t.Fatal("window 2 still drawn after the settle")
412 }
413}
414
415// Leaving entries land in the same order whatever the map's: a hidden stash
416// float (replaced in place) and absent tiles and floats (ID order, tiles
417// before the first float, floats last).
418func TestWithLeavingOrderIsDeterministic(t *testing.T) {
419 c, ic := indicatorCore(t)
420 c.cfg.Animations.On = true
421 sc := c.cur()
422 w := sc.mon.Current()
423 for id := WindowID(1); id <= 4; id++ {
424 sc.mon.AddWindow(id)
425 }
426 w.FocusID(4)
427 c.applyAction(ActionToggleWindowStash) // 4 stashed, shown
428 w.AddFloating(5, 20, 10)
429 settleShown(t, c)
430 var left []Placement
431 for _, p := range sc.settledLayout {
432 if p.ID == 2 || p.ID == 3 || p.ID == 4 || p.ID == 5 {
433 left = append(left, p)
434 }
435 }
436 if len(left) != 4 {
437 t.Fatalf("setup: %d placements", len(left))
438 }
439 // 2 and 3 close (absent tiles), 5 closes (absent float), 4 hides.
440 sc.mon.RemoveWindow(2)
441 sc.mon.RemoveWindow(3)
442 sc.mon.RemoveWindow(5)
443 c.applyAction(ActionToggleStashVisible)
444 for _, p := range left {
445 c.leaveFrom(sc, p, nil, ic.now)
446 }
447 var want []WindowID
448 for i := range 40 {
449 c.refreshShown()
450 var ids []WindowID
451 for _, p := range sc.settledLayout {
452 ids = append(ids, p.ID)
453 }
454 if i == 0 {
455 want = ids
456 if n := len(ids); n < 4 {
457 t.Fatalf("layout %v", ids)
458 }
459 i2, i3 := slices.Index(ids, 2), slices.Index(ids, 3)
460 i4, i5 := slices.Index(ids, 4), slices.Index(ids, 5)
461 // Absent tiles by ID, then the leaving stash float (in its
462 // slot), the absent float last.
463 if !(i2 >= 0 && i2 < i3 && i3 < i4 && i4 < i5 && i5 == len(ids)-1) {
464 t.Fatalf("order %v: want tiles 2, 3, then the stash float 4, then 5", ids)
465 }
466 if k := slices.Index(ids, 4); !sc.settledLayout[k].Leaving {
467 t.Fatal("the hidden stash window is not replaced in place")
468 }
469 continue
470 }
471 if !slices.Equal(ids, want) {
472 t.Fatalf("run %d: order %v, first run %v", i, ids, want)
473 }
474 }
475}
476
477// Walking the stash carousel slides its cards; a card that goes round to
478// the other end fades in there instead of crossing the row, whatever the
479// direction, the count or the size of the step (a fast wheel). A carousel
480// that does not wrap round slides every card.
481func TestCarouselWrapFadesIn(t *testing.T) {
482 for _, tc := range []struct {
483 name string
484 n, from, to int
485 wrapped []int
486 }{
487 {"right", 3, 0, 1, []int{2}},
488 {"left", 3, 0, 2, []int{1}},
489 {"even", 4, 3, 0, []int{1}},
490 {"jump", 5, 0, 2, []int{3, 4}},
491 {"two cards", 2, 0, 1, nil},
492 } {
493 t.Run(tc.name, func(t *testing.T) {
494 c, ic, _ := fadeCore(t)
495 c.cfg.Animations.On = true
496 sc := c.cur()
497 m := sc.mon
498 w := m.Current()
499 w.Overflow = OverflowCascade
500 m.AddWindow(1)
501 for id := WindowID(2); id < WindowID(2+tc.n); id++ {
502 m.AddWindow(id)
503 w.FocusID(id)
504 c.applyAction(ActionToggleWindowStash)
505 }
506 w.FocusID(1)
507 c.applyAction(ActionToggleOverview)
508 m.selectCard(w, tc.from)
509 settleShown(t, c)
510 shots := c.snapshot(ic.now)
511 m.selectCard(w, tc.to)
512 c.transition(shots, ic.now)
513 for i, e := range w.Stash {
514 if previewOf(t, m.Layout(), e.ID).Hidden {
515 continue
516 }
517 rm := sc.rects[e.ID]
518 if slices.Contains(tc.wrapped, i) {
519 if rm.dx != 0 || !rm.fade.on {
520 t.Fatalf("wrapped card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
521 }
522 continue
523 }
524 if rm.dx == 0 || rm.fade.on {
525 t.Fatalf("card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
526 }
527 }
528 })
529 }
530}