main f2d166b

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}

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