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}
59
60var _ ports.Selector = (*Selector)(nil)
61
62// New returns a selector for mode. Record selections carry video; a zero FPS
63// becomes DefaultFPS. workspaces is ext-workspace metadata: the W key is
64// offered only for the Active workspace with a valid (nonzero) ID on the output
65// an overlay covers. Other workspaces are never offered and nothing is
66// invented when no workspace qualifies; a NeferWL recording of that workspace
67// keeps following it when the user switches away. grid is the initial state of the
68// whole-monitor guides; the G key toggles them per overlay. The caller assigns
69// Selection.Path. The first output passed to Select opens first, so the caller
70// orders outputs to put the preferred one first.
71func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector {
72 if video.FPS == 0 {
73 video.FPS = DefaultFPS
74 }
75 return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid}
76}
77
78// WithCapabilities adapts the selector to the compositor: W is offered only
79// when it has workspaces, and record mode says so when it cannot show a
80// recording indicator. Without it the selector assumes nothing is missing.
81func (s *Selector) WithCapabilities(c ports.Capabilities) *Selector {
82 s.noIndicator = !c.Exclusion
83 if !c.Workspaces {
84 s.workspaces = nil
85 }
86 return s
87}
88
89// maxBackdrop is the largest side NeferGUI accepts for one image.
90const maxBackdrop = 4096
91
92// CanBackdrop reports whether an output of this physical mode can show a
93// frozen frame. A larger output must stay live: freeze only the others.
94func CanBackdrop(o ports.Output) bool {
95 return o.Width > 0 && o.Height > 0 && max(o.Width, o.Height) <= maxBackdrop
96}
97
98// WithBackdrops draws each output's frozen frame under its overlay instead of
99// the live screen; outputs without a frame stay live. A frame larger than
100// CanBackdrop allows is an error. The frames are copied, so the caller may
101// release them once this returns.
102func (s *Selector) WithBackdrops(frames map[uint32]ports.Frame) (*Selector, error) {
103 s.backdrops = make(map[uint32]image.Image, len(frames))
104 for id, f := range frames {
105 if f.Width > maxBackdrop || f.Height > maxBackdrop {
106 return s, fmt.Errorf("selection: frozen frame of output %d is %dx%d, at most %d per side", id, f.Width, f.Height, maxBackdrop)
107 }
108 s.backdrops[id] = backdropImage(f)
109 }
110 return s, nil
111}
112
113// backdropImage converts a validated frame to an opaque image. NRGBA is the
114// format NeferGUI uploads without another conversion.
115func backdropImage(f ports.Frame) *image.NRGBA {
116 img := image.NewNRGBA(image.Rect(0, 0, f.Width, f.Height))
117 for y := range f.Height {
118 src, dst := f.Row(y), img.Pix[y*img.Stride:]
119 for x := 0; x+3 < len(src); x += 4 {
120 dst[x], dst[x+1], dst[x+2], dst[x+3] = src[x+2], src[x+1], src[x], 0xff
121 }
122 }
123 return img
124}
125
126// AllowToggle lets Tab switch between screenshot and record selection. The
127// starting mode is New's; the target selection is kept across a switch.
128func (s *Selector) AllowToggle() *Selector {
129 s.toggle = true
130 return s
131}
132
133// Select blocks until the user accepts a selection (true), aborts with Escape
134// (false, nil) or ctx ends or the window system fails (error). One overlay
135// opens on every output at once, so the whole desktop is selectable without
136// reopening. The first decision from any overlay wins and ends all of them:
137// Select returns only after every overlay's Wayland session has closed, so an
138// accepted selection never races a window that is still mapped. The compositor
139// may still be processing that teardown, so a caller must confirm through a
140// fresh capture connection before capturing. Select creates no capture path or
141// directory and fails with ErrTooManyOutputs rather than dropping outputs. One
142// goroutine at a time.
143//
144// Keyboard: outputs[0] takes exclusive keyboard focus; the other overlays take
145// it on demand, which compositors grant when the pointer clicks them. Digits
146// 1..9 pick that whole monitor from whichever overlay has the keyboard.
147func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
148 if err := ctx.Err(); err != nil {
149 return ports.Selection{}, false, err
150 }
151 if len(outputs) == 0 {
152 return ports.Selection{}, false, errNoOutputs
153 }
154 if len(outputs) > MaxOutputs {
155 return ports.Selection{}, false, fmt.Errorf("%w: %d outputs, at most %d", ErrTooManyOutputs, len(outputs), MaxOutputs)
156 }
157 sheet, remove, err := uistyle.Write(styleSheet, "nefercap-selector-*.css")
158 if err != nil {
159 return ports.Selection{}, false, fmt.Errorf("selection: %w", err)
160 }
161 defer remove()
162
163 child, cancel := context.WithCancel(ctx)
164 defer cancel()
165 sess := newSession(cancel)
166 var wg sync.WaitGroup
167 for i := range outputs {
168 m := newModel(s.mode, outputs, i, sess, s.grid)
169 m.setWorkspaces(s.workspaces)
170 m.noIndicator = s.noIndicator
171 m.backdrop = s.backdrops[outputs[i].ID]
172 if s.toggle {
173 m.toggle, m.wake = true, make(chan struct{}, 1)
174 sess.wakes = append(sess.wakes, m.wake)
175 }
176 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator)
177 wg.Add(1)
178 go func() {
179 defer wg.Done()
180 sess.runEnded(ctx, outputs[i].Name, run(child, sheet, m))
181 }()
182 }
183 wg.Wait()
184 s.backdrops = nil // every overlay ended: nothing draws them any more
185 result, ok, err := sess.outcome(ctx)
186 if err != nil || !ok {
187 return ports.Selection{}, false, err
188 }
189 return s.selectionFor(sess.acceptedMode(), result), true, nil
190}
191
192// run shows one overlay on its own output until the context ends.
193func run(ctx context.Context, sheet string, m *model) error {
194 kb := neferclient.KeyboardOnDemand
195 if m.index == 0 {
196 kb = neferclient.KeyboardExclusive
197 }
198 return layerui.Run(ctx, layerui.Config{
199 Layer: layerConfig(m.outputs[m.index].Name, kb),
200 Styles: sheet,
201 Wake: m.wake, // nil without toggle: never ready
202 OnResize: m.resize,
203 OnInput: m.input,
204 }, m, view)
205}
206
207// layerConfig is one overlay: every edge, above windows, the given keyboard
208// mode, and an exclusive zone of -1 so no bar shifts it.
209func layerConfig(output string, keyboard neferclient.KeyboardMode) neferclient.LayerConfig {
210 return neferclient.LayerConfig{
211 Output: output,
212 Namespace: namespace,
213 Level: neferclient.LayerOverlay,
214 Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
215 Keyboard: keyboard,
216 ExclusiveZone: -1,
217 }
218}
219
220// selection converts an accepted pick to a port selection through the core
221// rules: a monitor and a workspace carry a zero Region (a workspace's geometry
222// comes live from the compositor, identified by WorkspaceID), a dragged region
223// keeps its explicit output-local geometry.
224func (s *Selector) selection(r core.PickResult) ports.Selection {
225 return s.selectionFor(s.mode, r)
226}
227
228func (s *Selector) selectionFor(mode ports.Mode, r core.PickResult) ports.Selection {
229 sel := ports.Selection{Mode: mode, Target: r.Target()}
230 if mode == ports.Record {
231 sel.Video = s.video
232 }
233 return sel
234}