1// Package indicator is the recording HUD: a small layer surface at the top
2// centre of the recorded output (of the workspace frame, when it is smaller)
3// showing a REC timer and a Stop button.
4//
5// It is one thin surface, not a full-output overlay: it takes no keyboard
6// focus, and only the Stop button receives pointer input; the rest of the HUD
7// and everything around it pass clicks through. The border around the
8// recorded area is not drawn here: the compositor marks it.
9//
10// Capture exclusion is explicit authorization, not a property of how the HUD is
11// built. The caller supplies authorize, which runs with the HUD's display and
12// wl_surface once it is configured and before its first buffer, to register whatever
13// exclusion the compositor contract requires on that same client; Run refuses
14// to start without it. The layer namespace is only a role hint and authorizes
15// nothing. If authorize fails, Run fails and nothing is shown.
16package indicator
17
18import (
19 "context"
20 _ "embed"
21 "errors"
22 "fmt"
23 "sync"
24 "time"
25
26 "github.com/bnema/neferclient"
27 "github.com/bnema/nefergui"
28 "github.com/bnema/wlturbo"
29
30 "github.com/bnema/nefercap/internal/adapters/layerui"
31 "github.com/bnema/nefercap/internal/adapters/uierrors"
32 "github.com/bnema/nefercap/internal/adapters/uistyle"
33 "github.com/bnema/nefercap/internal/ports"
34)
35
36//go:embed style.css
37var styleSheet string
38
39// HUD geometry in logical pixels. The Stop button is the only input region.
40const (
41 baseFont = 16
42 Width, Height = baseFont * 24, baseFont * 5 / 2
43 stopWidth = baseFont * 45 / 8 // 5.625rem in CSS
44 topMargin = baseFont / 2
45 namespace = "nefercap-indicator"
46 tick = time.Second
47)
48
49var errOverlayClosed = errors.New("overlay closed by the compositor")
50
51// Setup errors, returned before anything is shown.
52var (
53 // ErrNoAuthorize means a HUD was built without an exclusion hook.
54 ErrNoAuthorize = errors.New("indicator: capture exclusion hook is required")
55 // ErrNoStop means a HUD was built without a stop channel.
56 ErrNoStop = errors.New("indicator: stop channel is required")
57)
58
59// Indicator shows the recording HUD.
60type Indicator struct {
61 // TargetLabel optionally names what is recorded (a workspace or output
62 // name). It is shown after the timer, cut to 12 characters, and only as
63 // text: the HUD never draws geometry for it. Set it before Run.
64 TargetLabel string
65 // Frame optionally is the part of the output that is recorded, in
66 // output-local logical pixels (a workspace smaller than its output): the
67 // HUD sits at the top centre of it. The zero value is the whole output.
68 // The position is fixed when the recording starts: if the workspace frame
69 // changes during the recording, the video follows it and the HUD stays in
70 // place. Set it before Run.
71 Frame ports.Region
72
73 authorize Authorize
74 stop chan<- struct{}
75}
76
77// Authorize registers the HUD's surface with the compositor's capture
78// exclusion; see layerui.Config.OnSurface.
79type Authorize = func(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error
80
81// New returns an indicator. authorize is required and runs once per Run, on
82// its own goroutine; its context ends when Run's does, so a blocking wait
83// for the compositor's acknowledgement is interrupted by cancellation. stop
84// should be buffered; a click sends one non-blocking value. The caller reacts
85// by ending the recording.
86func New(authorize Authorize, stop chan<- struct{}) *Indicator {
87 return &Indicator{authorize: authorize, stop: stop}
88}
89
90// Run shows the HUD on output until ctx ends or the window system fails. It
91// returns nil when ctx was canceled by the caller, and an error otherwise; the
92// error names the output and its size. The elapsed time starts when Run is
93// called, before the window exists. Run blocks; call it from its own goroutine
94// and cancel ctx to remove the HUD.
95func (i *Indicator) Run(ctx context.Context, output ports.Output) error {
96 switch {
97 case i == nil || i.authorize == nil:
98 return ErrNoAuthorize
99 case i.stop == nil:
100 return ErrNoStop
101 case output.Name == "":
102 return fmt.Errorf("indicator: output %d has no name: %w", output.ID, ports.ErrOutputNotFound)
103 }
104 if err := ctx.Err(); err != nil {
105 return err
106 }
107 if err := i.run(ctx, output, time.Now()); err != nil {
108 return fmt.Errorf("indicator: output %s (%dx%d): %w", output.Name, output.Width, output.Height, err)
109 }
110 return nil
111}
112
113func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Time) error {
114 sheet, remove, err := uistyle.Write(styleSheet, "nefercap-indicator-*.css")
115 if err != nil {
116 return err
117 }
118 defer remove()
119
120 child, cancel := context.WithCancel(ctx)
121 wake := make(chan struct{}, 1)
122 var wg sync.WaitGroup
123 wg.Add(1)
124 go func() { defer wg.Done(); ticker(child, wake) }()
125 defer func() { cancel(); wg.Wait() }()
126
127 runErr := layerui.Run(child, layerui.Config{
128 Layer: layerConfig(output.Name, i.Frame),
129 Styles: sheet,
130 Wake: wake,
131 OnSurface: i.authorize,
132 }, newHUD(start, i.stop, i.TargetLabel), view)
133 // Run is only ever ended by ctx: the HUD never decides by itself. Read the
134 // parent once; a bare Canceled is then our own cancellation, and anything
135 // else, including an end with a live parent, is a failure that keeps every
136 // real error.
137 perr := ctx.Err()
138 return uierrors.Ended(runErr, perr != nil, perr, errOverlayClosed)
139}
140
141// layerConfig is a top-centre HUD that never takes keyboard focus and accepts
142// pointer input only inside the Stop button. Centre is that of the output, or
143// of frame when it is set: then the HUD is anchored to the top left of the
144// output and moved by margins.
145func layerConfig(output string, frame ports.Region) neferclient.LayerConfig {
146 c := neferclient.LayerConfig{
147 Output: output,
148 Namespace: namespace,
149 Level: neferclient.LayerOverlay,
150 Anchors: neferclient.AnchorTop,
151 Keyboard: neferclient.KeyboardNone,
152 ExclusiveZone: -1,
153 Margin: [4]int32{topMargin, 0, 0, 0},
154 InputRects: []neferclient.Rect{stopRect()},
155 Width: Width,
156 Height: Height,
157 }
158 if frame.Width > 0 && frame.Height > 0 {
159 c.Anchors = neferclient.AnchorTop | neferclient.AnchorLeft
160 c.Margin = [4]int32{int32(frame.Y) + topMargin, 0, 0, int32(frame.X + max((frame.Width-Width)/2, 0))}
161 }
162 return c
163}
164
165func stopRect() neferclient.Rect {
166 return neferclient.Rect{X: Width - stopWidth, Y: 0, Width: stopWidth, Height: Height}
167}
168
169// ticker sends a non-blocking wake token every second until ctx ends.
170func ticker(ctx context.Context, wake chan<- struct{}) {
171 t := time.NewTicker(tick)
172 defer t.Stop()
173 for {
174 select {
175 case <-ctx.Done():
176 return
177 case <-t.C:
178 select {
179 case wake <- struct{}{}:
180 default:
181 }
182 }
183 }
184}
185
186func view(f *nefergui.Frame, m *hud) {
187 root := f.Root(nefergui.Class("hud"))
188 row := root.Row(nefergui.Class("bar"))
189 row.Text(m.refresh(time.Now()), nefergui.Key("timer"), nefergui.Class("timer"))
190 if row.Button("Stop", nefergui.Key("stop"), nefergui.Class("stop")).Activated() {
191 m.requestStop()
192 }
193}