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