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