1package selection
2
3import (
4 "fmt"
5 "math"
6 "strconv"
7
8 "github.com/bnema/nefergui"
9
10 "github.com/bnema/nefercap/internal/core"
11 "github.com/bnema/nefercap/internal/ports"
12)
13
14// btnLeft is the Linux evdev BTN_LEFT code. The picker models the left button.
15const btnLeft = 0x110
16
17type endKind uint8
18
19const (
20 endNone endKind = iota
21 endAccepted // this overlay recorded the accepted pick in the session
22 endCanceled // Escape, or a failure
23)
24
25// model is the state of one output's selector overlay. All methods run on that
26// overlay's NeferGUI owner loop (OnResize, OnInput and the view), so nothing
27// here is locked. The decision leaves through the shared session, never through
28// this struct.
29type model struct {
30 base ports.Mode // mode the selector started in
31 mode ports.Mode // current mode: base flipped by the session's Tab toggles
32 toggle bool // Tab switches shot <-> rec (all-in-one only)
33 // noIndicator: the compositor cannot exclude a HUD from the video, so the
34 // record footer says there is none.
35 noIndicator bool
36 seen uint32 // session toggles already applied to mode
37 wake chan struct{}
38 outputs []ports.Output
39 index int // the output this overlay covers; every output has its own model
40
41 picker core.Picker
42 sized bool
43 w, h int
44 scale float64 // fractional output scale from OnResize; 0 before the first resize
45
46 // The monitor grid belongs to the overlay, not the picker: Begin resets
47 // the picker on a size change but the user's toggle must survive it.
48 grid bool
49 gridLines [core.GridLineCount]frect // whole-monitor guides, logical units
50
51 end endKind
52 sess *session // shared first-decision record; the only cross-overlay state
53
54 workspace ports.Workspace // zero ID: none
55 header string // static per surface
56 footerText string
57 footerKind core.PickKind
58 footerMode ports.Mode
59 footerWorkspace bool
60 label label
61}
62
63func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
64 return &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index), footerText: footerText(mode, core.PickRegion, false, false, false)}
65}
66
67// otherMode is the mode a Tab switches to.
68func otherMode(mode ports.Mode) ports.Mode {
69 if mode == ports.Record {
70 return ports.Screenshot
71 }
72 return ports.Record
73}
74
75// modeAfter is base after n toggles.
76func modeAfter(base ports.Mode, n uint32) ports.Mode {
77 if n%2 == 1 {
78 return otherMode(base)
79 }
80 return base
81}
82
83// syncMode applies toggles made on any overlay. It never allocates.
84func (m *model) syncMode() {
85 if n := m.sess.toggles.Load(); n != m.seen {
86 m.seen, m.mode = n, modeAfter(m.base, n)
87 }
88}
89
90// resize is the OnResize callback. A logical size change (re)starts the
91// picker; a scale-only change keeps the current pick. Either way the grid is
92// laid out again on this output's physical pixels.
93func (m *model) resize(w, h int, scale float64) {
94 if m.sized && w == m.w && h == m.h {
95 m.scale = scale
96 m.layoutGrid()
97 return
98 }
99 if err := m.picker.Begin(m.outputs[m.index].ID, w, h); err != nil {
100 m.fail(fmt.Errorf("resize to %dx%d: %w", w, h, err))
101 return
102 }
103 m.sized, m.w, m.h, m.scale = true, w, h, scale
104 m.label.clear()
105 m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region)
106 m.refreshFooter()
107 m.layoutGrid()
108}
109
110// layoutGrid derives the logical guide rectangles from exact physical lines:
111// snapping happens in physical pixels, and only then is each edge divided by
112// the scale, so the renderer's edge rounding returns the same whole pixels. An
113// unusable scale draws no grid instead of ending the selection.
114//
115// The lines follow the output mode when it explains the surface, because that
116// is the monitor the user sees: a fractional scale rounds the logical size, so
117// the surface buffer can be a pixel larger than the mode and the compositor
118// crops that pixel. Otherwise (unknown, stale or unrelated mode) they follow
119// the surface buffer, core.MonitorGrid.
120func (m *model) layoutGrid() {
121 m.gridLines = [core.GridLineCount]frect{}
122 lines, err := m.physicalGrid()
123 if err != nil {
124 return
125 }
126 for i, r := range lines {
127 m.gridLines[i] = frect{
128 float64(r.Min.X) / m.scale, float64(r.Min.Y) / m.scale,
129 float64(r.Dx()) / m.scale, float64(r.Dy()) / m.scale,
130 }
131 }
132}
133
134// physicalGrid is the grid in physical pixels; see layoutGrid.
135func (m *model) physicalGrid() (core.GridLines, error) {
136 buffer, err := core.MonitorGrid(m.w, m.h, m.scale)
137 if err != nil {
138 return buffer, err
139 }
140 bw, _ := core.PhysicalExtent(m.w, m.scale)
141 bh, _ := core.PhysicalExtent(m.h, m.scale)
142 out := m.outputs[m.index]
143 // A rotated output reports its mode unrotated, so try both orientations.
144 for _, mode := range [2][2]int{{out.Width, out.Height}, {out.Height, out.Width}} {
145 if modeFits(mode[0], m.w, bw, m.scale) && modeFits(mode[1], m.h, bh, m.scale) {
146 if lines, err := core.MonitorGridPhysical(mode[0], mode[1]); err == nil {
147 return lines, nil
148 }
149 }
150 }
151 return buffer, nil
152}
153
154// modeFits reports whether a physical mode extent is what the compositor shows
155// of a surface axis: the logical size is the mode divided by the scale and
156// rounded, and the buffer is the same size or one pixel larger, which the
157// compositor crops instead of resampling. Anything else (an unknown or stale
158// mode, a rounding gap, a buffer smaller than the mode) is not trusted.
159func modeFits(mode, logical, buffer int, scale float64) bool {
160 return mode > 0 && int(math.Round(float64(mode)/scale)) == logical && buffer-mode >= 0 && buffer-mode <= 1
161}
162
163// setWorkspaces keeps the workspace that is currently displayed on this
164// output, and only that one. The picker acts instantly on a click or Enter, so
165// offering a hidden workspace would highlight and capture something the user
166// cannot see. Workspaces without an ID, on other outputs or not Active are
167// never offered; with none, W does nothing.
168func (m *model) setWorkspaces(ws []ports.Workspace) {
169 m.workspace = ports.Workspace{}
170 id := m.outputs[m.index].ID
171 for _, w := range ws {
172 if w.OutputID == id && w.ID != 0 && w.Active {
173 m.workspace = w
174 return
175 }
176 }
177}
178
179// fail ends this overlay with a failure that names its output.
180func (m *model) fail(err error) {
181 if m.end == endNone {
182 m.end = endCanceled
183 m.sess.fail(fmt.Errorf("output %s: %w", m.outputs[m.index].Name, err))
184 }
185}
186
187// visible is everything the scene draws that input can change. Comparing two
188// snapshots tells the framework whether a redraw is needed; it is a plain value
189// and never allocates.
190type visible struct {
191 rect ports.Region
192 rec bool
193 dragging bool
194 kind core.PickKind
195 grid bool
196 status core.PickStatus
197}
198
199func (m *model) snapshot() visible {
200 r, _ := m.picker.Rect()
201 return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.picker.Status()}
202}
203
204// input is the OnInput callback. It reports whether the drawn state changed,
205// so pointer motion that moves nothing visible (hover, a clamped edge, axis
206// events, other buttons, key releases and repeats) costs no redraw. Accepting
207// or canceling reports true: the overlay is closing. It allocates only when the
208// dimension label text changes.
209func (m *model) input(ev nefergui.InputEvent) bool {
210 if m.end != endNone || !m.sized {
211 return false
212 }
213 m.syncMode()
214 before, endBefore := m.snapshot(), m.end
215 switch ev.Kind {
216 case nefergui.InputPointerMotion:
217 m.picker.MouseMove(ev.X, ev.Y)
218 case nefergui.InputPointerPress:
219 if ev.Button == btnLeft {
220 m.picker.MouseDown(ev.X, ev.Y)
221 }
222 case nefergui.InputPointerRelease:
223 if ev.Button == btnLeft {
224 m.picker.MouseUp(ev.X, ev.Y)
225 }
226 case nefergui.InputReset:
227 // Input was dropped: a release may never arrive. Abandon any press or
228 // drag, keeping the chosen kind, grid and workspace.
229 m.picker.AbandonPress()
230 case nefergui.InputKey:
231 if ev.Pressed && !ev.Repeat && ev.Modifiers&nefergui.ModCtrl == 0 {
232 m.key(ev.KeyName)
233 }
234 }
235 m.settle()
236 return m.end != endBefore || m.snapshot() != before
237}
238
239func (m *model) key(name string) {
240 switch name {
241 case "Escape":
242 m.picker.Cancel()
243 case "m", "M":
244 m.picker.SelectMonitor()
245 case "w", "W":
246 m.picker.SelectWorkspace() // a no-op until a real workspace is supplied
247 case "r", "R":
248 m.picker.SelectRegion()
249 case "g", "G":
250 m.grid = !m.grid
251 case "Tab":
252 if m.toggle {
253 m.sess.toggle()
254 m.syncMode()
255 }
256 case "Return", "KP_Enter":
257 m.picker.Confirm()
258 default:
259 // A digit picks that whole monitor from any overlay, without reopening.
260 if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {
261 m.end = endAccepted
262 m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode)
263 }
264 }
265}
266
267// outputKey maps "1".."9" to an output index below n.
268func outputKey(name string, n int) (int, bool) {
269 if len(name) != 1 || name[0] < '1' || name[0] > '9' {
270 return 0, false
271 }
272 i := int(name[0] - '1')
273 return i, i < n
274}
275
276// refreshFooter rebuilds the footer only when the mode, target kind or workspace changes.
277func (m *model) refreshFooter() {
278 kind := m.picker.Kind()
279 _, _, workspace := m.picker.Workspace()
280 if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode {
281 m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode
282 m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator)
283 }
284}
285
286// settle mirrors the picker's terminal state and refreshes the label.
287func (m *model) settle() {
288 m.refreshFooter()
289 switch m.picker.Status() {
290 case core.PickAccepted:
291 r, _ := m.picker.Result()
292 m.end = endAccepted
293 m.sess.accept(r, m.mode)
294 return
295 case core.PickCanceled:
296 m.end = endCanceled
297 m.sess.abort()
298 return
299 }
300 if r, ok := m.picker.Rect(); ok {
301 m.label.set(r.Width, r.Height)
302 } else if m.picker.Kind() == core.PickMonitor {
303 m.label.set(m.w, m.h)
304 } else if _, r, ok := m.picker.Workspace(); ok && m.picker.Kind() == core.PickWorkspace {
305 m.label.set(r.Width, r.Height)
306 } else {
307 m.label.clear()
308 }
309}
310
311// label caches the "W × H" text so it is rebuilt only when the size changes.
312type label struct {
313 w, h int
314 text string
315 buf []byte
316}
317
318func (l *label) set(w, h int) {
319 if l.text != "" && l.w == w && l.h == h {
320 return
321 }
322 l.w, l.h = w, h
323 b := strconv.AppendInt(l.buf[:0], int64(w), 10)
324 b = append(b, " × "...)
325 b = strconv.AppendInt(b, int64(h), 10)
326 l.buf = b
327 l.text = string(b)
328}
329
330func (l *label) clear() { l.text = "" }
331
332// headerText names the output and, with several outputs, the digit keys that
333// pick a whole monitor.
334func headerText(outputs []ports.Output, index int) string {
335 b := append([]byte(nil), outputs[index].Name...)
336 if n := len(outputs); n > 1 {
337 b = append(b, " · "...)
338 b = strconv.AppendInt(b, int64(index+1), 10)
339 b = append(b, '/')
340 b = strconv.AppendInt(b, int64(n), 10)
341 b = append(b, " · keys 1-"...)
342 b = strconv.AppendInt(b, int64(n), 10)
343 b = append(b, " pick monitor"...)
344 }
345 return string(b)
346}