feat/freeze-key 753c87f

model.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close