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

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