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