main 3d41bc5

selector.go RawHistoryFolder

1// Package selection is the layer-shell capture selector, drawn with NeferGUI.
2//
3// Select opens one transparent overlay layer surface on every output. Each
4// dims everything except the dragged region, draws thin accent lines and a
5// size label, and the first decision from any of them is returned. It needs no
6// screenshot: the live compositor shows through the transparent surface.
7//
8// One overlay per output: every monitor is selectable at once. A Wayland
9// client cannot hold exclusive keyboard focus on several surfaces, so the first
10// output's overlay is keyboard-exclusive and the others take the keyboard on
11// demand (on click). Each overlay is a separate layer surface on its own
12// Wayland connection, with its own model on its own owner loop; they share only
13// a first-decision-wins session.
14package selection
15
16import (
17	"context"
18	_ "embed"
19	"errors"
20	"fmt"
21	"image"
22	"sync"
23
24	"github.com/bnema/neferclient"
25
26	"github.com/bnema/nefercap/internal/adapters/layerui"
27	"github.com/bnema/nefercap/internal/adapters/uistyle"
28	"github.com/bnema/nefercap/internal/core"
29	"github.com/bnema/nefercap/internal/ports"
30)
31
32//go:embed style.css
33var styleSheet string
34
35// DefaultFPS is the recording frame rate assigned to record selections.
36const DefaultFPS = 30
37
38// namespace is an opaque role hint for the compositor, not an authorization.
39const namespace = "nefercap-selector"
40
41// MaxOutputs is the most outputs one selection covers: one per digit key.
42const MaxOutputs = 9
43
44// ErrTooManyOutputs reports more outputs than MaxOutputs. Nothing is opened.
45var ErrTooManyOutputs = errors.New("selection: too many outputs")
46
47var errNoOutputs = fmt.Errorf("selection: no outputs to select: %w", ports.ErrOutputNotFound)
48
49// Selector shows the layer-shell selection overlay.
50type Selector struct {
51	mode        ports.Mode
52	video       ports.VideoSettings
53	workspaces  []ports.Workspace
54	grid        bool
55	toggle      bool
56	noIndicator bool
57	backdrops   map[uint32]image.Image // frozen screens by output ID
58	frozen      bool                   // the accepted selection showed the frozen screen
59}
60
61var _ ports.Selector = (*Selector)(nil)
62
63// New returns a selector for mode. Record selections carry video; a zero FPS
64// becomes DefaultFPS. workspaces is ext-workspace metadata: the W key is
65// offered only for the Active workspace with a valid (nonzero) ID on the output
66// an overlay covers. Other workspaces are never offered and nothing is
67// invented when no workspace qualifies; a NeferWL recording of that workspace
68// keeps following it when the user switches away. grid is the initial state of the
69// whole-monitor guides; the G key toggles them per overlay. The caller assigns
70// Selection.Path. The first output passed to Select opens first, so the caller
71// orders outputs to put the preferred one first.
72func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector {
73	if video.FPS == 0 {
74		video.FPS = DefaultFPS
75	}
76	return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid}
77}
78
79// WithCapabilities adapts the selector to the compositor: W is offered only
80// when it has workspaces, and record mode says so when it cannot show a
81// recording indicator. Without it the selector assumes nothing is missing.
82func (s *Selector) WithCapabilities(c ports.Capabilities) *Selector {
83	s.noIndicator = !c.Exclusion
84	if !c.Workspaces {
85		s.workspaces = nil
86	}
87	return s
88}
89
90// These bounds mirror NeferGUI's image dimension and cache byte limits.
91const (
92	maxBackdrop      = 8192
93	maxBackdropBytes = 64 << 20
94)
95
96// CanBackdrop reports whether an output's physical mode fits NeferGUI's image
97// limits. Outputs exceeding either the side or byte limit stay live.
98func CanBackdrop(o ports.Output) bool {
99	return o.Width > 0 && o.Height > 0 && max(o.Width, o.Height) <= maxBackdrop &&
100		o.Width <= maxBackdropBytes/ports.BytesPerPixel/o.Height
101}
102
103// WithBackdrops draws each output's frozen frame under its overlay instead of
104// the live screen; outputs without a frame stay live. A frame larger than
105// CanBackdrop allows is an error. The frames are copied, so the caller may
106// release them once this returns.
107func (s *Selector) WithBackdrops(frames map[uint32]ports.Frame) (*Selector, error) {
108	s.backdrops = make(map[uint32]image.Image, len(frames))
109	for id, f := range frames {
110		if !CanBackdrop(ports.Output{Width: f.Width, Height: f.Height}) {
111			return s, fmt.Errorf("selection: frozen frame of output %d is %dx%d, at most %d per side and %d bytes", id, f.Width, f.Height, maxBackdrop, maxBackdropBytes)
112		}
113		s.backdrops[id] = backdropImage(f)
114	}
115	return s, nil
116}
117
118// backdropImage converts a validated frame to an opaque image. NRGBA is the
119// format NeferGUI uploads without another conversion.
120func backdropImage(f ports.Frame) *image.NRGBA {
121	img := image.NewNRGBA(image.Rect(0, 0, f.Width, f.Height))
122	for y := range f.Height {
123		src, dst := f.Row(y), img.Pix[y*img.Stride:]
124		for x := 0; x+3 < len(src); x += 4 {
125			dst[x], dst[x+1], dst[x+2], dst[x+3] = src[x+2], src[x+1], src[x], 0xff
126		}
127	}
128	return img
129}
130
131// Frozen reports whether the last accepted selection still showed the frozen
132// screen, so its screenshot must come from the frozen copy. It is false after
133// F switched to the live screen, and when there were no backdrops.
134func (s *Selector) Frozen() bool { return s.frozen }
135
136// AllowToggle lets Tab switch between screenshot and record selection. The
137// starting mode is New's; the target selection is kept across a switch.
138func (s *Selector) AllowToggle() *Selector {
139	s.toggle = true
140	return s
141}
142
143// Select blocks until the user accepts a selection (true), aborts with Escape
144// (false, nil) or ctx ends or the window system fails (error). One overlay
145// opens on every output at once, so the whole desktop is selectable without
146// reopening. The first decision from any overlay wins and ends all of them:
147// Select returns only after every overlay's Wayland session has closed, so an
148// accepted selection never races a window that is still mapped. The compositor
149// may still be processing that teardown, so a caller must confirm through a
150// fresh capture connection before capturing. Select creates no capture path or
151// directory and fails with ErrTooManyOutputs rather than dropping outputs. One
152// goroutine at a time.
153//
154// Keyboard: outputs[0] takes exclusive keyboard focus; the other overlays take
155// it on demand, which compositors grant when the pointer clicks them. Digits
156// 1..9 pick that whole monitor from whichever overlay has the keyboard.
157func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
158	if err := ctx.Err(); err != nil {
159		return ports.Selection{}, false, err
160	}
161	if len(outputs) == 0 {
162		return ports.Selection{}, false, errNoOutputs
163	}
164	if len(outputs) > MaxOutputs {
165		return ports.Selection{}, false, fmt.Errorf("%w: %d outputs, at most %d", ErrTooManyOutputs, len(outputs), MaxOutputs)
166	}
167	sheet, remove, err := uistyle.Write(styleSheet, "nefercap-selector-*.css")
168	if err != nil {
169		return ports.Selection{}, false, fmt.Errorf("selection: %w", err)
170	}
171	defer remove()
172
173	child, cancel := context.WithCancel(ctx)
174	defer cancel()
175	sess := newSession(cancel)
176	sess.canFreeze = len(s.backdrops) > 0
177	s.frozen = false
178	// Build every model and the complete wakes slice before any overlay
179	// starts: a running overlay reads sess.wakes on Tab or F.
180	models := make([]*model, len(outputs))
181	for i := range outputs {
182		m := newModel(s.mode, outputs, i, sess, s.grid)
183		m.setWorkspaces(s.workspaces)
184		m.noIndicator = s.noIndicator
185		m.backdrop = s.backdrops[outputs[i].ID]
186		// Every overlay has a wake: Tab on another overlay and the key
187		// effects both redraw through it.
188		m.wake, m.anim = make(chan struct{}, 1), newAnimator()
189		if s.toggle {
190			m.toggle = true
191		}
192		if s.toggle || sess.canFreeze {
193			sess.wakes = append(sess.wakes, m.wake)
194		}
195		m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator, m.freezeHint())
196		models[i] = m
197	}
198	var wg sync.WaitGroup
199	for i, m := range models {
200		wg.Add(1)
201		go func() {
202			defer wg.Done()
203			fx, stop := context.WithCancel(child)
204			var anim sync.WaitGroup
205			anim.Go(func() { m.anim.run(fx, m.wake) })
206			err := run(child, sheet, m)
207			stop()
208			anim.Wait()
209			sess.runEnded(ctx, outputs[i].Name, err)
210		}()
211	}
212	wg.Wait()
213	s.backdrops = nil // every overlay ended: nothing draws them any more
214	result, ok, err := sess.outcome(ctx)
215	if err != nil || !ok {
216		return ports.Selection{}, false, err
217	}
218	s.frozen = sess.acceptedFrozen()
219	return s.selectionFor(sess.acceptedMode(), result), true, nil
220}
221
222// run shows one overlay on its own output until the context ends.
223func run(ctx context.Context, sheet string, m *model) error {
224	kb := neferclient.KeyboardOnDemand
225	if m.index == 0 {
226		kb = neferclient.KeyboardExclusive
227	}
228	return layerui.Run(ctx, layerui.Config{
229		Layer:    layerConfig(m.outputs[m.index].Name, kb),
230		Styles:   sheet,
231		Wake:     m.wake,
232		OnResize: m.resize,
233		OnInput:  m.input,
234	}, m, view)
235}
236
237// layerConfig is one overlay: every edge, above windows, the given keyboard
238// mode, and an exclusive zone of -1 so no bar shifts it.
239func layerConfig(output string, keyboard neferclient.KeyboardMode) neferclient.LayerConfig {
240	return neferclient.LayerConfig{
241		Output:        output,
242		Namespace:     namespace,
243		Level:         neferclient.LayerOverlay,
244		Anchors:       neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
245		Keyboard:      keyboard,
246		ExclusiveZone: -1,
247	}
248}
249
250// selection converts an accepted pick to a port selection through the core
251// rules: a monitor and a workspace carry a zero Region (a workspace's geometry
252// comes live from the compositor, identified by WorkspaceID), a dragged region
253// keeps its explicit output-local geometry.
254func (s *Selector) selection(r core.PickResult) ports.Selection {
255	return s.selectionFor(s.mode, r)
256}
257
258func (s *Selector) selectionFor(mode ports.Mode, r core.PickResult) ports.Selection {
259	sel := ports.Selection{Mode: mode, Target: r.Target()}
260	if mode == ports.Record {
261		sel.Video = s.video
262	}
263	return sel
264}

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