v0.2.0 a558f49

record_interactive.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8
9	"github.com/bnema/nefercap/internal/adapters/control"
10	"github.com/bnema/nefercap/internal/adapters/ffmpeg"
11	"github.com/bnema/nefercap/internal/adapters/indicator"
12	"github.com/bnema/nefercap/internal/adapters/uierrors"
13	"github.com/bnema/nefercap/internal/adapters/wayland"
14	"github.com/bnema/nefercap/internal/core"
15	"github.com/bnema/nefercap/internal/logging"
16	"github.com/bnema/nefercap/internal/ports"
17	"github.com/bnema/nefergui"
18)
19
20// recordInteractive records and listens for stop requests. With the
21// compositor's capture exclusion it also shows the HUD, excluded from the
22// video; without it nothing is drawn, so the user stops with the same
23// shortcut or `nefercap stop`. A stop requested by the user is success; the
24// outcome says whether a file exists.
25func recordInteractive(ctx context.Context, notices io.Writer, source *wayland.Source, outputs []ports.Output, sel ports.Selection) (outcome recordingOutcome, err error) {
26	var output ports.Output
27	for _, candidate := range outputs {
28		if candidate.ID == sel.Target.OutputID {
29			output = candidate
30			break
31		}
32	}
33	if output.ID == 0 {
34		return outcome, ports.ErrOutputNotFound
35	}
36	token, withHUD, err := startExclusion(ctx, notices, source, sel.Target)
37	if err != nil {
38		return outcome, err
39	}
40	server, err := control.Listen()
41	if err != nil {
42		return outcome, err
43	}
44	defer func() {
45		if closeErr := server.Close(); closeErr != nil {
46			err = errors.Join(err, closeErr)
47		}
48	}()
49
50	recordCtx, cancelRecord := context.WithCancelCause(ctx)
51	defer cancelRecord(nil)
52	hudCtx, cancelHUD := context.WithCancel(ctx)
53	defer cancelHUD()
54	life := newLifecycle(ctx, cancelRecord, cancelHUD)
55	hudStop := make(chan struct{}, 1)
56	watchCtx, stopWatch := context.WithCancel(context.Background())
57	controlDone := stopOnRequest(watchCtx, server.Requests(), life.userStop)
58	buttonDone := stopOnSignal(watchCtx, hudStop, life.userStop)
59	defer func() { stopWatch(); <-controlDone; <-buttonDone }()
60
61	end := ending{}
62	var video *ffmpeg.Writer
63	run := func() <-chan error {
64		video = ffmpeg.New()
65		captureDone := make(chan error, 1)
66		go func() { captureDone <- core.New(source, nil, video).Run(recordCtx, sel) }()
67		return captureDone
68	}
69	if !withHUD {
70		end.runErr = <-run()
71		life.beginShutdown()
72	} else {
73		hud := startHUD(hudCtx, output, recordedFrame(ctx, source, sel.Target), token, hudStop, life)
74		// Do not start the encoder until the compositor acknowledges the
75		// exclusion: a HUD that is not excluded would be in the video.
76		select {
77		case <-hud.ready:
78			captureDone := run()
79			select {
80			case end.runErr = <-captureDone:
81				life.beginShutdown()
82				cancelHUD()
83				end.hudErr = <-hud.done
84			case hudErr := <-hud.done:
85				end.hudErr = life.hudEnded(hudErr, false)
86				end.runErr = <-captureDone
87			}
88		case hudErr := <-hud.done:
89			end.hudErr = life.hudEnded(hudErr, !authorized(hud.ready))
90		case <-recordCtx.Done():
91			life.beginShutdown()
92			cancelHUD()
93			end.hudErr = <-hud.done
94		}
95	}
96	end.failure = life.failed()
97	end.parentErr = ctx.Err()
98	end.userStop = context.Cause(recordCtx) == errUserStop
99	err = classify(end)
100	if video != nil {
101		// Finalization may retain frames despite a late capture or HUD error.
102		// The writer knows whether it kept its file, not a pre-existing path.
103		outcome.Saved = video.Saved
104	}
105	return outcome, err
106}
107
108// startExclusion asks the compositor to keep the HUD out of the recording. It
109// reports whether a HUD can be shown: without the extension, or when the
110// compositor refuses the exclusion because another one is live (busy), the
111// recording goes on without HUD, like on a compositor with no extension. Any
112// other failure is fatal, including a session the compositor stopped.
113func startExclusion(ctx context.Context, notices io.Writer, source *wayland.Source, target ports.Target) (token string, withHUD bool, err error) {
114	if !source.Capabilities().Exclusion {
115		return "", false, nil
116	}
117	token, err = source.BeginExclusion(ctx, target)
118	switch {
119	case err == nil:
120		return token, true, nil
121	case errors.Is(err, wayland.ErrExclusionBusy) && ctx.Err() == nil:
122		log := logging.For(ctx, "app")
123		log.Warn().Err(err).Msg("capture exclusion refused: recording without indicator")
124		fmt.Fprintln(notices, "recording without indicator: stop with the same shortcut or `nefercap stop`")
125		return "", false, nil
126	}
127	return "", false, exclusionError(ctx, err)
128}
129
130// exclusionError classifies a BeginExclusion failure. The caller's own
131// cancellation stays the bare context error; every other failure gets the
132// phase and keeps its identity.
133func exclusionError(ctx context.Context, err error) error {
134	if ctx.Err() != nil && uierrors.IsCancellation(err) {
135		return ctx.Err()
136	}
137	return fmt.Errorf("begin capture exclusion: %w", err)
138}
139
140// authorized reports, without blocking, whether the HUD signalled ready. Ready
141// and done can both be pending; a HUD that was authorized did not close early.
142func authorized(ready <-chan struct{}) bool {
143	select {
144	case <-ready:
145		return true
146	default:
147		return false
148	}
149}
150
151// hudHandle is the owner's view of a running HUD: ready fires once when the
152// compositor acknowledged the exclusion, done delivers Run's result once.
153type hudHandle struct {
154	ready <-chan struct{}
155	done  <-chan error
156}
157
158// startHUD runs the indicator on its own goroutine. Its detach callback and
159// button only signal the lifecycle; they never touch the recording.
160func startHUD(ctx context.Context, output ports.Output, frame ports.Region, token string, stop chan<- struct{}, life *lifecycle) hudHandle {
161	ready := make(chan struct{}, 1)
162	done := make(chan error, 1)
163	authorize := func(hookCtx context.Context, surface nefergui.WaylandSurface) error {
164		if err := wayland.AuthorizeLayer(hookCtx, surface.Display, surface.Surface, token, life.detached); err != nil {
165			return err
166		}
167		ready <- struct{}{}
168		return nil
169	}
170	go func() {
171		hud := indicator.New(authorize, stop)
172		hud.Frame = frame
173		done <- hud.Run(ctx, output)
174	}()
175	return hudHandle{ready: ready, done: done}
176}
177
178// recordedFrame is where a workspace target is on its output, so that the HUD
179// sits in it. Anything else, or a workspace that cannot be found, is the whole
180// output (the zero Region).
181func recordedFrame(ctx context.Context, source *wayland.Source, t ports.Target) ports.Region {
182	if t.WorkspaceID == 0 {
183		return ports.Region{}
184	}
185	list, err := source.Workspaces(ctx)
186	if err != nil {
187		log := logging.For(ctx, "app")
188		log.Warn().Err(err).Msg("workspace frame unavailable: HUD on the whole output")
189		return ports.Region{}
190	}
191	for _, w := range list {
192		if w.ID == t.WorkspaceID && w.OutputID == t.OutputID {
193			return w.Region
194		}
195	}
196	return ports.Region{}
197}

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 2

Tags 9

↑↓ moveenter openesc close