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}

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