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 "sync"
22
23 "github.com/bnema/neferclient"
24
25 "github.com/bnema/nefercap/internal/adapters/layerui"
26 "github.com/bnema/nefercap/internal/adapters/uistyle"
27 "github.com/bnema/nefercap/internal/core"
28 "github.com/bnema/nefercap/internal/ports"
29)
30
31//go:embed style.css
32var styleSheet string
33
34// DefaultFPS is the recording frame rate assigned to record selections.
35const DefaultFPS = 30
36
37// namespace is an opaque role hint for the compositor, not an authorization.
38const namespace = "nefercap-selector"
39
40// MaxOutputs is the most outputs one selection covers: one per digit key.
41const MaxOutputs = 9
42
43// ErrTooManyOutputs reports more outputs than MaxOutputs. Nothing is opened.
44var ErrTooManyOutputs = errors.New("selection: too many outputs")
45
46var errNoOutputs = fmt.Errorf("selection: no outputs to select: %w", ports.ErrOutputNotFound)
47
48// Selector shows the layer-shell selection overlay.
49type Selector struct {
50 mode ports.Mode
51 video ports.VideoSettings
52 workspaces []ports.Workspace
53 grid bool
54 toggle bool
55 noIndicator bool
56}
57
58var _ ports.Selector = (*Selector)(nil)
59
60// New returns a selector for mode. Record selections carry video; a zero FPS
61// becomes DefaultFPS. workspaces is ext-workspace metadata: the W key is
62// offered only for the Active workspace with a valid (nonzero) ID on the output
63// an overlay covers. Other workspaces are never offered and nothing is
64// invented when no workspace qualifies; a NeferWL recording of that workspace
65// keeps following it when the user switches away. grid is the initial state of the
66// whole-monitor guides; the G key toggles them per overlay. The caller assigns
67// Selection.Path. The first output passed to Select opens first, so the caller
68// orders outputs to put the preferred one first.
69func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace, grid bool) *Selector {
70 if video.FPS == 0 {
71 video.FPS = DefaultFPS
72 }
73 return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...), grid: grid}
74}
75
76// WithCapabilities adapts the selector to the compositor: W is offered only
77// when it has workspaces, and record mode says so when it cannot show a
78// recording indicator. Without it the selector assumes nothing is missing.
79func (s *Selector) WithCapabilities(c ports.Capabilities) *Selector {
80 s.noIndicator = !c.Exclusion
81 if !c.Workspaces {
82 s.workspaces = nil
83 }
84 return s
85}
86
87// AllowToggle lets Tab switch between screenshot and record selection. The
88// starting mode is New's; the target selection is kept across a switch.
89func (s *Selector) AllowToggle() *Selector {
90 s.toggle = true
91 return s
92}
93
94// Select blocks until the user accepts a selection (true), aborts with Escape
95// (false, nil) or ctx ends or the window system fails (error). One overlay
96// opens on every output at once, so the whole desktop is selectable without
97// reopening. The first decision from any overlay wins and ends all of them:
98// Select returns only after every overlay's Wayland session has closed, so an
99// accepted selection never races a window that is still mapped. The compositor
100// may still be processing that teardown, so a caller must confirm through a
101// fresh capture connection before capturing. Select creates no capture path or
102// directory and fails with ErrTooManyOutputs rather than dropping outputs. One
103// goroutine at a time.
104//
105// Keyboard: outputs[0] takes exclusive keyboard focus; the other overlays take
106// it on demand, which compositors grant when the pointer clicks them. Digits
107// 1..9 pick that whole monitor from whichever overlay has the keyboard.
108func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
109 if err := ctx.Err(); err != nil {
110 return ports.Selection{}, false, err
111 }
112 if len(outputs) == 0 {
113 return ports.Selection{}, false, errNoOutputs
114 }
115 if len(outputs) > MaxOutputs {
116 return ports.Selection{}, false, fmt.Errorf("%w: %d outputs, at most %d", ErrTooManyOutputs, len(outputs), MaxOutputs)
117 }
118 sheet, remove, err := uistyle.Write(styleSheet, "nefercap-selector-*.css")
119 if err != nil {
120 return ports.Selection{}, false, fmt.Errorf("selection: %w", err)
121 }
122 defer remove()
123
124 child, cancel := context.WithCancel(ctx)
125 defer cancel()
126 sess := newSession(cancel)
127 var wg sync.WaitGroup
128 for i := range outputs {
129 m := newModel(s.mode, outputs, i, sess, s.grid)
130 m.setWorkspaces(s.workspaces)
131 m.noIndicator = s.noIndicator
132 if s.toggle {
133 m.toggle, m.wake = true, make(chan struct{}, 1)
134 sess.wakes = append(sess.wakes, m.wake)
135 }
136 m.footerText = footerText(m.mode, m.footerKind, m.footerWorkspace, m.toggle, m.noIndicator)
137 wg.Add(1)
138 go func() {
139 defer wg.Done()
140 sess.runEnded(ctx, outputs[i].Name, run(child, sheet, m))
141 }()
142 }
143 wg.Wait()
144 result, ok, err := sess.outcome(ctx)
145 if err != nil || !ok {
146 return ports.Selection{}, false, err
147 }
148 return s.selectionFor(sess.acceptedMode(), result), true, nil
149}
150
151// run shows one overlay on its own output until the context ends.
152func run(ctx context.Context, sheet string, m *model) error {
153 kb := neferclient.KeyboardOnDemand
154 if m.index == 0 {
155 kb = neferclient.KeyboardExclusive
156 }
157 return layerui.Run(ctx, layerui.Config{
158 Layer: layerConfig(m.outputs[m.index].Name, kb),
159 Styles: sheet,
160 Wake: m.wake, // nil without toggle: never ready
161 OnResize: m.resize,
162 OnInput: m.input,
163 }, m, view)
164}
165
166// layerConfig is one overlay: every edge, above windows, the given keyboard
167// mode, and an exclusive zone of -1 so no bar shifts it.
168func layerConfig(output string, keyboard neferclient.KeyboardMode) neferclient.LayerConfig {
169 return neferclient.LayerConfig{
170 Output: output,
171 Namespace: namespace,
172 Level: neferclient.LayerOverlay,
173 Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
174 Keyboard: keyboard,
175 ExclusiveZone: -1,
176 }
177}
178
179// selection converts an accepted pick to a port selection through the core
180// rules: a monitor and a workspace carry a zero Region (a workspace's geometry
181// comes live from the compositor, identified by WorkspaceID), a dragged region
182// keeps its explicit output-local geometry.
183func (s *Selector) selection(r core.PickResult) ports.Selection {
184 return s.selectionFor(s.mode, r)
185}
186
187func (s *Selector) selectionFor(mode ports.Mode, r core.PickResult) ports.Selection {
188 sel := ports.Selection{Mode: mode, Target: r.Target()}
189 if mode == ports.Record {
190 sel.Video = s.video
191 }
192 return sel
193}