1package core_test
2
3import (
4 "context"
5 "errors"
6 "github.com/bnema/neferwl/internal/adapters/config"
7 "github.com/bnema/neferwl/internal/core"
8 "github.com/bnema/neferwl/internal/ports"
9 "testing"
10 "time"
11)
12
13func receive[T any](t *testing.T, ch <-chan T) T {
14 t.Helper()
15 select {
16 case v := <-ch:
17 return v
18 case <-time.After(time.Second):
19 t.Fatal("timed out")
20 var zero T
21 return zero
22 }
23}
24
25// scene receives the next scene set and returns the first output's scene.
26func scene(t *testing.T, ch <-chan []ports.Scene) ports.Scene {
27 t.Helper()
28 return receive(t, ch)[0]
29}
30
31// command receives the next client command, skipping output scale updates.
32func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
33 t.Helper()
34 for {
35 if v := receive(t, ch); v != nil {
36 if _, ok := v.(ports.SetOutputs); !ok {
37 return v
38 }
39 }
40 }
41}
42
43func TestOwner(t *testing.T) {
44 cfg := config.Defaults()
45 cfg.Keyboard.CmdKey = "alt"
46 cfg.Border.Width = 0
47 cfg.Layout.Gaps = 8
48 client := make(chan ports.ClientEvent, 128)
49 input := make(chan ports.InputEvent, 32)
50 output := make(chan ports.OutputEvent, 8)
51 reload := make(chan ports.ConfigChanged, 8)
52 commands := make(chan ports.ClientCommand, 256)
53 spawn := make(chan ports.SpawnRequest, 8)
54 scenes := make(chan []ports.Scene, 1)
55 errs := make(chan error, 8)
56 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs})
57 if err != nil {
58 t.Fatal(err)
59 }
60 ctx, cancel := context.WithCancel(context.Background())
61 defer cancel()
62 done := make(chan error, 1)
63 go func() { done <- c.Run(ctx) }()
64 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
65 s := scene(t, scenes)
66 if s.OutputWidth != 100 {
67 t.Fatal(s)
68 }
69 client <- ports.WindowMapped{ID: 1}
70 s = scene(t, scenes)
71 // A single column fills the usable width.
72 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
73 t.Fatal(s)
74 }
75 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {
76 t.Fatal(v)
77 }
78 command(t, commands)
79 client <- ports.WindowMapped{ID: 2}
80 s = scene(t, scenes)
81 if len(s.Windows) != 2 {
82 t.Fatal(s)
83 }
84 // Commands for focus changes and second mapping.
85 for len(commands) > 0 {
86 <-commands
87 }
88 client <- ports.WindowMapped{ID: 2}
89 scene(t, scenes)
90 if len(commands) != 0 {
91 t.Fatal("duplicate configure")
92 }
93 input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true}
94 scene(t, scenes)
95 if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" {
96 t.Fatal(v)
97 }
98 input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt}
99 input <- ports.KeyEvent{Keysym: "x", Pressed: true}
100 if v := command(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {
101 t.Fatal(v)
102 }
103 input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true}
104 scene(t, scenes)
105 if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) {
106 t.Fatal(v)
107 }
108 client <- ports.WindowUnmapped{ID: 2}
109 scene(t, scenes)
110 for len(commands) > 0 {
111 <-commands
112 }
113 cfg.Layout.Gaps = 4
114 reload <- ports.ConfigChanged{Config: cfg}
115 s = scene(t, scenes)
116 if s.Windows[0].Rect.W != 92 {
117 t.Fatal(s)
118 }
119 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1"}) {
120 t.Fatal(v)
121 }
122 cfg.Layout.MaxColumns = 0
123 reload <- ports.ConfigChanged{Config: cfg}
124 receive(t, errs)
125 for i := 0; i < 100; i++ {
126 client <- ports.WindowMapped{ID: ports.WindowID(i + 3)}
127 }
128 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
129 if err := receive(t, done); !errors.Is(err, core.ErrQuit) {
130 t.Fatal(err)
131 }
132}
133func TestCancelAndSceneCapacity(t *testing.T) {
134 cfg := config.Defaults()
135 if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}); err == nil {
136 t.Fatal("capacity")
137 }
138 ctx, cancel := context.WithCancel(context.Background())
139 c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)})
140 if err != nil {
141 t.Fatal(err)
142 }
143 done := make(chan error, 1)
144 go func() { done <- c.Run(ctx) }()
145 cancel()
146 if err := receive(t, done); err != nil {
147 t.Fatal(err)
148 }
149}
150
151func TestKeyPressState(t *testing.T) {
152 cfg := config.Defaults()
153 client := make(chan ports.ClientEvent, 8)
154 input := make(chan ports.InputEvent, 8)
155 commands := make(chan ports.ClientCommand, 16)
156 scenes := make(chan []ports.Scene, 1)
157 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes})
158 if err != nil {
159 t.Fatal(err)
160 }
161 ctx, cancel := context.WithCancel(context.Background())
162 done := make(chan error, 1)
163 go func() { done <- c.Run(ctx) }()
164 defer func() { cancel(); receive(t, done) }()
165 client <- ports.WindowMapped{ID: 1}
166 scene(t, scenes)
167 command(t, commands)
168 command(t, commands)
169 press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
170 input <- press
171 scene(t, scenes)
172 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper}
173 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
174 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) {
175 t.Fatal(v)
176 }
177 input <- ports.KeyEvent{Keysym: "Left"}
178 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) {
179 t.Fatal(v)
180 }
181 input <- press
182 scene(t, scenes)
183 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
184 command(t, commands)
185 input <- ports.KeyEvent{Keysym: "Left"}
186 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) {
187 t.Fatal(v)
188 }
189}
190
191func TestBoundReleaseSwallowed(t *testing.T) {
192 cfg := config.Defaults()
193 input := make(chan ports.InputEvent, 4)
194 client := make(chan ports.ClientEvent, 2)
195 commands := make(chan ports.ClientCommand, 8)
196 scenes := make(chan []ports.Scene, 1)
197 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
198 if err != nil {
199 t.Fatal(err)
200 }
201 ctx, cancel := context.WithCancel(context.Background())
202 done := make(chan error, 1)
203 go func() { done <- c.Run(ctx) }()
204 defer func() { cancel(); receive(t, done) }()
205 client <- ports.WindowMapped{ID: 1}
206 scene(t, scenes)
207 command(t, commands)
208 command(t, commands)
209 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
210 scene(t, scenes)
211 input <- ports.KeyEvent{Keysym: "Left"}
212 input <- ports.KeyEvent{Keysym: "x", Pressed: true}
213 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {
214 t.Fatal(v)
215 }
216}
217
218func TestPointerFocusAndGrab(t *testing.T) {
219 cfg := config.Defaults()
220 cfg.Border.Width = 0
221 cfg.Layout.Gaps = 8
222 client := make(chan ports.ClientEvent, 8)
223 input := make(chan ports.InputEvent, 8)
224 output := make(chan ports.OutputEvent, 8)
225 commands := make(chan ports.ClientCommand, 64)
226 scenes := make(chan []ports.Scene, 1)
227 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
228 if err != nil {
229 t.Fatal(err)
230 }
231 ctx, cancel := context.WithCancel(context.Background())
232 defer cancel()
233 go c.Run(ctx)
234 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
235 scene(t, scenes)
236 client <- ports.WindowMapped{ID: 1}
237 first := scene(t, scenes).Windows[0].Rect
238 client <- ports.WindowMapped{ID: 2}
239 scene(t, scenes)
240 for len(commands) > 0 {
241 <-commands
242 }
243 input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1}
244 if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) {
245 t.Fatal(v)
246 }
247 if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) {
248 t.Fatal(v)
249 }
250 input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
251 if v := command(t, commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}}) {
252 t.Fatal(v)
253 }
254 input <- ports.PointerButton{Button: 0x110, Pressed: true}
255 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
256 t.Fatal(v)
257 }
258 scene(t, scenes)
259 found := false
260 for len(commands) > 0 {
261 if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 {
262 found = true
263 }
264 }
265 if !found {
266 t.Fatal("missing keyboard focus")
267 }
268 input <- ports.PointerMotion{X: 0, Y: 0}
269 if v := command(t, commands); v != (ports.PointerFocus{}) {
270 t.Fatal(v)
271 }
272 // Mid-drag off the window, scroll goes to what is under the pointer:
273 // nothing here, not the grab window that got a leave.
274 input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
275 input <- ports.PointerButton{Button: 0x110}
276 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {
277 t.Fatal(v)
278 }
279}
280
281func TestBorderInset(t *testing.T) {
282 cfg := config.Defaults()
283 cfg.Border.Width = 2
284 client := make(chan ports.ClientEvent, 8)
285 input := make(chan ports.InputEvent, 8)
286 output := make(chan ports.OutputEvent, 8)
287 commands := make(chan ports.ClientCommand, 64)
288 scenes := make(chan []ports.Scene, 1)
289 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
290 if err != nil {
291 t.Fatal(err)
292 }
293 ctx, cancel := context.WithCancel(context.Background())
294 defer cancel()
295 go c.Run(ctx)
296 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
297 scene(t, scenes)
298 // Alone, the window is borderless and gets the full rect.
299 client <- ports.WindowMapped{ID: 1}
300 r := scene(t, scenes).Windows[0].Rect
301 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {
302 t.Fatalf("%v for outer %v", v, r)
303 }
304 // With a second column, the left window owns the shared separator: its
305 // client is 2px narrower on the right; the right window keeps its rect.
306 client <- ports.WindowMapped{ID: 2}
307 s := scene(t, scenes)
308 r = s.Windows[1].Rect
309 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
310 t.Fatal(s)
311 }
312 want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}
313 found := false
314 for len(commands) > 0 {
315 if v := <-commands; v == want {
316 found = true
317 }
318 }
319 if !found {
320 t.Fatalf("no %v for outer %v", want, r)
321 }
322 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
323 if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 2, Y: 5}) {
324 t.Fatal(v)
325 }
326}
327
328func TestLayerKeyboardFocus(t *testing.T) {
329 cfg := config.Defaults()
330 client := make(chan ports.ClientEvent, 8)
331 input := make(chan ports.InputEvent, 8)
332 output := make(chan ports.OutputEvent, 8)
333 commands := make(chan ports.ClientCommand, 64)
334 scenes := make(chan []ports.Scene, 1)
335 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
336 if err != nil {
337 t.Fatal(err)
338 }
339 ctx, cancel := context.WithCancel(context.Background())
340 defer cancel()
341 go c.Run(ctx)
342 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
343 scene(t, scenes)
344 client <- ports.WindowMapped{ID: 1}
345 scene(t, scenes)
346 focusOf := func() ports.WindowID {
347 t.Helper()
348 for {
349 if v, ok := command(t, commands).(ports.FocusWindow); ok {
350 return v.ID
351 }
352 }
353 }
354 if id := focusOf(); id != 1 {
355 t.Fatalf("window focus %d", id)
356 }
357 // An on-demand bar does not steal focus; an exclusive launcher does.
358 launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1}
359 bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2}
360 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
361 scene(t, scenes)
362 if id := focusOf(); id != 7 {
363 t.Fatalf("layer focus %d", id)
364 }
365 input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}
366 for {
367 if v, ok := command(t, commands).(ports.ForwardKey); ok {
368 if v.ID != 7 {
369 t.Fatalf("key went to %d", v.ID)
370 }
371 break
372 }
373 }
374 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
375 scene(t, scenes)
376 if id := focusOf(); id != 1 {
377 t.Fatalf("focus not restored: %d", id)
378 }
379}
380
381func TestWorkspaceSwitch(t *testing.T) {
382 cfg := config.Defaults()
383 client := make(chan ports.ClientEvent, 8)
384 input := make(chan ports.InputEvent, 8)
385 output := make(chan ports.OutputEvent, 8)
386 commands := make(chan ports.ClientCommand, 64)
387 scenes := make(chan []ports.Scene, 1)
388 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
389 if err != nil {
390 t.Fatal(err)
391 }
392 ctx, cancel := context.WithCancel(context.Background())
393 defer cancel()
394 go c.Run(ctx)
395 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
396 scene(t, scenes)
397 client <- ports.WindowMapped{ID: 1}
398 scene(t, scenes)
399 for len(commands) > 0 {
400 <-commands
401 }
402 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
403 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
404 s := scene(t, scenes)
405 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
406 t.Fatal(s)
407 }
408 // Window 1 is deactivated and loses keyboard focus.
409 var sawDeactivate, sawFocus bool
410 for len(commands) > 0 {
411 switch v := (<-commands).(type) {
412 case ports.ConfigureWindow:
413 sawDeactivate = sawDeactivate || (v.ID == 1 && !v.Activated)
414 case ports.FocusWindow:
415 sawFocus = sawFocus || v.ID == 0
416 }
417 }
418 if !sawDeactivate || !sawFocus {
419 t.Fatal(sawDeactivate, sawFocus)
420 }
421 // A new window opens on workspace 2, alone and borderless.
422 client <- ports.WindowMapped{ID: 2}
423 s = scene(t, scenes)
424 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
425 t.Fatal(s)
426 }
427}
428
429func TestClickAfterWorkspaceSwitch(t *testing.T) {
430 cfg := config.Defaults()
431 client := make(chan ports.ClientEvent, 8)
432 input := make(chan ports.InputEvent, 8)
433 output := make(chan ports.OutputEvent, 8)
434 commands := make(chan ports.ClientCommand, 64)
435 scenes := make(chan []ports.Scene, 1)
436 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
437 if err != nil {
438 t.Fatal(err)
439 }
440 ctx, cancel := context.WithCancel(context.Background())
441 defer cancel()
442 go c.Run(ctx)
443 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
444 scene(t, scenes)
445 client <- ports.WindowMapped{ID: 1}
446 scene(t, scenes)
447 input <- ports.PointerMotion{X: 50, Y: 40}
448 for len(commands) < 3 {
449 time.Sleep(time.Millisecond)
450 }
451 for len(commands) > 0 {
452 <-commands
453 }
454 // Cmd+2 hides window 1; the pointer must leave it.
455 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
456 scene(t, scenes)
457 var cleared bool
458 for len(commands) > 0 {
459 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
460 cleared = true
461 }
462 }
463 if !cleared {
464 t.Fatal("pointer focus kept on hidden window")
465 }
466 // A click without moving does not reach window 1 nor switch back.
467 input <- ports.PointerButton{Button: 0x110, Pressed: true}
468 input <- ports.PointerButton{Button: 0x110}
469 client <- ports.WindowMapped{ID: 2}
470 s := scene(t, scenes)
471 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
472 t.Fatal(s)
473 }
474 for len(commands) > 0 {
475 if v, ok := (<-commands).(ports.PointerButtonTo); ok {
476 t.Fatal(v)
477 }
478 }
479}
480
481func TestShiftReleasedFirst(t *testing.T) {
482 cfg := config.Defaults()
483 input := make(chan ports.InputEvent, 8)
484 client := make(chan ports.ClientEvent, 2)
485 commands := make(chan ports.ClientCommand, 16)
486 scenes := make(chan []ports.Scene, 1)
487 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
488 if err != nil {
489 t.Fatal(err)
490 }
491 ctx, cancel := context.WithCancel(context.Background())
492 defer cancel()
493 go c.Run(ctx)
494 client <- ports.WindowMapped{ID: 1}
495 scene(t, scenes)
496 for len(commands) > 0 {
497 <-commands
498 }
499 // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-column-to-workspace 1.
500 input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true}
501 scene(t, scenes)
502 // Shift goes up first, so the release reports "1": still swallowed.
503 input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper}
504 input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
505 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) {
506 t.Fatal(v)
507 }
508}
509
510func TestOutputScale(t *testing.T) {
511 cfg := config.Defaults()
512 cfg.Border.Width = 0
513 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
514 cfg.Binds["Cmd+equal"] = "scale-up"
515 cfg.Binds["Cmd+minus"] = "scale-down"
516 client := make(chan ports.ClientEvent, 8)
517 input := make(chan ports.InputEvent, 8)
518 output := make(chan ports.OutputEvent, 8)
519 reload := make(chan ports.ConfigChanged, 1)
520 commands := make(chan ports.ClientCommand, 64)
521 scenes := make(chan []ports.Scene, 1)
522 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes})
523 if err != nil {
524 t.Fatal(err)
525 }
526 ctx, cancel := context.WithCancel(context.Background())
527 defer cancel()
528 go c.Run(ctx)
529 // 5120x2160 at scale 2 is 2560x1080 logical.
530 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
531 s := scene(t, scenes)
532 if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 {
533 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
534 }
535 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" {
536 t.Fatal(v)
537 }
538 client <- ports.WindowMapped{ID: 1}
539 scene(t, scenes)
540 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2"}) {
541 t.Fatal(v)
542 }
543 for len(commands) > 0 {
544 <-commands
545 }
546 // The pointer arrives in global logical pixels.
547 input <- ports.PointerMotion{X: 500, Y: 200}
548 if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) {
549 t.Fatal(v)
550 }
551 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.
552 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
553 s = scene(t, scenes)
554 if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 {
555 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
556 }
557 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper}
558 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
559 if s = scene(t, scenes); s.Scale != 2 {
560 t.Fatal(s.Scale)
561 }
562 // A reload that keeps the config scale keeps the live one too.
563 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper}
564 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
565 if s = scene(t, scenes); s.Scale != 2.5 {
566 t.Fatal(s.Scale)
567 }
568 cfg.Layout.Gaps = 1
569 reload <- ports.ConfigChanged{Config: cfg}
570 if s = scene(t, scenes); s.Scale != 2.5 {
571 t.Fatal(s.Scale)
572 }
573 // Changing the config scale wins.
574 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}}
575 reload <- ports.ConfigChanged{Config: cfg}
576 if s = scene(t, scenes); s.Scale != 1.25 || s.OutputWidth != 4096 {
577 t.Fatal(s.Scale, s.OutputWidth)
578 }
579}
580
581func TestPointerConstraint(t *testing.T) {
582 cfg := config.Defaults()
583 cfg.Border.Width = 0
584 cfg.Layout.Gaps = 0
585 client := make(chan ports.ClientEvent, 8)
586 input := make(chan ports.InputEvent, 8)
587 output := make(chan ports.OutputEvent, 8)
588 commands := make(chan ports.ClientCommand, 64)
589 scenes := make(chan []ports.Scene, 1)
590 constraints := make(chan ports.PointerConstraint, 1)
591 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints})
592 if err != nil {
593 t.Fatal(err)
594 }
595 ctx, cancel := context.WithCancel(context.Background())
596 defer cancel()
597 go c.Run(ctx)
598 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
599 scene(t, scenes)
600 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
601 scene(t, scenes)
602 input <- ports.PointerMotion{X: 150, Y: 40}
603 scene(t, scenes) // the pointer moves the focus to B
604 client <- ports.WindowMapped{ID: 1}
605 r := receive(t, scenes)[1].Windows[0].Rect
606 // The confine region is window-local and clipped to the window.
607 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
608 scene(t, scenes)
609 want := ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 100 + r.X + 10, Y: r.Y + 10, W: r.W - 10, H: 20}}
610 // Input gets core's cursor, clamped into the region.
611 if got := receive(t, constraints); got.Mode != want.Mode || got.Rect != want.Rect || got.X != 150 || got.Y != 29 {
612 t.Fatalf("got %+v, want %+v", got, want)
613 }
614 for len(commands) > 0 {
615 <-commands
616 }
617 // Motion is clamped into the region; deltas pass through.
618 input <- ports.PointerMotion{X: 199, Y: 79, DX: 5, DY: 6}
619 for {
620 if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
621 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 {
622 t.Fatalf("motion %+v", v)
623 }
624 break
625 }
626 }
627 // A lock keeps the pointer still.
628 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintLock}}
629 scene(t, scenes)
630 if got := receive(t, constraints); got.Mode != ports.ConstraintLock {
631 t.Fatalf("lock %+v", got)
632 }
633 for len(commands) > 0 {
634 <-commands
635 }
636 input <- ports.PointerMotion{X: 101, Y: 1, DX: -50}
637 for {
638 if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
639 if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.DX != -50 {
640 t.Fatalf("locked motion %+v", v)
641 }
642 break
643 }
644 }
645 client <- ports.PointerConstrained{}
646 scene(t, scenes)
647 if got := receive(t, constraints); got.Mode != ports.ConstraintNone {
648 t.Fatalf("release %+v", got)
649 }
650}
651
652// A click outside a grabbing menu chain closes it, the topmost popup first.
653func TestPopupChainDismissOrder(t *testing.T) {
654 cfg := config.Defaults()
655 cfg.Border.Width = 0
656 client := make(chan ports.ClientEvent, 8)
657 input := make(chan ports.InputEvent, 8)
658 output := make(chan ports.OutputEvent, 8)
659 commands := make(chan ports.ClientCommand, 64)
660 scenes := make(chan []ports.Scene, 1)
661 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
662 if err != nil {
663 t.Fatal(err)
664 }
665 ctx, cancel := context.WithCancel(context.Background())
666 defer cancel()
667 go c.Run(ctx)
668 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
669 scene(t, scenes)
670 client <- ports.WindowMapped{ID: 1}
671 scene(t, scenes)
672 pos := ports.Positioner{Width: 10, Height: 10, AnchorRect: ports.Rect{W: 1, H: 1}, Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight}
673 for _, v := range []struct{ id, parent ports.WindowID }{{2, 1}, {3, 2}} {
674 client <- ports.PopupRequest{ID: v.id, Parent: v.parent, Positioner: pos, Grab: true}
675 for {
676 if cp, ok := command(t, commands).(ports.ConfigurePopup); ok && cp.ID == v.id {
677 break
678 }
679 }
680 client <- ports.PopupMapped{ID: v.id}
681 scene(t, scenes)
682 }
683 for len(commands) > 0 {
684 <-commands
685 }
686 // The window's far corner is outside both popups.
687 input <- ports.PointerMotion{X: 90, Y: 70}
688 input <- ports.PointerButton{Button: 0x110, Pressed: true}
689 var closed []ports.WindowID
690 for len(closed) < 2 {
691 if v, ok := command(t, commands).(ports.ClosePopup); ok {
692 closed = append(closed, v.ID)
693 }
694 }
695 if closed[0] != 3 || closed[1] != 2 {
696 t.Fatalf("close order %v, want [3 2]", closed)
697 }
698}
699
700// A dialog over a fullscreen window gets the pointer.
701func TestFloatOverFullscreenHit(t *testing.T) {
702 cfg := config.Defaults()
703 cfg.Border.Width = 0
704 client := make(chan ports.ClientEvent, 8)
705 input := make(chan ports.InputEvent, 8)
706 output := make(chan ports.OutputEvent, 8)
707 commands := make(chan ports.ClientCommand, 64)
708 scenes := make(chan []ports.Scene, 1)
709 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
710 if err != nil {
711 t.Fatal(err)
712 }
713 ctx, cancel := context.WithCancel(context.Background())
714 defer cancel()
715 go c.Run(ctx)
716 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
717 scene(t, scenes)
718 client <- ports.WindowMapped{ID: 1}
719 scene(t, scenes)
720 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
721 scene(t, scenes)
722 client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
723 scene(t, scenes)
724 for len(commands) > 0 {
725 <-commands
726 }
727 input <- ports.PointerMotion{X: 50, Y: 40}
728 if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
729 t.Fatalf("pointer went to %v, want the dialog", v)
730 }
731}
732
733// An activated window on another workspace comes on screen with focus.
734func TestWindowActivateShowsAndFocuses(t *testing.T) {
735 cfg := config.Defaults()
736 client := make(chan ports.ClientEvent, 8)
737 input := make(chan ports.InputEvent, 8)
738 output := make(chan ports.OutputEvent, 8)
739 commands := make(chan ports.ClientCommand, 64)
740 scenes := make(chan []ports.Scene, 1)
741 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
742 if err != nil {
743 t.Fatal(err)
744 }
745 ctx, cancel := context.WithCancel(context.Background())
746 defer cancel()
747 go c.Run(ctx)
748 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
749 scene(t, scenes)
750 client <- ports.WindowMapped{ID: 1}
751 scene(t, scenes)
752 // Window 2 opens on workspace 2, then the user goes back to 1.
753 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
754 scene(t, scenes)
755 client <- ports.WindowMapped{ID: 2}
756 scene(t, scenes)
757 input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true}
758 visible := func(sc ports.Scene) (shown []ports.SceneWindow) {
759 for _, w := range sc.Windows {
760 if !w.Hidden {
761 shown = append(shown, w)
762 }
763 }
764 return shown
765 }
766 if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 {
767 t.Fatalf("workspace 1: %+v", v)
768 }
769 client <- ports.WindowActivate{ID: 2}
770 v := visible(scene(t, scenes))
771 for len(v) != 1 || v[0].ID != 2 {
772 v = visible(scene(t, scenes))
773 }
774 if !v[0].Focused {
775 t.Fatalf("after activate: %+v", v)
776 }
777 for {
778 if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
779 break
780 }
781 }
782
783}