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

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