main 3d41bc5

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	assert.Equal(t, core.PickMonitor, none.picker.Kind(), "W without workspace metadata falls back to the monitor")
238	assert.True(t, none.toast.warn)
239	assert.Equal(t, toastNoWS, none.toast.text)
240
241	m, _ := newBareModel()
242	m.setWorkspaces(ws)
243	m.resize(1920, 1080, 1)
244	m.input(key("w"))
245	m.input(key("Return"))
246	require.Equal(t, endAccepted, m.end)
247	res, _, _ := m.sess.outcome(context.Background())
248	assert.Equal(t, core.PickWorkspace, res.Kind)
249	assert.Equal(t, uint64(11), res.WorkspaceID)
250	sel := New(ports.Screenshot, ports.VideoSettings{}, ws, false).selection(res)
251	assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "workspace geometry is live from the compositor, not a cached crop")
252}
253
254func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) {
255	m, _ := newBareModel()
256	m.setWorkspaces([]ports.Workspace{
257		{ID: 1, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}},
258		{ID: 2, OutputID: 7, Region: ports.Region{X: 960, Width: 960, Height: 1080}, Active: true},
259		{ID: 3, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}},
260	})
261	m.resize(1920, 1080, 1)
262	id, _, ok := m.picker.Workspace()
263	require.True(t, ok)
264	assert.Equal(t, uint64(2), id)
265
266	hidden, _ := newBareModel()
267	hidden.setWorkspaces([]ports.Workspace{{ID: 1, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}}})
268	hidden.resize(1920, 1080, 1)
269	_, _, ok = hidden.picker.Workspace()
270	assert.False(t, ok, "a workspace that is not displayed is never offered")
271	hidden.input(key("w"))
272	hidden.input(key("Return"))
273	require.Equal(t, endAccepted, hidden.end)
274	res, _, _ := hidden.sess.outcome(context.Background())
275	assert.Equal(t, core.PickMonitor, res.Kind, "a hidden workspace is never captured; W falls back to the monitor")
276}
277
278func TestSelectionConversionUsesCoreTarget(t *testing.T) {
279	s := New(ports.Screenshot, ports.VideoSettings{}, nil, false)
280	region := ports.Region{X: 10, Y: 20, Width: 300, Height: 200}
281	assert.Equal(t, ports.Target{OutputID: 7, Region: region},
282		s.selection(core.PickResult{OutputID: 7, Kind: core.PickRegion, Region: region}).Target, "a dragged region keeps its geometry")
283	assert.Equal(t, ports.Target{OutputID: 7},
284		s.selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor, Region: ports.Region{Width: 1920, Height: 1080}}).Target)
285	assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11},
286		s.selection(core.PickResult{OutputID: 7, Kind: core.PickWorkspace, Region: region, WorkspaceID: 11}).Target)
287}
288
289func TestNewSelectionDefaults(t *testing.T) {
290	sel := New(ports.Record, ports.VideoSettings{}, nil, false).selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor})
291	assert.Equal(t, ports.Record, sel.Mode)
292	assert.Equal(t, DefaultFPS, sel.Video.FPS)
293	assert.Empty(t, sel.Path)
294
295	custom := ports.VideoSettings{FPS: 60, Width: 640, Height: 360}
296	assert.Equal(t, custom, New(ports.Record, custom, nil, false).selection(core.PickResult{}).Video)
297	assert.Equal(t, ports.VideoSettings{}, New(ports.Screenshot, custom, nil, false).selection(core.PickResult{}).Video)
298}
299
300func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) {
301	outs := make([]ports.Output, MaxOutputs+1)
302	for i := range outs {
303		outs[i] = ports.Output{ID: uint32(i + 1), Name: "O"}
304	}
305	_, ok, err := New(ports.Screenshot, ports.VideoSettings{}, nil, false).Select(context.Background(), outs)
306	assert.False(t, ok)
307	assert.ErrorIs(t, err, ErrTooManyOutputs)
308}
309
310func TestSelectRejectsBadInput(t *testing.T) {
311	s := New(ports.Screenshot, ports.VideoSettings{}, nil, false)
312	_, ok, err := s.Select(context.Background(), nil)
313	assert.False(t, ok)
314	assert.ErrorIs(t, err, ports.ErrOutputNotFound)
315
316	ctx, cancel := context.WithCancel(context.Background())
317	cancel()
318	_, ok, err = s.Select(ctx, testOutputs)
319	assert.False(t, ok)
320	assert.ErrorIs(t, err, context.Canceled)
321}
322
323func TestSessionFirstDecisionWins(t *testing.T) {
324	sess, ctx := testSession()
325	first := core.PickResult{OutputID: 7, Kind: core.PickMonitor}
326	sess.accept(first, ports.Screenshot, false)
327	sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor}, ports.Record, false)
328	sess.abort()
329	assert.Error(t, ctx.Err())
330	res, ok, err := sess.outcome(context.Background())
331	require.NoError(t, err)
332	assert.True(t, ok)
333	assert.Equal(t, first, res)
334	assert.Equal(t, ports.Screenshot, sess.acceptedMode())
335}
336
337// The recorded mode is the one the confirming overlay showed: a Tab on
338// another overlay after the decision changes nothing.
339func TestLateTabKeepsConfirmedMode(t *testing.T) {
340	sess, _ := testSession()
341	a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
342	b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
343	a.toggle, b.toggle = true, true
344	a.wake, b.wake = make(chan struct{}, 1), make(chan struct{}, 1)
345	sess.wakes = []chan struct{}{a.wake, b.wake}
346	a.resize(1920, 1080, 1)
347	b.resize(1280, 720, 1)
348	b.input(key("Tab"))
349	<-a.wake
350	<-b.wake
351	require.Equal(t, ports.Record, b.mode)
352	b.input(key("m"))
353	b.input(key("Return"))
354	res, ok, err := sess.outcome(context.Background())
355	require.NoError(t, err)
356	require.True(t, ok)
357	a.input(key("Tab")) // arrives before a sees the cancel
358	assert.Empty(t, a.wake, "no redraw after the decision")
359	assert.Equal(t, ports.Record, sess.acceptedMode())
360	sel := New(ports.Screenshot, ports.VideoSettings{}, nil, false).AllowToggle().selectionFor(sess.acceptedMode(), res)
361	assert.Equal(t, ports.Record, sel.Mode)
362}
363
364func TestSessionRunEnded(t *testing.T) {
365	boom := errors.New("boom")
366
367	sess, _ := testSession()
368	sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
369	sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel
370	_, ok, err := sess.outcome(context.Background())
371	assert.True(t, ok)
372	assert.NoError(t, err)
373
374	sess, _ = testSession()
375	sess.runEnded(context.Background(), "DP-1", errors.Join(context.Canceled, boom))
376	_, ok, err = sess.outcome(context.Background())
377	assert.False(t, ok)
378	assert.ErrorIs(t, err, boom)
379
380	// GO012: a real failure joined with Canceled survives whole, including the
381	// output name, after our own cancel.
382	sess, _ = testSession()
383	sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
384	real := fmt.Errorf("surface close: %w", context.Canceled)
385	sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom))
386	_, ok, err = sess.outcome(context.Background())
387	assert.False(t, ok, "a real teardown failure rejects even an accepted pick")
388	require.ErrorIs(t, err, boom)
389	assert.ErrorContains(t, err, "output DP-2")
390	assert.ErrorContains(t, err, "surface close", "a wrapped Canceled is a real failure, not hidden")
391
392	sess, _ = testSession()
393	sess.runEnded(context.Background(), "DP-1", nil) // closed without any decision
394	_, _, err = sess.outcome(context.Background())
395	assert.ErrorIs(t, err, errOverlayClosed)
396	assert.ErrorContains(t, err, "output DP-1")
397
398	sess, _ = testSession()
399	sess.accept(core.PickResult{OutputID: 7}, ports.Screenshot, false)
400	sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick
401	_, ok, err = sess.outcome(context.Background())
402	assert.False(t, ok || err == nil)
403
404	parent, cancel := context.WithCancel(context.Background())
405	cancel()
406	sess, _ = testSession()
407	sess.runEnded(parent, "DP-1", context.Canceled)
408	_, ok, err = sess.outcome(parent)
409	assert.False(t, ok)
410	assert.ErrorIs(t, err, context.Canceled)
411	sess, _ = testSession()
412	sess.runEnded(parent, "DP-1", boom)
413	_, _, err = sess.outcome(parent)
414	assert.ErrorIs(t, err, context.Canceled)
415	assert.ErrorIs(t, err, boom)
416}
417
418func TestLayerConfig(t *testing.T) {
419	c := layerConfig("DP-1", neferclient.KeyboardExclusive)
420	assert.Equal(t, "DP-1", c.Output)
421	assert.Equal(t, neferclient.LayerOverlay, c.Level)
422	assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorBottom|neferclient.AnchorLeft|neferclient.AnchorRight, c.Anchors)
423	assert.Equal(t, neferclient.KeyboardExclusive, c.Keyboard)
424	assert.Equal(t, int32(-1), c.ExclusiveZone)
425	assert.Nil(t, c.InputRects, "whole surface takes input")
426	assert.Equal(t, neferclient.KeyboardOnDemand, layerConfig("DP-2", neferclient.KeyboardOnDemand).Keyboard)
427}
428
429func TestSceneGeometry(t *testing.T) {
430	m := newTestModel(t)
431	s := m.scene()
432	assert.Equal(t, frect{0, 0, 1920, 1080}, s.dim[0], "idle: whole output dimmed")
433	assert.Equal(t, frect{}, s.edges[0])
434	assert.Equal(t, frect{}, s.label)
435
436	m.input(button(100, 50, true))
437	m.input(motion(300, 250))
438	s = m.scene()
439	assert.Equal(t, [4]frect{
440		{0, 0, 1920, 50}, {0, 250, 1920, 830}, {0, 50, 100, 200}, {300, 50, 1620, 200},
441	}, s.dim)
442	assert.Equal(t, frect{100, 50, 200, 1}, s.edges[0])
443	assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
444	assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
445	assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])
446	// The HUD fades out during a drag, so the label stays above the region.
447	assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, s.label)
448	// Dim strips and region tile the output exactly.
449	area := 200.0 * 200.0
450	for _, d := range s.dim {
451		area += d.w * d.h
452	}
453	assert.Equal(t, 1920.0*1080.0, area)
454}
455
456func TestSceneMonitorOutlineAndGrid(t *testing.T) {
457	m := newTestModel(t)
458	m.input(key("m"))
459	m.input(key("g"))
460	s := m.scene()
461	assert.Equal(t, [4]frect{}, s.dim, "monitor choice is not dimmed")
462	assert.Equal(t, frect{0, 0, 1920, 1}, s.edges[0])
463	assert.Equal(t, frect{640, 0, 1, 1080}, s.grid[0])
464	assert.Equal(t, frect{0, 720, 1920, 1}, s.grid[5])
465}
466
467// physicalEdges converts a logical rectangle the way the renderer does:
468// each edge is rounded to a physical pixel independently.
469func physicalEdges(r frect, scale float64) image.Rectangle {
470	return image.Rect(
471		int(math.Round(r.x*scale)), int(math.Round(r.y*scale)),
472		int(math.Round((r.x+r.w)*scale)), int(math.Round((r.y+r.h)*scale)))
473}
474
475func gridModel(t *testing.T, grid bool, w, h int, scale float64) *model {
476	t.Helper()
477	sess, _ := testSession()
478	m := newModel(ports.Screenshot, testOutputs, 0, sess, grid)
479	m.resize(w, h, scale)
480	require.Equal(t, core.PickActive, m.picker.Status())
481	return m
482}
483
484func TestGridDefaultAndToggle(t *testing.T) {
485	on := gridModel(t, true, 1920, 1080, 1)
486	assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "configured on: drawn at once")
487	assert.True(t, on.input(key("g")))
488	assert.Equal(t, [core.GridLineCount]frect{}, on.scene().grid, "G turns it off")
489	assert.True(t, on.input(key("G")))
490	assert.NotEqual(t, [core.GridLineCount]frect{}, on.scene().grid, "and on again")
491
492	off := gridModel(t, false, 1920, 1080, 1)
493	assert.Equal(t, [core.GridLineCount]frect{}, off.scene().grid, "configured off: nothing drawn")
494	assert.True(t, off.input(key("g")))
495	assert.NotEqual(t, [core.GridLineCount]frect{}, off.scene().grid)
496}
497
498func TestGridRendersExactPhysicalPixels(t *testing.T) {
499	for _, c := range []struct {
500		w, h  int
501		scale float64
502	}{
503		{1920, 1080, 1}, {1600, 900, 1.2}, {1536, 864, 1.25}, {1280, 720, 1.5}, {960, 540, 2},
504		{1366, 768, 1}, {1367, 769, 1.25}, {1001, 601, 1.2}, {1279, 719, 1.5}, {853, 481, 2.25},
505	} {
506		m := gridModel(t, true, c.w, c.h, c.scale)
507		want, err := core.MonitorGrid(c.w, c.h, c.scale)
508		require.NoError(t, err)
509		for i, r := range m.scene().grid {
510			got := physicalEdges(r, c.scale)
511			assert.Equal(t, want[i], got, "%dx%d@%g line %d", c.w, c.h, c.scale, i)
512			if i < 3 {
513				assert.Equal(t, 1, got.Dx(), "%dx%d@%g vertical %d is one physical pixel", c.w, c.h, c.scale, i)
514			} else {
515				assert.Equal(t, 1, got.Dy(), "%dx%d@%g horizontal %d is one physical pixel", c.w, c.h, c.scale, i)
516			}
517		}
518	}
519}
520
521func TestGridSweepRendersOnePhysicalPixel(t *testing.T) {
522	// Every compositor fractional scale (k/120) over many logical sizes.
523	for k := 120; k <= 360; k++ {
524		scale := float64(k) / 120
525		for w := 640; w <= 3000; w += 37 {
526			h := w*9/16 + 1
527			m := gridModel(t, true, w, h, scale)
528			want, err := core.MonitorGrid(w, h, scale)
529			require.NoError(t, err)
530			for i, r := range m.scene().grid {
531				if got := physicalEdges(r, scale); got != want[i] {
532					t.Fatalf("%dx%d@%d/120 line %d renders %v, want %v", w, h, k, i, got, want[i])
533				}
534			}
535		}
536	}
537}
538
539func TestGridIsPerOutputScale(t *testing.T) {
540	sess, _ := testSession()
541	a := newModel(ports.Screenshot, testOutputs, 0, sess, true)
542	b := newModel(ports.Screenshot, testOutputs, 1, sess, true)
543	a.resize(1920, 1080, 1)
544	b.resize(1280, 720, 1.5)
545	assert.Equal(t, frect{640, 0, 1, 1080}, a.scene().grid[0])
546	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")
547	assert.Equal(t, image.Rect(640, 0, 641, 1080), physicalEdges(b.scene().grid[0], 1.5))
548}
549
550func TestGridStaysOnWholeMonitorWhileDragging(t *testing.T) {
551	m := gridModel(t, true, 1920, 1080, 1)
552	idle := m.scene().grid
553	m.input(button(100, 50, true))
554	m.input(motion(300, 250))
555	require.True(t, m.picker.Dragging())
556	assert.Equal(t, idle, m.scene().grid, "the drag does not move the grid")
557	m.input(motion(1000, 700))
558	assert.Equal(t, idle, m.scene().grid)
559	m.input(key("m"))
560	m.input(key("r"))
561	assert.Equal(t, idle, m.scene().grid, "nor does a kind change")
562	m.input(button(1000, 700, false))
563	assert.Equal(t, core.PickAccepted, m.picker.Status())
564}
565
566func TestGridToggleSurvivesResizeAndScaleChange(t *testing.T) {
567	m := gridModel(t, true, 1920, 1080, 1)
568	m.input(key("g"))
569	require.False(t, m.grid)
570	m.resize(1280, 720, 1.5) // size change resets the picker, not the toggle
571	assert.False(t, m.grid)
572	assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid)
573	m.input(key("g"))
574	m.resize(1280, 720, 2) // scale only
575	assert.True(t, m.grid)
576	m.resize(1600, 900, 1.2)
577	assert.True(t, m.grid)
578}
579
580func TestGridScaleOnlyChangeKeepsPickAndRelays(t *testing.T) {
581	m := gridModel(t, true, 1920, 1080, 1)
582	m.input(button(10, 10, true))
583	m.input(motion(200, 200))
584	before := m.scene().grid
585	m.resize(1920, 1080, 1.25)
586	assert.True(t, m.picker.Dragging(), "a scale-only change keeps the drag")
587	after := m.scene().grid
588	assert.NotEqual(t, before, after, "the grid follows the new scale")
589	want, err := core.MonitorGrid(1920, 1080, 1.25)
590	require.NoError(t, err)
591	for i, r := range after {
592		assert.Equal(t, want[i], physicalEdges(r, 1.25), "line %d", i)
593	}
594}
595
596func TestGridBadScaleDrawsNothingAndKeepsSelecting(t *testing.T) {
597	for _, scale := range []float64{0, -1, math.NaN(), math.Inf(1), 100} {
598		m := gridModel(t, true, 1920, 1080, scale)
599		assert.Equal(t, endNone, m.end, "scale %v", scale)
600		assert.Equal(t, [core.GridLineCount]frect{}, m.scene().grid, "scale %v", scale)
601	}
602}
603
604func TestGridAllocations(t *testing.T) {
605	if raceEnabled {
606		t.Skip("the race detector adds allocations")
607	}
608	m := gridModel(t, true, 1600, 900, 1.2)
609	var s scene
610	n := testing.AllocsPerRun(200, func() {
611		s = m.scene()
612		m.input(key("g"))
613		m.input(key("g"))
614		m.resize(1600, 900, 1.2)
615	})
616	assert.Zero(t, n, "grid toggle, relayout and scene do not allocate")
617	_ = s
618}
619
620func BenchmarkGridResizeScene(b *testing.B) {
621	sess, _ := testSession()
622	m := newModel(ports.Screenshot, testOutputs, 0, sess, true)
623	m.resize(1600, 900, 1.2)
624	b.ReportAllocs()
625	for i := 0; b.Loop(); i++ {
626		m.resize(1600, 900, 1.2+float64(i%2)*0.05)
627		_ = m.scene()
628	}
629}
630
631func TestLabelPlacementStaysOnSurface(t *testing.T) {
632	// Above the region, with a 4px gap.
633	assert.Equal(t, frect{1920 - labelW, 1010 - labelH - 4, labelW, labelH}, labelRect(frect{1800, 1010, 100, 50}, 1920, 1080))
634	// No room above: just inside the region's top-left corner.
635	assert.Equal(t, frect{4, 4, labelW, labelH}, labelRect(frect{0, 0, 500, 500}, 1920, 1080))
636	// Exactly enough room above: the label touches the gap, not the edge.
637	assert.Equal(t, frect{10, 0, labelW, labelH}, labelRect(frect{10, labelH + 4, 300, 100}, 1920, 1080))
638	// One pixel short falls back to inside the region.
639	assert.Equal(t, frect{14, labelH + 3 + 4, labelW, labelH}, labelRect(frect{10, labelH + 3, 300, 100}, 1920, 1080))
640	// Right edge: shifted left to stay on the surface.
641	assert.Equal(t, frect{1920 - labelW, 100, labelW, labelH}, labelRect(frect{1900, 100 + labelH + 4, 20, 20}, 1920, 1080))
642	// Inside fallback near the bottom is clamped onto the surface.
643	assert.Equal(t, frect{4, 40 - labelH, labelW, labelH}, labelRect(frect{0, 20, 500, 20}, 1920, 40))
644	// A surface smaller than the label pins it to the origin.
645	assert.Equal(t, frect{0, 0, labelW, labelH}, labelRect(frect{5, 5, 10, 10}, labelW-1, labelH-1))
646}
647
648func TestLabelStaysOnSurfaceWhenItFits(t *testing.T) {
649	const w, h = 300.0, 100.0
650	for y := 0.0; y <= h; y += 7 {
651		for x := 0.0; x <= w; x += 13 {
652			l := labelRect(frect{x, y, w - x, h - y}, w, h)
653			assert.GreaterOrEqual(t, l.x, 0.0)
654			assert.GreaterOrEqual(t, l.y, 0.0)
655			assert.LessOrEqual(t, l.x+l.w, w, "region %v,%v", x, y)
656			assert.LessOrEqual(t, l.y+l.h, h, "region %v,%v", x, y)
657		}
658	}
659}
660
661// Noto Sans Mono's normal line is approximately 1.37em tall.
662// Keep the sizing assumptions explicit rather than partially parsing CSS.
663func TestLabelContentHoldsMonospaceLine(t *testing.T) {
664	const line, padY = baseFont * 1.37, tagPadY
665	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")
666	content := labelH - 2*padY
667	assert.GreaterOrEqual(t, content, line, "label clips its text line")
668	assert.GreaterOrEqual(t, content-line+padY, 1.0, "less than 1px below the text line")
669}
670
671func TestFooterAndHeaderText(t *testing.T) {
672	assert.Contains(t, footerText(ports.Screenshot, core.PickRegion, false, false, false, freezeOff), "shot · R region")
673	assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, false, freezeOff), "rec · R region")
674	assert.NotContains(t, footerText(ports.Record, core.PickRegion, false, false, false, freezeOff), "No recording indicator")
675	assert.Contains(t, footerText(ports.Record, core.PickRegion, false, false, true, freezeOff), "No recording indicator on this compositor")
676	assert.NotContains(t, footerText(ports.Screenshot, core.PickRegion, false, false, true, freezeOff), "No recording indicator", "only record mode needs the hint")
677	assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
678	assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
679	i, ok := outputKey("2", 2)
680	assert.True(t, ok)
681	assert.Equal(t, 1, i)
682	_, ok = outputKey("0", 2)
683	assert.False(t, ok)
684	_, ok = outputKey("12", 2)
685	assert.False(t, ok)
686}
687
688func TestSelectorAllocations(t *testing.T) {
689	if raceEnabled {
690		t.Skip("the race detector adds allocations")
691	}
692	m := newTestModel(t)
693	m.input(button(100, 50, true))
694	same := []nefergui.Input{motion(300, 250), motion(300, 250), motion(300, 250)}
695	for _, ev := range same { // warm the label buffer
696		m.input(ev)
697	}
698	var s scene
699	n := testing.AllocsPerRun(200, func() {
700		for _, ev := range same {
701			m.input(ev)
702		}
703		s = m.scene()
704	})
705	assert.Equal(t, 0.0, n, "unchanged pointer position and scene geometry allocate nothing")
706
707	// A changed size builds exactly one label string and nothing else.
708	i := 0
709	n = testing.AllocsPerRun(200, func() {
710		i++
711		m.input(motion(float64(300+i%2*7), 250))
712		s = m.scene()
713	})
714	assert.LessOrEqual(t, n, 1.0, "a changed size allocates only its label text")
715	_ = s
716}
717
718func BenchmarkMotion(b *testing.B) {
719	m, _ := newBareModel()
720	m.resize(1920, 1080, 1)
721	m.input(button(100, 50, true))
722	b.ReportAllocs()
723	for i := 0; b.Loop(); i++ {
724		m.input(motion(float64(200+i%300), 250))
725		_ = m.scene()
726	}
727}
728
729func TestIdleSceneEmitsNoEmptyChip(t *testing.T) {
730	m := newTestModel(t)
731	s := m.scene()
732	assert.Equal(t, frect{}, s.label, "no label box without a size")
733	assert.Zero(t, s.label.w)
734	assert.Equal(t, frect{}, s.edges[0])
735	assert.Greater(t, s.header.w, 0.0)
736	assert.Greater(t, s.footer.w, 0.0)
737}
738
739func TestInputReportsOnlyVisibleChanges(t *testing.T) {
740	m := newTestModel(t)
741	assert.False(t, m.input(motion(50, 50)), "hover draws nothing")
742	assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerAxis, DX: 1, DY: 2}), "axis is ignored")
743	other := button(50, 50, true)
744	other.Button = 0x111
745	assert.False(t, m.input(other), "other buttons are ignored")
746	assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputFocusIn}))
747	assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerLeave}))
748	release := key("g")
749	release.Pressed = false
750	assert.False(t, m.input(release), "release")
751	repeat := key("g")
752	repeat.Repeat = true
753	assert.False(t, m.input(repeat), "repeat")
754	assert.False(t, m.input(key("q")), "unknown key")
755	assert.True(t, m.input(key("w")), "W without a workspace warns and falls back")
756	m = newTestModel(t)
757
758	assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
759	assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
760	assert.True(t, m.input(motion(300, 250)), "the drag appears")
761	assert.False(t, m.input(motion(300, 250)), "same position")
762	assert.False(t, m.input(motion(300, 250)))
763	assert.True(t, m.input(motion(300, 251)), "the rectangle grew")
764
765	m = newTestModel(t)
766	assert.True(t, m.input(key("g")), "grid toggles")
767	assert.True(t, m.input(key("m")), "monitor outline")
768	assert.True(t, m.input(key("m")), "an already chosen target is confirmed again")
769	assert.True(t, m.input(key("r")))
770}
771
772func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) {
773	m := newTestModel(t)
774	m.input(button(100, 100, true))
775	require.True(t, m.input(motion(1920, 200)))
776	assert.False(t, m.input(motion(2500, 200)), "the clamped rectangle did not change")
777	assert.False(t, m.input(motion(3000, 200)))
778}
779
780func TestInputReportsClosing(t *testing.T) {
781	m := newTestModel(t)
782	assert.True(t, m.input(key("Escape")), "canceling closes the overlay")
783	assert.False(t, m.input(key("g")), "a closed overlay ignores input")
784
785	m = newTestModel(t)
786	assert.True(t, m.input(key("2")), "a digit accepts")
787
788	m = newTestModel(t)
789	m.input(button(10, 10, true))
790	assert.True(t, m.input(button(10, 10, false)), "a click accepts the monitor")
791
792	m = newTestModel(t)
793	m.input(button(10, 10, true))
794	m.input(motion(200, 200))
795	assert.True(t, m.input(button(200, 200, false)), "a drag release accepts the region")
796}
797
798func TestCapabilitiesHideWorkspacesAndHintIndicator(t *testing.T) {
799	ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Width: 640, Height: 480}}}
800	s := New(ports.Record, ports.VideoSettings{}, ws, false).WithCapabilities(ports.Capabilities{})
801	assert.Empty(t, s.workspaces, "no ext-workspace: W is not offered")
802	assert.True(t, s.noIndicator)
803	s = New(ports.Record, ports.VideoSettings{}, ws, false).WithCapabilities(ports.Capabilities{Workspaces: true, Exclusion: true})
804	assert.Len(t, s.workspaces, 1)
805	assert.False(t, s.noIndicator)
806}

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