v0.2.0 a558f49

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

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