transition_fade_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// fadeCore is indicatorCore with its commands readable.
13func fadeCore(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand) {
14 t.Helper()
15 c, ic := indicatorCore(t)
16 cmds := make(chan ports.ClientCommand, 64)
17 c.ch.Commands = cmds
18 return c, ic, cmds
19}
20
21func sceneWindow(t *testing.T, s ports.Scene, id WindowID) ports.SceneWindow {
22 t.Helper()
23 i := slices.IndexFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id })
24 if i < 0 {
25 t.Fatalf("window %d not in the scene: %+v", id, s.Windows)
26 }
27 return s.Windows[i]
28}
29
30// appear fades a window in from invisible and grows it from 90 % of its
31// settled rect, its content zoomed to the drawn size; the shown placement
32// carries the derived Fade and Zoom, the settled one neither; Preview
33// stays 0 (not a card).
34func TestAppearDerivesFadeAndZoom(t *testing.T) {
35 c, ic, cmds := fadeCore(t)
36 c.cfg.Animations.On = true
37 sc := c.cur()
38 sc.mon.AddWindow(1)
39 sc.mon.AddWindow(2)
40 settleShown(t, c)
41 drainConfigures(cmds)
42 settled := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
43
44 c.appearAt(sc, settledOf(t, sc, 2), ic.now)
45 s := indicatorScene(t, c)
46 w := sceneWindow(t, s, 2)
47 if w.Fade != 1 || w.Hidden {
48 t.Fatalf("right after appear: fade %v hidden %v, want invisible and shown", w.Fade, w.Hidden)
49 }
50 want := scaledRect(settled.Rect, appearScale)
51 if w.Rect != want {
52 t.Fatalf("first rect %+v, want 90 %% of %+v = %+v", w.Rect, settled.Rect, want)
53 }
54 if math.Abs(w.Zoom-0.9) > 0.02 || w.Preview != 0 {
55 t.Fatalf("zoom %v preview %v, want ~0.9 and 0 (content follows the drawn size, not a card)", w.Zoom, w.Preview)
56 }
57 if ps := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]; ps != settled {
58 t.Fatalf("settled placement changed: %+v vs %+v", ps, settled)
59 }
60 if ids := drainConfigures(cmds); len(ids) != 0 {
61 t.Fatalf("appear reconfigured %v", ids)
62 }
63 // The derived zoom is not a card: the window takes input and keeps
64 // its popups while it appears.
65 if id, _, _ := c.hit(float64(w.Rect.X+w.Rect.W/2), float64(w.Rect.Y+w.Rect.H/2)); id != 2 {
66 t.Fatalf("hit %d while appearing, want 2", id)
67 }
68 if _, _, ok := c.windowRect(2); !ok {
69 t.Fatal("windowRect hides the appearing window")
70 }
71
72 ic.now = ic.now.Add(40 * time.Millisecond)
73 c.animate(ic.now, nil)
74 s = indicatorScene(t, c)
75 w = sceneWindow(t, s, 2)
76 if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
77 t.Fatalf("mid-way fade %v zoom %v, want strictly between", w.Fade, w.Zoom)
78 }
79 if !(w.Rect.W > want.W && w.Rect.W < settled.Rect.W) {
80 t.Fatalf("mid-way width %d, want between %d and %d", w.Rect.W, want.W, settled.Rect.W)
81 }
82
83 ic.now = ic.now.Add(5 * time.Second)
84 c.animate(ic.now, nil)
85 s = indicatorScene(t, c)
86 w = sceneWindow(t, s, 2)
87 if w.Fade != 0 || w.Zoom != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
88 t.Fatalf("settled: %+v, rects %d", w, len(sc.rects))
89 }
90 if c.animating() {
91 t.Fatal("still animating after the settle")
92 }
93}
94
95// With animations off appear does nothing.
96func TestAppearOffIsInstant(t *testing.T) {
97 c, ic := indicatorCore(t)
98 sc := c.cur()
99 sc.mon.AddWindow(1)
100 settleShown(t, c)
101 c.appearAt(sc, settledOf(t, sc, 1), ic.now)
102 if len(sc.rects) != 0 || c.animating() {
103 t.Fatal("appear started a motion with animations off")
104 }
105 if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Zoom != 0 {
106 t.Fatalf("faded with animations off: %+v", w)
107 }
108}
109
110// A leaving window stays in the scene while it fades out and shrinks, is
111// hidden to input, focus and configures, keeps settled and shown aligned,
112// and goes once settled.
113func TestLeavingEntryLifecycle(t *testing.T) {
114 c, ic, cmds := fadeCore(t)
115 c.cfg.Animations.On = true
116 sc := c.cur()
117 sc.mon.AddWindow(1)
118 sc.mon.AddWindow(2)
119 settleShown(t, c)
120 drainConfigures(cmds)
121 left := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
122
123 // Close window 2: the layout re-flows; its last placement leaves.
124 sc.mon.RemoveWindow(2)
125 c.leaveFrom(sc, left, nil, ic.now)
126 s := indicatorScene(t, c)
127 if len(sc.settledLayout) != len(sc.shown) {
128 t.Fatalf("settled %d and shown %d entries", len(sc.settledLayout), len(sc.shown))
129 }
130 for k := range sc.shown {
131 if sc.shown[k].ID != sc.settledLayout[k].ID {
132 t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
133 }
134 }
135 k := slices.IndexFunc(sc.shown, func(p Placement) bool { return p.ID == 2 })
136 if k < 0 || !sc.shown[k].Leaving || !sc.shown[k].Hidden || !sc.settledLayout[k].Hidden {
137 t.Fatalf("leaving entry: shown %+v settled %+v", sc.shown[k], sc.settledLayout[k])
138 }
139 w := sceneWindow(t, s, 2)
140 if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Zoom != 0 || w.Preview != 0 {
141 t.Fatalf("first leaving frame %+v, want drawn opaque at %+v", w, left.Rect)
142 }
143 if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
144 t.Fatalf("the leaving window was configured: %v", ids)
145 }
146 // Window 1 took the whole frame: the leaving one over it is not hit.
147 cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2)
148 if id, _, _ := c.hit(cx, cy); id != 1 {
149 t.Fatalf("hit %d at the leaving window's centre, want 1", id)
150 }
151 if id, _ := sc.mon.Focused(); id == 2 {
152 t.Fatal("the leaving window is focused")
153 }
154 if _, _, ok := c.windowRect(2); ok {
155 t.Fatal("windowRect reports the leaving window on screen")
156 }
157 if !c.animating() {
158 t.Fatal("no motion runs for the leaving window")
159 }
160
161 ic.now = ic.now.Add(40 * time.Millisecond)
162 c.animate(ic.now, nil)
163 s = indicatorScene(t, c)
164 w = sceneWindow(t, s, 2)
165 if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Zoom > 0.9 && w.Zoom < 1) {
166 t.Fatalf("mid-way %+v", w)
167 }
168
169 ic.now = ic.now.Add(5 * time.Second)
170 c.animate(ic.now, nil)
171 s = indicatorScene(t, c)
172 if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
173 t.Fatalf("the leaving window is still in the scene: %+v", s.Windows)
174 }
175 if len(sc.rects) != 0 || c.animating() || len(sc.shown) != len(sc.settledLayout) {
176 t.Fatalf("after the settle: rects %d animating %v", len(sc.rects), c.animating())
177 }
178}
179
180// A leaving window the layout shows again (the stash reopened before the
181// fade ended) drops its leaving entry at once: the layout's placement wins.
182func TestLeavingEntryDroppedWhenShownAgain(t *testing.T) {
183 c, ic := indicatorCore(t)
184 c.cfg.Animations.On = true
185 sc := c.cur()
186 sc.mon.AddWindow(1)
187 sc.mon.AddWindow(2)
188 settleShown(t, c)
189 left := sc.settledLayout[1]
190 sc.mon.RemoveWindow(2)
191 c.leaveFrom(sc, left, nil, ic.now)
192 if s := indicatorScene(t, c); len(s.Windows) != 2 || !sc.shown[1].Leaving {
193 t.Fatalf("no leaving entry: %+v", s.Windows)
194 }
195 sc.mon.AddWindow(2)
196 s := indicatorScene(t, c)
197 n := 0
198 for _, w := range s.Windows {
199 if w.ID == 2 {
200 n++
201 if w.Fade != 0 || w.Zoom != 0 {
202 t.Fatalf("the re-added window keeps the leaving fade: %+v", w)
203 }
204 }
205 }
206 if n != 1 || len(sc.rects) != 0 || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
207 t.Fatalf("window 2 listed %d times, rects %d: %+v", n, len(sc.rects), sc.shown)
208 }
209}
210
211// stopRects (lock, off, overview, workspace change, unplug) drops leaving
212// entries with the other motions.
213func TestLeavingEntryDroppedByStopRects(t *testing.T) {
214 c, ic := indicatorCore(t)
215 c.cfg.Animations.On = true
216 sc := c.cur()
217 sc.mon.AddWindow(1)
218 sc.mon.AddWindow(2)
219 settleShown(t, c)
220 left := sc.settledLayout[1]
221 sc.mon.RemoveWindow(2)
222 c.leaveFrom(sc, left, nil, ic.now)
223 if s := indicatorScene(t, c); len(s.Windows) != 2 {
224 t.Fatalf("%d windows, want the tile and the leaving entry", len(s.Windows))
225 }
226 c.stopAnimations()
227 s := indicatorScene(t, c)
228 if len(s.Windows) != 1 || s.Windows[0].ID != 1 || len(sc.shown) != 1 {
229 t.Fatalf("after stop: %+v", s.Windows)
230 }
231}
232
233// With animations off a leaving window is simply gone.
234func TestLeaveOffIsInstant(t *testing.T) {
235 c, ic := indicatorCore(t)
236 sc := c.cur()
237 sc.mon.AddWindow(1)
238 settleShown(t, c)
239 left := sc.settledLayout[0]
240 sc.mon.RemoveWindow(1)
241 c.leaveFrom(sc, left, nil, ic.now)
242 if s := indicatorScene(t, c); len(s.Windows) != 0 || len(sc.rects) != 0 {
243 t.Fatalf("leaving entry with animations off: %+v", s.Windows)
244 }
245}
246
247// A dim motion animates the veil of a shown window from an offset to the
248// settled Dim; a peeking window adds it to the configured stash dim.
249func TestDimMotionAnimatesSceneDim(t *testing.T) {
250 c, ic := indicatorCore(t)
251 c.cfg.Animations.On = true
252 sc := c.cur()
253 sc.mon.AddWindow(1)
254 settleShown(t, c)
255 sc.rects = map[WindowID]rectMotion{}
256 var rm rectMotion
257 c.retargetLevel(&rm.dim, &rm.ddim, 0.5, 0, ic.now)
258 sc.rects[1], sc.rectsWS = rm, sc.mon.Current()
259 if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 {
260 t.Fatalf("dim %v, want the 0.5 offset", w.Dim)
261 }
262 ic.now = ic.now.Add(40 * time.Millisecond)
263 c.animate(ic.now, nil)
264 if w := sceneWindow(t, indicatorScene(t, c), 1); !(w.Dim > 0 && w.Dim < 0.5) {
265 t.Fatalf("mid-way dim %v", w.Dim)
266 }
267 ic.now = ic.now.Add(5 * time.Second)
268 c.animate(ic.now, nil)
269 if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0 || len(sc.rects) != 0 {
270 t.Fatalf("settled dim %v, rects %d", w.Dim, len(sc.rects))
271 }
272}
273
274// A scale motion on an overview card multiplies the card's settled zoom
275// into Zoom; Preview stays settled (input tells cards by it).
276func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
277 var rm rectMotion
278 rm.scale, rm.dw = true, 50
279 p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2}
280 rm.dh = 50 // aspect kept: the zoom follows the smaller ratio
281 rm.show(&p)
282 if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
283 t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
284 }
285 // A plain window drawn at its size: Zoom 1 (publish emits Zoom 0).
286 rm.dw = 0.01
287 p = Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
288 rm.show(&p)
289 if p.Zoom != 1 {
290 t.Fatalf("zoom %v for an unscaled window, want 1", p.Zoom)
291 }
292}
293
294// snapshot leaves leaving entries out (they are Hidden), so a transition
295// after a close never moves them; the neighbours' rects are recorded.
296func TestSnapshotSkipsLeavingEntries(t *testing.T) {
297 c, ic := indicatorCore(t)
298 c.cfg.Animations.On = true
299 sc := c.cur()
300 sc.mon.AddWindow(1)
301 sc.mon.AddWindow(2)
302 settleShown(t, c)
303 left := sc.settledLayout[1]
304 sc.mon.RemoveWindow(2)
305 c.leaveFrom(sc, left, nil, ic.now)
306 settleShown(t, c)
307 shots := c.snapshot(ic.now)
308 if len(shots) != 1 || len(shots[0].rects) != 1 || shots[0].rects[0].id != 1 {
309 t.Fatalf("snapshot rects %+v, want window 1 only", shots[0].rects)
310 }
311}
312
313// A leaving tile is listed where the tiles are painted, before the first
314// float that opens them; a leaving float goes last. Settled and shown stay
315// aligned. The veil keeps following the leaving float, fading with it.
316func TestLeavingPaintOrder(t *testing.T) {
317 c, ic := indicatorCore(t)
318 c.cfg.Animations.On = true
319 c.cfg.Floating.Dim = 0.4
320 sc := c.cur()
321 ws := sc.mon.Current()
322 sc.mon.AddWindow(1)
323 sc.mon.AddWindow(2)
324 ws.AddFloating(3, 80, 60)
325 settleShown(t, c)
326 tile := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
327 float := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 3 })]
328 if !float.Floating || tile.Floating {
329 t.Fatalf("setup: tile %+v float %+v", tile, float)
330 }
331
332 // Close the tile: its entry sits before the float.
333 sc.mon.RemoveWindow(2)
334 c.leaveFrom(sc, tile, nil, ic.now)
335 s := indicatorScene(t, c)
336 order := func(s ports.Scene) []WindowID {
337 ids := make([]WindowID, 0, len(s.Windows))
338 for _, w := range s.Windows {
339 ids = append(ids, w.ID)
340 }
341 return ids
342 }
343 if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
344 t.Fatalf("order with a leaving tile %v, want [1 2 3] (tile before the float)", got)
345 }
346 for k := range sc.shown {
347 if sc.shown[k].ID != sc.settledLayout[k].ID {
348 t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
349 }
350 }
351 if s.Dim != 0.4 {
352 t.Fatalf("veil %v with the float still shown, want 0.4", s.Dim)
353 }
354
355 // Close the float too: its entry goes last, and the veil goes at
356 // once (a fading veil would redraw the whole output every frame of
357 // the fade; the float's own fade redraws only its rect).
358 sc.mon.RemoveWindow(3)
359 c.leaveFrom(sc, float, nil, ic.now)
360 s = indicatorScene(t, c)
361 if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
362 t.Fatalf("order with a leaving float %v, want [1 2 3] (float last)", got)
363 }
364 if s.Dim != 0 {
365 t.Fatalf("veil %v right after the float left, want none", s.Dim)
366 }
367 ic.now = ic.now.Add(60 * time.Millisecond)
368 c.animate(ic.now, nil)
369 s = indicatorScene(t, c)
370 if f := sceneWindow(t, s, 3); s.Dim != 0 || !(f.Fade > 0 && f.Fade < 1) {
371 t.Fatalf("veil %v mid-fade (float fade %v), want none while the float fades", s.Dim, f.Fade)
372 }
373 ic.now = ic.now.Add(5 * time.Second)
374 c.animate(ic.now, nil)
375 s = indicatorScene(t, c)
376 if s.Dim != 0 || len(s.Windows) != 1 {
377 t.Fatalf("after the settle: dim %v windows %+v", s.Dim, s.Windows)
378 }
379}
380
381// A re-flow that retargets an appearing window's rect motion stops its
382// content zoom (the aspect may change) and keeps its fade.
383func TestRetargetClearsScaleKeepsFade(t *testing.T) {
384 c, ic := indicatorCore(t)
385 c.cfg.Animations.On = true
386 sc := c.cur()
387 sc.mon.AddWindow(1)
388 settleShown(t, c)
389 c.appearAt(sc, settledOf(t, sc, 1), ic.now)
390 settleShown(t, c)
391 if rm := sc.rects[1]; !rm.scale || !rm.fade.on {
392 t.Fatalf("setup: %+v", rm)
393 }
394 ic.now = ic.now.Add(20 * time.Millisecond)
395 c.animate(ic.now, nil)
396 settleShown(t, c)
397 before := c.snapshot(ic.now)
398 sc.mon.AddWindow(2) // re-flows window 1
399 c.transition(before, ic.now)
400 rm, ok := sc.rects[1]
401 if !ok || rm.scale || !rm.fade.on || !rm.on() {
402 t.Fatalf("after the re-flow: %+v", rm)
403 }
404 w := sceneWindow(t, indicatorScene(t, c), 1)
405 if w.Zoom != 0 || !(w.Fade > 0 && w.Fade < 1) {
406 t.Fatalf("zoom %v fade %v, want no zoom and a running fade", w.Zoom, w.Fade)
407 }
408}
409
410// Dim offsets are clamped into 0..1 on the shown placement.
411func TestDimOffsetClamped(t *testing.T) {
412 var rm rectMotion
413 rm.ddim = -0.3
414 p := Placement{ID: 1, Rect: Rect{W: 10, H: 10}}
415 rm.show(&p)
416 if p.Dim != 0 {
417 t.Fatalf("dim %v, want 0", p.Dim)
418 }
419 rm.ddim = 1.5
420 rm.show(&p)
421 if p.Dim != 1 {
422 t.Fatalf("dim %v, want 1", p.Dim)
423 }
424}