v0.3.0 ea9abc7

selection_test.go RawHistoryFolder

1package selection
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"image"
8	"math"
9	"strings"
10	"testing"
11
12	"github.com/bnema/neferclient"
13	"github.com/bnema/nefergui"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	"github.com/bnema/nefercap/internal/core"
18	"github.com/bnema/nefercap/internal/ports"
19)
20
21var testOutputs = []ports.Output{
22	{ID: 7, Name: "DP-1", Width: 1920, Height: 1080, Scale: 1},
23	{ID: 9, Name: "HDMI-A-1", Width: 1280, Height: 720, Scale: 1},
24}
25
26// testSession is a real session whose cancel is observable.
27func testSession() (*session, context.Context) {
28	ctx, cancel := context.WithCancel(context.Background())
29	return newSession(cancel), ctx
30}
31
32func newTestModel(t *testing.T) *model {
33	t.Helper()
34	sess, _ := testSession()
35	m := newModel(ports.Screenshot, testOutputs, 0, sess, false)
36	m.resize(1920, 1080, 1)
37	require.Equal(t, core.PickActive, m.picker.Status())
38	return m
39}
40
41// newBareModel has no size yet, like an overlay before its first configure.
42func newBareModel() (*model, context.Context) {
43	sess, ctx := testSession()
44	return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx
45}
46
47func motion(x, y float64) nefergui.Input {
48	return nefergui.Input{Kind: nefergui.InputPointerMotion, X: x, Y: y}
49}
50
51func button(x, y float64, pressed bool) nefergui.Input {
52	k := nefergui.InputPointerRelease
53	if pressed {
54		k = nefergui.InputPointerPress
55	}
56	return nefergui.Input{Kind: k, X: x, Y: y, Button: btnLeft, Pressed: pressed}
57}
58
59func key(name string) nefergui.Input {
60	return nefergui.Input{Kind: nefergui.InputKey, Keysym: keysym(name), Pressed: true}
61}
62
63// keysym is the xkb keysym of a key name used by the tests; an unknown name
64// is a keysym the selector ignores.
65func keysym(name string) uint32 {
66	switch name {
67	case "Escape":
68		return 0xff1b
69	case "Tab":
70		return 0xff09
71	case "Return":
72		return 0xff0d
73	case "KP_Enter":
74		return 0xff8d
75	}
76	if len(name) == 1 {
77		return uint32(name[0])
78	}
79	return 0xffffff
80}
81
82func TestKeyNameRoundTrip(t *testing.T) {
83	for _, name := range []string{"Escape", "Tab", "Return", "KP_Enter", "1", "9", "a", "z", "A", "Z", "m", "W"} {
84		assert.Equal(t, name, keyName(keysym(name)), name)
85	}
86	for _, sym := range []uint32{0, '0', ' ', ':', '@', '[', '`', '{', 0xffffff, 0xff08} {
87		assert.Empty(t, keyName(sym), "%#x", sym)
88	}
89	assert.Zero(t, testing.AllocsPerRun(100, func() { _ = keyName('m') }))
90}
91
92func TestDragReleaseAcceptsRegion(t *testing.T) {
93	m := newTestModel(t)
94	m.input(button(100, 50, true))
95	m.input(motion(300, 250))
96	assert.Equal(t, "200 × 200", m.label.text)
97	assert.Equal(t, endNone, m.end)
98	m.input(button(300, 250, false))
99	require.Equal(t, endAccepted, m.end)
100	res, ok, err := m.sess.outcome(context.Background())
101	require.NoError(t, err)
102	require.True(t, ok)
103	assert.Equal(t, core.PickRegion, res.Kind)
104	assert.Equal(t, ports.Region{X: 100, Y: 50, Width: 200, Height: 200}, res.Region)
105	assert.Equal(t, uint32(7), res.OutputID)
106}
107
108func TestClickAcceptsMonitor(t *testing.T) {
109	m := newTestModel(t)
110	m.input(button(500, 500, true))
111	m.input(button(500, 500, false))
112	require.Equal(t, endAccepted, m.end)
113	res, _, _ := m.sess.outcome(context.Background())
114	assert.Equal(t, core.PickMonitor, res.Kind)
115	sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).selection(res)
116	assert.Equal(t, ports.Target{OutputID: 7}, sel.Target)
117}
118
119func TestMonitorKeyThenEnter(t *testing.T) {
120	m := newTestModel(t)
121	m.input(key("m"))
122	assert.Equal(t, "1920 × 1080", m.label.text)
123	assert.Equal(t, endNone, m.end)
124	m.input(key("Return"))
125	require.Equal(t, endAccepted, m.end)
126	res, _, _ := m.sess.outcome(context.Background())
127	assert.Equal(t, core.PickMonitor, res.Kind)
128}
129
130func TestEscapeAbortsEveryOverlay(t *testing.T) {
131	sess, ctx := testSession()
132	m := newModel(ports.Screenshot, testOutputs, 1, sess, false)
133	m.resize(1280, 720, 1)
134	m.input(key("Escape"))
135	assert.Equal(t, endCanceled, m.end)
136	assert.Error(t, ctx.Err(), "the shared context ends every overlay")
137	_, ok, err := sess.outcome(context.Background())
138	assert.False(t, ok)
139	assert.NoError(t, err)
140}
141
142func TestDigitPicksMonitorFromAnyOverlay(t *testing.T) {
143	m := newTestModel(t) // overlay on output 1 (ID 7)
144	m.input(key("3"))    // no such output
145	assert.Equal(t, endNone, m.end)
146	m.input(key("2"))
147	require.Equal(t, endAccepted, m.end)
148	res, ok, err := m.sess.outcome(context.Background())
149	require.NoError(t, err)
150	require.True(t, ok)
151	assert.Equal(t, core.PickResult{OutputID: 9, Kind: core.PickMonitor}, res)
152	sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).selection(res)
153	assert.Equal(t, ports.Target{OutputID: 9}, sel.Target)
154
155	own := newTestModel(t)
156	own.input(key("1"))
157	res, _, _ = own.sess.outcome(context.Background())
158	assert.Equal(t, uint32(7), res.OutputID, "a digit may also pick the overlay's own monitor")
159}
160
161func TestOutputKeyIgnoredWhileDragging(t *testing.T) {
162	m := newTestModel(t)
163	m.input(button(10, 10, true))
164	m.input(motion(100, 100))
165	m.input(key("2"))
166	assert.Equal(t, endNone, m.end)
167}
168
169func TestOtherButtonsAndReleasedKeysIgnored(t *testing.T) {
170	m := newTestModel(t)
171	ev := button(10, 10, true)
172	ev.Button = 0x111
173	m.input(ev)
174	m.input(motion(200, 200))
175	assert.False(t, m.picker.Dragging())
176	up := key("Escape")
177	up.Pressed = false
178	m.input(up)
179	rep := key("Escape")
180	rep.Repeat = true
181	m.input(rep)
182	assert.Equal(t, endNone, m.end)
183}
184
185func TestWorkspaceSelectionTarget(t *testing.T) {
186	ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}}
187	m, _ := newBareModel()
188	m.setWorkspaces(ws)
189	m.resize(1920, 1080, 1)
190	m.input(key("w"))
191	m.input(key("g"))
192	require.Equal(t, core.PickWorkspace, m.picker.Kind())
193
194	m.input(key("Return"))
195	require.Equal(t, endAccepted, m.end)
196	res, ok, err := m.sess.outcome(context.Background())
197	require.NoError(t, err)
198	require.True(t, ok)
199	sel := New(ports.Screenshot, ports.VideoSettings{}, ws, false).selection(res)
200	assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "a workspace has a stable ID and a zero Region")
201}
202
203func TestResizeRestartsOnSizeChangeOnly(t *testing.T) {
204	m := newTestModel(t)
205	m.input(button(10, 10, true))
206	m.input(motion(200, 200))
207	m.resize(1920, 1080, 2) // scale only
208	assert.True(t, m.picker.Dragging())
209	m.resize(1280, 720, 2)
210	assert.False(t, m.picker.Dragging())
211	w, h := m.picker.Bounds()
212	assert.Equal(t, [2]int{1280, 720}, [2]int{w, h})
213}
214
215func TestResizeInvalidSizeFails(t *testing.T) {
216	m, ctx := newBareModel()
217	m.resize(0, 0, 1)
218	assert.Equal(t, endCanceled, m.end)
219	assert.Error(t, ctx.Err())
220	_, ok, err := m.sess.outcome(context.Background())
221	assert.False(t, ok)
222	assert.ErrorIs(t, err, core.ErrInvalidBounds)
223	assert.ErrorContains(t, err, "output DP-1", "the failing output is named")
224	assert.ErrorContains(t, err, "resize to 0x0", "the rejected size is reported")
225}
226
227func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) {
228	ws := []ports.Workspace{
229		{ID: 0, OutputID: 7, Active: true, Region: ports.Region{Width: 100, Height: 100}}, // no identity
230		{ID: 5, OutputID: 9, Active: true, Region: ports.Region{Width: 100, Height: 100}}, // other output
231		{ID: 6, OutputID: 7, Region: ports.Region{Width: 100, Height: 100}},               // not displayed
232		{ID: 11, OutputID: 7, Active: true, Region: ports.Region{X: 0, Y: 30, Width: 1920, Height: 1050}},
233	}
234	none, _ := newBareModel()
235	none.resize(1920, 1080, 1)
236	none.input(key("w"))
237	none.input(key("Return"))
238	assert.Equal(t, endNone, none.end, "W without workspace metadata must do nothing")
239
240	m, _ := newBareModel()
241	m.setWorkspaces(ws)
242	m.resize(1920, 1080, 1)
243	m.input(key("w"))
244	m.input(key("Return"))
245	require.Equal(t, endAccepted, m.end)
246	res, _, _ := m.sess.outcome(context.Background())
247	assert.Equal(t, core.PickWorkspace, res.Kind)
248	assert.Equal(t, uint64(11), res.WorkspaceID)
249	sel := New(ports.Screenshot, ports.VideoSettings{}, ws, false).selection(res)
250	assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "workspace geometry is live from the compositor, not a cached crop")
251}
252
253func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) {
254	m, _ := newBareModel()
255	m.setWorkspaces([]ports.Workspace{
256		{ID: 1, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}},
257		{ID: 2, OutputID: 7, Region: ports.Region{X: 960, Width: 960, Height: 1080}, Active: true},
258		{ID: 3, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}},
259	})
260	m.resize(1920, 1080, 1)
261	id, _, ok := m.picker.Workspace()
262	require.True(t, ok)
263	assert.Equal(t, uint64(2), id)
264
265	hidden, _ := newBareModel()
266	hidden.setWorkspaces([]ports.Workspace{{ID: 1, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}}})
267	hidden.resize(1920, 1080, 1)
268	_, _, ok = hidden.picker.Workspace()
269	assert.False(t, ok, "a workspace that is not displayed is never offered")
270	hidden.input(key("w"))
271	hidden.input(key("Return"))
272	assert.Equal(t, endNone, hidden.end)
273}
274
275func TestSelectionConversionUsesCoreTarget(t *testing.T) {
276	s := New(ports.Screenshot, ports.VideoSettings{}, nil, false)
277	region := ports.Region{X: 10, Y: 20, Width: 300, Height: 200}
278	assert.Equal(t, ports.Target{OutputID: 7, Region: region},
279		s.selection(core.PickResult{OutputID: 7, Kind: core.PickRegion, Region: region}).Target, "a dragged region keeps its geometry")
280	assert.Equal(t, ports.Target{OutputID: 7},
281		s.selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor, Region: ports.Region{Width: 1920, Height: 1080}}).Target)
282	assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11},
283		s.selection(core.PickResult{OutputID: 7, Kind: core.PickWorkspace, Region: region, WorkspaceID: 11}).Target)
284}
285
286func TestNewSelectionDefaults(t *testing.T) {
287	sel := New(ports.Record, ports.VideoSettings{}, nil, false).selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor})
288	assert.Equal(t, ports.Record, sel.Mode)
289	assert.Equal(t, DefaultFPS, sel.Video.FPS)
290	assert.Empty(t, sel.Path)
291
292	custom := ports.VideoSettings{FPS: 60, Width: 640, Height: 360}
293	assert.Equal(t, custom, New(ports.Record, custom, nil, false).selection(core.PickResult{}).Video)
294	assert.Equal(t, ports.VideoSettings{}, New(ports.Screenshot, custom, nil, false).selection(core.PickResult{}).Video)
295}
296
297func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) {
298	outs := make([]ports.Output, MaxOutputs+1)
299	for i := range outs {
300		outs[i] = ports.Output{ID: uint32(i + 1), Name: "O"}
301	}
302	_, ok, err := New(ports.Screenshot, ports.VideoSettings{}, nil, false).Select(context.Background(), outs)
303	assert.False(t, ok)
304	assert.ErrorIs(t, err, ErrTooManyOutputs)
305}
306
307func TestSelectRejectsBadInput(t *testing.T) {
308	s := New(ports.Screenshot, ports.VideoSettings{}, nil, false)
309	_, ok, err := s.Select(context.Background(), nil)
310	assert.False(t, ok)
311	assert.ErrorIs(t, err, ports.ErrOutputNotFound)
312
313	ctx, cancel := context.WithCancel(context.Background())
314	cancel()
315	_, ok, err = s.Select(ctx, testOutputs)
316	assert.False(t, ok)
317	assert.ErrorIs(t, err, context.Canceled)
318}
319
320func TestSessionFirstDecisionWins(t *testing.T) {
321	sess, ctx := testSession()
322	first := core.PickResult{OutputID: 7, Kind: core.PickMonitor}
323	sess.accept(first, ports.Screenshot)
324	sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record)
325	sess.abort()
326	assert.Error(t, ctx.Err())
327	res, ok, err := sess.outcome(context.Background())
328	require.NoError(t, err)
329	assert.True(t, ok)
330	assert.Equal(t, first, res)
331	assert.Equal(t, ports.Screenshot, sess.acceptedMode())
332}
333
334// The recorded mode is the one the confirming overlay showed: a Tab on
335// another overlay after the decision changes nothing.
336func TestLateTabKeepsConfirmedMode(t *testing.T) {
337	sess, _ := testSession()
338	a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
339	b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
340	a.toggle, b.toggle = true, true
341	a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1)
342	sess.wakes = []chan struct{}{a.wake, b.wake}
343	a.resize(1920, 1080, 1)
344	b.resize(1280, 720, 1)
345	b.input(key("Tab"))
346	<-a.wake
347	<-b.wake
348	require.Equal(t, ports.Record, b.mode)
349	b.input(key("m"))
350	b.input(key("Return"))
351	res, ok, err := sess.outcome(context.Background())
352	require.NoError(t, err)
353	require.True(t, ok)
354	a.input(key("Tab")) // arrives before a sees the cancel
355	assert.Empty(t, a.wake, "no redraw after the decision")
356	assert.Equal(t, ports.Record, sess.acceptedMode())
357	sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle().selectionFor(sess.acceptedMode(), res)
358	assert.Equal(t, ports.Record, sel.Mode)
359}
360
361func TestSessionRunEnded(t *testing.T) {
362	boom := errors.New("boom")
363
364	sess, _ := testSession()
365	sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
366	sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel
367	_, ok, err := sess.outcome(context.Background())
368	assert.True(t, ok)
369	assert.NoError(t, err)
370
371	sess, _ = testSession()
372	sess.runEnded(context.Background(), "DP-1", errors.Join(context.Canceled, boom))
373	_, ok, err = sess.outcome(context.Background())
374	assert.False(t, ok)
375	assert.ErrorIs(t, err, boom)
376
377	// GO012: a real failure joined with Canceled survives whole, including the
378	// output name, after our own cancel.
379	sess, _ = testSession()
380	sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
381	real := fmt.Errorf("surface close: %w", context.Canceled)
382	sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom))
383	_, ok, err = sess.outcome(context.Background())
384	assert.False(t, ok, "a real teardown failure rejects even an accepted pick")
385	require.ErrorIs(t, err, boom)
386	assert.ErrorContains(t, err, "output DP-2")
387	assert.ErrorContains(t, err, "surface close", "a wrapped Canceled is a real failure, not hidden")
388
389	sess, _ = testSession()
390	sess.runEnded(context.Background(), "DP-1", nil) // closed without any decision
391	_, _, err = sess.outcome(context.Background())
392	assert.ErrorIs(t, err, errOverlayClosed)
393	assert.ErrorContains(t, err, "output DP-1")
394
395	sess, _ = testSession()
396	sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot)
397	sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick
398	_, ok, err = sess.outcome(context.Background())
399	assert.False(t, ok || err == nil)
400
401	parent, cancel := context.WithCancel(context.Background())
402	cancel()
403	sess, _ = testSession()
404	sess.runEnded(parent, "DP-1", context.Canceled)
405	_, ok, err = sess.outcome(parent)
406	assert.False(t, ok)
407	assert.ErrorIs(t, err, context.Canceled)
408	sess, _ = testSession()
409	sess.runEnded(parent, "DP-1", boom)
410	_, _, err = sess.outcome(parent)
411	assert.ErrorIs(t, err, context.Canceled)
412	assert.ErrorIs(t, err, boom)
413}
414
415func TestLayerConfig(t *testing.T) {
416	c := layerConfig("DP-1", neferclient.KeyboardExclusive)
417	assert.Equal(t, "DP-1", c.Output)
418	assert.Equal(t, neferclient.LayerOverlay, c.Level)
419	assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorBottom|neferclient.AnchorLeft|neferclient.AnchorRight, c.Anchors)
420	assert.Equal(t, neferclient.KeyboardExclusive, c.Keyboard)
421	assert.Equal(t, int32(-1), c.ExclusiveZone)
422	assert.Nil(t, c.InputRects, "whole surface takes input")
423	assert.Equal(t, neferclient.KeyboardOnDemand, layerConfig("DP-2", neferclient.KeyboardOnDemand).Keyboard)
424}
425
426func TestSceneGeometry(t *testing.T) {
427	m := newTestModel(t)
428	s := m.scene()
429	assert.Equal(t, frect{0, 0, 1920, 1080}, s.dim[0], "idle: whole output dimmed")
430	assert.Equal(t, frect{}, s.edges[0])
431	assert.Equal(t, frect{}, s.label)
432
433	m.input(button(100, 50, true))
434	m.input(motion(300, 250))
435	s = m.scene()
436	assert.Equal(t, [4]frect{
437		{0, 0, 1920, 50}, {0, 250, 1920, 830}, {0, 50, 100, 200}, {300, 50, 1620, 200},
438	}, s.dim)
439	assert.Equal(t, frect{100, 50, 200, 1}, s.edges[0])
440	assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
441	assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
442	assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])
443	assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label)
444	// Dim strips and region tile the output exactly.
445	area := 200.0 * 200.0
446	for _, d := range s.dim {
447		area += d.w * d.h
448	}
449	assert.Equal(t, 1920.0*1080.0, area)
450}
451
452func TestSceneMonitorOutlineAndGrid(t *testing.T) {
453	m := newTestModel(t)
454	m.input(key("m"))
455	m.input(key("g"))
456	s := m.scene()
457	assert.Equal(t, [4]frect{}, s.dim, "monitor choice is not dimmed")
458	assert.Equal(t, frect{0, 0, 1920, 1}, s.edges[0])
459	assert.Equal(t, frect{640, 0, 1, 1080}, s.grid[0])
460	assert.Equal(t, frect{0, 720, 1920, 1}, s.grid[5])
461}
462
463// physicalEdges converts a logical rectangle the way the renderer does:
464// each edge is rounded to a physical pixel independently.
465func physicalEdges(r frect, scale float64) image.Rectangle {
466	return image.Rect(
467		int(math.Round(r.x*scale)), int(math.Round(r.y*scale)),
468		int(math.Round((r.x+r.w)*scale)), int(math.Round((r.y+r.h)*scale)))
469}
470
471func gridModel(t *testing.T, grid bool, w, h int, scale float64) *model {
472	t.Helper()
473	sess, _ := testSession()
474	m := newModel(ports.Screenshot, testOutputs, 0, sess, grid)
475	m.resize(w, h, scale)
476	require.Equal(t, core.PickActive, m.picker.Status())
477	return m
478}
479
480func TestGridDefaultAndToggle(t *testing.T) {
481	on := gridModel(t, true, 1920, 1080, 1)
482	assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "configured on: drawn at once")
483	assert.True(t, on.input(key("g")))
484	assert.Equal(t, [core.GridLineCount]frect{}, on.scene().grid, "G turns it off")
485	assert.True(t, on.input(key("G")))
486	assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "and on again")
487
488	off := gridModel(t, false, 1920, 1080, 1)
489	assert.Equal(t, [core.GridLineCount]frect{}, off.scene().grid, "configured off: nothing drawn")
490	assert.True(t, off.input(key("g")))
491	assert.NotEqual(t, [core.GridLineCount]frect{}, off.scene().grid)
492}
493
494func TestGridRendersExactPhysicalPixels(t *testing.T) {
495	for _, c := range []struct {
496		w, h  int
497		scale float64
498	}{
499		{1920, 1080, 1}, {1600, 900, 1.2}, {1536, 864, 1.25}, {1280, 720, 1.5}, {960, 540, 2},
500		{1366, 768, 1}, {1367, 769, 1.25}, {1001, 601, 1.2}, {1279, 719, 1.5}, {853, 481, 2.25},
501	} {
502		m := gridModel(t, true, c.w, c.h, c.scale)
503		want, err := core.MonitorGrid(c.w, c.h, c.scale)
504		require.NoError(t, err)
505		for i, r := range m.scene().grid {
506			got := physicalEdges(r, c.scale)
507			assert.Equal(t, want[i], got, "%dx%d@%g line %d", c.w, c.h, c.scale, i)
508			if i < 3 {
509				assert.Equal(t, 1, got.Dx(), "%dx%d@%g vertical %d is one physical pixel", c.w, c.h, c.scale, i)
510			} else {
511				assert.Equal(t, 1, got.Dy(), "%dx%d@%g horizontal %d is one physical pixel", c.w, c.h, c.scale, i)
512			}
513		}
514	}
515}
516
517func TestGridSweepRendersOnePhysicalPixel(t *testing.T) {
518	// Every compositor fractional scale (k/120) over many logical sizes.
519	for k := 120; k <= 360; k++ {
520		scale := float64(k) / 120
521		for w := 640; w <= 3000; w += 37 {
522			h := w*9/16 + 1
523			m := gridModel(t, true, w, h, scale)
524			want, err := core.MonitorGrid(w, h, scale)
525			require.NoError(t, err)
526			for i, r := range m.scene().grid {
527				if got := physicalEdges(r, scale); got != want[i] {
528					t.Fatalf("%dx%d@%d/120 line %d renders %v, want %v", w, h, k, i, got, want[i])
529				}
530			}
531		}
532	}
533}
534
535func TestGridIsPerOutputScale(t *testing.T) {
536	sess, _ := testSession()
537	a := newModel(ports.Screenshot, testOutputs, 0, sess, true)
538	b := newModel(ports.Screenshot, testOutputs, 1, sess, true)
539	a.resize(1920, 1080, 1)
540	b.resize(1280, 720, 1.5)
541	assert.Equal(t, frect{640, 0, 1, 1080}, a.scene().grid[0])
542	assert.Equal(t, frect{640 / 1.5, 0, 1 / 1.5, 1080 / 1.5}, b.scene().grid[0], "physical 640 of 1920 on the scaled output")
543	assert.Equal(t, image.Rect(640, 0, 641, 1080), physicalEdges(b.scene().grid[0], 1.5))
544}
545
546func TestGridStaysOnWholeMonitorWhileDragging(t *testing.T) {
547	m := gridModel(t, true, 1920, 1080, 1)
548	idle := m.scene().grid
549	m.input(button(100, 50, true))
550	m.input(motion(300, 250))
551	require.True(t, m.picker.Dragging())
552	assert.Equal(t, idle, m.scene().grid, "the drag does not move the grid")
553	m.input(motion(1000, 700))
554	assert.Equal(t, idle, m.scene().grid)
555	m.input(key("m"))
556	m.input(key("r"))
557	assert.Equal(t, idle, m.scene().grid, "nor does a kind change")
558	m.input(button(1000, 700, false))
559	assert.Equal(t, core.PickAccepted, m.picker.Status())
560}
561
562func TestGridToggleSurvivesResizeAndScaleChange(t *testing.T) {
563	m := gridModel(t, true, 1920, 1080, 1)
564	m.input(key("g"))
565	require.False(t, m.grid)
566	m.resize(1280, 720, 1.5) // size change resets the picker, not the toggle
567	assert.False(t, m.grid)
568	assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid)
569	m.input(key("g"))
570	m.resize(1280, 720, 2) // scale only
571	assert.True(t, m.grid)
572	m.resize(1600, 900, 1.2)
573	assert.True(t, m.grid)
574}
575
576func TestGridScaleOnlyChangeKeepsPickAndRelays(t *testing.T) {
577	m := gridModel(t, true, 1920, 1080, 1)
578	m.input(button(10, 10, true))
579	m.input(motion(200, 200))
580	before := m.scene().grid
581	m.resize(1920, 1080, 1.25)
582	assert.True(t, m.picker.Dragging(), "a scale-only change keeps the drag")
583	after := m.scene().grid
584	assert.NotEqual(t, before, after, "the grid follows the new scale")
585	want, err := core.MonitorGrid(1920, 1080, 1.25)
586	require.NoError(t, err)
587	for i, r := range after {
588		assert.Equal(t, want[i], physicalEdges(r, 1.25), "line %d", i)
589	}
590}
591
592func TestGridBadScaleDrawsNothingAndKeepsSelecting(t *testing.T) {
593	for _, scale := range []float64{0, -1, math.NaN(), math.Inf(1), 100} {
594		m := gridModel(t, true, 1920, 1080, scale)
595		assert.Equal(t, endNone, m.end, "scale %v", scale)
596		assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid, "scale %v", scale)
597	}
598}
599
600func TestGridAllocations(t *testing.T) {
601	if raceEnabled {
602		t.Skip("the race detector adds allocations")
603	}
604	m := gridModel(t, true, 1600, 900, 1.2)
605	var s scene
606	n := testing.AllocsPerRun(200, func() {
607		s = m.scene()
608		m.input(key("g"))
609		m.input(key("g"))
610		m.resize(1600, 900, 1.2)
611	})
612	assert.Zero(t, n, "grid toggle, relayout and scene do not allocate")
613	_ = s
614}
615
616func BenchmarkGridResizeScene(b *testing.B) {
617	sess, _ := testSession()
618	m := newModel(ports.Screenshot, testOutputs, 0, sess, true)
619	m.resize(1600, 900, 1.2)
620	b.ReportAllocs()
621	for i := 0; b.Loop(); i++ {
622		m.resize(1600, 900, 1.2+float64(i%2)*0.05)
623		_ = m.scene()
624	}
625}
626
627func TestLabelPlacementStaysOnSurface(t *testing.T) {
628	// Above the region, with a 4px gap.
629	assert.Equal(t, frect{1920 - labelW, 1010 - labelH - 4, labelW, labelH}, labelRect(frect{1800, 1010, 100, 50}, 1920, 1080))
630	// No room above: just inside the region's top-left corner.
631	assert.Equal(t, frect{4, 4, labelW, labelH}, labelRect(frect{0, 0, 500, 500}, 1920, 1080))
632	// Exactly enough room above: the label touches the gap, not the edge.
633	assert.Equal(t, frect{10, 0, labelW, labelH}, labelRect(frect{10, labelH + 4, 300, 100}, 1920, 1080))
634	// One pixel short falls back to inside the region.
635	assert.Equal(t, frect{14, labelH + 3 + 4, labelW, labelH}, labelRect(frect{10, labelH + 3, 300, 100}, 1920, 1080))
636	// Right edge: shifted left to stay on the surface.
637	assert.Equal(t, frect{1920 - labelW, 100, labelW, labelH}, labelRect(frect{1900, 100 + labelH + 4, 20, 20}, 1920, 1080))
638	// Inside fallback near the bottom is clamped onto the surface.
639	assert.Equal(t, frect{4, 40 - labelH, labelW, labelH}, labelRect(frect{0, 20, 500, 20}, 1920, 40))
640	// A surface smaller than the label pins it to the origin.
641	assert.Equal(t, frect{0, 0, labelW, labelH}, labelRect(frect{5, 5, 10, 10}, labelW-1, labelH-1))
642}
643
644func TestLabelStaysOnSurfaceWhenItFits(t *testing.T) {
645	const w, h = 300.0, 100.0
646	for y := 0.0; y <= h; y += 7 {
647		for x := 0.0; x <= w; x += 13 {
648			l := labelRect(frect{x, y, w - x, h - y}, w, h)
649			assert.GreaterOrEqual(t, l.x, 0.0)
650			assert.GreaterOrEqual(t, l.y, 0.0)
651			assert.LessOrEqual(t, l.x+l.w, w, "region %v,%v", x, y)
652			assert.LessOrEqual(t, l.y+l.h, h, "region %v,%v", x, y)
653		}
654	}
655}
656
657// Noto Sans Mono's normal line is approximately 1.37em tall.
658// Keep the sizing assumptions explicit rather than partially parsing CSS.
659func TestLabelContentHoldsMonospaceLine(t *testing.T) {
660	const line, padY = baseFont * 1.37, tagPadY
661	require.True(t, strings.Contains(styleSheet, "box-sizing: border-box; padding: 0.25em 0.625em; overflow: hidden;"), "update label sizing when tag box sizing changes")
662	content := labelH - 2*padY
663	assert.GreaterOrEqual(t, content, line, "label clips its text line")
664	assert.GreaterOrEqual(t, content-line+padY, 1.0, "less than 1px below the text line")
665}
666
667func TestFooterAndHeaderText(t *testing.T) {
668	assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false, false), "shot · R region")
669	assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, false), "rec · R region")
670	assert.NotContains(t, footerText(ports.Record, core.PickRegion, false, false, false), "No recording indicator")
671	assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, true), "No recording indicator on this compositor")
672	assert.NotContains(t, footerText(ports.Screenshot, core.PickRegion, false, false, true), "No recording indicator", "only record mode needs the hint")
673	assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
674	assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
675	i, ok := outputKey("2", 2)
676	assert.True(t, ok)
677	assert.Equal(t, 1, i)
678	_, ok = outputKey("0", 2)
679	assert.False(t, ok)
680	_, ok = outputKey("12", 2)
681	assert.False(t, ok)
682}
683
684func TestSelectorAllocations(t *testing.T) {
685	if raceEnabled {
686		t.Skip("the race detector adds allocations")
687	}
688	m := newTestModel(t)
689	m.input(button(100, 50, true))
690	same := []nefergui.Input{motion(300, 250), motion(300, 250), motion(300, 250)}
691	for _, ev := range same { // warm the label buffer
692		m.input(ev)
693	}
694	var s scene
695	n := testing.AllocsPerRun(200, func() {
696		for _, ev := range same {
697			m.input(ev)
698		}
699		s = m.scene()
700	})
701	assert.Equal(t, 0.0, n, "unchanged pointer position and scene geometry allocate nothing")
702
703	// A changed size builds exactly one label string and nothing else.
704	i := 0
705	n = testing.AllocsPerRun(200, func() {
706		i++
707		m.input(motion(float64(300+i%2*7), 250))
708		s = m.scene()
709	})
710	assert.LessOrEqual(t, n, 1.0, "a changed size allocates only its label text")
711	_ = s
712}
713
714func BenchmarkMotion(b *testing.B) {
715	m, _ := newBareModel()
716	m.resize(1920, 1080, 1)
717	m.input(button(100, 50, true))
718	b.ReportAllocs()
719	for i := 0; b.Loop(); i++ {
720		m.input(motion(float64(200+i%300), 250))
721		_ = m.scene()
722	}
723}
724
725func TestIdleSceneEmitsNoEmptyChip(t *testing.T) {
726	m := newTestModel(t)
727	s := m.scene()
728	assert.Equal(t, frect{}, s.label, "no label box without a size")
729	assert.Zero(t, s.label.w)
730	assert.Equal(t, frect{}, s.edges[0])
731	assert.Greater(t, s.header.w, 0.0)
732	assert.Greater(t, s.footer.w, 0.0)
733}
734
735func TestInputReportsOnlyVisibleChanges(t *testing.T) {
736	m := newTestModel(t)
737	assert.False(t, m.input(motion(50, 50)), "hover draws nothing")
738	assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerAxis, DX: 1, DY: 2}), "axis is ignored")
739	other := button(50, 50, true)
740	other.Button = 0x111
741	assert.False(t, m.input(other), "other buttons are ignored")
742	assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputFocusIn}))
743	assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerLeave}))
744	release := key("g")
745	release.Pressed = false
746	assert.False(t, m.input(release), "release")
747	repeat := key("g")
748	repeat.Repeat = true
749	assert.False(t, m.input(repeat), "repeat")
750	assert.False(t, m.input(key("q")), "unknown key")
751	assert.False(t, m.input(key("w")), "W without a workspace")
752
753	assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
754	assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
755	assert.True(t, m.input(motion(300, 250)), "the drag appears")
756	assert.False(t, m.input(motion(300, 250)), "same position")
757	assert.False(t, m.input(motion(300, 250)))
758	assert.True(t, m.input(motion(300, 251)), "the rectangle grew")
759
760	m = newTestModel(t)
761	assert.True(t, m.input(key("g")), "grid toggles")
762	assert.True(t, m.input(key("m")), "monitor outline")
763	assert.False(t, m.input(key("m")), "already chosen")
764	assert.True(t, m.input(key("r")))
765	assert.False(t, m.input(key("r")))
766}
767
768func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) {
769	m := newTestModel(t)
770	m.input(button(100, 100, true))
771	require.True(t, m.input(motion(1920, 200)))
772	assert.False(t, m.input(motion(2500, 200)), "the clamped rectangle did not change")
773	assert.False(t, m.input(motion(3000, 200)))
774}
775
776func TestInputReportsClosing(t *testing.T) {
777	m := newTestModel(t)
778	assert.True(t, m.input(key("Escape")), "canceling closes the overlay")
779	assert.False(t, m.input(key("g")), "a closed overlay ignores input")
780
781	m = newTestModel(t)
782	assert.True(t, m.input(key("2")), "a digit accepts")
783
784	m = newTestModel(t)
785	m.input(button(10, 10, true))
786	assert.True(t, m.input(button(10, 10, false)), "a click accepts the monitor")
787
788	m = newTestModel(t)
789	m.input(button(10, 10, true))
790	m.input(motion(200, 200))
791	assert.True(t, m.input(button(200, 200, false)), "a drag release accepts the region")
792}
793
794func TestCapabilitiesHideWorkspacesAndHintIndicator(t *testing.T) {
795	ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Width: 640, Height: 480}}}
796	s := New(ports.Record, ports.VideoSettings{}, ws, false).WithCapabilities(ports.Capabilities{})
797	assert.Empty(t, s.workspaces, "no ext-workspace: W is not offered")
798	assert.True(t, s.noIndicator)
799	s = New(ports.Record, ports.VideoSettings{}, ws, false).WithCapabilities(ports.Capabilities{Workspaces: true, Exclusion: true})
800	assert.Len(t, s.workspaces, 1)
801	assert.False(t, s.noIndicator)
802}

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