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 layerui 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.Input) 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.InputKey:
227 if ev.Pressed && !ev.Repeat && ev.Modifiers&nefergui.ModCtrl == 0 {
228 m.key(keyName(ev.Keysym))
229 }
230 }
231 m.settle()
232 return m.end != endBefore || m.snapshot() != before
233}
234
235func (m *model) key(name string) {
236 switch name {
237 case "Escape":
238 m.picker.Cancel()
239 case "m", "M":
240 m.picker.SelectMonitor()
241 case "w", "W":
242 m.picker.SelectWorkspace() // a no-op until a real workspace is supplied
243 case "r", "R":
244 m.picker.SelectRegion()
245 case "g", "G":
246 m.grid = !m.grid
247 case "Tab":
248 if m.toggle {
249 m.sess.toggle()
250 m.syncMode()
251 }
252 case "Return", "KP_Enter":
253 m.picker.Confirm()
254 default:
255 // A digit picks that whole monitor from any overlay, without reopening.
256 if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {
257 m.end = endAccepted
258 m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode)
259 }
260 }
261}
262
263// keyName is the xkb name of the keysyms the selector handles, "" for the
264// others. Latin-1 keysyms are their code points. It never allocates.
265func keyName(sym uint32) string {
266 switch sym {
267 case 0xff1b:
268 return "Escape"
269 case 0xff09:
270 return "Tab"
271 case 0xff0d:
272 return "Return"
273 case 0xff8d:
274 return "KP_Enter"
275 }
276 if sym >= '1' && sym <= '9' || sym >= 'A' && sym <= 'Z' || sym >= 'a' && sym <= 'z' {
277 return asciiNames[sym-'1' : sym-'1'+1]
278 }
279 return ""
280}
281
282// asciiNames holds every character from '1' to 'z', so a one-character key
283// name is a substring of it.
284const asciiNames = "123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz"
285
286// outputKey maps "1".."9" to an output index below n.
287func outputKey(name string, n int) (int, bool) {
288 if len(name) != 1 || name[0] < '1' || name[0] > '9' {
289 return 0, false
290 }
291 i := int(name[0] - '1')
292 return i, i < n
293}
294
295// refreshFooter rebuilds the footer only when the mode, target kind or workspace changes.
296func (m *model) refreshFooter() {
297 kind := m.picker.Kind()
298 _, _, workspace := m.picker.Workspace()
299 if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode {
300 m.footerKind, m.footerWorkspace, m.footerMode = kind, workspace, m.mode
301 m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator)
302 }
303}
304
305// settle mirrors the picker's terminal state and refreshes the label.
306func (m *model) settle() {
307 m.refreshFooter()
308 switch m.picker.Status() {
309 case core.PickAccepted:
310 r, _ := m.picker.Result()
311 m.end = endAccepted
312 m.sess.accept(r, m.mode)
313 return
314 case core.PickCanceled:
315 m.end = endCanceled
316 m.sess.abort()
317 return
318 }
319 if r, ok := m.picker.Rect(); ok {
320 m.label.set(r.Width, r.Height)
321 } else if m.picker.Kind() == core.PickMonitor {
322 m.label.set(m.w, m.h)
323 } else if _, r, ok := m.picker.Workspace(); ok && m.picker.Kind() == core.PickWorkspace {
324 m.label.set(r.Width, r.Height)
325 } else {
326 m.label.clear()
327 }
328}
329
330// label caches the "W × H" text so it is rebuilt only when the size changes.
331type label struct {
332 w, h int
333 text string
334 buf []byte
335}
336
337func (l *label) set(w, h int) {
338 if l.text != "" && l.w == w && l.h == h {
339 return
340 }
341 l.w, l.h = w, h
342 b := strconv.AppendInt(l.buf[:0], int64(w), 10)
343 b = append(b, " × "...)
344 b = strconv.AppendInt(b, int64(h), 10)
345 l.buf = b
346 l.text = string(b)
347}
348
349func (l *label) clear() { l.text = "" }
350
351// headerText names the output and, with several outputs, the digit keys that
352// pick a whole monitor.
353func headerText(outputs []ports.Output, index int) string {
354 b := append([]byte(nil), outputs[index].Name...)
355 if n := len(outputs); n > 1 {
356 b = append(b, " · "...)
357 b = strconv.AppendInt(b, int64(index+1), 10)
358 b = append(b, '/')
359 b = strconv.AppendInt(b, int64(n), 10)
360 b = append(b, " · keys 1-"...)
361 b = strconv.AppendInt(b, int64(n), 10)
362 b = append(b, " pick monitor"...)
363 }
364 return string(b)
365}