1package core_test
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "regexp"
8 "slices"
9 "testing"
10 "testing/synctest"
11 "time"
12
13 "github.com/bnema/neferwl/internal/core"
14 "github.com/bnema/neferwl/internal/ports"
15)
16
17func receive[T any](t *testing.T, ch <-chan T) T {
18 t.Helper()
19 select {
20 case v := <-ch:
21 return v
22 case <-time.After(time.Second):
23 t.Fatal("timed out")
24 var zero T
25 return zero
26 }
27}
28
29// scene receives the next scene set and returns the first output's scene.
30func scene(t *testing.T, ch <-chan []ports.Scene) ports.Scene {
31 t.Helper()
32 return receive(t, ch)[0]
33}
34
35// command receives the next client command, skipping output scale updates
36// and user activity reports.
37func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
38 t.Helper()
39 for {
40 switch v := receive(t, ch); v.(type) {
41 case nil, ports.SetOutputs, ports.UserActivity:
42 case ports.PlaceWindow:
43 // Only Positioned (X11) windows are placed; every scenario
44 // using this helper maps xdg windows.
45 t.Fatalf("unexpected %+v", v)
46 default:
47 return v
48 }
49 }
50}
51
52// modeLanding gives a hand-rolled core test its animation mode: off, or on with
53// a clock the test moves and the page flips it sends. land returns the scene
54// set once every spring has landed (the set itself when none runs, and
55// always with animations off). An on run that never saw a spring would be
56// the off run again, so it fails.
57type modeLanding struct {
58 t *testing.T
59 clk *stepClock
60 frames chan ports.OutputFrame
61 scenes chan []ports.Scene
62 outputs []string
63 lands int
64}
65
66// newLanding sets cfg's animations and, when on, the clock of opts and flips of ch.
67func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, opts *core.Options, outputs ...string) *modeLanding {
68 t.Helper()
69 cfg.Animations.On = animated
70 l := &modeLanding{t: t, scenes: ch.Scenes, outputs: outputs}
71 if animated {
72 l.clk, l.frames = newStepClock(t), make(chan ports.OutputFrame)
73 opts.Clock, ch.Frames = l.clk.clock, l.frames
74 t.Cleanup(func() {
75 if l.lands == 0 {
76 t.Error("no spring ever ran: the animations-on variant checked nothing")
77 }
78 })
79 }
80 return l
81}
82
83func (l *modeLanding) land(set []ports.Scene) []ports.Scene {
84 l.t.Helper()
85 if l.clk == nil {
86 return set
87 }
88 landed, ok := l.clk.settle(l.t, l.frames, l.scenes, l.outputs...)
89 if !ok {
90 return set
91 }
92 l.lands++
93 return landed
94}
95
96// one is land for the first output's scene.
97func (l *modeLanding) one(s ports.Scene) ports.Scene { return l.land([]ports.Scene{s})[0] }
98
99func TestOwner(t *testing.T) { both(t, owner) }
100
101func owner(t *testing.T, animated bool) {
102 cfg := altCmdDefaults()
103 cfg.Border.Width = 0
104 cfg.Layout.Gaps = 8
105 client := make(chan ports.ClientEvent, 128)
106 input := make(chan ports.InputEvent, 32)
107 output := make(chan ports.OutputEvent, 8)
108 reload := make(chan ports.ConfigChanged, 8)
109 commands := make(chan ports.ClientCommand, 256)
110 spawn := make(chan ports.SpawnRequest, 8)
111 scenes := make(chan []ports.Scene, 1)
112 errs := make(chan error, 8)
113 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}
114 opts := core.Options{}
115 l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
116 c, err := core.New(cfg, ch, opts)
117 if err != nil {
118 t.Fatal(err)
119 }
120 ctx, cancel := context.WithCancel(context.Background())
121 defer cancel()
122 done := make(chan error, 1)
123 go func() { done <- c.Run(ctx) }()
124 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
125 s := scene(t, scenes)
126 if s.OutputWidth != 100 {
127 t.Fatal(s)
128 }
129 client <- ports.WindowMapped{ID: 1}
130 s = l.one(scene(t, scenes))
131 // A single column fills the usable width.
132 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != ports.SideAll {
133 t.Fatal(s)
134 }
135 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {
136 t.Fatal(v)
137 }
138 command(t, commands)
139 client <- ports.WindowMapped{ID: 2}
140 s = scene(t, scenes)
141 if len(s.Windows) != 2 {
142 t.Fatal(s)
143 }
144 // Commands for focus changes and second mapping.
145 for len(commands) > 0 {
146 <-commands
147 }
148 client <- ports.WindowMapped{ID: 2}
149 scene(t, scenes)
150 if len(commands) != 0 {
151 t.Fatal("duplicate configure")
152 }
153 input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true}
154 scene(t, scenes)
155 if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" {
156 t.Fatal(v)
157 }
158 input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt}
159 input <- ports.KeyEvent{Keysym: "x", Pressed: true}
160 if v := command(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {
161 t.Fatal(v)
162 }
163 input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true}
164 scene(t, scenes)
165 if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) {
166 t.Fatal(v)
167 }
168 client <- ports.WindowUnmapped{ID: 2}
169 scene(t, scenes)
170 for len(commands) > 0 {
171 <-commands
172 }
173 cfg.Layout.Gaps = 4
174 reload <- ports.ConfigChanged{Config: cfg}
175 s = l.one(scene(t, scenes))
176 if s.Windows[0].Rect.W != 92 {
177 t.Fatal(s)
178 }
179 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1", Visible: true}) {
180 t.Fatal(v)
181 }
182 cfg.Layout.MaxColumns = 0
183 reload <- ports.ConfigChanged{Config: cfg}
184 receive(t, errs)
185 for i := 0; i < 100; i++ {
186 client <- ports.WindowMapped{ID: ports.WindowID(i + 3)}
187 }
188 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
189 if err := receive(t, done); !errors.Is(err, core.ErrQuit) {
190 t.Fatal(err)
191 }
192}
193func TestCancelAndSceneCapacity(t *testing.T) {
194 cfg := scrollDefaults()
195 if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}, core.Options{}); err == nil {
196 t.Fatal("capacity")
197 }
198 ctx, cancel := context.WithCancel(context.Background())
199 c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}, core.Options{})
200 if err != nil {
201 t.Fatal(err)
202 }
203 done := make(chan error, 1)
204 go func() { done <- c.Run(ctx) }()
205 cancel()
206 if err := receive(t, done); err != nil {
207 t.Fatal(err)
208 }
209}
210
211func TestKeyPressState(t *testing.T) {
212 cfg := scrollDefaults()
213 client := make(chan ports.ClientEvent, 8)
214 input := make(chan ports.InputEvent, 8)
215 commands := make(chan ports.ClientCommand, 16)
216 scenes := make(chan []ports.Scene, 1)
217 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes}, core.Options{})
218 if err != nil {
219 t.Fatal(err)
220 }
221 ctx, cancel := context.WithCancel(context.Background())
222 done := make(chan error, 1)
223 go func() { done <- c.Run(ctx) }()
224 defer func() { cancel(); receive(t, done) }()
225 client <- ports.WindowMapped{ID: 1}
226 scene(t, scenes)
227 command(t, commands)
228 command(t, commands)
229 press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
230 input <- press
231 scene(t, scenes)
232 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper}
233 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
234 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) {
235 t.Fatal(v)
236 }
237 input <- ports.KeyEvent{Keysym: "Left"}
238 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) {
239 t.Fatal(v)
240 }
241 input <- press
242 scene(t, scenes)
243 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
244 command(t, commands)
245 input <- ports.KeyEvent{Keysym: "Left"}
246 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) {
247 t.Fatal(v)
248 }
249}
250
251func TestBoundReleaseSwallowed(t *testing.T) {
252 cfg := scrollDefaults()
253 input := make(chan ports.InputEvent, 4)
254 client := make(chan ports.ClientEvent, 2)
255 commands := make(chan ports.ClientCommand, 8)
256 scenes := make(chan []ports.Scene, 1)
257 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}, core.Options{})
258 if err != nil {
259 t.Fatal(err)
260 }
261 ctx, cancel := context.WithCancel(context.Background())
262 done := make(chan error, 1)
263 go func() { done <- c.Run(ctx) }()
264 defer func() { cancel(); receive(t, done) }()
265 client <- ports.WindowMapped{ID: 1}
266 scene(t, scenes)
267 command(t, commands)
268 command(t, commands)
269 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
270 scene(t, scenes)
271 input <- ports.KeyEvent{Keysym: "Left"}
272 input <- ports.KeyEvent{Keysym: "x", Pressed: true}
273 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {
274 t.Fatal(v)
275 }
276}
277
278func TestPointerFocusAndGrab(t *testing.T) {
279 cfg := scrollDefaults()
280 cfg.Border.Width = 0
281 cfg.Layout.Gaps = 8
282 client := make(chan ports.ClientEvent, 8)
283 input := make(chan ports.InputEvent, 8)
284 output := make(chan ports.OutputEvent, 8)
285 commands := make(chan ports.ClientCommand, 64)
286 scenes := make(chan []ports.Scene, 1)
287 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
288 if err != nil {
289 t.Fatal(err)
290 }
291 ctx, cancel := context.WithCancel(context.Background())
292 defer cancel()
293 go c.Run(ctx)
294 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
295 scene(t, scenes)
296 client <- ports.WindowMapped{ID: 1}
297 first := scene(t, scenes).Windows[0].Rect
298 client <- ports.WindowMapped{ID: 2}
299 scene(t, scenes)
300 for len(commands) > 0 {
301 <-commands
302 }
303 input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), Time: 1 * time.Millisecond}
304 if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) {
305 t.Fatal(v)
306 }
307 if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, Time: 1 * time.Millisecond}) {
308 t.Fatal(v)
309 }
310 input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
311 if v := command(t, commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}}) {
312 t.Fatal(v)
313 }
314 input <- ports.PointerButton{Button: 0x110, Pressed: true}
315 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true, Grab: true}) {
316 t.Fatal(v)
317 }
318 scene(t, scenes)
319 found := false
320 for len(commands) > 0 {
321 if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 {
322 found = true
323 }
324 }
325 if !found {
326 t.Fatal("missing keyboard focus")
327 }
328 input <- ports.PointerMotion{X: 0, Y: 0}
329 if v := command(t, commands); v != (ports.PointerFocus{}) {
330 t.Fatal(v)
331 }
332 // Mid-drag off the window, scroll goes to what is under the pointer:
333 // nothing here, not the grab window that got a leave.
334 input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
335 input <- ports.PointerButton{Button: 0x110}
336 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {
337 t.Fatal(v)
338 }
339}
340
341func TestBorderInset(t *testing.T) { both(t, borderInset) }
342
343func borderInset(t *testing.T, animated bool) {
344 cfg := scrollDefaults()
345 cfg.Border.Width = 2
346 client := make(chan ports.ClientEvent, 8)
347 input := make(chan ports.InputEvent, 8)
348 output := make(chan ports.OutputEvent, 8)
349 commands := make(chan ports.ClientCommand, 64)
350 scenes := make(chan []ports.Scene, 1)
351 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
352 opts := core.Options{}
353 l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
354 c, err := core.New(cfg, ch, opts)
355 if err != nil {
356 t.Fatal(err)
357 }
358 ctx, cancel := context.WithCancel(context.Background())
359 defer cancel()
360 go c.Run(ctx)
361 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
362 scene(t, scenes)
363 // Alone, the window is borderless and gets the full rect.
364 client <- ports.WindowMapped{ID: 1}
365 r := l.one(scene(t, scenes)).Windows[0].Rect
366 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) {
367 t.Fatalf("%v for outer %v", v, r)
368 }
369 // With a second column, the left window owns the shared separator: its
370 // client is 2px narrower on the right; the right window keeps its rect.
371 client <- ports.WindowMapped{ID: 2}
372 s := l.one(scene(t, scenes))
373 r = s.Windows[1].Rect
374 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
375 t.Fatal(s)
376 }
377 want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1", Visible: true}
378 found := false
379 for len(commands) > 0 {
380 if v := <-commands; v == want {
381 found = true
382 }
383 }
384 if !found {
385 t.Fatalf("no %v for outer %v", want, r)
386 }
387 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), Time: 1 * time.Millisecond}
388 if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 2, Y: 5}) {
389 t.Fatal(v)
390 }
391}
392
393func TestLayerKeyboardFocus(t *testing.T) {
394 cfg := scrollDefaults()
395 client := make(chan ports.ClientEvent, 8)
396 input := make(chan ports.InputEvent, 8)
397 output := make(chan ports.OutputEvent, 8)
398 commands := make(chan ports.ClientCommand, 64)
399 scenes := make(chan []ports.Scene, 1)
400 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
401 if err != nil {
402 t.Fatal(err)
403 }
404 ctx, cancel := context.WithCancel(context.Background())
405 defer cancel()
406 go c.Run(ctx)
407 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
408 scene(t, scenes)
409 client <- ports.WindowMapped{ID: 1}
410 scene(t, scenes)
411 focusOf := func() ports.WindowID {
412 t.Helper()
413 for {
414 if v, ok := command(t, commands).(ports.FocusWindow); ok {
415 return v.ID
416 }
417 }
418 }
419 if id := focusOf(); id != 1 {
420 t.Fatalf("window focus %d", id)
421 }
422 // An on-demand bar does not steal focus; an exclusive launcher does.
423 launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1}
424 bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2}
425 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
426 scene(t, scenes)
427 if id := focusOf(); id != 7 {
428 t.Fatalf("layer focus %d", id)
429 }
430 input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}
431 for {
432 if v, ok := command(t, commands).(ports.ForwardKey); ok {
433 if v.ID != 7 {
434 t.Fatalf("key went to %d", v.ID)
435 }
436 break
437 }
438 }
439 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
440 scene(t, scenes)
441 if id := focusOf(); id != 1 {
442 t.Fatalf("focus not restored: %d", id)
443 }
444}
445
446func TestWorkspaceSwitch(t *testing.T) { workspaceSwitch(t, false) }
447
448// With animations on the scene right after the key shows the slide's start:
449// the test lands it before it asserts.
450func TestWorkspaceSwitchAnimated(t *testing.T) { workspaceSwitch(t, true) }
451
452// settledScene lands the springs an action started on the single output
453// OUT-1 of a rig with a stepClock and returns its scene; animations off:
454// the scene itself.
455func settledScene(t *testing.T, sc *stepClock, frames chan ports.OutputFrame, scenes chan []ports.Scene, s ports.Scene, mustAnimate bool) ports.Scene {
456 t.Helper()
457 if sc == nil {
458 return s
459 }
460 set, ok := sc.settle(t, frames, scenes, "OUT-1")
461 if !ok {
462 if mustAnimate {
463 t.Fatal("the action started no animation: the case checks nothing")
464 }
465 return s
466 }
467 return set[0]
468}
469
470func workspaceSwitch(t *testing.T, animated bool) {
471 cfg := scrollDefaults()
472 cfg.Animations.On = animated
473 client := make(chan ports.ClientEvent, 8)
474 input := make(chan ports.InputEvent, 8)
475 output := make(chan ports.OutputEvent, 8)
476 commands := make(chan ports.ClientCommand, 64)
477 scenes := make(chan []ports.Scene, 1)
478 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
479 opts := core.Options{}
480 var sc *stepClock
481 frames := make(chan ports.OutputFrame)
482 if animated {
483 sc = newStepClock(t)
484 opts.Clock, ch.Frames = sc.clock, frames
485 }
486 c, err := core.New(cfg, ch, opts)
487 if err != nil {
488 t.Fatal(err)
489 }
490 ctx, cancel := context.WithCancel(context.Background())
491 defer cancel()
492 go c.Run(ctx)
493 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
494 scene(t, scenes)
495 client <- ports.WindowMapped{ID: 1}
496 scene(t, scenes)
497 for len(commands) > 0 {
498 <-commands
499 }
500 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
501 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
502 s := settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
503 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
504 t.Fatal(s)
505 }
506 // Window 1 is deactivated and loses keyboard focus.
507 var sawDeactivate, sawFocus bool
508 for len(commands) > 0 {
509 switch v := (<-commands).(type) {
510 case ports.ConfigureWindow:
511 sawDeactivate = sawDeactivate || (v.ID == 1 && !v.Activated)
512 case ports.FocusWindow:
513 sawFocus = sawFocus || v.ID == 0
514 }
515 }
516 if !sawDeactivate || !sawFocus {
517 t.Fatal(sawDeactivate, sawFocus)
518 }
519 // A new window opens on workspace 2, alone and borderless.
520 client <- ports.WindowMapped{ID: 2}
521 s = settledScene(t, sc, frames, scenes, scene(t, scenes), false)
522 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
523 t.Fatal(s)
524 }
525}
526
527func TestClickAfterWorkspaceSwitch(t *testing.T) {
528 synctest.Test(t, func(t *testing.T) { clickAfterWorkspaceSwitch(t, false) })
529}
530
531func TestClickAfterWorkspaceSwitchAnimated(t *testing.T) {
532 synctest.Test(t, func(t *testing.T) { clickAfterWorkspaceSwitch(t, true) })
533}
534
535func clickAfterWorkspaceSwitch(t *testing.T, animated bool) {
536 cfg := scrollDefaults()
537 cfg.Animations.On = animated
538 client := make(chan ports.ClientEvent, 8)
539 input := make(chan ports.InputEvent, 8)
540 output := make(chan ports.OutputEvent, 8)
541 commands := make(chan ports.ClientCommand, 64)
542 scenes := make(chan []ports.Scene, 1)
543 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
544 opts := core.Options{}
545 var sc *stepClock
546 frames := make(chan ports.OutputFrame)
547 if animated {
548 sc = newStepClock(t)
549 opts.Clock, ch.Frames = sc.clock, frames
550 }
551 c, err := core.New(cfg, ch, opts)
552 if err != nil {
553 t.Fatal(err)
554 }
555 ctx, cancel := context.WithCancel(context.Background())
556 defer cancel()
557 go c.Run(ctx)
558 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
559 scene(t, scenes)
560 client <- ports.WindowMapped{ID: 1}
561 scene(t, scenes)
562 input <- ports.PointerMotion{X: 50, Y: 40}
563 synctest.Wait()
564 if len(commands) < 3 {
565 t.Fatalf("%d commands after pointer motion, want at least 3", len(commands))
566 }
567 for len(commands) > 0 {
568 <-commands
569 }
570 // Cmd+2 hides window 1; the pointer must leave it.
571 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
572 settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
573 var cleared bool
574 for len(commands) > 0 {
575 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
576 cleared = true
577 }
578 }
579 if !cleared {
580 t.Fatal("pointer focus kept on hidden window")
581 }
582 // A click without moving does not reach window 1 nor switch back.
583 input <- ports.PointerButton{Button: 0x110, Pressed: true}
584 input <- ports.PointerButton{Button: 0x110}
585 // Input and client are separate channels: let core handle the click
586 // before window 2 maps under the pointer.
587 synctest.Wait()
588 client <- ports.WindowMapped{ID: 2}
589 s := settledScene(t, sc, frames, scenes, scene(t, scenes), false)
590 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
591 t.Fatal(s)
592 }
593 for len(commands) > 0 {
594 if v, ok := (<-commands).(ports.PointerButtonTo); ok {
595 t.Fatal(v)
596 }
597 }
598}
599
600func TestShiftReleasedFirst(t *testing.T) {
601 cfg := scrollDefaults()
602 input := make(chan ports.InputEvent, 8)
603 client := make(chan ports.ClientEvent, 2)
604 commands := make(chan ports.ClientCommand, 16)
605 scenes := make(chan []ports.Scene, 1)
606 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}, core.Options{})
607 if err != nil {
608 t.Fatal(err)
609 }
610 ctx, cancel := context.WithCancel(context.Background())
611 defer cancel()
612 go c.Run(ctx)
613 client <- ports.WindowMapped{ID: 1}
614 scene(t, scenes)
615 for len(commands) > 0 {
616 <-commands
617 }
618 // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-column-to-workspace 1.
619 input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true}
620 scene(t, scenes)
621 // Shift goes up first, so the release reports "1": still swallowed.
622 input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper}
623 input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
624 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) {
625 t.Fatal(v)
626 }
627}
628
629func TestOutputScale(t *testing.T) { both(t, outputScale) }
630
631func outputScale(t *testing.T, animated bool) {
632 cfg := scrollDefaults()
633 cfg.Border.Width = 0
634 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
635 cfg.Binds["Cmd+equal"] = "scale-up"
636 cfg.Binds["Cmd+minus"] = "scale-down"
637 client := make(chan ports.ClientEvent, 8)
638 input := make(chan ports.InputEvent, 8)
639 output := make(chan ports.OutputEvent, 8)
640 reload := make(chan ports.ConfigChanged, 1)
641 commands := make(chan ports.ClientCommand, 64)
642 scenes := make(chan []ports.Scene, 1)
643 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}
644 opts := core.Options{}
645 l := newLanding(t, animated, &cfg, &ch, &opts, "DP-2")
646 c, err := core.New(cfg, ch, opts)
647 if err != nil {
648 t.Fatal(err)
649 }
650 ctx, cancel := context.WithCancel(context.Background())
651 defer cancel()
652 go c.Run(ctx)
653 // 5120x2160 at scale 2 is 2560x1080 logical.
654 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
655 s := scene(t, scenes)
656 if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 {
657 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
658 }
659 if v := receive(t, commands).(ports.SetOutputs); len(v.Outputs) != 1 || v.Outputs[0].Scale != 2 || v.Outputs[0].Width != 2560 || v.Outputs[0].Height != 1080 || v.Focused != "DP-2" {
660 t.Fatal(v)
661 }
662 client <- ports.WindowMapped{ID: 1}
663 l.one(scene(t, scenes))
664 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) {
665 t.Fatal(v)
666 }
667 for len(commands) > 0 {
668 <-commands
669 }
670 // The pointer arrives in global logical pixels.
671 input <- ports.PointerMotion{X: 500, Y: 200}
672 if animated {
673 // The page flips that landed the map's springs re-hit the pointer
674 // (still at the origin, over the window): focus was already sent.
675 if v, ok := command(t, commands).(ports.PointerMotionTo); !ok || v.ID != 1 || v.X != 500 || v.Y != 200 {
676 t.Fatal(v)
677 }
678 } else if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) {
679 t.Fatal(v)
680 }
681 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.
682 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
683 s = scene(t, scenes)
684 if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 {
685 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
686 }
687 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper}
688 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
689 if s = scene(t, scenes); s.Scale != 2 {
690 t.Fatal(s.Scale)
691 }
692 // A reload that keeps the config scale keeps the live one too.
693 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper}
694 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
695 if s = scene(t, scenes); s.Scale != 2.5 {
696 t.Fatal(s.Scale)
697 }
698 cfg.Layout.Gaps = 1
699 reload <- ports.ConfigChanged{Config: cfg}
700 if s = scene(t, scenes); s.Scale != 2.5 {
701 t.Fatal(s.Scale)
702 }
703 // Changing the config scale wins.
704 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}}
705 reload <- ports.ConfigChanged{Config: cfg}
706 if s = scene(t, scenes); s.Scale != 1.25 || s.OutputWidth != 4096 {
707 t.Fatal(s.Scale, s.OutputWidth)
708 }
709}
710
711// A pointer warp moves the cursor on the window under it: input takes the
712// position and the window gets the motion. Other windows cannot warp.
713func TestPointerWarp(t *testing.T) {
714 cfg := scrollDefaults()
715 cfg.Border.Width = 0
716 cfg.Layout.Gaps = 0
717 client := make(chan ports.ClientEvent, 8)
718 input := make(chan ports.InputEvent, 8)
719 output := make(chan ports.OutputEvent, 8)
720 commands := make(chan ports.ClientCommand, 64)
721 scenes := make(chan []ports.Scene, 1)
722 constraints := make(chan ports.PointerConstraint, 1)
723 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}, core.Options{})
724 if err != nil {
725 t.Fatal(err)
726 }
727 ctx, cancel := context.WithCancel(context.Background())
728 defer cancel()
729 go c.Run(ctx)
730 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
731 scene(t, scenes)
732 client <- ports.WindowMapped{ID: 1}
733 r := receive(t, scenes)[0].Windows[0].Rect
734 input <- ports.PointerMotion{X: 50, Y: 40}
735 for {
736 if _, ok := command(t, commands).(ports.PointerMotionTo); ok {
737 break
738 }
739 }
740 // Another window cannot move the pointer.
741 client <- ports.PointerWarp{ID: 2, X: 5, Y: 5}
742 client <- ports.PointerWarp{ID: 1, X: 10, Y: 20}
743 got := receive(t, constraints)
744 if !got.Warp || got.X != float64(r.X+10) || got.Y != float64(r.Y+20) {
745 t.Fatalf("warp %+v, window %+v", got, r)
746 }
747 for {
748 if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
749 if v.ID != 1 || v.X != 10 || v.Y != 20 || v.DX != 0 || v.DY != 0 {
750 t.Fatalf("motion %+v", v)
751 }
752 break
753 }
754 }
755 // A point outside the window is refused.
756 client <- ports.PointerWarp{ID: 1, X: float64(r.W), Y: 0}
757 client <- ports.PointerWarp{ID: 1, X: 1, Y: 1}
758 if got := receive(t, constraints); got.X != float64(r.X+1) || got.Y != float64(r.Y+1) {
759 t.Fatalf("warp %+v", got)
760 }
761}
762
763func TestPointerConstraint(t *testing.T) { both(t, pointerConstraint) }
764
765func pointerConstraint(t *testing.T, animated bool) {
766 cfg := scrollDefaults()
767 cfg.Border.Width = 0
768 cfg.Layout.Gaps = 0
769 client := make(chan ports.ClientEvent, 8)
770 input := make(chan ports.InputEvent, 8)
771 output := make(chan ports.OutputEvent, 8)
772 commands := make(chan ports.ClientCommand, 64)
773 scenes := make(chan []ports.Scene, 1)
774 constraints := make(chan ports.PointerConstraint, 1)
775 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}
776 opts := core.Options{}
777 l := newLanding(t, animated, &cfg, &ch, &opts, "A", "B")
778 c, err := core.New(cfg, ch, opts)
779 if err != nil {
780 t.Fatal(err)
781 }
782 ctx, cancel := context.WithCancel(context.Background())
783 defer cancel()
784 go c.Run(ctx)
785 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
786 scene(t, scenes)
787 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
788 scene(t, scenes)
789 input <- ports.PointerMotion{X: 150, Y: 40}
790 scene(t, scenes) // the pointer moves the focus to B
791 client <- ports.WindowMapped{ID: 1}
792 r := l.land(receive(t, scenes))[1].Windows[0].Rect
793 // The confine region is window-local and clipped to the window.
794 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
795 scene(t, scenes)
796 want := ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 100 + r.X + 10, Y: r.Y + 10, W: r.W - 10, H: 20}}
797 // Input gets core's cursor, clamped into the region.
798 if got := receive(t, constraints); got.Mode != want.Mode || got.Rect != want.Rect || got.X != 150 || got.Y != 29 {
799 t.Fatalf("got %+v, want %+v", got, want)
800 }
801 for len(commands) > 0 {
802 <-commands
803 }
804 // Motion is clamped into the region; deltas pass through.
805 input <- ports.PointerMotion{X: 199, Y: 79, DX: 5, DY: 6}
806 for {
807 if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
808 if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.Y != float64(want.Rect.Y+want.Rect.H-1-r.Y) || v.DX != 5 || v.DY != 6 {
809 t.Fatalf("motion %+v", v)
810 }
811 break
812 }
813 }
814 // A lock keeps the pointer still.
815 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintLock}}
816 scene(t, scenes)
817 if got := receive(t, constraints); got.Mode != ports.ConstraintLock {
818 t.Fatalf("lock %+v", got)
819 }
820 for len(commands) > 0 {
821 <-commands
822 }
823 input <- ports.PointerMotion{X: 101, Y: 1, DX: -50}
824 for {
825 if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
826 if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.DX != -50 {
827 t.Fatalf("locked motion %+v", v)
828 }
829 break
830 }
831 }
832 client <- ports.PointerConstrained{}
833 scene(t, scenes)
834 if got := receive(t, constraints); got.Mode != ports.ConstraintNone {
835 t.Fatalf("release %+v", got)
836 }
837}
838
839// A click outside a grabbing menu chain closes it, the topmost popup first.
840func TestPopupChainDismissOrder(t *testing.T) {
841 cfg := scrollDefaults()
842 cfg.Border.Width = 0
843 client := make(chan ports.ClientEvent, 8)
844 input := make(chan ports.InputEvent, 8)
845 output := make(chan ports.OutputEvent, 8)
846 commands := make(chan ports.ClientCommand, 64)
847 scenes := make(chan []ports.Scene, 1)
848 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
849 if err != nil {
850 t.Fatal(err)
851 }
852 ctx, cancel := context.WithCancel(context.Background())
853 defer cancel()
854 go c.Run(ctx)
855 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
856 scene(t, scenes)
857 client <- ports.WindowMapped{ID: 1}
858 scene(t, scenes)
859 pos := ports.Positioner{Width: 10, Height: 10, AnchorRect: ports.Rect{W: 1, H: 1}, Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight}
860 for _, v := range []struct{ id, parent ports.WindowID }{{2, 1}, {3, 2}} {
861 client <- ports.PopupRequest{ID: v.id, Parent: v.parent, Positioner: pos, Grab: true}
862 for {
863 if cp, ok := command(t, commands).(ports.ConfigurePopup); ok && cp.ID == v.id {
864 break
865 }
866 }
867 client <- ports.PopupMapped{ID: v.id}
868 scene(t, scenes)
869 }
870 for len(commands) > 0 {
871 <-commands
872 }
873 // The window's far corner is outside both popups.
874 input <- ports.PointerMotion{X: 90, Y: 70}
875 input <- ports.PointerButton{Button: 0x110, Pressed: true}
876 var closed []ports.WindowID
877 for len(closed) < 2 {
878 if v, ok := command(t, commands).(ports.ClosePopup); ok {
879 closed = append(closed, v.ID)
880 }
881 }
882 if closed[0] != 3 || closed[1] != 2 {
883 t.Fatalf("close order %v, want [3 2]", closed)
884 }
885}
886
887// A dialog opened under a fullscreen window stays hidden and out of reach
888// of the pointer until the window leaves fullscreen.
889func TestFloatOverFullscreenHit(t *testing.T) {
890 cfg := scrollDefaults()
891 cfg.Border.Width = 0
892 client := make(chan ports.ClientEvent, 8)
893 input := make(chan ports.InputEvent, 8)
894 output := make(chan ports.OutputEvent, 8)
895 commands := make(chan ports.ClientCommand, 64)
896 scenes := make(chan []ports.Scene, 1)
897 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
898 if err != nil {
899 t.Fatal(err)
900 }
901 ctx, cancel := context.WithCancel(context.Background())
902 defer cancel()
903 go c.Run(ctx)
904 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
905 scene(t, scenes)
906 client <- ports.WindowMapped{ID: 1}
907 scene(t, scenes)
908 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
909 scene(t, scenes)
910 client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
911 scene(t, scenes)
912 for len(commands) > 0 {
913 <-commands
914 }
915 input <- ports.PointerMotion{X: 50, Y: 40}
916 if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 1 {
917 t.Fatalf("pointer went to %v, want the fullscreen window", v)
918 }
919 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: false}
920 scene(t, scenes)
921 for len(commands) > 0 {
922 <-commands
923 }
924 input <- ports.PointerMotion{X: 50, Y: 41}
925 if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
926 t.Fatalf("pointer went to %v, want the dialog", v)
927 }
928}
929
930// A dialog of the fullscreen window (a portal dialog parented through
931// xdg-foreign) shows over it and takes the pointer at once.
932func TestDialogOverFullscreenHit(t *testing.T) {
933 cfg := scrollDefaults()
934 cfg.Border.Width = 0
935 client := make(chan ports.ClientEvent, 8)
936 input := make(chan ports.InputEvent, 8)
937 output := make(chan ports.OutputEvent, 8)
938 commands := make(chan ports.ClientCommand, 64)
939 scenes := make(chan []ports.Scene, 1)
940 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
941 if err != nil {
942 t.Fatal(err)
943 }
944 ctx, cancel := context.WithCancel(context.Background())
945 defer cancel()
946 go c.Run(ctx)
947 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
948 scene(t, scenes)
949 client <- ports.WindowMapped{ID: 1}
950 scene(t, scenes)
951 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
952 scene(t, scenes)
953 client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10, Parent: 1}
954 s := scene(t, scenes)
955 shown := false
956 for _, w := range s.Windows {
957 if w.ID == 2 {
958 shown = !w.Hidden && w.Rect.W > 0
959 }
960 }
961 if !shown {
962 t.Fatalf("dialog not shown: %+v", s.Windows)
963 }
964 for len(commands) > 0 {
965 <-commands
966 }
967 input <- ports.PointerMotion{X: 50, Y: 40}
968 if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
969 t.Fatalf("pointer went to %v, want the dialog", v)
970 }
971}
972
973// A window that asks for fullscreen as it maps (Wine at a remembered
974// monitor size) stays in its column: the windows it opens next are seen.
975// A later request is honoured.
976func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored) }
977
978func fullscreenAtMapIgnored(t *testing.T, animated bool) {
979 cfg := scrollDefaults()
980 cfg.Layout.Overflow = "fixed"
981 client := make(chan ports.ClientEvent, 8)
982 output := make(chan ports.OutputEvent, 8)
983 commands := make(chan ports.ClientCommand, 64)
984 scenes := make(chan []ports.Scene, 1)
985 // Core reads the time on its goroutine: the clock is the test's.
986 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
987 opts := core.Options{}
988 l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
989 clk := l.clk
990 if clk == nil {
991 clk = newStepClock(t)
992 opts.Clock = clk.clock
993 }
994 c, err := core.New(cfg, ch, opts)
995 if err != nil {
996 t.Fatal(err)
997 }
998 ctx, cancel := context.WithCancel(context.Background())
999 defer cancel()
1000 go c.Run(ctx)
1001 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
1002 scene(t, scenes)
1003 client <- ports.WindowMapped{ID: 1}
1004 scene(t, scenes)
1005 client <- ports.WindowMapped{ID: 2}
1006 scene(t, scenes)
1007 client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
1008 client <- ports.WindowMapped{ID: 3, Floating: true, Width: 20, Height: 10}
1009 s := scene(t, scenes)
1010 for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) {
1011 s = scene(t, scenes)
1012 }
1013 for _, w := range s.Windows {
1014 if w.Fullscreen || w.Hidden {
1015 t.Fatalf("startup fullscreen applied: %+v", s.Windows)
1016 }
1017 }
1018 // With animations on this lands the maps' springs: the clock moves past
1019 // the grace, as the next line does anyway.
1020 l.one(s)
1021 clk.advance(time.Second)
1022 // Only the focused window may cover the screen (ADR 011).
1023 client <- ports.WindowUnmapped{ID: 3}
1024 scene(t, scenes)
1025 client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
1026 s = scene(t, scenes)
1027 // Honoured, in place.
1028 if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && !w.Hidden }) {
1029 t.Fatalf("later fullscreen ignored: %+v", s.Windows)
1030 }
1031}
1032
1033// Under fixed overflow, a window opening over a fullscreen game waits
1034// hidden: the game keeps the screen and the keyboard. An activation (a
1035// user action in the new client) leaves fullscreen and shows both tiles;
1036// a focus move back to the game makes it fullscreen again.
1037func TestFixedFullscreenArrivalWaits(t *testing.T) {
1038 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1039 r.mapWindow(t, 1)
1040 r.key(t, "f", ports.ModAlt|ports.ModShift)
1041 set := r.mapWindow(t, 2)
1042 for len(r.commands) > 0 {
1043 <-r.commands
1044 }
1045 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
1046 t.Fatal("game disturbed:", got, focused)
1047 }
1048 r.client <- ports.WindowActivate{ID: 2}
1049 set = receive(t, r.scenes)
1050 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 {
1051 t.Fatal("after activate:", got, focused)
1052 }
1053 for {
1054 if f, ok := command(t, r.commands).(ports.FocusWindow); ok && f.ID == 2 {
1055 break
1056 }
1057 }
1058 set = r.key(t, "Left", ports.ModAlt)
1059 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
1060 t.Fatal("game again:", got, focused)
1061 }
1062}
1063
1064// A taskbar's fullscreen request is the user's: it applies at once.
1065func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscreenAtMapApplies) }
1066
1067func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
1068 cfg := scrollDefaults()
1069 client := make(chan ports.ClientEvent, 8)
1070 output := make(chan ports.OutputEvent, 8)
1071 commands := make(chan ports.ClientCommand, 64)
1072 scenes := make(chan []ports.Scene, 1)
1073 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
1074 opts := core.Options{}
1075 l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
1076 if l.clk == nil {
1077 opts.Clock = newStepClock(t).clock
1078 }
1079 c, err := core.New(cfg, ch, opts)
1080 if err != nil {
1081 t.Fatal(err)
1082 }
1083 ctx, cancel := context.WithCancel(context.Background())
1084 defer cancel()
1085 go c.Run(ctx)
1086 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
1087 scene(t, scenes)
1088 client <- ports.WindowMapped{ID: 1}
1089 l.one(scene(t, scenes))
1090 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
1091 s := scene(t, scenes)
1092 if len(s.Windows) != 1 || !s.Windows[0].Fullscreen {
1093 t.Fatalf("taskbar fullscreen ignored: %+v", s.Windows)
1094 }
1095}
1096
1097// Under fixed overflow, a taskbar's fullscreen on an unfocused window is
1098// the user's choice: the window takes the focus and covers the screen.
1099func TestExternalFullscreenFocusesFixed(t *testing.T) {
1100 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1101 r.mapWindow(t, 1)
1102 r.mapWindow(t, 2)
1103 r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
1104 set := receive(t, r.scenes)
1105 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
1106 t.Fatal("taskbar fullscreen:", got, focused)
1107 }
1108 // Over another covering window too: it leaves fullscreen.
1109 r.client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true, External: true}
1110 set = receive(t, r.scenes)
1111 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1112 t.Fatal("taskbar fullscreen over another:", got, focused)
1113 }
1114 // Leaving from the taskbar keeps the focus.
1115 r.client <- ports.WindowFullscreenRequest{ID: 2, External: true}
1116 set = receive(t, r.scenes)
1117 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 {
1118 t.Fatal("taskbar leave:", got, focused)
1119 }
1120}
1121
1122// A taskbar fullscreen on a window of a workspace off screen, on another
1123// output, brings it on screen with the focus, as an activation.
1124func TestExternalFullscreenOffScreen(t *testing.T) {
1125 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
1126 r.mapWindow(t, 1)
1127 r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2, not followed
1128 r.key(t, "Right", ports.ModAlt|ports.ModCtrl) // focus DP-2
1129 r.mapWindow(t, 2)
1130 r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
1131 set := receive(t, r.scenes)
1132 found := false
1133 for _, s := range set {
1134 for _, w := range s.Windows {
1135 if w.ID != 1 {
1136 continue
1137 }
1138 found = true
1139 if s.Output != "DP-1" || w.Hidden || !w.Fullscreen || !w.Focused {
1140 t.Fatalf("window 1 on %s: %+v", s.Output, w)
1141 }
1142 }
1143 }
1144 if !found {
1145 t.Fatal("window 1 in no scene")
1146 }
1147}
1148
1149// In the overview, a taskbar fullscreen on a window of another workspace
1150// selects its row: closing the overview lands on it, fullscreen.
1151func TestExternalFullscreenInOverview(t *testing.T) {
1152 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1153 r.mapWindow(t, 1)
1154 r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2
1155 r.mapWindow(t, 2)
1156 r.key(t, "o", ports.ModAlt)
1157 r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
1158 receive(t, r.scenes)
1159 set := r.key(t, "o", ports.ModAlt)
1160 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
1161 t.Fatal("after the overview:", got, focused)
1162 }
1163}
1164
1165// focus-window-down from a fullscreen tile at the bottom of its column
1166// goes to the next workspace and fullscreen stays.
1167func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
1168 both(t, func(t *testing.T, animated bool) {
1169 r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1170 r.mapLanded(t, 1)
1171 r.mapLanded(t, 2)
1172 r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)
1173 set := r.keyLanded(t, "Down", ports.ModAlt)
1174 if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
1175 t.Fatal("still on the first workspace:", got)
1176 }
1177 set = r.keyLanded(t, "Up", ports.ModAlt)
1178 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1179 t.Fatal("fullscreen lost:", got, focused)
1180 }
1181 })
1182}
1183
1184// A refused fullscreen request still gets a configure, unchanged: clients
1185// wait for it (xdg-shell).
1186func TestRefusedFullscreenConfigured(t *testing.T) {
1187 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1188 r.mapWindow(t, 1)
1189 r.mapWindow(t, 2)
1190 for len(r.commands) > 0 {
1191 <-r.commands
1192 }
1193 r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
1194 receive(t, r.scenes)
1195 for len(r.commands) > 0 {
1196 if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 {
1197 if v.Fullscreen {
1198 t.Fatalf("refused request applied: %+v", v)
1199 }
1200 return
1201 }
1202 }
1203 t.Fatal("no configure answered the request")
1204}
1205
1206// An activated window on another workspace comes on screen with focus.
1207func TestWindowActivateShowsAndFocuses(t *testing.T) {
1208 cfg := scrollDefaults()
1209 client := make(chan ports.ClientEvent, 8)
1210 input := make(chan ports.InputEvent, 8)
1211 output := make(chan ports.OutputEvent, 8)
1212 commands := make(chan ports.ClientCommand, 64)
1213 scenes := make(chan []ports.Scene, 1)
1214 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
1215 if err != nil {
1216 t.Fatal(err)
1217 }
1218 ctx, cancel := context.WithCancel(context.Background())
1219 defer cancel()
1220 go c.Run(ctx)
1221 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
1222 scene(t, scenes)
1223 client <- ports.WindowMapped{ID: 1}
1224 scene(t, scenes)
1225 // Window 2 opens on workspace 2, then the user goes back to 1.
1226 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
1227 scene(t, scenes)
1228 client <- ports.WindowMapped{ID: 2}
1229 scene(t, scenes)
1230 input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true}
1231 visible := func(sc ports.Scene) (shown []ports.SceneWindow) {
1232 for _, w := range sc.Windows {
1233 if !w.Hidden {
1234 shown = append(shown, w)
1235 }
1236 }
1237 return shown
1238 }
1239 if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 {
1240 t.Fatalf("workspace 1: %+v", v)
1241 }
1242 client <- ports.WindowActivate{ID: 2}
1243 v := visible(scene(t, scenes))
1244 for len(v) != 1 || v[0].ID != 2 {
1245 v = visible(scene(t, scenes))
1246 }
1247 if !v[0].Focused {
1248 t.Fatalf("after activate: %+v", v)
1249 }
1250 for {
1251 if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
1252 break
1253 }
1254 }
1255
1256}
1257
1258// A window that maps with the fullscreen state (an X11 game with
1259// _NET_WM_STATE_FULLSCREEN set before the map) covers its screen, focused,
1260// in a tile of its own even when it was classified floating; a rule that
1261// floats it does not change that. The grace still drops a later request.
1262func TestMappedFullscreenHonoured(t *testing.T) {
1263 for _, tc := range []struct {
1264 name string
1265 rule bool
1266 }{{"fixed size", false}, {"floating rule", true}} {
1267 t.Run(tc.name, func(t *testing.T) {
1268 cfg := scrollDefaults()
1269 cfg.Border.Width = 0
1270 if tc.rule {
1271 on := true
1272 cfg.Rules = []ports.WindowRule{{AppID: regexp.MustCompile("^game$"), Floating: &on}}
1273 }
1274 client := make(chan ports.ClientEvent, 8)
1275 output := make(chan ports.OutputEvent, 8)
1276 commands := make(chan ports.ClientCommand, 64)
1277 scenes := make(chan []ports.Scene, 1)
1278 c, err := core.New(cfg, core.Channels{Client: client, Input: make(chan ports.InputEvent, 8), Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
1279 if err != nil {
1280 t.Fatal(err)
1281 }
1282 ctx, cancel := context.WithCancel(context.Background())
1283 defer cancel()
1284 go c.Run(ctx)
1285 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
1286 scene(t, scenes)
1287 client <- ports.WindowMapped{ID: 1}
1288 scene(t, scenes)
1289 client <- ports.WindowMapped{ID: 2, AppID: "game", Floating: true, Width: 100, Height: 80, Fullscreen: true}
1290 s := scene(t, scenes)
1291 for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && w.Focused && !w.Hidden }) {
1292 s = scene(t, scenes)
1293 }
1294 for _, w := range s.Windows {
1295 if w.ID == 2 && (w.Rect.W != 100 || w.Rect.H != 80) {
1296 t.Fatalf("fullscreen rect %+v, want the output", w.Rect)
1297 }
1298 }
1299 })
1300 }
1301}