v0.3.0 cf370a0

capture_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"slices"
6	"sync/atomic"
7	"testing"
8	"time"
9
10	"github.com/bnema/neferwl/internal/adapters/config"
11	"github.com/bnema/neferwl/internal/core"
12	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13	"github.com/bnema/neferwl/internal/ports"
14)
15
16type captureRig struct {
17	client     chan ports.ClientEvent
18	output     chan ports.OutputEvent
19	commands   chan ports.ClientCommand
20	scenes     chan []ports.Scene
21	workspaces chan ports.Workspaces
22}
23
24// captureCore runs a core with one 100x80 output OUT-1.
25func captureCore(t *testing.T) *captureRig {
26	t.Helper()
27	cfg := config.Defaults()
28	cfg.Border.Width = 0
29	r := &captureRig{
30		client:     make(chan ports.ClientEvent, 16),
31		output:     make(chan ports.OutputEvent, 8),
32		commands:   make(chan ports.ClientCommand, 64),
33		scenes:     make(chan []ports.Scene, 1),
34		workspaces: make(chan ports.Workspaces, 1),
35	}
36	c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces})
37	if err != nil {
38		t.Fatal(err)
39	}
40	ctx, cancel := context.WithCancel(context.Background())
41	t.Cleanup(cancel)
42	go c.Run(ctx)
43	r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
44	scene(t, r.scenes)
45	return r
46}
47
48// state returns the next CaptureSessionState, skipping other commands.
49func (r *captureRig) state(t *testing.T) ports.CaptureSessionState {
50	t.Helper()
51	for {
52		if v, ok := receive(t, r.commands).(ports.CaptureSessionState); ok {
53			return v
54		}
55	}
56}
57
58// sceneWith returns the next scene whose capture presence equals want.
59func (r *captureRig) sceneWith(t *testing.T, want bool) ports.Scene {
60	t.Helper()
61	for {
62		if s := scene(t, r.scenes); (s.Capture != nil) == want {
63			return s
64		}
65	}
66}
67
68func (r *captureRig) layer(id ports.WindowID, layer ports.Layer, keyboard uint32) ports.LayerSurface {
69	return ports.LayerSurface{ID: id, Layer: layer, Anchor: ports.AnchorTop | ports.AnchorLeft, Width: 10, Height: 10, Output: "OUT-1", Keyboard: keyboard}
70}
71
72// excludedScene waits for a scene whose exclusion list has n entries.
73func (r *captureRig) excludedScene(t *testing.T, n int) ports.Scene {
74	t.Helper()
75	for {
76		if s := r.sceneWith(t, true); len(s.Capture.Excluded) == n {
77			return s
78		}
79	}
80}
81
82func TestCaptureSessionOutputTarget(t *testing.T) {
83	r := captureCore(t)
84	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1", Region: ports.Rect{X: -10, Y: 10, W: 50, H: 200}}
85	st := r.state(t)
86	if !st.Active || st.Hidden || st.Workspace != 0 || st.Output != "OUT-1" || st.Rect != (ports.Rect{X: 0, Y: 10, W: 40, H: 70}) {
87		t.Fatalf("state %+v", st)
88	}
89	// Without an exclusion the scene carries no exclusion and no hidden workspace.
90	if s := r.sceneWith(t, true); s.Capture.Session != 0 || len(s.Capture.Excluded) != 0 || s.Capture.Workspace != 0 || s.CaptureScene != nil {
91		t.Fatalf("capture %+v", s.Capture)
92	}
93	r.client <- ports.CaptureSessionClose{ID: 1}
94	r.sceneWith(t, false)
95	// A zero region is the whole output.
96	r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1"}
97	if st := r.state(t); st.Rect != (ports.Rect{W: 100, H: 80}) {
98		t.Fatalf("state %+v", st)
99	}
100}
101
102func TestCaptureSessionWorkspaceTarget(t *testing.T) {
103	r := captureCore(t)
104	r.client <- ports.WindowMapped{ID: 1}
105	scene(t, r.scenes)
106	ws := receive(t, r.workspaces).Outputs[0].Workspaces
107	if ws[0].Frame != (ports.Rect{W: 100, H: 80}) {
108		t.Fatalf("frame %+v", ws[0])
109	}
110	r.client <- ports.CaptureSessionOpen{ID: 1, Workspace: ws[0].ID}
111	if st := r.state(t); !st.Active || st.Hidden || st.Output != "OUT-1" || st.Workspace != ws[0].ID || st.Rect != ws[0].Frame {
112		t.Fatalf("state %+v", st)
113	}
114	if s := r.sceneWith(t, true); s.Capture.Shown != ws[0].ID || s.Capture.Workspace != 0 {
115		t.Fatalf("capture %+v", s.Capture)
116	}
117	r.client <- ports.CaptureSessionClose{ID: 1}
118	r.sceneWith(t, false)
119	// A workspace that does not exist ends the session.
120	r.client <- ports.CaptureSessionOpen{ID: 2, Workspace: 9999}
121	if st := r.state(t); st.Active || st.Reason != ports.CaptureReasonWorkspaceGone || !st.Reason.Terminal() {
122		t.Fatalf("state %+v", st)
123	}
124}
125
126// Only one hidden workspace is rendered at a time: a second session on
127// another hidden workspace is not Active until the first one ends.
128func TestCaptureSessionOneHiddenWorkspaceAtATime(t *testing.T) {
129	r := captureCore(t)
130	r.client <- ports.WindowMapped{ID: 1}
131	scene(t, r.scenes)
132	var ws []ports.WorkspaceInfo
133	for len(ws) < 2 {
134		ws = receive(t, r.workspaces).Outputs[0].Workspaces
135	}
136	// Workspace 2 is on screen after this; 1 and a third are hidden.
137	r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
138	r.client <- ports.CaptureSessionOpen{ID: 1, Workspace: ws[0].ID}
139	if st := r.state(t); !st.Active || !st.Hidden {
140		t.Fatalf("first hidden session %+v", st)
141	}
142	r.client <- ports.CaptureSessionOpen{ID: 2, Workspace: ws[0].ID}
143	r.client <- ports.CaptureSessionOpen{ID: 3, Workspace: ws[1].ID}
144	for {
145		if st := r.state(t); st.ID == 3 {
146			if !st.Active || st.Hidden {
147				t.Fatalf("session on the workspace on screen %+v", st)
148			}
149			break
150		}
151	}
152}
153
154func TestCaptureSessionTerminalReasons(t *testing.T) {
155	r := captureCore(t)
156	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "NOPE"}
157	if st := r.state(t); st.Reason != ports.CaptureReasonOutputGone || st.Active {
158		t.Fatalf("state %+v", st)
159	}
160	r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1", Region: ports.Rect{X: 500, Y: 500, W: 5, H: 5}}
161	if st := r.state(t); st.Reason != ports.CaptureReasonInvalidRegion {
162		t.Fatalf("state %+v", st)
163	}
164	// An unplugged output ends the session.
165	r.client <- ports.CaptureSessionOpen{ID: 3, Output: "OUT-1"}
166	if st := r.state(t); !st.Active {
167		t.Fatalf("state %+v", st)
168	}
169	r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
170	r.output <- ports.OutputRemoved{Name: "OUT-1"}
171	if st := r.state(t); st.ID != 3 || st.Reason != ports.CaptureReasonOutputGone {
172		t.Fatalf("state %+v", st)
173	}
174	r.sceneWith(t, false)
175}
176
177// Several sessions live at once; each is decided on its own.
178func TestCaptureSessionsAreIndependent(t *testing.T) {
179	r := captureCore(t)
180	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
181	r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1", Region: ports.Rect{W: 10, H: 10}}
182	got := map[uint64]ports.CaptureSessionState{}
183	for len(got) < 2 {
184		st := r.state(t)
185		got[st.ID] = st
186	}
187	if !got[1].Active || !got[2].Active || got[2].Rect != (ports.Rect{W: 10, H: 10}) {
188		t.Fatalf("states %+v", got)
189	}
190	r.client <- ports.CaptureSessionClose{ID: 1}
191	r.client <- ports.CaptureSessionClose{ID: 7} // unknown: ignored
192	r.client <- ports.WindowMapped{ID: 1}
193	r.sceneWith(t, true) // session 2 lives on
194}
195
196// An exclusion needs a live session; attached layers stay over a fullscreen
197// window, are excluded with their popups, and other layers keep hiding.
198func TestCaptureExclusionLayersOverFullscreen(t *testing.T) {
199	r := captureCore(t)
200	r.client <- ports.WindowMapped{ID: 1}
201	scene(t, r.scenes)
202	r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
203	scene(t, r.scenes)
204	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerTop, 0)}}
205	if s := scene(t, r.scenes); len(s.Layers) != 0 {
206		t.Fatalf("fullscreen should hide layers: %+v", s.Layers)
207	}
208	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
209	r.client <- ports.CaptureExclusionBegin{Session: 1}
210	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
211	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true} // duplicate
212	r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
213	r.client <- ports.PopupMapped{ID: 20}
214	s := r.excludedScene(t, 2)
215	if s.Capture.Session != 1 || s.Capture.Excluded[0] != 10 || s.Capture.Excluded[1] != 20 {
216		t.Fatalf("capture %+v", s.Capture)
217	}
218	if len(s.Layers) != 1 || s.Layers[0].ID != 10 {
219		t.Fatalf("only the attached layer shows over fullscreen: %+v", s.Layers)
220	}
221	// The exclusion ends with its session.
222	r.client <- ports.CaptureSessionClose{ID: 1}
223	if s = r.sceneWith(t, false); len(s.Layers) != 0 {
224		t.Fatalf("layers after the session: %+v", s.Layers)
225	}
226}
227
228// The exclusion of a session that is not registered, or of another session
229// than the live one, does nothing.
230func TestCaptureExclusionNeedsItsSession(t *testing.T) {
231	r := captureCore(t)
232	r.client <- ports.CaptureExclusionBegin{Session: 5} // unknown session
233	r.client <- ports.CaptureExclusionLayer{Session: 5, Layer: 10, Attached: true}
234	r.client <- ports.WindowMapped{ID: 1}
235	if s := scene(t, r.scenes); s.Capture != nil {
236		t.Fatalf("capture without session: %+v", s.Capture)
237	}
238	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
239	r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1"}
240	r.client <- ports.CaptureExclusionBegin{Session: 1}
241	r.client <- ports.CaptureExclusionBegin{Session: 2}                            // only one is live
242	r.client <- ports.CaptureExclusionLayer{Session: 2, Layer: 10, Attached: true} // not the owner
243	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 11, Attached: true}
244	if s := r.excludedScene(t, 1); s.Capture.Session != 1 || s.Capture.Excluded[0] != 11 {
245		t.Fatalf("capture %+v", s.Capture)
246	}
247}
248
249// Wayland ends an exclusion whose object was destroyed; the session goes on.
250func TestCaptureExclusionEndKeepsSession(t *testing.T) {
251	r := captureCore(t)
252	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
253	r.client <- ports.CaptureExclusionBegin{Session: 1}
254	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
255	r.excludedScene(t, 1)
256	r.client <- ports.CaptureExclusionEnd{Session: 1}
257	for {
258		s := r.sceneWith(t, true)
259		if s.Capture.Session == 0 && len(s.Capture.Excluded) == 0 {
260			break
261		}
262	}
263	// A new exclusion can begin on the live session.
264	r.client <- ports.CaptureExclusionBegin{Session: 1}
265	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 12, Attached: true}
266	if s := r.excludedScene(t, 1); s.Capture.Excluded[0] != 12 {
267		t.Fatalf("capture %+v", s.Capture)
268	}
269}
270
271// When the exclusion object is destroyed and the session goes on, its HUD
272// layers that a screen still lists stay excluded (and are no longer kept over
273// a fullscreen window) until they are not listed: the recorder's frames never
274// show the HUD after the exclusion ended.
275func TestCaptureExclusionEndKeepsListedLayersExcluded(t *testing.T) {
276	r := captureCore(t)
277	r.client <- ports.WindowMapped{ID: 1}
278	scene(t, r.scenes)
279	r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
280	scene(t, r.scenes)
281	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
282	scene(t, r.scenes)
283	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
284	r.client <- ports.CaptureExclusionBegin{Session: 1}
285	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
286	s := r.excludedScene(t, 1)
287	if len(s.Layers) != 1 {
288		t.Fatalf("attached layer shows over fullscreen: %+v", s.Layers)
289	}
290	before := s.Capture.Revision
291	r.client <- ports.CaptureExclusionEnd{Session: 1}
292	s = r.excludedScene(t, 1)
293	if s.Capture.Session != 1 || s.Capture.Excluded[0] != 10 || s.Capture.Revision <= before {
294		t.Fatalf("ended exclusion's layer must stay excluded and fenced: %+v", s.Capture)
295	}
296	if len(s.Layers) != 0 {
297		t.Fatalf("an ended exclusion keeps nothing over fullscreen: %+v", s.Layers)
298	}
299	st := r.state(t)
300	for st.Revision != s.Capture.Revision {
301		st = r.state(t)
302	}
303	if !st.Exclusion || len(st.Layers) != 0 {
304		t.Fatalf("state %+v", st)
305	}
306	// The layer goes: nothing is excluded any more and the fence is lifted.
307	r.client <- ports.LayerChanged{}
308	for {
309		s = r.sceneWith(t, true)
310		if s.Capture.Session == 0 && len(s.Capture.Excluded) == 0 {
311			break
312		}
313	}
314	for st := r.state(t); st.Exclusion; st = r.state(t) {
315	}
316}
317
318func TestCaptureExclusionLayerBounds(t *testing.T) {
319	r := captureCore(t)
320	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
321	r.client <- ports.CaptureExclusionBegin{Session: 1}
322	for id := ports.WindowID(1); id <= ports.MaxExclusionLayers+3; id++ {
323		r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: id, Attached: true}
324	}
325	s := r.excludedScene(t, ports.MaxExclusionLayers)
326	for _, id := range s.Capture.Excluded {
327		if id > ports.MaxExclusionLayers {
328			t.Fatalf("excluded %v", s.Capture.Excluded)
329		}
330	}
331	// Detaching removes one.
332	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 1}
333	r.excludedScene(t, ports.MaxExclusionLayers-1)
334}
335
336// More excluded surfaces than a capture can leave out (layers and popups)
337// stop the session: fail closed.
338func TestCaptureExclusionTooManyExcluded(t *testing.T) {
339	r := captureCore(t)
340	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
341	scene(t, r.scenes)
342	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
343	r.client <- ports.CaptureExclusionBegin{Session: 1}
344	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
345	stopped := make(chan struct{})
346	go func() {
347		// Drain the commands while the popups are sent.
348		for cmd := range r.commands {
349			if st, ok := cmd.(ports.CaptureSessionState); ok && st.Reason == ports.CaptureReasonTooManyExcluded {
350				close(stopped)
351				return
352			}
353		}
354	}()
355	for i := range ports.MaxCaptureExcluded {
356		r.client <- ports.PopupRequest{ID: ports.WindowID(100 + i), Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
357		r.client <- ports.PopupMapped{ID: ports.WindowID(100 + i)}
358	}
359	select {
360	case <-stopped:
361	case <-time.After(5 * time.Second):
362		t.Fatal("session not stopped")
363	}
364}
365
366// An attached layer asking for exclusive keyboard never gets it; an
367// unrelated layer may.
368func TestCaptureExclusionKeyboardClamp(t *testing.T) {
369	r := captureCore(t)
370	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerOverlay, 0)}}
371	scene(t, r.scenes)
372	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
373	r.client <- ports.CaptureExclusionBegin{Session: 1}
374	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
375	r.excludedScene(t, 1)
376	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 1), r.layer(11, ports.LayerOverlay, 1)}}
377	for len(r.commands) > 0 {
378		<-r.commands
379	}
380	scene(t, r.scenes)
381	for {
382		if f, ok := receive(t, r.commands).(ports.FocusWindow); ok {
383			if f.ID != 11 {
384				t.Fatalf("keyboard went to %d, want the unattached layer 11", f.ID)
385			}
386			return
387		}
388	}
389}
390
391// An attached layer never gets keyboard interactivity, exclusive or on demand.
392func TestCaptureExclusionKeyboardNone(t *testing.T) {
393	r := captureCore(t)
394	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 2), r.layer(11, ports.LayerOverlay, 2)}}
395	scene(t, r.scenes)
396	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
397	r.client <- ports.CaptureExclusionBegin{Session: 1}
398	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
399	r.excludedScene(t, 1)
400	for len(r.commands) > 0 {
401		<-r.commands
402	}
403	r.client <- ports.WindowMapped{ID: 1}
404	scene(t, r.scenes)
405	for len(r.commands) > 0 {
406		if f, ok := (<-r.commands).(ports.FocusWindow); ok && f.ID == 10 {
407			t.Fatalf("keyboard went to the attached layer")
408		}
409	}
410}
411
412// Every change an excluded frame depends on bumps the revision, in the state
413// and in the scene that carries it; an unchanged republish does not.
414func TestCaptureExclusionRevisionFence(t *testing.T) {
415	r := captureCore(t)
416	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
417	if st := r.state(t); st.Exclusion || st.Revision != 0 {
418		t.Fatalf("state without exclusion %+v", st)
419	}
420	r.client <- ports.CaptureExclusionBegin{Session: 1}
421	st := r.state(t)
422	if !st.Exclusion || st.Revision != 1 || len(st.Layers) != 0 {
423		t.Fatalf("state %+v", st)
424	}
425	for {
426		if s := r.sceneWith(t, true); s.Capture.Revision == 1 {
427			break
428		}
429	}
430	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
431	st = r.state(t)
432	if st.Revision != 2 || len(st.Layers) != 1 || st.Layers[0] != 10 {
433		t.Fatalf("state after attach %+v", st)
434	}
435	var s ports.Scene
436	for {
437		if s = r.sceneWith(t, true); s.Capture.Revision == 2 {
438			break
439		}
440	}
441	if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
442		t.Fatalf("scene %+v", s.Capture)
443	}
444	// An unrelated publish keeps the revision and sends no state.
445	r.client <- ports.WindowMapped{ID: 1}
446	if s = r.sceneWith(t, true); s.Capture.Revision != 2 {
447		t.Fatalf("revision moved without a change: %d", s.Capture.Revision)
448	}
449	for len(r.commands) > 0 {
450		if v, ok := (<-r.commands).(ports.CaptureSessionState); ok {
451			t.Fatalf("state without a change: %+v", v)
452		}
453	}
454	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
455	if st = r.state(t); st.Revision != 3 || len(st.Layers) != 0 {
456		t.Fatalf("state after detach %+v", st)
457	}
458}
459
460// A detached layer stays excluded until a LayerChanged stops listing it, so
461// the last frame of a destroyed HUD never leaks into a capture.
462func TestCaptureExclusionDetachRetains(t *testing.T) {
463	r := captureCore(t)
464	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
465	scene(t, r.scenes)
466	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
467	r.client <- ports.CaptureExclusionBegin{Session: 1}
468	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
469	r.excludedScene(t, 1)
470	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
471	if s := r.sceneWith(t, true); len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
472		t.Fatalf("detached layer no longer excluded while mapped: %+v", s.Capture)
473	}
474	r.client <- ports.LayerChanged{Layers: nil}
475	r.excludedScene(t, 0)
476}
477
478// An unmap queued before the detach (the wayland order) never leaves a
479// window where the layer is listed but not excluded.
480func TestCaptureExclusionUnmapThenDetach(t *testing.T) {
481	r := captureCore(t)
482	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
483	scene(t, r.scenes)
484	r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
485	r.client <- ports.CaptureExclusionBegin{Session: 1}
486	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
487	r.excludedScene(t, 1)
488	r.client <- ports.LayerChanged{Layers: nil}
489	r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
490	for {
491		s := r.sceneWith(t, true)
492		if len(s.Layers) != 0 && len(s.Capture.Excluded) == 0 {
493			t.Fatalf("layer shown but not excluded: %+v %+v", s.Layers, s.Capture)
494		}
495		if len(s.Layers) == 0 {
496			return
497		}
498	}
499}
500
501// A captured frame shows the indicator for one second on the clock core
502// reads, then the scene goes back without it, whoever asked for the frame.
503func TestCaptureFrameFlashRunsOnTheCoreClock(t *testing.T) {
504	cfg := config.Defaults()
505	cfg.Border.Width = 0
506	fire := make(chan time.Time)
507	timer := portsmocks.NewMockTimer(t)
508	timer.EXPECT().C().Return((<-chan time.Time)(fire))
509	timer.EXPECT().Stop().Return(true).Maybe()
510	var now atomic.Int64
511	clock := portsmocks.NewMockClock(t)
512	clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }).Maybe()
513	clock.EXPECT().NewTimer(ports.CaptureFlash).Return(timer).Once()
514	client := make(chan ports.ClientEvent, 4)
515	output := make(chan ports.OutputEvent, 4)
516	scenes := make(chan []ports.Scene, 1)
517	c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Clock: clock})
518	if err != nil {
519		t.Fatal(err)
520	}
521	ctx, cancel := context.WithCancel(context.Background())
522	t.Cleanup(cancel)
523	go c.Run(ctx)
524	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
525	scene(t, scenes)
526	// A wlr-screencopy frame: no session.
527	client <- ports.CaptureFrameTaken{Output: "OUT-1", Region: ports.Rect{X: 10, Y: 10, W: 20, H: 20}}
528	if s := scene(t, scenes); len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != (ports.Rect{X: 10, Y: 10, W: 20, H: 20}) || s.CaptureIndicators[0].Pill {
529		t.Fatalf("indicators %+v", s.CaptureIndicators)
530	}
531	now.Store(1)
532	fire <- time.Time{}
533	if s := scene(t, scenes); s.CaptureIndicators != nil {
534		t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)
535	}
536}
537
538// popupIDs lists the popups of a scene.
539func popupIDs(s ports.Scene) []ports.WindowID {
540	var ids []ports.WindowID
541	for _, w := range s.Windows {
542		if w.Popup {
543			ids = append(ids, w.ID)
544		}
545	}
546	return ids
547}
548
549// A popup of a HUD layer is excluded for as long as it is in the scene, from
550// the session's end to the layer's: when the session closes, or the layer is
551// destroyed, the popup is never left in the scene while no longer excluded.
552func TestCaptureExclusionPopupOfHUDLayerNeverLeaksWhenTheExclusionEnds(t *testing.T) {
553	setup := func(t *testing.T) *captureRig {
554		r := captureCore(t)
555		r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
556		r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
557		r.client <- ports.CaptureExclusionBegin{Session: 1}
558		r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
559		r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
560		r.client <- ports.PopupMapped{ID: 20}
561		for {
562			s := r.excludedScene(t, 2)
563			if ids := popupIDs(s); len(ids) == 1 && ids[0] == 20 {
564				break
565			}
566		}
567		return r
568	}
569	// leaks: a scene that draws the popup without excluding it, while a
570	// session that could still capture it with the exclusion is alive.
571	t.Run("exclusion object destroyed, session lives, layer goes away", func(t *testing.T) {
572		r := setup(t)
573		r.client <- ports.CaptureExclusionEnd{Session: 1}
574		// The layer is still listed: it and its popup stay excluded.
575		s := r.excludedScene(t, 2)
576		if ids := popupIDs(s); len(ids) != 1 || !slices.Contains(s.Capture.Excluded, 20) {
577			t.Fatalf("popup %v of a retained HUD layer not excluded: %+v", ids, s.Capture)
578		}
579		r.client <- ports.LayerChanged{} // the layer is destroyed
580		for {
581			s := scene(t, r.scenes)
582			if len(s.Layers) != 0 {
583				continue
584			}
585			for _, w := range s.Windows {
586				if w.ID == 20 && (s.Capture == nil || !slices.Contains(s.Capture.Excluded, 20)) {
587					t.Fatalf("popup of a destroyed layer stays in the scene, unexcluded: %+v", s.Capture)
588				}
589			}
590			break
591		}
592	})
593	t.Run("session closed", func(t *testing.T) {
594		r := setup(t)
595		r.client <- ports.CaptureSessionClose{ID: 1}
596		s := r.sceneWith(t, false)
597		if ids := popupIDs(s); len(ids) != 0 && len(s.Layers) == 0 {
598			t.Fatalf("popup %v outlived its HUD layer's exclusion while the layer is hidden", ids)
599		}
600	})
601}

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