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