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}