v0.4.0 76bcd84

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	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}

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