v0.2.0 a558f49

model.go RawHistoryFolder

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

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