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