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}