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 // live: F turned the frozen screen off; seenFreezes is the session F
39 // count already applied to it.
40 live bool
41 seenFreezes uint32
42 wake chan struct{}
43 outputs []ports.Output
44 index int // the output this overlay covers; every output has its own model
45
46 picker core.Picker
47 sized bool
48 w, h int
49 scale float64 // fractional output scale from OnResize; 0 before the first resize
50
51 // The monitor grid belongs to the overlay, not the picker: Begin resets
52 // the picker on a size change but the user's toggle must survive it.
53 grid bool
54 gridLines [core.GridLineCount]frect // whole-monitor guides, logical units
55
56 end endKind
57 sess *session // shared first-decision record; the only cross-overlay state
58
59 // backdrop is the frozen screen drawn under everything, nil for a live
60 // (transparent) overlay. It is never modified after the overlay starts.
61 backdrop image.Image
62
63 workspace ports.Workspace // zero ID: none
64 header string // static per surface
65 footerText string
66 footerKind core.PickKind
67 footerMode ports.Mode
68 footerWorkspace bool
69 footerLive bool
70 label label
71
72 // Key feedback: a toast and a border pulse, advanced by animate. fx counts
73 // triggered effects so input reports them as a visible change.
74 toast toast
75 pulse pulse
76 hud hudFade
77 fx uint32
78 edgeW float64 // border width this frame; edge outside a pulse
79 anim *animator // nil in tests: effects still advance, nothing wakes
80}
81
82func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session, grid bool) *model {
83 m := &model{base: mode, mode: mode, footerMode: mode, outputs: outputs, index: index, sess: sess, grid: grid, header: headerText(outputs, index)}
84 m.footerText = footerText(mode, core.PickRegion, false, false, false, m.freezeHint())
85 return m
86}
87
88// otherMode is the mode a Tab switches to.
89func otherMode(mode ports.Mode) ports.Mode {
90 if mode == ports.Record {
91 return ports.Screenshot
92 }
93 return ports.Record
94}
95
96// modeAfter is base after n toggles.
97func modeAfter(base ports.Mode, n uint32) ports.Mode {
98 if n%2 == 1 {
99 return otherMode(base)
100 }
101 return base
102}
103
104// syncMode applies toggles made on any overlay. It never allocates.
105func (m *model) syncMode() {
106 if n := m.sess.toggles.Load(); n != m.seen {
107 m.seen, m.mode = n, modeAfter(m.base, n)
108 m.showToast(modeToast(m.mode), modeHold, false)
109 m.pulseBorder()
110 }
111 if n := m.sess.freezes.Load(); n != m.seenFreezes {
112 m.seenFreezes, m.live = n, n%2 == 1
113 m.showToast(freezeToast(m.live), modeHold, false)
114 m.pulseBorder()
115 }
116}
117
118// frozen reports whether this overlay shows, and its screenshot captures, the
119// frozen screen.
120func (m *model) frozen() bool { return m.sess.canFreeze && !m.live }
121
122// freezeHint is the F state the footer shows: none without a frozen screen
123// or in record mode, which always captures the live screen.
124func (m *model) freezeHint() freezeState {
125 switch {
126 case !m.sess.canFreeze || m.mode == ports.Record:
127 return freezeOff
128 case m.live:
129 return freezeLive
130 }
131 return freezeOn
132}
133
134// resize is the OnResize callback. A logical size change (re)starts the
135// picker; a scale-only change keeps the current pick. Either way the grid is
136// laid out again on this output's physical pixels.
137func (m *model) resize(w, h int, scale float64) {
138 if m.sized && w == m.w && h == m.h {
139 m.scale = scale
140 m.layoutGrid()
141 return
142 }
143 if err := m.picker.Begin(m.outputs[m.index].ID, w, h); err != nil {
144 m.fail(fmt.Errorf("resize to %dx%d: %w", w, h, err))
145 return
146 }
147 m.sized, m.w, m.h, m.scale = true, w, h, scale
148 m.label.clear()
149 m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region)
150 m.refreshFooter()
151 m.layoutGrid()
152}
153
154// layoutGrid derives the logical guide rectangles from exact physical lines:
155// snapping happens in physical pixels, and only then is each edge divided by
156// the scale, so the renderer's edge rounding returns the same whole pixels. An
157// unusable scale draws no grid instead of ending the selection.
158//
159// The lines follow the output mode when it explains the surface, because that
160// is the monitor the user sees: a fractional scale rounds the logical size, so
161// the surface buffer can be a pixel larger than the mode and the compositor
162// crops that pixel. Otherwise (unknown, stale or unrelated mode) they follow
163// the surface buffer, core.MonitorGrid.
164func (m *model) layoutGrid() {
165 m.gridLines = [core.GridLineCount]frect{}
166 lines, err := m.physicalGrid()
167 if err != nil {
168 return
169 }
170 for i, r := range lines {
171 m.gridLines[i] = frect{
172 float64(r.Min.X) / m.scale, float64(r.Min.Y) / m.scale,
173 float64(r.Dx()) / m.scale, float64(r.Dy()) / m.scale,
174 }
175 }
176}
177
178// physicalGrid is the grid in physical pixels; see layoutGrid.
179func (m *model) physicalGrid() (core.GridLines, error) {
180 buffer, err := core.MonitorGrid(m.w, m.h, m.scale)
181 if err != nil {
182 return buffer, err
183 }
184 bw, _ := core.PhysicalExtent(m.w, m.scale)
185 bh, _ := core.PhysicalExtent(m.h, m.scale)
186 out := m.outputs[m.index]
187 // A rotated output reports its mode unrotated, so try both orientations.
188 for _, mode := range [2][2]int{{out.Width, out.Height}, {out.Height, out.Width}} {
189 if modeFits(mode[0], m.w, bw, m.scale) && modeFits(mode[1], m.h, bh, m.scale) {
190 if lines, err := core.MonitorGridPhysical(mode[0], mode[1]); err == nil {
191 return lines, nil
192 }
193 }
194 }
195 return buffer, nil
196}
197
198// modeFits reports whether a physical mode extent is what the compositor shows
199// of a surface axis: the logical size is the mode divided by the scale and
200// rounded, and the buffer is the same size or one pixel larger, which the
201// compositor crops instead of resampling. Anything else (an unknown or stale
202// mode, a rounding gap, a buffer smaller than the mode) is not trusted.
203func modeFits(mode, logical, buffer int, scale float64) bool {
204 return mode > 0 && int(math.Round(float64(mode)/scale)) == logical && buffer-mode >= 0 && buffer-mode <= 1
205}
206
207// setWorkspaces keeps the workspace that is currently displayed on this
208// output, and only that one. The picker acts instantly on a click or Enter, so
209// offering a hidden workspace would highlight and capture something the user
210// cannot see. Workspaces without an ID, on other outputs or not Active are
211// never offered; with none, W does nothing.
212func (m *model) setWorkspaces(ws []ports.Workspace) {
213 m.workspace = ports.Workspace{}
214 id := m.outputs[m.index].ID
215 for _, w := range ws {
216 if w.OutputID == id && w.ID != 0 && w.Active {
217 m.workspace = w
218 return
219 }
220 }
221}
222
223// fail ends this overlay with a failure that names its output.
224func (m *model) fail(err error) {
225 if m.end == endNone {
226 m.end = endCanceled
227 m.sess.fail(fmt.Errorf("output %s: %w", m.outputs[m.index].Name, err))
228 }
229}
230
231// visible is everything the scene draws that input can change. Comparing two
232// snapshots tells the framework whether a redraw is needed; it is a plain value
233// and never allocates.
234type visible struct {
235 rect ports.Region
236 rec bool
237 dragging bool
238 kind core.PickKind
239 grid bool
240 live bool
241 status core.PickStatus
242 fx uint32
243}
244
245func (m *model) snapshot() visible {
246 r, _ := m.picker.Rect()
247 return visible{r, m.mode == ports.Record, m.picker.Dragging(), m.picker.Kind(), m.grid, m.live, m.picker.Status(), m.fx}
248}
249
250// input is the OnInput callback. It reports whether the drawn state changed,
251// so pointer motion that moves nothing visible (hover, a clamped edge, axis
252// events, other buttons, key releases and repeats) costs no redraw. Accepting
253// or canceling reports true: the overlay is closing. It allocates only when the
254// dimension label text changes.
255func (m *model) input(ev nefergui.Input) bool {
256 if m.end != endNone || !m.sized {
257 return false
258 }
259 m.syncMode()
260 before, endBefore := m.snapshot(), m.end
261 switch ev.Kind {
262 case nefergui.InputPointerMotion:
263 m.picker.MouseMove(ev.X, ev.Y)
264 case nefergui.InputPointerPress:
265 if ev.Button == btnLeft {
266 m.picker.MouseDown(ev.X, ev.Y)
267 }
268 case nefergui.InputPointerRelease:
269 if ev.Button == btnLeft {
270 m.picker.MouseUp(ev.X, ev.Y)
271 }
272 case nefergui.InputKey:
273 if ev.Pressed && !ev.Repeat && ev.Modifiers&nefergui.ModCtrl == 0 {
274 m.key(keyName(ev.Keysym))
275 }
276 }
277 m.settle()
278 return m.end != endBefore || m.snapshot() != before
279}
280
281func (m *model) key(name string) {
282 switch name {
283 case "Escape":
284 m.picker.Cancel()
285 case "m", "M":
286 m.selectTarget(core.PickMonitor)
287 case "w", "W":
288 m.selectTarget(core.PickWorkspace)
289 case "r", "R":
290 m.selectTarget(core.PickRegion)
291 case "g", "G":
292 m.grid = !m.grid
293 case "f", "F":
294 if m.freezeHint() != freezeOff {
295 m.sess.toggleFreeze()
296 m.syncMode()
297 }
298 case "Tab":
299 if m.toggle {
300 m.sess.toggle()
301 m.syncMode()
302 }
303 case "Return", "KP_Enter":
304 m.picker.Confirm()
305 default:
306 // A digit picks that whole monitor from any overlay, without reopening.
307 if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {
308 m.end = endAccepted
309 m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor}, m.mode, m.frozen())
310 }
311 }
312}
313
314// selectTarget switches the picker target and confirms it with a toast and a
315// border pulse, even when the target was already selected. W without a
316// workspace on this output falls back to the monitor and says so. A drag in
317// progress keeps its target and shows nothing.
318func (m *model) selectTarget(kind core.PickKind) {
319 if m.picker.Dragging() {
320 return
321 }
322 if kind == core.PickWorkspace {
323 if _, _, ok := m.picker.Workspace(); !ok {
324 m.picker.SelectMonitor()
325 m.showToast(toastNoWS, warnHold, true)
326 m.pulseBorder()
327 return
328 }
329 }
330 switch kind {
331 case core.PickMonitor:
332 m.picker.SelectMonitor()
333 case core.PickWorkspace:
334 m.picker.SelectWorkspace()
335 default:
336 m.picker.SelectRegion()
337 }
338 m.showToast(targetToast(kind), targetHold, false)
339 m.pulseBorder()
340}
341
342// keyName is the xkb name of the keysyms the selector handles, "" for the
343// others. Latin-1 keysyms are their code points. It never allocates.
344func keyName(sym uint32) string {
345 switch sym {
346 case 0xff1b:
347 return "Escape"
348 case 0xff09:
349 return "Tab"
350 case 0xff0d:
351 return "Return"
352 case 0xff8d:
353 return "KP_Enter"
354 }
355 if sym >= '1' && sym <= '9' || sym >= 'A' && sym <= 'Z' || sym >= 'a' && sym <= 'z' {
356 return asciiNames[sym-'1' : sym-'1'+1]
357 }
358 return ""
359}
360
361// asciiNames holds every character from '1' to 'z', so a one-character key
362// name is a substring of it.
363const asciiNames = "123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz"
364
365// outputKey maps "1".."9" to an output index below n.
366func outputKey(name string, n int) (int, bool) {
367 if len(name) != 1 || name[0] < '1' || name[0] > '9' {
368 return 0, false
369 }
370 i := int(name[0] - '1')
371 return i, i < n
372}
373
374// refreshFooter rebuilds the footer only when the mode, target kind,
375// workspace or frozen state changes.
376func (m *model) refreshFooter() {
377 kind := m.picker.Kind()
378 _, _, workspace := m.picker.Workspace()
379 if kind != m.footerKind || workspace != m.footerWorkspace || m.mode != m.footerMode || m.live != m.footerLive {
380 m.footerKind, m.footerWorkspace, m.footerMode, m.footerLive = kind, workspace, m.mode, m.live
381 m.footerText = footerText(m.mode, kind, workspace, m.toggle, m.noIndicator, m.freezeHint())
382 }
383}
384
385// settle mirrors the picker's terminal state and refreshes the label.
386func (m *model) settle() {
387 m.refreshFooter()
388 switch m.picker.Status() {
389 case core.PickAccepted:
390 r, _ := m.picker.Result()
391 m.end = endAccepted
392 m.sess.accept(r, m.mode, m.frozen())
393 return
394 case core.PickCanceled:
395 m.end = endCanceled
396 m.sess.abort()
397 return
398 }
399 if r, ok := m.picker.Rect(); ok {
400 m.label.set(r.Width, r.Height)
401 } else if m.picker.Kind() == core.PickMonitor {
402 m.label.set(m.w, m.h)
403 } else if _, r, ok := m.picker.Workspace(); ok && m.picker.Kind() == core.PickWorkspace {
404 m.label.set(r.Width, r.Height)
405 } else {
406 m.label.clear()
407 }
408}
409
410// label caches the "W × H" text so it is rebuilt only when the size changes.
411type label struct {
412 w, h int
413 text string
414 buf []byte
415}
416
417func (l *label) set(w, h int) {
418 if l.text != "" && l.w == w && l.h == h {
419 return
420 }
421 l.w, l.h = w, h
422 b := strconv.AppendInt(l.buf[:0], int64(w), 10)
423 b = append(b, " × "...)
424 b = strconv.AppendInt(b, int64(h), 10)
425 l.buf = b
426 l.text = string(b)
427}
428
429func (l *label) clear() { l.text = "" }
430
431// headerText names the output and, with several outputs, the digit keys that
432// pick a whole monitor.
433func headerText(outputs []ports.Output, index int) string {
434 b := append([]byte(nil), outputs[index].Name...)
435 if n := len(outputs); n > 1 {
436 b = append(b, " · "...)
437 b = strconv.AppendInt(b, int64(index+1), 10)
438 b = append(b, '/')
439 b = strconv.AppendInt(b, int64(n), 10)
440 b = append(b, " · keys 1-"...)
441 b = strconv.AppendInt(b, int64(n), 10)
442 b = append(b, " pick monitor"...)
443 }
444 return string(b)
445}