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}