1package core
2
3import (
4 "slices"
5 "time"
6
7 "github.com/bnema/neferwl/internal/ports"
8)
9
10// The capture indicator: the compositor shows that something is captured,
11// for every capture of every client and protocol, and no client can turn it
12// off. Core owns what is shown and until when; the renderer draws it over the
13// physical output only (Scene.CaptureIndicators) and never in a capture.
14//
15// - a session that produced a frame (CaptureFrameTaken with a Session)
16// marks its target for as long as it lives;
17// - every captured frame marks its target for ports.CaptureFlash after it
18// (a flash), so a one-shot screenshot shows for that long and periodic
19// captures stay visible;
20// - a target drawn on screen (output, region, workspace frame) gets a
21// border; a workspace rendered for capture only has no place on screen, so
22// its output gets a pill.
23//
24// Marks are the union of all live sessions and flashes. Expiry follows the
25// core clock: one timer, armed at the earliest flash, is the only time
26// source. Only the core goroutine touches this state; nothing here runs, or
27// allocates, while no session produced a frame and no flash is live.
28
29// maxCaptureFlashes bounds the live flashes. Past it the flashes of regions
30// of an output collapse into one flash of the whole output (a larger mark,
31// never a missing one); targets that are not regions are bounded by the
32// outputs and workspaces that exist.
33const maxCaptureFlashes = 256
34
35// capFlash is a captured frame's target and when its mark ends.
36type capFlash struct {
37 target capTarget
38 until time.Time
39}
40
41// captureFrame records a captured frame. It reports whether what is shown
42// changed, so a scene must be published; extending a flash that shows
43// already does not.
44func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
45 changed := false
46 if s := c.capt.session(v.Session); v.Session != 0 && s != nil && !s.recording {
47 s.recording, changed = true, true
48 }
49 t := capTarget{output: v.Output, workspace: v.Workspace, window: v.Window, rect: v.Region}
50 if _, reason := c.capResolve(t); reason != ports.CaptureReasonNone {
51 // Nothing of the target is left on screen to mark.
52 return changed
53 }
54 return c.capt.flash(t, c.now(), c.opts.Clock) || changed
55}
56
57// flash marks t until ports.CaptureFlash after now and arms the timer. It
58// reports whether a new mark shows; extending one does not.
59func (s *captureState) flash(t capTarget, now time.Time, clock ports.Clock) bool {
60 until := now.Add(ports.CaptureFlash)
61 defer s.armTimer(now, clock)
62 for i := range s.flashes {
63 if s.flashes[i].target == t {
64 s.flashes[i].until = until
65 return false
66 }
67 }
68 if len(s.flashes) >= maxCaptureFlashes {
69 s.collapseFlashes(t)
70 }
71 s.flashes = append(s.flashes, capFlash{target: t, until: until})
72 return true
73}
74
75// collapseFlashes makes room: the flashes of regions of t's output become one
76// whole-output flash lasting as long as the longest of them; when that is
77// not enough the oldest flash goes.
78func (s *captureState) collapseFlashes(t capTarget) {
79 if t.workspace == 0 && t.output != "" {
80 var until time.Time
81 n := 0
82 s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool {
83 if f.target.workspace != 0 || f.target.output != t.output || f.target.rect == (Rect{}) {
84 return false
85 }
86 if f.until.After(until) {
87 until = f.until
88 }
89 n++
90 return true
91 })
92 if n > 0 {
93 s.flashes = append(s.flashes, capFlash{target: capTarget{output: t.output}, until: until})
94 }
95 }
96 if len(s.flashes) >= maxCaptureFlashes {
97 old := 0
98 for i, f := range s.flashes {
99 if f.until.Before(s.flashes[old].until) {
100 old = i
101 }
102 }
103 s.flashes = slices.Delete(s.flashes, old, old+1)
104 }
105}
106
107// expire forgets the flashes that ended by now.
108func (s *captureState) expire(now time.Time) {
109 s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool { return !f.until.After(now) })
110}
111
112// armTimer arms the timer at the earliest flash when none runs. A flash that
113// was extended leaves it: the timer fires early, finds nothing to forget and
114// arms again. No flash stops it. A nil clock uses the system timer.
115func (s *captureState) armTimer(now time.Time, clock ports.Clock) {
116 if len(s.flashes) == 0 {
117 s.stopTimer()
118 return
119 }
120 if s.timerStop != nil {
121 return
122 }
123 first := s.flashes[0].until
124 for _, f := range s.flashes[1:] {
125 if f.until.Before(first) {
126 first = f.until
127 }
128 }
129 s.timerC, s.timerStop = newTimer(clock, max(first.Sub(now), time.Millisecond))
130}
131
132func (s *captureState) stopTimer() {
133 if s.timerStop != nil {
134 s.timerStop()
135 }
136 s.timerC, s.timerStop = nil, nil
137}
138
139// captureExpire forgets the flashes that ended. With none it reads no clock.
140func (c *Core) captureExpire() {
141 if len(c.capt.flashes) > 0 {
142 c.capt.expire(c.now())
143 }
144}
145
146// captureFlashTick runs when the flash timer fired: it forgets the flashes
147// that ended and re-arms for the next. It reports whether one ended.
148func (c *Core) captureFlashTick() bool {
149 n := len(c.capt.flashes)
150 if n == 0 {
151 c.capt.stopTimer()
152 return false
153 }
154 now := c.now()
155 c.capt.expire(now)
156 c.capt.armTimer(now, c.opts.Clock)
157 return len(c.capt.flashes) != n
158}
159
160func (c *Core) armCaptureTimer() {
161 if len(c.capt.flashes) == 0 {
162 c.capt.stopTimer()
163 return
164 }
165 if c.capt.timerStop == nil {
166 c.capt.armTimer(c.now(), c.opts.Clock)
167 }
168}
169
170func (c *Core) stopCaptureTimer() { c.capt.stopTimer() }
171
172// captureIndicators is the marks of one output's scene; nil when there are
173// none. The slice is immutable once published and shared with the next scene
174// of the screen while the marks are the same; it is cloned only when they
175// change, so a steady recording allocates nothing.
176func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
177 if c.capt.idle() {
178 sc.capMarks = nil
179 return nil
180 }
181 out := c.capt.marks[:0]
182 add := func(t capTarget) {
183 r, reason := c.capResolve(t)
184 if reason != ports.CaptureReasonNone || r.sc != sc {
185 return
186 }
187 o := sc.mon.Output()
188 mark := func(m ports.CaptureIndicator) {
189 if !slices.Contains(out, m) {
190 out = append(out, m)
191 }
192 }
193 switch {
194 case t.window != 0:
195 // A window is rendered off screen, so its frames need the pill;
196 // while it is on screen it is outlined too.
197 mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
198 if r.visible {
199 mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
200 }
201 case r.hidden:
202 mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
203 default:
204 mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
205 }
206 }
207 for _, s := range c.capt.sessions {
208 if s.recording {
209 add(s.target())
210 }
211 }
212 for _, f := range c.capt.flashes {
213 add(f.target)
214 }
215 c.capt.marks = out
216 switch {
217 case len(out) == 0:
218 sc.capMarks = nil
219 case !slices.Equal(sc.capMarks, out):
220 sc.capMarks = slices.Clone(out)
221 }
222 return sc.capMarks
223}
224
225// thickenMark inflates a mark to at least ports.MinCaptureMark on each side,
226// around the target and clamped to the output (a target one logical pixel
227// wide gets a border with something to draw; the border then covers the
228// target whole). An output smaller than that is marked whole.
229func thickenMark(r, out Rect) Rect {
230 grow := func(pos, size, lo, span int) (int, int) {
231 if size >= ports.MinCaptureMark {
232 return pos, size
233 }
234 if span <= ports.MinCaptureMark {
235 return lo, span
236 }
237 pos -= (ports.MinCaptureMark - size) / 2
238 pos = min(max(pos, lo), lo+span-ports.MinCaptureMark)
239 return pos, ports.MinCaptureMark
240 }
241 r.X, r.W = grow(r.X, r.W, 0, out.W)
242 r.Y, r.H = grow(r.Y, r.H, 0, out.H)
243 return r
244}
245
246// pillRect is the pill in the top-right corner of an output, inset, and always
247// whole inside it: an output too small for the pill (clamped to the output it
248// would be cut) is filled whole instead, so the mark is never empty.
249func pillRect(o Rect) Rect {
250 r := Rect{X: max(o.W-ports.CapturePillInset-ports.CapturePillSize, 0), Y: ports.CapturePillInset, W: ports.CapturePillSize, H: ports.CapturePillSize}
251 if in := intersect(r, Rect{W: o.W, H: o.H}); in != r {
252 return Rect{W: o.W, H: o.H}
253 }
254 return r
255}