main f2d166b

capture.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"slices"
6	"time"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11// Capture state (ports/capture.go). Only the core goroutine touches it.
12//
13// Things that live here:
14//   - the registered ext-image-copy-capture sessions, decided on every publish
15//     (target, hidden workspace, end);
16//   - the one exclusion, whose HUD layers stay visible over a fullscreen
17//     window and are left out of its session's frames;
18//   - the workspace rendered off screen for a session (Scene.CaptureScene);
19//   - the capture indicator (capindicator below): what shows on screen that
20//     something is captured, whoever captures.
21//
22// With no session, no exclusion and no flash none of this allocates: the
23// scenes carry no Capture and no CaptureIndicators.
24
25// captureState is everything core keeps about captures. Methods on it touch
26// only this state; those that need screens, layers or popups stay on Core.
27type captureState struct {
28	sessions []*capSession
29	excl     *capExclusion // the one live exclusion, nil when none
30	// flashes are the targets of captured frames still marked; timerC fires
31	// at the earliest of them (capindicator.go).
32	flashes   []capFlash
33	timerC    <-chan time.Time
34	timerStop func() bool
35	// scratch, states and marks are reused by every publish.
36	scratch capView
37	states  []ports.CaptureSessionState
38	marks   []ports.CaptureIndicator
39}
40
41func (s *captureState) session(id uint64) *capSession {
42	for _, cs := range s.sessions {
43		if cs.open.ID == id {
44			return cs
45		}
46	}
47	return nil
48}
49
50// idle reports whether nothing is captured or marked: scenes carry no
51// capture state.
52func (s *captureState) idle() bool {
53	return len(s.sessions) == 0 && len(s.flashes) == 0
54}
55
56// capTarget is what a capture covers, as asked.
57type capTarget struct {
58	output    string
59	workspace uint64
60	window    WindowID
61	rect      Rect // output-local logical; zero is the whole output or frame
62}
63
64type capSession struct {
65	open    ports.CaptureSessionOpen
66	last    ports.CaptureSessionState
67	hasLast bool
68	// recording: the session produced a frame. Its target shows the
69	// indicator for as long as the session lives.
70	recording bool
71}
72
73func (s *capSession) target() capTarget {
74	return capTarget{output: s.open.Output, workspace: s.open.Workspace, window: s.open.Window, rect: s.open.Region}
75}
76
77// capExclusion is the one live exclusion.
78type capExclusion struct {
79	session uint64
80	layers  []WindowID // attached layer surfaces, at most ports.MaxExclusionLayers
81	// retained are detached layer surfaces still listed by a screen: they
82	// stay excluded until a LayerChanged stops listing them, so a HUD that
83	// is destroyed is never captured during its last frame.
84	retained []WindowID
85	// ended: the exclusion object was destroyed and the session lives on.
86	// Its layers are retained only: still excluded and fenced (frames of the
87	// session keep leaving them out) until no screen lists them, then the
88	// exclusion is dropped. It never shows layers over a fullscreen window.
89	ended bool
90	// keep is the set of attached layers, shared by every screen
91	// (screen.capture) and rebuilt only when the attached set changes.
92	keep   map[WindowID]bool
93	rev    uint64 // revision fence, bumped when what a frame must leave out changes
94	key    capExclusionKey
95	hasKey bool
96	// scratch is the list captureExcluded builds.
97	scratch []WindowID
98}
99
100// capExclusionKey is what the revision fence follows.
101type capExclusionKey struct {
102	excluded []WindowID
103	layers   []WindowID
104	target   ports.CaptureSessionState
105}
106
107// capView is what one evaluation decided, for the scenes of this publish.
108type capView struct {
109	hidden    *Workspace // captured off screen, nil when none
110	hiddenScr *screen
111	hiddenID  uint64
112	// window is the window rendered off screen for a session, 0 when none.
113	// One target at most, a workspace or a window, is rendered off screen.
114	window    WindowID
115	windowScr *screen
116	windowSz  Rect // its client size, logical, at the origin
117	excluded  []WindowID
118	exclusion *capExclusion
119}
120
121// offscreen reports whether the view renders a target off screen.
122func (v *capView) offscreen() bool { return v.hiddenID != 0 || v.window != 0 }
123
124// capResolved is a target located on the current screens.
125type capResolved struct {
126	sc     *screen
127	ws     *Workspace
128	rect   Rect // clipped, output-local logical
129	hidden bool
130	// size is a window's client size at the origin; visible tells whether
131	// the window is on screen, at rect.
132	size    Rect
133	visible bool
134}
135
136// capResolve locates a target. A zero reason is success.
137func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
138	var r capResolved
139	if t.window != 0 {
140		sc, w := c.screenOf(t.window)
141		if w == nil || sc.name() == "" {
142			return r, ports.CaptureReasonWindowGone
143		}
144		r.sc, r.ws, r.hidden, r.size = sc, w, true, c.windowSize(w, t.window)
145		if vs, vr, ok := c.windowRect(t.window); ok && vs == sc {
146			r.rect, r.visible = vr, true
147		}
148		return r, ports.CaptureReasonNone
149	}
150	if t.workspace != 0 {
151		sc, w := c.workspaceByID(t.workspace)
152		if w == nil || sc.name() == "" {
153			return r, ports.CaptureReasonWorkspaceGone
154		}
155		r.sc, r.ws, r.rect = sc, w, w.Output
156		r.hidden = sc.mon.Current() != w || sc.mon.previewing()
157		return r, ports.CaptureReasonNone
158	}
159	i := c.screenIndex(t.output)
160	if i < 0 || t.output == "" {
161		return r, ports.CaptureReasonOutputGone
162	}
163	r.sc = c.screens[i]
164	r.rect = r.sc.mon.Output()
165	if t.rect.W != 0 || t.rect.H != 0 {
166		r.rect = intersect(r.rect, t.rect)
167	}
168	if r.rect.W <= 0 || r.rect.H <= 0 {
169		return r, ports.CaptureReasonInvalidRegion
170	}
171	return r, ports.CaptureReasonNone
172}
173
174// captureOpen registers a session.
175func (c *Core) captureOpen(v ports.CaptureSessionOpen) {
176	c.capt.sessions = append(c.capt.sessions, &capSession{open: v})
177}
178
179// captureClose forgets a session and its exclusion. Its flashes stay.
180func (c *Core) captureClose(id uint64) {
181	c.capt.sessions = slices.DeleteFunc(c.capt.sessions, func(s *capSession) bool { return s.open.ID == id })
182	if c.capt.excl != nil && c.capt.excl.session == id {
183		c.dropExclusion()
184	}
185}
186
187// captureExclusionBegin starts the exclusion of a session. Wayland refused
188// it already when another one was live.
189func (c *Core) captureExclusionBegin(v ports.CaptureExclusionBegin) {
190	if c.capt.session(v.Session) == nil {
191		return
192	}
193	if old := c.capt.excl; old != nil {
194		if !old.ended {
195			return
196		}
197		// An ended exclusion still excludes its listed HUD layers: a new one
198		// inherits them, so they are left out until no screen lists them.
199		c.capt.excl = &capExclusion{session: v.Session, retained: old.retained, rev: old.rev}
200		return
201	}
202	c.capt.excl = &capExclusion{session: v.Session}
203}
204
205// captureExclusionLayer attaches or detaches a layer surface.
206func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {
207	e := c.capt.excl
208	if e == nil || e.ended || e.session != v.Session {
209		return
210	}
211	i := slices.Index(e.layers, v.Layer)
212	switch {
213	case v.Attached && i < 0 && len(e.layers) < ports.MaxExclusionLayers:
214		e.layers = append(e.layers, v.Layer)
215		e.keep = nil
216		c.clampCaptureKeyboard()
217	case !v.Attached && i >= 0:
218		e.layers = slices.Delete(e.layers, i, i+1)
219		e.keep = nil
220		if c.layerListed(v.Layer) {
221			e.retained = append(e.retained, v.Layer)
222		}
223	}
224}
225
226// captureExclusionEnd ends the exclusion of a session that lives on. Its
227// layers that a screen still lists stay excluded until none does (the
228// retained set): the recorder's frames never show a HUD that just ended.
229func (c *Core) captureExclusionEnd(session uint64) {
230	e := c.capt.excl
231	if e == nil || e.ended || e.session != session {
232		return
233	}
234	for _, id := range e.layers {
235		if c.layerListed(id) && !slices.Contains(e.retained, id) {
236			e.retained = append(e.retained, id)
237		}
238	}
239	e.layers, e.keep, e.ended = nil, nil, true
240	c.pruneRetained()
241	if c.capt.excl != nil {
242		// Attached layers no longer stay over a fullscreen window.
243		for _, sc := range c.screens {
244			sc.capture = nil
245		}
246	}
247}
248
249// dropCaptureSessions forgets every session and the exclusion: the session is
250// protected, no capture is served, and wayland has stopped them all.
251func (c *Core) dropCaptureSessions() {
252	c.capt.sessions = nil
253	// No capture indicator outlives the lock: nothing is drawn for a capture
254	// that can no longer happen, and none is left to show after the unlock.
255	c.capt.flashes = nil
256	c.stopCaptureTimer()
257	c.dropExclusion()
258}
259
260func (c *Core) dropExclusion() {
261	c.capt.excl = nil
262	c.configures.cw.reset()
263	for _, sc := range c.screens {
264		sc.capture = nil
265	}
266}
267
268// layerListed reports whether a screen still lists the layer surface.
269func (c *Core) layerListed(id WindowID) bool {
270	for _, sc := range c.screens {
271		for _, l := range sc.layers {
272			if l.ID == id {
273				return true
274			}
275		}
276	}
277	return false
278}
279
280// pruneRetained forgets the detached layers no screen lists any more, and
281// the ended exclusion with the last of them.
282func (c *Core) pruneRetained() {
283	e := c.capt.excl
284	if e == nil {
285		return
286	}
287	e.retained = slices.DeleteFunc(e.retained, func(id WindowID) bool { return !c.layerListed(id) })
288	if e.ended && len(e.retained) == 0 {
289		c.dropExclusion()
290	}
291}
292
293// exclusionKeep is the layers of the exclusion that stay visible over a
294// fullscreen window. It is built once per change of the attached set and
295// shared, read-only, by every screen.
296func (c *Core) exclusionKeep(e *capExclusion) map[WindowID]bool {
297	if e.ended {
298		return nil
299	}
300	if e.keep == nil {
301		e.keep = make(map[WindowID]bool, len(e.layers))
302		for _, id := range e.layers {
303			e.keep[id] = true
304		}
305	}
306	return e.keep
307}
308
309// clampCaptureKeyboard gives every attached layer surface no keyboard
310// interactivity at all, whatever it asks for (exclusive or on demand): a
311// capture HUD never takes or steals the keyboard. Run whenever the layer
312// list or the attached set changes.
313func (c *Core) clampCaptureKeyboard() {
314	e := c.capt.excl
315	if e == nil {
316		return
317	}
318	for _, sc := range c.screens {
319		for i := range sc.layers {
320			if sc.layers[i].Keyboard != 0 && (slices.Contains(e.layers, sc.layers[i].ID) || slices.Contains(e.retained, sc.layers[i].ID)) {
321				sc.layers[i].Keyboard = 0
322			}
323		}
324	}
325}
326
327// excluded lists the attached layers and every popup hanging from one of
328// them, sorted. Layers not mapped yet are listed too: they are excluded from
329// their first frame.
330// The result is scratch of the exclusion, valid until the next call.
331func (c *Core) captureExcluded(e *capExclusion) []WindowID {
332	out := append(append(e.scratch[:0], e.layers...), e.retained...)
333	for id := range c.popups {
334		if root := c.popupRoot(id); slices.Contains(e.layers, root) || slices.Contains(e.retained, root) {
335			out = append(out, id)
336		}
337	}
338	slices.Sort(out)
339	e.scratch = out
340	return out
341}
342
343// captureEvaluate decides every session against the current screens, sends
344// the states that changed and returns the view of this publish; nil when
345// there is no session and no exclusion. A terminal state
346// is sent once and the session is forgotten.
347func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
348	if len(c.capt.sessions) == 0 && c.capt.excl == nil {
349		return nil, nil
350	}
351	c.pruneRetained()
352	// The scratch is the view and the states of this evaluation; nothing the
353	// scenes or the commands keep points into it.
354	v := &c.capt.scratch
355	*v = capView{}
356	// Decide each session. The live ones are kept in place.
357	states := c.capt.states[:0]
358	alive := c.capt.sessions[:0]
359	for i, s := range c.capt.sessions {
360		st := ports.CaptureSessionState{ID: s.open.ID}
361		r, reason := c.capResolve(s.target())
362		switch {
363		case reason != ports.CaptureReasonNone:
364		case s.open.Window != 0:
365			// A window is always rendered off screen, alone at its size.
366			st.Output, st.Rect, st.Hidden = r.sc.name(), r.size, true
367			if !v.offscreen() && r.size.W > 0 && r.size.H > 0 {
368				v.window, v.windowScr, v.windowSz = s.open.Window, r.sc, r.size
369			}
370			st.Active = v.window == s.open.Window
371		default:
372			st.Output, st.Rect, st.Workspace, st.Hidden = r.sc.name(), r.rect, s.open.Workspace, r.hidden
373			if r.hidden && !v.offscreen() {
374				v.hidden, v.hiddenScr, v.hiddenID = r.ws, r.sc, s.open.Workspace
375			}
376			// One target is rendered off screen at a time.
377			st.Active = !r.hidden || v.hiddenID == s.open.Workspace
378		}
379		st.Reason = reason
380		states = append(states, st)
381		if reason != ports.CaptureReasonNone {
382			if err := c.command(ctx, st); err != nil {
383				c.capt.sessions = append(alive, c.capt.sessions[i:]...)
384				return nil, err
385			}
386			if c.capt.excl != nil && c.capt.excl.session == s.open.ID {
387				c.dropExclusion()
388			}
389			continue
390		}
391		alive = append(alive, s)
392	}
393	clear(c.capt.sessions[len(alive):])
394	c.capt.sessions = alive
395	c.capt.states = states
396	// Exclusion.
397	if e := c.capt.excl; e != nil {
398		ex := c.captureExcluded(e)
399		if len(ex) > ports.MaxCaptureExcluded {
400			// Fail closed: the session cannot leave them all out.
401			st := ports.CaptureSessionState{ID: e.session, Reason: ports.CaptureReasonTooManyExcluded}
402			if err := c.command(ctx, st); err != nil {
403				return nil, err
404			}
405			c.capt.sessions = slices.DeleteFunc(c.capt.sessions, func(s *capSession) bool { return s.open.ID == e.session })
406			c.dropExclusion()
407		} else {
408			v.exclusion = e
409			keep := c.exclusionKeep(e)
410			for _, sc := range c.screens {
411				sc.capture = keep
412			}
413			var st ports.CaptureSessionState
414			for _, x := range states {
415				if x.ID == e.session {
416					st = x
417				}
418			}
419			if !e.hasKey || !slices.Equal(e.key.excluded, ex) || !slices.Equal(e.key.layers, e.layers) || !sameCaptureState(e.key.target, st) {
420				e.rev++
421				e.key = capExclusionKey{excluded: slices.Clone(ex), layers: slices.Clone(e.layers), target: st}
422				e.hasKey = true
423			}
424			// What the scenes carry is the key's list, shared while it holds.
425			v.excluded = e.key.excluded
426		}
427	}
428	// Tell wayland what changed.
429	for _, s := range c.capt.sessions {
430		var st ports.CaptureSessionState
431		for _, x := range states {
432			if x.ID == s.open.ID {
433				st = x
434			}
435		}
436		if e := c.capt.excl; e != nil && e.session == s.open.ID {
437			st.Exclusion, st.Revision = true, e.rev
438			// The list is sent, read by another goroutine: shared, never
439			// rebuilt, while the attached set is the same.
440			if s.hasLast && slices.Equal(s.last.Layers, e.layers) {
441				st.Layers = s.last.Layers
442			} else {
443				st.Layers = slices.Clone(e.layers)
444			}
445		}
446		if !s.hasLast || !sameCaptureState(s.last, st) {
447			s.last, s.hasLast = st, true
448			if err := c.command(ctx, st); err != nil {
449				return nil, err
450			}
451		}
452	}
453	return v, nil
454}
455
456// sameCaptureState reports whether two session states say the same.
457func sameCaptureState(a, b ports.CaptureSessionState) bool {
458	return a.ID == b.ID && a.Output == b.Output && a.Rect == b.Rect && a.Workspace == b.Workspace &&
459		a.Hidden == b.Hidden && a.Active == b.Active && a.Reason == b.Reason &&
460		a.Exclusion == b.Exclusion && a.Revision == b.Revision && slices.Equal(a.Layers, b.Layers)
461}
462
463// captureSceneFor is the capture state of one screen's scene; nil when
464// there is no session. Shown is the workspace drawn as itself on screen.
465func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
466	if v == nil || len(c.capt.sessions) == 0 && v.exclusion == nil {
467		return nil
468	}
469	want := ports.SceneCapture{}
470	if !sc.mon.previewing() {
471		want.Shown = sc.mon.Current().ID
472	}
473	if e := v.exclusion; e != nil {
474		want.Session, want.Revision, want.Excluded = e.session, e.rev, v.excluded
475	}
476	if v.hiddenScr == sc {
477		want.Workspace = v.hiddenID
478	}
479	if v.windowScr == sc {
480		want.Window = v.window
481	}
482	// The scene is read by the output owner and never changed: the last one
483	// is shared while it says the same.
484	if p := sc.capScene; p != nil && p.Shown == want.Shown && p.Session == want.Session && p.Revision == want.Revision &&
485		p.Workspace == want.Workspace && p.Window == want.Window && slices.Equal(p.Excluded, want.Excluded) {
486		return p
487	}
488	shared := want
489	sc.capScene = &shared
490	return sc.capScene
491}
492
493// workspaceByID finds a workspace, numbered or hidden, on any screen.
494func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) {
495	for _, sc := range c.screens {
496		for w := range sc.mon.all() {
497			if w.ID == id {
498				return sc, w
499			}
500		}
501	}
502	return nil, nil
503}

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