feat/data-dnd e05cb15

screencast.go RawHistoryFolder

1package wayland
2
3import (
4	"errors"
5	"image"
6	"reflect"
7	"slices"
8	"time"
9
10	"github.com/bnema/neferwl/internal/ports"
11	"golang.org/x/sys/unix"
12)
13
14// Screencast. The screencast adapter (a D-Bus service that feeds PipeWire
15// streams) drives casts through ports.CastCommand on Channels.CastCommands
16// and hears back through Options.Casts (ports.CastSink). A cast is a capture
17// target registered with core like an ext-image-copy-capture session
18// (ports.CaptureSessionOpen), so the capture indicator stays on while it
19// lives, but its frames are written straight into the PipeWire buffer the
20// adapter lends (ports.CaptureRequest.Mapped) and it has no wayland client:
21// the adapter checked its caller, so casts bypass mayCapture, not protected().
22//
23// Everything here runs on the display goroutine, which owns the casts. The
24// frame cycle (CastFrame, the capture reply, CastFrameDone) and the damage
25// events allocate nothing; opening a cast and publishing the inventory may.
26
27// maxCasts bounds the live casts. Each has at most one frame in flight, and
28// a frame takes one of the maxCaptureInflight admission credits like any
29// capture, so capturedRingSize (MaxCaptureInflight + maxCasts) holds every
30// reply.
31const maxCasts = 8
32
33// castRingSize bounds the cast commands waiting for the display goroutine.
34const castRingSize = 64
35
36var (
37	errCastNoTarget  = errors.New("cast: target missing")
38	errCastLimit     = errors.New("cast: too many casts")
39	errCastProtected = errors.New("cast: session is protected")
40	errCastStopped   = errors.New("cast: stopped")
41	errCastNotReady  = errors.New("cast: target not ready")
42	errCastSize      = errors.New("cast: frame size differs from the target")
43	errCastBusy      = errors.New("cast: frame already in flight")
44	errCastUnknown   = errors.New("cast: unknown cast")
45)
46
47// castSession is one cast.
48type castSession struct {
49	captureTarget
50	cast   uint64 // the adapter's ID
51	cursor bool
52	// first: CaptureFrameTaken was sent, which turns the indicator on.
53	first bool
54	size  image.Point // the size the adapter was last told
55	// dirty: the window committed since the last CastDamage (window casts).
56	dirty   bool
57	stopped bool
58	// frame is the frame the adapter waits for (waiting) and, once its
59	// capture was accepted, whether the output owns the buffer (inflight).
60	frame      ports.CastCommand
61	waiting    bool
62	inflight   bool
63	deadline   time.Time
64	retryTimer ports.Timer
65	// doneFn, retryFn and timerFn are bound once, so a frame binds no
66	// closure: the capture reply, the retry on the display goroutine and the
67	// timer callback that posts it.
68	doneFn  func(ports.CaptureDone)
69	retryFn func()
70	timerFn func()
71	// ending: the cast ended while a capture still wrote into the adapter's
72	// buffer; CastStopped follows its reply.
73	ending, announce bool
74	endReason        ports.CaptureReason
75	endErr           error
76}
77
78// postRetry is the retry timer's callback, on the timer's goroutine: it
79// posts the retry to the display goroutine.
80func (c *castSession) postRetry() { c.s.display.Post(c.retryFn) }
81
82// push hands an event to the adapter.
83func (c *castSession) push(ev ports.CastEvent) {
84	ev.ID = c.cast
85	c.s.pushCast(ev)
86}
87
88// pushCast sends ev to the sink; Push never blocks.
89func (s *Server) pushCast(ev ports.CastEvent) {
90	if s.casts != nil {
91		s.casts.Push(ev)
92	}
93}
94
95// applyCast runs one command of the adapter on the display goroutine.
96func (s *Server) applyCast(cmd ports.CastCommand) {
97	switch cmd.Kind {
98	case ports.CastOpen:
99		s.openCast(cmd)
100	case ports.CastFrame:
101		c := s.castSessions[cmd.ID]
102		switch {
103		case c == nil:
104			s.refuseCastFrame(cmd, errCastUnknown)
105		case c.waiting:
106			s.refuseCastFrame(cmd, errCastBusy)
107		default:
108			c.frame, c.waiting = cmd, true
109			c.deadline = s.clock.Now().Add(captureRetryFor)
110			c.attempt()
111		}
112	case ports.CastClose:
113		if c := s.castSessions[cmd.ID]; c != nil {
114			c.end(ports.CaptureReasonNone, nil, true, false)
115		}
116	}
117}
118
119// refuseCastFrame answers a frame no cast takes, so the adapter gets its
120// buffer back.
121func (s *Server) refuseCastFrame(cmd ports.CastCommand, err error) {
122	s.pushCast(ports.CastEvent{Kind: ports.CastFrameDone, ID: cmd.ID, Buf: cmd.Buf, Err: err})
123}
124
125// refuseCast ends a cast that never began.
126func (s *Server) refuseCast(id uint64, reason ports.CaptureReason, err error) {
127	s.log.Info().Uint64("cast", id).Str("reason", string(reason)).Err(err).Msg("cast refused")
128	s.pushCast(ports.CastEvent{Kind: ports.CastStopped, ID: id, Reason: reason, Err: err})
129}
130
131// openCast registers a cast with core and tells the adapter its size.
132func (s *Server) openCast(cmd ports.CastCommand) {
133	switch {
134	case s.castSessions[cmd.ID] != nil:
135		s.log.Warn().Uint64("cast", cmd.ID).Msg("cast open: duplicate ID ignored")
136		return
137	case len(s.castSessions) >= maxCasts:
138		s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastLimit)
139		return
140	case s.protected():
141		s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastProtected)
142		return
143	}
144	src := &captureSource{kind: srcDead}
145	open := ports.CaptureSessionOpen{}
146	gone := ports.CaptureReasonOutputGone
147	if cmd.Window != 0 {
148		gone = ports.CaptureReasonWindowGone
149		if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {
150			src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151			open.Window = cmd.Window
152		}
153	} else if o := s.outputByNameExact(cmd.Output); o != nil {
154		src = &captureSource{kind: srcOutput, output: o}
155		open.Output = o.name()
156	}
157	c := &castSession{captureTarget: captureTarget{s: s, src: src}, cast: cmd.ID, cursor: cmd.Cursor}
158	if src.kind == srcDead {
159		s.refuseCast(cmd.ID, gone, errCastNoTarget)
160		return
161	}
162	_, rect, _, ok := c.geometry()
163	if !ok {
164		s.refuseCast(cmd.ID, gone, errCastNoTarget)
165		return
166	}
167	c.doneFn, c.retryFn, c.timerFn = c.onDone, c.retryNow, c.postRetry
168	s.nextSession++
169	c.id = s.nextSession
170	open.ID = c.id
171	c.size = image.Pt(rect.Dx(), rect.Dy())
172	s.castSessions[cmd.ID], s.castByCore[c.id] = c, c
173	s.emit(open)
174	s.log.Info().Uint64("cast", cmd.ID).Uint64("session", c.id).Int("w", c.size.X).Int("h", c.size.Y).Msg("cast open")
175	c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y})
176}
177
178// state applies what core decided about the cast.
179func (c *castSession) state(st ports.CaptureSessionState) {
180	if c.applyState(st) {
181		c.end(st.Reason, nil, false, true)
182		return
183	}
184	c.refresh()
185}
186
187// refresh follows the target: a new size goes to the adapter, which has to
188// renegotiate its stream; nothing left of the target ends the cast.
189func (c *castSession) refresh() {
190	if c.stopped {
191		return
192	}
193	fate, rect, ok := c.follow()
194	switch {
195	case fate == targetWorkspaceGone:
196		c.end(ports.CaptureReasonWorkspaceGone, nil, true, true)
197	case fate == targetEmpty:
198		c.s.log.Info().Uint64("cast", c.cast).Msg("cast end: target empty")
199		c.end(ports.CaptureReasonOutputGone, nil, true, true)
200	case ok && image.Pt(rect.Dx(), rect.Dy()) != c.size:
201		c.size = image.Pt(rect.Dx(), rect.Dy())
202		c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y})
203	}
204}
205
206// end stops the cast once. notify tells core (which has to forget it unless
207// it decided the end itself), announce tells the adapter (CastStopped; not
208// for its own CastClose). A capture that still writes into the adapter's
209// buffer finishes first: its CastFrameDone, then CastStopped, so CastStopped
210// means the buffers are the adapter's again.
211func (c *castSession) end(reason ports.CaptureReason, err error, notify, announce bool) {
212	if c.stopped {
213		return
214	}
215	s := c.s
216	c.stopped = true
217	if c.retryTimer != nil {
218		c.retryTimer.Stop()
219	}
220	delete(s.castSessions, c.cast)
221	delete(s.castByCore, c.id)
222	if notify {
223		s.emit(ports.CaptureSessionClose{ID: c.id})
224	}
225	c.announce, c.endReason, c.endErr = announce, reason, err
226	if c.inflight {
227		c.ending = true
228		return
229	}
230	if c.waiting {
231		c.frameDone(errCastStopped, time.Time{})
232	}
233	c.finish()
234}
235
236// finish tells the adapter the cast is over.
237func (c *castSession) finish() {
238	if c.announce {
239		c.push(ports.CastEvent{Kind: ports.CastStopped, Reason: c.endReason, Err: c.endErr})
240	}
241}
242
243// frameDone answers the frame the adapter waits for.
244func (c *castSession) frameDone(err error, at time.Time) {
245	c.waiting = false
246	c.push(ports.CastEvent{Kind: ports.CastFrameDone, Buf: c.frame.Buf, Err: err, Time: at})
247	c.frame = ports.CastCommand{}
248}
249
250// retryNow is the posted retry timer: the frame is tried again.
251func (c *castSession) retryNow() {
252	if !c.stopped && c.waiting && !c.inflight {
253		c.attempt()
254	}
255}
256
257// retry tries the frame again shortly, or fails it past its deadline, as a
258// frame of an ext session does (captureExtFrame.retry).
259func (c *castSession) retry(why error) {
260	s := c.s
261	if !s.clock.Now().Before(c.deadline) {
262		s.log.Info().Uint64("cast", c.cast).Err(why).Msg("cast frame failed after retries")
263		c.frameDone(why, time.Time{})
264		return
265	}
266	if c.retryTimer == nil {
267		c.retryTimer = s.clock.AfterFunc(captureRetryEvery, c.timerFn)
268		return
269	}
270	c.retryTimer.Reset(captureRetryEvery)
271}
272
273// attempt serves the waiting frame from the target as it is now.
274func (c *castSession) attempt() {
275	s := c.s
276	if s.protected() {
277		c.end(ports.CaptureReasonNone, errCastProtected, true, true)
278		return
279	}
280	o, rect, hidden, ok := c.geometry()
281	if !ok || c.haveState && !c.st.Active {
282		c.retry(errCastNotReady)
283		return
284	}
285	if rect.Dx() != c.frame.Width || rect.Dy() != c.frame.Height {
286		// The target moved on: the adapter has to renegotiate before it can
287		// ask again; tell it now if refresh has not yet.
288		c.refresh()
289		if !c.stopped {
290			c.frameDone(errCastSize, time.Time{})
291		}
292		return
293	}
294	if !s.requestCast(c, o, rect, c.targetTag(hidden)) {
295		c.retry(ports.ErrCaptureBusy)
296	}
297}
298
299// requestCast queues the capture of the waiting frame into the adapter's
300// buffer. The reply is the cast's own prebound function, not a closure per
301// frame. False: the in-flight bound or the renderer queue is full.
302func (s *Server) requestCast(c *castSession, o *output, rect image.Rectangle, tag captureTag) bool {
303	f := c.frame
304	if o == nil || s.channels.Captures == nil || s.channels.Captured == nil || len(s.captureInflight) >= maxCaptureInflight {
305		return false
306	}
307	s.nextCapture++
308	id := s.nextCapture
309	s.captureInflight[id] = struct{}{}
310	s.captureReplies[id] = c.doneFn
311	req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: c.cursor, Mapped: f.Data, Width: f.Width, Height: f.Height, Stride: f.Stride, Format: f.Format, Session: tag.session, Workspace: tag.workspace, Window: tag.window, OffScreen: tag.offscreen, Indicate: true}
312	select {
313	case s.channels.Captures <- req:
314	default:
315		delete(s.captureReplies, id)
316		delete(s.captureInflight, id)
317		return false
318	}
319	c.inflight = true
320	if !c.first {
321		// The indicator is on screen while the frame is rendered; the cast
322		// keeps it on for as long as it lives.
323		c.first = true
324		taken := tag.taken
325		taken.Session = tag.session
326		s.emit(taken)
327	}
328	return true
329}
330
331// onDone settles the capture of the waiting frame.
332func (c *castSession) onDone(done ports.CaptureDone) {
333	c.inflight = false
334	if errors.Is(done.Err, ports.ErrCaptureTransient) && !c.stopped {
335		c.retry(done.Err)
336		return
337	}
338	err := done.Err
339	if err != nil {
340		c.s.log.Info().Uint64("cast", c.cast).Err(err).Msg("cast frame failed")
341	}
342	if c.ending {
343		// The cast is over: the adapter must not queue this frame.
344		err = errors.Join(errCastStopped, err)
345	}
346	if c.waiting {
347		c.frameDone(err, done.Time)
348	}
349	if c.ending {
350		c.ending = false
351		c.finish()
352	}
353}
354
355// castFlip answers a flip of an output: the casts on it have something new
356// to show. An output cast always has; a window cast only when its window
357// committed since the last damage. Display goroutine; no allocation.
358func (s *Server) castFlip(output string, f *ports.FlipInfo) {
359	if len(s.castSessions) == 0 {
360		return
361	}
362	var at time.Time
363	for _, c := range s.castSessions {
364		switch c.src.kind {
365		case srcOutput:
366			if c.src.output.name() != output {
367				continue
368			}
369		case srcWindow:
370			if !c.dirty {
371				continue
372			}
373			if o, _, _, ok := c.geometry(); !ok || o.name() != output {
374				continue
375			}
376		default:
377			continue
378		}
379		if at.IsZero() {
380			at = flipTime(f)
381		}
382		c.dirty = false
383		c.push(ports.CastEvent{Kind: ports.CastDamage, Time: at})
384	}
385}
386
387// flipTime is when a flip happened, on CLOCK_MONOTONIC as capture times are.
388func flipTime(f *ports.FlipInfo) time.Time {
389	if f.When > 0 {
390		return time.Unix(0, int64(f.When))
391	}
392	var ts unix.Timespec
393	if unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) != nil {
394		return time.Time{}
395	}
396	return time.Unix(ts.Sec, ts.Nsec)
397}
398
399// castCommitted marks the casts of a window that committed content. A popup
400// is drawn with its toplevel (captureWindowScene), so its commits, map,
401// unmap and reposition mark the casts of the toplevel it hangs from, found
402// through the same parent chain as toplevelRoot.
403func (s *Server) castCommitted(id ports.WindowID) {
404	if len(s.castSessions) == 0 {
405		return
406	}
407	// A popup chain is as deep as menus nest; the bound only guards a cycle.
408	for range 64 {
409		w := s.windows[id]
410		if w == nil || w.popup == nil || w.popup.parent == nil {
411			break
412		}
413		id = w.popup.parent.id
414	}
415	for _, c := range s.castSessions {
416		if c.src.kind == srcWindow && c.src.window == id {
417			c.dirty = true
418		}
419	}
420}
421
422// forwardCasts moves the adapter's commands to the display goroutine through
423// castRing, in order; a full ring blocks this goroutine, never the display.
424func (s *Server) forwardCasts(ctxDone <-chan struct{}) {
425	for {
426		select {
427		case <-ctxDone:
428			return
429		case <-s.display.Stopped():
430			return
431		case cmd, ok := <-s.channels.CastCommands:
432			if !ok {
433				return
434			}
435			if !s.castRing.Push(cmd) {
436				return
437			}
438		}
439	}
440}
441
442// castInventoryChanged schedules a publication of the inventory after the
443// outputs, the titles, the app ids or the mapped windows may have changed.
444// Changes made in one display loop turn share one publication.
445func (s *Server) castInventoryChanged() {
446	if s.casts == nil || s.castInvPosted {
447		return
448	}
449	s.castInvPosted = true
450	s.display.Post(s.castInvFn)
451}
452
453// publishCastInventory sends the inventory when it differs from the last one.
454func (s *Server) publishCastInventory() {
455	s.castInvPosted = false
456	inv := s.castInventory()
457	if s.castInvSent && reflect.DeepEqual(inv, s.castInvLast) {
458		return
459	}
460	// The receiver owns what it is given: keep a copy of our own to compare.
461	s.castInvLast, s.castInvSent = inv, true
462	s.casts.Inventory(s.castInventory())
463}
464
465// castInventory is the outputs and, unless the session is protected, the
466// mapped toplevels now.
467func (s *Server) castInventory() ports.CastInventory {
468	inv := ports.CastInventory{Focused: s.focusedOutput, Outputs: make([]ports.CastOutput, 0, len(s.outputs))}
469	for _, o := range s.outputs {
470		p := o.place
471		out := ports.CastOutput{Info: p.Info, Current: -1, X: p.X, Y: p.Y, Width: p.Width, Height: p.Height, Scale: p.Scale, Transform: p.Transform}
472		for _, h := range s.outputHeads.Heads {
473			if h.Info.Name == p.Info.Name {
474				out.Modes = slices.Clone(h.Modes)
475				if h.Current != nil {
476					out.Current = slices.Index(out.Modes, *h.Current)
477				}
478			}
479		}
480		if out.Current < 0 {
481			// No head inventory (headless) or the mode is not listed: the
482			// mode the output runs.
483			mode := ports.OutputMode{Width: p.Info.Width, Height: p.Info.Height, RefreshMilli: p.Info.RefreshMilli, Preferred: len(out.Modes) == 0}
484			if out.Current = slices.IndexFunc(out.Modes, func(m ports.OutputMode) bool {
485				return m.Width == mode.Width && m.Height == mode.Height && m.RefreshMilli == mode.RefreshMilli
486			}); out.Current < 0 {
487				out.Modes, out.Current = append(out.Modes, mode), len(out.Modes)
488			}
489		}
490		inv.Outputs = append(inv.Outputs, out)
491	}
492	if s.protected() {
493		return inv
494	}
495	for _, id := range s.mappedToplevels() {
496		w := s.windows[id]
497		inv.Windows = append(inv.Windows, ports.CastWindow{ID: id, AppID: w.appID, Title: w.title})
498	}
499	return inv
500}

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 23

Tags 10

↑↓ moveenter openesc close