feat/hints a471fa4

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

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