v0.6.1 3d41bc5

selection_test.go RawHistoryFolder

1package core_test
2
3import (
4	"math"
5	"testing"
6
7	"github.com/stretchr/testify/assert"
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/nefercap/internal/core"
11	"github.com/bnema/nefercap/internal/ports"
12)
13
14func newPicker(t testing.TB, id uint32, w, h int) *core.Picker {
15	t.Helper()
16	p := &core.Picker{}
17	require.NoError(t, p.Begin(id, w, h))
18	return p
19}
20
21func TestPickerBeginValidation(t *testing.T) {
22	for name, c := range map[string]struct {
23		id   uint32
24		w, h int
25	}{
26		"zero id":     {0, 100, 100},
27		"zero width":  {1, 0, 100},
28		"neg height":  {1, 100, -1},
29		"too large":   {1, ports.MaxDimension + 1, 100},
30		"too large h": {1, 100, ports.MaxDimension + 1},
31	} {
32		t.Run(name, func(t *testing.T) {
33			p := &core.Picker{}
34			assert.ErrorIs(t, p.Begin(c.id, c.w, c.h), core.ErrInvalidBounds)
35			assert.Equal(t, core.PickIdle, p.Status())
36			assert.False(t, p.MouseDown(1, 1))
37		})
38	}
39	p := newPicker(t, 3, 1920, 1080)
40	assert.Equal(t, core.PickActive, p.Status())
41	assert.Equal(t, core.PickRegion, p.Kind())
42}
43
44func TestPickerDragAcceptsOnRelease(t *testing.T) {
45	p := newPicker(t, 5, 1000, 800)
46	assert.True(t, p.MouseDown(100, 200))
47	assert.True(t, p.MouseMove(250, 300))
48	r, ok := p.Rect()
49	assert.True(t, ok)
50	assert.Equal(t, ports.Region{X: 100, Y: 200, Width: 150, Height: 100}, r)
51	assert.Equal(t, core.PickActive, p.Status(), "not accepted before release")
52	_, ok = p.Result()
53	assert.False(t, ok)
54
55	assert.True(t, p.MouseUp(250, 300))
56	res, ok := p.Result()
57	require.True(t, ok)
58	assert.Equal(t, core.PickResult{OutputID: 5, Region: ports.Region{X: 100, Y: 200, Width: 150, Height: 100}, Kind: core.PickRegion}, res)
59	assert.Equal(t, ports.Target{OutputID: 5, Region: res.Region}, res.Target())
60}
61
62func TestPickerReverseDragNormalizes(t *testing.T) {
63	for name, pts := range map[string][4]float64{
64		"up-left":    {300, 300, 100, 120},
65		"up-right":   {100, 300, 300, 120},
66		"down-left":  {300, 120, 100, 300},
67		"down-right": {100, 120, 300, 300},
68	} {
69		t.Run(name, func(t *testing.T) {
70			p := newPicker(t, 1, 1000, 1000)
71			p.MouseDown(pts[0], pts[1])
72			p.MouseMove(pts[2], pts[3])
73			p.MouseUp(pts[2], pts[3])
74			res, ok := p.Result()
75			require.True(t, ok)
76			assert.Equal(t, ports.Region{X: 100, Y: 120, Width: 200, Height: 180}, res.Region)
77		})
78	}
79}
80
81func TestPickerClampsToOutput(t *testing.T) {
82	p := newPicker(t, 1, 500, 400)
83	p.MouseDown(-50, -20)
84	p.MouseMove(9000, 9000)
85	r, dragging := p.Rect()
86	require.True(t, dragging)
87	assert.Equal(t, ports.Region{Width: 500, Height: 400}, r, "live drag is clamped too")
88	p.MouseUp(9000, 9000)
89	res, ok := p.Result()
90	require.True(t, ok)
91	assert.Equal(t, ports.Region{Width: 500, Height: 400}, res.Region)
92	assert.Equal(t, core.PickRegion, res.Kind, "a dragged full-output region stays a region")
93
94	// Never leaves the output, wherever it starts.
95	q := newPicker(t, 1, 500, 400)
96	q.MouseDown(480, 390)
97	q.MouseMove(700, -100)
98	q.MouseUp(700, -100)
99	res, ok = q.Result()
100	require.True(t, ok)
101	assert.Equal(t, ports.Region{X: 480, Y: 0, Width: 20, Height: 390}, res.Region)
102
103	// NaN is ignored; infinities clamp to the edges.
104	n := newPicker(t, 1, 500, 400)
105	assert.False(t, n.MouseMove(math.NaN(), 5))
106	assert.False(t, n.MouseMove(5, math.NaN()))
107	i := newPicker(t, 1, 500, 400)
108	i.MouseDown(math.Inf(-1), math.Inf(-1))
109	i.MouseMove(math.Inf(1), math.Inf(1))
110	i.MouseUp(math.Inf(1), math.Inf(1))
111	res, ok = i.Result()
112	require.True(t, ok)
113	assert.Equal(t, ports.Region{Width: 500, Height: 400}, res.Region)
114}
115
116func TestPickerThresholdAndClick(t *testing.T) {
117	p := newPicker(t, 9, 1920, 1080)
118	p.MouseDown(500, 500)
119	assert.True(t, p.MouseMove(502, 502), "pointer moved")
120	assert.False(t, p.Dragging(), "under threshold")
121	assert.False(t, p.MouseMove(502, 502), "no change no redraw")
122	assert.True(t, p.MouseMove(504, 500), "exactly the threshold starts a drag")
123	assert.True(t, p.Dragging())
124
125	// A click, even with jitter below the threshold, picks the monitor.
126	c := newPicker(t, 9, 1920, 1080)
127	c.MouseDown(500, 500)
128	c.MouseMove(503, 500)
129	assert.True(t, c.MouseUp(503, 500))
130	res, ok := c.Result()
131	require.True(t, ok)
132	assert.Equal(t, core.PickResult{OutputID: 9, Region: ports.Region{Width: 1920, Height: 1080}, Kind: core.PickMonitor}, res)
133	assert.Equal(t, ports.Target{OutputID: 9}, res.Target(), "monitor is the full-output zero region")
134}
135
136func TestPickerMinimumSize(t *testing.T) {
137	p := newPicker(t, 1, 1000, 1000)
138	p.MouseDown(100, 100)
139	p.MouseMove(300, 102) // past threshold horizontally, height 2
140	assert.True(t, p.MouseUp(300, 102))
141	assert.Equal(t, core.PickActive, p.Status(), "tiny drag is discarded")
142	_, dragging := p.Rect()
143	assert.False(t, dragging)
144
145	// picking continues
146	p.MouseDown(10, 10)
147	p.MouseMove(60, 60)
148	p.MouseUp(60, 60)
149	res, ok := p.Result()
150	require.True(t, ok)
151	assert.Equal(t, ports.Region{X: 10, Y: 10, Width: 50, Height: 50}, res.Region)
152}
153
154func TestPickerEscape(t *testing.T) {
155	p := newPicker(t, 1, 100, 100)
156	p.MouseDown(10, 10)
157	p.MouseMove(50, 50)
158	assert.True(t, p.Cancel())
159	assert.Equal(t, core.PickCanceled, p.Status())
160	_, ok := p.Result()
161	assert.False(t, ok)
162	assert.False(t, p.MouseUp(50, 50))
163	assert.False(t, p.Confirm())
164	assert.False(t, p.Cancel())
165	assert.False(t, p.MouseDown(1, 1))
166}
167
168func TestPickerMonitorKeyAndConfirm(t *testing.T) {
169	p := newPicker(t, 2, 2560, 1440)
170	assert.False(t, p.Confirm(), "region kind needs a drag")
171	assert.True(t, p.SelectMonitor())
172	assert.False(t, p.SelectMonitor())
173	assert.Equal(t, core.PickMonitor, p.Kind())
174	assert.True(t, p.Confirm())
175	res, ok := p.Result()
176	require.True(t, ok)
177	assert.Equal(t, core.PickMonitor, res.Kind)
178	assert.Equal(t, ports.Region{Width: 2560, Height: 1440}, res.Region)
179	assert.Zero(t, res.WorkspaceID)
180
181	// A click also picks the monitor when m was chosen, even under the pointer.
182	q := newPicker(t, 2, 2560, 1440)
183	q.MouseMove(1000, 1000)
184	q.SelectMonitor()
185	q.MouseDown(1000, 1000)
186	q.MouseUp(1000, 1000)
187	res, ok = q.Result()
188	require.True(t, ok)
189	assert.Equal(t, core.PickMonitor, res.Kind)
190}
191
192func TestPickerWorkspaceNeedsRealIdentity(t *testing.T) {
193	rect := ports.Region{X: 0, Y: 0, Width: 1900, Height: 1000}
194
195	p := newPicker(t, 4, 1920, 1080)
196	assert.False(t, p.SelectWorkspace(), "no workspace supplied")
197	assert.False(t, p.SetWorkspace(0, rect), "zero id is not an identity")
198	assert.False(t, p.SelectWorkspace())
199	assert.False(t, p.SetWorkspace(7, ports.Region{}), "empty rect is not geometry")
200	assert.False(t, p.SetWorkspace(7, ports.Region{X: 5000, Y: 0, Width: 10, Height: 10}), "outside output")
201	assert.False(t, p.SetWorkspace(7, ports.Region{Width: -5, Height: 10}))
202	_, _, ok := p.Workspace()
203	assert.False(t, ok)
204	assert.False(t, p.SelectWorkspace())
205
206	assert.True(t, p.SetWorkspace(7, rect))
207	assert.False(t, p.SetWorkspace(7, rect), "unchanged")
208	assert.True(t, p.SelectWorkspace())
209	assert.True(t, p.Confirm())
210	res, ok := p.Result()
211	require.True(t, ok)
212	assert.Equal(t, core.PickResult{OutputID: 4, Region: rect, Kind: core.PickWorkspace, WorkspaceID: 7}, res)
213	assert.Equal(t, rect, res.Region, "geometry stays available for drawing")
214	assert.Equal(t, ports.Target{OutputID: 4, WorkspaceID: 7}, res.Target())
215}
216
217func TestPickResultTargetWorkspaceKeepsIdentity(t *testing.T) {
218	// A workspace that fills the whole output is still a workspace, not the
219	// monitor: the exact ID survives and no geometry snapshot is cached.
220	full := ports.Region{Width: 1920, Height: 1080}
221	p := newPicker(t, 4, 1920, 1080)
222	require.True(t, p.SetWorkspace(1<<40+5, full))
223	require.True(t, p.SelectWorkspace())
224	require.True(t, p.Confirm())
225	res, ok := p.Result()
226	require.True(t, ok)
227	assert.Equal(t, full, res.Region)
228	assert.Equal(t, uint64(1<<40+5), res.WorkspaceID)
229	got := res.Target()
230	assert.Equal(t, ports.Target{OutputID: 4, WorkspaceID: 1<<40 + 5}, got)
231	assert.Zero(t, got.Region)
232	assert.NotEqual(t, ports.Target{OutputID: 4}, got)
233
234	// Monitor and region results never carry a workspace ID.
235	assert.Equal(t, ports.Target{OutputID: 4}, core.PickResult{OutputID: 4, Kind: core.PickMonitor, Region: full, WorkspaceID: 9}.Target())
236	assert.Equal(t, ports.Target{OutputID: 4, Region: full}, core.PickResult{OutputID: 4, Kind: core.PickRegion, Region: full, WorkspaceID: 9}.Target())
237}
238
239func TestPickerWorkspaceClickAndClip(t *testing.T) {
240	p := newPicker(t, 4, 1000, 500)
241	p.SetWorkspace(11, ports.Region{X: -20, Y: 30, Width: 2000, Height: 2000})
242	id, r, ok := p.Workspace()
243	require.True(t, ok)
244	assert.Equal(t, uint64(11), id)
245	assert.Equal(t, ports.Region{X: 0, Y: 30, Width: 1000, Height: 470}, r, "clipped to the output")
246	p.SelectWorkspace()
247	p.MouseDown(5, 5)
248	p.MouseUp(5, 5)
249	res, ok := p.Result()
250	require.True(t, ok)
251	assert.Equal(t, core.PickWorkspace, res.Kind)
252	assert.Equal(t, uint64(11), res.WorkspaceID)
253
254	// Drag still wins as a region even with workspace chosen.
255	q := newPicker(t, 4, 1000, 500)
256	q.SetWorkspace(11, ports.Region{Width: 1000, Height: 500})
257	q.SelectWorkspace()
258	q.MouseDown(10, 10)
259	q.MouseMove(90, 90)
260	q.MouseUp(90, 90)
261	res, ok = q.Result()
262	require.True(t, ok)
263	assert.Equal(t, core.PickRegion, res.Kind)
264	assert.Zero(t, res.WorkspaceID)
265}
266
267func TestPickerWorkspaceLostFallsBack(t *testing.T) {
268	p := newPicker(t, 4, 1000, 500)
269	p.SetWorkspace(3, ports.Region{Width: 100, Height: 100})
270	p.SelectWorkspace()
271	assert.True(t, p.SetWorkspace(0, ports.Region{}))
272	assert.Equal(t, core.PickRegion, p.Kind())
273	assert.False(t, p.Confirm())
274}
275
276func TestPickerFullscreenMaxMonitor(t *testing.T) {
277	const max = ports.MaxDimension
278	p := newPicker(t, 1, max, max)
279	p.MouseDown(0, 0)
280	p.MouseMove(1e12, 1e12)
281	p.MouseUp(1e12, 1e12)
282	res, ok := p.Result()
283	require.True(t, ok)
284	assert.Equal(t, ports.Region{Width: max, Height: max}, res.Region)
285
286	q := newPicker(t, 1, max, max)
287	q.MouseDown(0, 0)
288	q.MouseUp(0, 0)
289	res, ok = q.Result()
290	require.True(t, ok)
291	assert.Equal(t, core.PickMonitor, res.Kind)
292	assert.Equal(t, ports.Region{Width: max, Height: max}, res.Region)
293}
294
295func TestPickerChangedFlags(t *testing.T) {
296	p := newPicker(t, 1, 100, 100)
297	assert.True(t, p.MouseMove(10, 10))
298	assert.False(t, p.MouseMove(10, 10), "same position")
299	assert.False(t, p.MouseUp(10, 10), "no press")
300	assert.True(t, p.MouseDown(10, 10))
301	assert.False(t, p.MouseDown(20, 20), "already pressed")
302	assert.False(t, p.SelectMonitor(), "mode cannot change mid-press")
303	assert.False(t, p.Confirm(), "no confirm mid-press")
304}
305
306func TestPickerAllocations(t *testing.T) {
307	p := newPicker(t, 1, 1920, 1080)
308	p.SetWorkspace(1, ports.Region{Width: 100, Height: 100})
309	n := testing.AllocsPerRun(10, func() {
310		p.MouseDown(10, 10)
311		for i := 0; i < 1000; i++ {
312			p.MouseMove(float64(10+i%900), float64(10+(i*7)%900))
313			_, _ = p.Rect()
314			_, _ = p.Result()
315		}
316		p.SelectMonitor()
317		p.SelectRegion()
318		p.Cancel()
319		_ = p.Begin(1, 1920, 1080)
320		p.SetWorkspace(1, ports.Region{Width: 100, Height: 100})
321	})
322	assert.Zero(t, n)
323}
324
325func BenchmarkPickerDrag(b *testing.B) {
326	p := &core.Picker{}
327	_ = p.Begin(1, 3840, 2160)
328	b.ReportAllocs()
329	b.ResetTimer()
330	for i := 0; i < b.N; i++ {
331		p.MouseDown(100, 100)
332		for j := 0; j < 100; j++ {
333			p.MouseMove(float64(100+j*30), float64(100+j*15))
334		}
335		p.MouseUp(3000, 1500)
336		_ = p.Begin(1, 3840, 2160)
337	}
338}

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