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