transition_overview_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// overviewRig is indicatorCore with animations on, windows 1 and 2 in two
14// columns of the first workspace and window 3 alone on the second, settled,
15// the first workspace on screen.
16func overviewRig(t *testing.T) (*Core, *indicatorClock, *screen) {
17 t.Helper()
18 c, ic := indicatorCore(t)
19 c.cfg.Animations.On = true
20 sc := c.cur()
21 m := sc.mon
22 m.AddWindow(1)
23 m.AddWindow(2)
24 m.Focus(1)
25 m.AddWindow(3)
26 m.Focus(0)
27 settleShown(t, c)
28 return c, ic, sc
29}
30
31// toggleOverview is what a bind does: snapshot, toggle, transition.
32func toggleOverview(c *Core, sc *screen, now time.Time) {
33 shots := c.snapshot(now)
34 sc.mon.ToggleOverview()
35 c.transition(shots, now)
36}
37
38// frame moves the clock by d, steps the springs and returns the scene.
39func frame(t *testing.T, c *Core, ic *indicatorClock, d time.Duration) ports.Scene {
40 t.Helper()
41 ic.now = ic.now.Add(d)
42 c.animate(ic.now, nil)
43 return indicatorScene(t, c)
44}
45
46// contentScale is the scale the renderer draws the window's content at:
47// Zoom, else Preview, else 1 (vulkan walk).
48func contentScale(w ports.SceneWindow) float64 {
49 if w.Zoom > 0 {
50 return w.Zoom
51 }
52 if w.Preview > 0 {
53 return w.Preview
54 }
55 return 1
56}
57
58// checkContent fails unless the content scale the renderer will use draws
59// the content at the frame's size (within rounding) and never magnifies it.
60func checkContent(t *testing.T, w ports.SceneWindow, full ports.Rect, when string) {
61 t.Helper()
62 k := contentScale(w)
63 if k <= 0 || k > 1 {
64 t.Fatalf("%s: content scale %v out of (0, 1]: %+v", when, k, w)
65 }
66 if got := k * float64(full.W); math.Abs(got-float64(w.Rect.W)) > 1.5 {
67 t.Fatalf("%s: content drawn %.1f wide in a %d wide frame (scale %v, full %d): %+v", when, got, w.Rect.W, k, full.W, w)
68 }
69 if got := k * float64(full.H); math.Abs(got-float64(w.Rect.H)) > 1.5 {
70 t.Fatalf("%s: content drawn %.1f high in a %d high frame (scale %v, full %d): %+v", when, got, w.Rect.H, k, full.H, w)
71 }
72}
73
74// Opening: a card starts at its window's rect with its content at full
75// size, shrinks to its overview card with the content following every frame,
76// and settles on the exact overview layout; the other rows fade in. Closing
77// mirrors it and animations off are instant.
78func TestOverviewOpenCloseAnimatesCards(t *testing.T) {
79 c, ic, sc := overviewRig(t)
80 full := map[WindowID]ports.Rect{}
81 for _, id := range []WindowID{1, 2} {
82 full[id] = sceneWindow(t, indicatorScene(t, c), id).Rect
83 }
84
85 toggleOverview(c, sc, ic.now)
86 if !sc.mon.ov.open || !c.animating() {
87 t.Fatalf("overview open %v, animating %v", sc.mon.ov.open, c.animating())
88 }
89 s := indicatorScene(t, c)
90 card := map[WindowID]Placement{}
91 for _, p := range sc.settledLayout {
92 card[p.ID] = p
93 }
94 for _, id := range []WindowID{1, 2} {
95 w := sceneWindow(t, s, id)
96 settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
97 if w.Rect != full[id] || w.Preview != settled.Preview || w.Zoom != 1 {
98 t.Fatalf("window %d first frame %+v, want the full rect %+v at content scale 1 (card preview %v)", id, w, full[id], settled.Preview)
99 }
100 checkContent(t, w, full[id], "first frame")
101 }
102 // The other row: invisible, drawn as a card, no motion of its own.
103 w3 := sceneWindow(t, s, 3)
104 if w3.Hidden || w3.Fade != 1 || w3.Preview == 0 || w3.Zoom != 0 {
105 t.Fatalf("other row first frame %+v, want invisible card without zoom", w3)
106 }
107
108 for i := range 6 {
109 s = frame(t, c, ic, 25*time.Millisecond)
110 for _, id := range []WindowID{1, 2} {
111 w := sceneWindow(t, s, id)
112 checkContent(t, w, full[id], "opening")
113 if i == 1 {
114 p := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
115 if !(w.Rect.W < full[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > p.Preview && w.Zoom < 1) {
116 t.Fatalf("window %d mid-way %+v, want a frame between %+v and %+v and a zoom between %v and 1", id, w, full[id], p.Rect, p.Preview)
117 }
118 }
119 }
120 if i == 1 {
121 if w := sceneWindow(t, s, 3); !(w.Fade > 0 && w.Fade < 1) {
122 t.Fatalf("other row mid-way fade %v, want strictly between 0 and 1", w.Fade)
123 }
124 }
125 }
126 s = frame(t, c, ic, 5*time.Second)
127 for _, p := range sc.mon.Layout() {
128 if p.Hidden {
129 continue
130 }
131 w := sceneWindow(t, s, p.ID)
132 if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != p.Preview {
133 t.Fatalf("settled window %d %+v, want the exact overview layout %+v", p.ID, w, p)
134 }
135 }
136 if c.animating() || len(sc.rects) != 0 {
137 t.Fatalf("still animating after the settle: %d rects", len(sc.rects))
138 }
139
140 // Close: the cards grow back to the windows, content following.
141 toggleOverview(c, sc, ic.now)
142 if sc.mon.ov.open {
143 t.Fatal("the overview did not close")
144 }
145 s = indicatorScene(t, c)
146 for _, id := range []WindowID{1, 2} {
147 w := sceneWindow(t, s, id)
148 if w.Preview != 0 || w.Rect != card[id].Rect {
149 t.Fatalf("window %d first closing frame %+v, want the card rect %+v as a window", id, w, card[id].Rect)
150 }
151 checkContent(t, w, full[id], "first closing frame")
152 }
153 for i := range 6 {
154 s = frame(t, c, ic, 25*time.Millisecond)
155 for _, id := range []WindowID{1, 2} {
156 w := sceneWindow(t, s, id)
157 checkContent(t, w, full[id], "closing")
158 if i == 1 && !(w.Rect.W > card[id].Rect.W && w.Rect.W < full[id].W && w.Zoom > 0 && w.Zoom < 1) {
159 t.Fatalf("window %d mid-way closing %+v", id, w)
160 }
161 }
162 }
163 s = frame(t, c, ic, 5*time.Second)
164 for _, id := range []WindowID{1, 2} {
165 if w := sceneWindow(t, s, id); w.Rect != full[id] || w.Zoom != 0 || w.Preview != 0 {
166 t.Fatalf("window %d settled after closing %+v, want %+v", id, w, full[id])
167 }
168 }
169 if w := sceneWindow(t, s, 3); !w.Hidden {
170 t.Fatalf("window 3 %+v after closing, want hidden (no fade-out)", w)
171 }
172 if c.animating() {
173 t.Fatal("still animating after closing")
174 }
175}
176
177// With animations off the overview opens and closes at once.
178func TestOverviewOffIsInstant(t *testing.T) {
179 c, ic, sc := overviewRig(t)
180 c.cfg.Animations.On = false
181 toggleOverview(c, sc, ic.now)
182 s := indicatorScene(t, c)
183 for _, p := range sc.mon.Layout() {
184 if p.Hidden {
185 continue
186 }
187 if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
188 t.Fatalf("window %d %+v with animations off, want %+v", p.ID, w, p)
189 }
190 }
191 if c.animating() || len(sc.rects) != 0 {
192 t.Fatal("a motion runs with animations off")
193 }
194 toggleOverview(c, sc, ic.now)
195 if indicatorScene(t, c); c.animating() || len(sc.rects) != 0 {
196 t.Fatal("a motion runs after closing with animations off")
197 }
198}
199
200// Moving the selection inside the open overview slides the cards (its rows
201// scroll): same rect motions, contents scaled; the row that comes in fades.
202func TestOverviewNavigationAnimates(t *testing.T) {
203 c, ic, sc := overviewRig(t)
204 toggleOverview(c, sc, ic.now)
205 s := frame(t, c, ic, 5*time.Second)
206 before := map[WindowID]ports.SceneWindow{}
207 for _, w := range s.Windows {
208 before[w.ID] = w
209 }
210 if !before[3].Hidden && before[3].Fade != 0 {
211 t.Fatalf("setup: %+v", before[3])
212 }
213 // Down: the second row becomes the current one; rows scroll up.
214 shots := c.snapshot(ic.now)
215 sc.mon.OverviewMove(0, 1)
216 c.transition(shots, ic.now)
217 s = indicatorScene(t, c)
218 moved := 0
219 for _, w := range s.Windows {
220 if w.Hidden {
221 continue
222 }
223 b, ok := before[w.ID]
224 if !ok || b.Hidden {
225 continue
226 }
227 if w.Rect != b.Rect {
228 t.Fatalf("window %d jumped on the first frame: %+v then %+v", w.ID, b.Rect, w.Rect)
229 }
230 moved++
231 }
232 if moved == 0 {
233 t.Fatalf("no card was shown before and after: %+v", s.Windows)
234 }
235 s = frame(t, c, ic, 40*time.Millisecond)
236 slid := false
237 for _, w := range s.Windows {
238 if b, ok := before[w.ID]; ok && !b.Hidden && !w.Hidden && w.Rect != b.Rect {
239 slid = true
240 // A card moving between cards keeps its content scale.
241 if k := contentScale(w); k <= 0 || k > 1 {
242 t.Fatalf("window %d scale %v", w.ID, k)
243 }
244 }
245 }
246 if !slid {
247 t.Fatal("no card moved mid-way")
248 }
249 s = frame(t, c, ic, 5*time.Second)
250 for _, p := range sc.mon.Layout() {
251 if p.Hidden {
252 continue
253 }
254 if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
255 t.Fatalf("window %d settled %+v, want %+v", p.ID, w, p)
256 }
257 }
258}
259
260// A click while the cards move picks the card where it is drawn (the shown
261// layout), and a click on a still overview is unchanged.
262func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) {
263 c, ic, sc := overviewRig(t)
264 toggleOverview(c, sc, ic.now)
265 s := frame(t, c, ic, 30*time.Millisecond)
266 // The pointer sits on the right half of the output: the shown card of
267 // window 2 covers it; its settled card does not reach that far left.
268 w2 := sceneWindow(t, s, 2)
269 x, y := float64(w2.Rect.X+w2.Rect.W-1), float64(w2.Rect.Y+w2.Rect.H/2)
270 settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == 2 })]
271 if x < float64(settled.Rect.X+settled.Rect.W) {
272 t.Fatalf("setup: x %v is inside the settled card %+v", x, settled.Rect)
273 }
274 if id := overviewIn(sc.mon.Layout(), x, y); id == 2 {
275 t.Fatalf("setup: the settled layout picks 2 at %v,%v", x, y)
276 }
277 c.cursorX, c.cursorY = x+float64(sc.x), y+float64(sc.y)
278 picked, err := c.overviewClick(context.Background())
279 if err != nil || !picked {
280 t.Fatalf("picked %v err %v during the opening", picked, err)
281 }
282 if sc.mon.ov.open {
283 t.Fatal("the overview stayed open")
284 }
285 if id, _ := sc.mon.Focused(); id != 2 {
286 t.Fatalf("focused %d, want 2 (the card drawn there)", id)
287 }
288}
289
290// A scale motion on a card never magnifies content: a shown frame wider than
291// the card's settled one gives at most scale 1, and a card in flight at its
292// full size still carries Zoom 1 (Preview alone would shrink it).
293func TestCardZoomClampedAndEmitted(t *testing.T) {
294 var rm rectMotion
295 rm.scale, rm.dw, rm.dh = true, 400, 240
296 p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom}
297 rm.show(&p)
298 if p.Zoom != 1 || p.Preview != overviewCardZoom {
299 t.Fatalf("shown %+v, want zoom clamped to 1 and the card's preview", p)
300 }
301 c, ic, sc := overviewRig(t)
302 toggleOverview(c, sc, ic.now)
303 if w := sceneWindow(t, indicatorScene(t, c), 1); w.Zoom != 1 || w.Preview == 0 {
304 t.Fatalf("card at full size emitted %+v, want Zoom 1 with its Preview", w)
305 }
306}
307
308// Rect motions belong to the overview state: a state change with no
309// transition (animations off meanwhile, a session lock) drops them, and
310// leaving the overview never keeps a card's motion on a window.
311func TestOverviewRectsDroppedOnStateChange(t *testing.T) {
312 c, ic, sc := overviewRig(t)
313 toggleOverview(c, sc, ic.now)
314 indicatorScene(t, c)
315 if len(sc.rects) == 0 {
316 t.Fatal("no motion for the opening")
317 }
318 sc.mon.ToggleOverview()
319 indicatorScene(t, c)
320 if len(sc.rects) != 0 {
321 t.Fatalf("%d card motions survived the close without a transition", len(sc.rects))
322 }
323}
324
325// The overview has no camera: toggling it, moving in it and closing it never
326// start a column or workspace slide, right after the action (settled or not).
327func TestOverviewStartsNoCameraSlide(t *testing.T) {
328 c, ic, sc := overviewRig(t)
329 m := sc.mon
330 for _, id := range []WindowID{4, 5} {
331 m.AddWindow(id) // more columns than fit: the view scrolls
332 }
333 settleShown(t, c)
334 c.animate(ic.now.Add(5*time.Second), nil)
335 ic.now = ic.now.Add(5 * time.Second)
336 noCamera := func(when string) {
337 t.Helper()
338 if m.switchView.motion.on || m.Current().view.motion.on {
339 t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchView.motion.on, m.Current().view.motion.on)
340 }
341 }
342 noCamera("before")
343 toggleOverview(c, sc, ic.now)
344 noCamera("after open")
345 frame(t, c, ic, 30*time.Millisecond)
346 noCamera("mid open")
347 for _, mv := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
348 shots := c.snapshot(ic.now)
349 m.OverviewMove(mv[0], mv[1])
350 c.transition(shots, ic.now)
351 noCamera("after move")
352 frame(t, c, ic, 30*time.Millisecond)
353 }
354 toggleOverview(c, sc, ic.now)
355 noCamera("after close")
356 frame(t, c, ic, 5*time.Second)
357 noCamera("settled")
358}
359
360// A card that still fades in (Fade >= 0.5) takes no click; a visible one
361// does, and a faded card lets the one under it be picked.
362func TestOverviewInSkipsFadedCards(t *testing.T) {
363 r := Rect{X: 0, Y: 0, W: 100, H: 100}
364 layout := []Placement{
365 {ID: 1, Rect: r, Preview: 0.2},
366 {ID: 2, Rect: r, Preview: 0.2, Fade: 0.6},
367 }
368 if id := overviewIn(layout, 50, 50); id != 1 {
369 t.Fatalf("picked %d, want 1 (2 is nearly invisible)", id)
370 }
371 layout[1].Fade = 0.4
372 if id := overviewIn(layout, 50, 50); id != 2 {
373 t.Fatalf("picked %d, want 2 (visible enough)", id)
374 }
375 layout = layout[1:]
376 layout[0].Fade = 0.9
377 if id := overviewIn(layout, 50, 50); id != 0 {
378 t.Fatalf("picked %d, want none", id)
379 }
380 // And through the shown layout of a running opening.
381 c, ic, sc := overviewRig(t)
382 toggleOverview(c, sc, ic.now)
383 s := indicatorScene(t, c)
384 w3 := sceneWindow(t, s, 3)
385 if w3.Fade != 1 {
386 t.Fatalf("setup: window 3 fade %v", w3.Fade)
387 }
388 if id := overviewIn(sc.shownLayout(), float64(w3.Rect.X+w3.Rect.W/2), float64(w3.Rect.Y+w3.Rect.H/2)); id == 3 {
389 t.Fatal("the invisible card of the next row was picked")
390 }
391}
392
393// A fade that runs on a card carries over a state change: picking it
394// mid-fade (here by closing the overview on it) starts from the fade it
395// had, it does not jump to opaque.
396func TestOverviewCarriesFadeAcrossState(t *testing.T) {
397 c, ic, sc := overviewRig(t)
398 toggleOverview(c, sc, ic.now)
399 s := frame(t, c, ic, 40*time.Millisecond)
400 mid := sceneWindow(t, s, 3).Fade
401 if !(mid > 0.05 && mid < 0.95) {
402 t.Fatalf("setup: window 3 fade %v mid-way", mid)
403 }
404 shots := c.snapshot(ic.now)
405 sc.mon.OverviewPick(3)
406 c.transition(shots, ic.now)
407 if sc.mon.ov.open {
408 t.Fatal("the overview stayed open")
409 }
410 got := sceneWindow(t, indicatorScene(t, c), 3)
411 if math.Abs(got.Fade-mid) > 0.02 {
412 t.Fatalf("fade %v after the pick, want the sampled %v", got.Fade, mid)
413 }
414 s = frame(t, c, ic, 5*time.Second)
415 if w := sceneWindow(t, s, 3); w.Fade != 0 {
416 t.Fatalf("settled fade %v", w.Fade)
417 }
418}
419
420// The content zoom follows the smaller of the width and height ratios: when
421// the aspect does not hold, content never exceeds the frame.
422func TestScaleZoomFollowsSmallerRatio(t *testing.T) {
423 for _, tc := range []struct {
424 name string
425 dw, dh float64
426 want float64
427 }{
428 {"narrower", -50, 0, 0.5},
429 {"shorter", 0, -50, 0.5},
430 {"both", -20, -40, 0.6},
431 {"aspect kept", -30, -30, 0.7},
432 } {
433 t.Run(tc.name, func(t *testing.T) {
434 rm := rectMotion{scale: true, dw: tc.dw, dh: tc.dh}
435 p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
436 rm.show(&p)
437 if math.Abs(p.Zoom-tc.want) > 1e-9 {
438 t.Fatalf("zoom %v, want %v", p.Zoom, tc.want)
439 }
440 if float64(p.Rect.W)*1 < p.Zoom*100-1e-9 || float64(p.Rect.H) < p.Zoom*100-1e-9 {
441 t.Fatalf("content %vx%v exceeds the frame %+v", p.Zoom*100, p.Zoom*100, p.Rect)
442 }
443 })
444 }
445}