v0.2.0 a558f49

indicator.go RawHistoryFolder

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

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