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