1package core_test
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/neferwl/internal/adapters/config"
8 "github.com/bnema/neferwl/internal/core"
9 "github.com/bnema/neferwl/internal/ports"
10)
11
12// next returns the next command of type T matching ok, skipping others.
13func next[T ports.ClientCommand](t *testing.T, ch <-chan ports.ClientCommand, ok func(T) bool) T {
14 t.Helper()
15 for {
16 if v, is := command(t, ch).(T); is && ok(v) {
17 return v
18 }
19 }
20}
21
22func anyOf[T any](T) bool { return true }
23
24// Bars and launchers get the pointer, popups and, on click, the keyboard.
25func TestLayerPointerPopupAndFocus(t *testing.T) {
26 cfg := config.Defaults()
27 cfg.Border.Width = 0
28 client := make(chan ports.ClientEvent, 8)
29 input := make(chan ports.InputEvent, 8)
30 output := make(chan ports.OutputEvent, 8)
31 commands := make(chan ports.ClientCommand, 256)
32 scenes := make(chan []ports.Scene, 1)
33 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
34 if err != nil {
35 t.Fatal(err)
36 }
37 ctx, cancel := context.WithCancel(context.Background())
38 defer cancel()
39 go c.Run(ctx)
40 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
41 scene(t, scenes)
42 client <- ports.WindowMapped{ID: 1}
43 scene(t, scenes)
44 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 {
45 t.Fatalf("focus %d", f.ID)
46 }
47 bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 100, Height: 10, ExclusiveZone: 10, Keyboard: 2}
48 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
49 scene(t, scenes)
50
51 // The bar gets the pointer, in its own coordinates.
52 input <- ports.PointerMotion{X: 50, Y: 5}
53 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 || v.X != 50 || v.Y != 5 {
54 t.Fatalf("pointer focus %+v, want the bar", v)
55 }
56 // A click gives the on-demand bar the keyboard.
57 input <- ports.PointerButton{Button: 0x110, Pressed: true}
58 if v := next(t, commands, anyOf[ports.PointerButtonTo]); v.ID != 6 {
59 t.Fatalf("button to %d", v.ID)
60 }
61 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 {
62 t.Fatalf("focus %d, want the bar", f.ID)
63 }
64 input <- ports.PointerButton{Button: 0x110}
65
66 // A menu from the bar is placed under it and slides inside the output.
67 pos := ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 90, Y: 0, W: 10, H: 10}, Anchor: ports.EdgeBottomLeft, Gravity: ports.EdgeBottomRight, Adjust: ports.AdjustSlideX}
68 client <- ports.PopupRequest{ID: 9, Parent: 6, Positioner: pos, Grab: true}
69 cp := next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
70 if cp.Rect != (ports.Rect{X: 70, Y: 10, W: 30, H: 20}) {
71 t.Fatalf("popup rect %+v", cp.Rect)
72 }
73 client <- ports.PopupMapped{ID: 9}
74 sceneMatch(t, scenes, func(sc ports.Scene) bool {
75 w := sc.Windows[len(sc.Windows)-1]
76 return w.Popup && w.ID == 9 && w.Rect == (ports.Rect{X: 70, Y: 10, W: 30, H: 20})
77 })
78 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 9 {
79 t.Fatalf("grab focus %d", f.ID)
80 }
81 input <- ports.PointerMotion{X: 80, Y: 15}
82 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 9 || v.X != 10 || v.Y != 5 {
83 t.Fatalf("pointer focus %+v, want the menu", v)
84 }
85
86 // A click on the window closes the menu and takes the keyboard back.
87 input <- ports.PointerMotion{X: 20, Y: 50}
88 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 {
89 t.Fatalf("pointer focus %+v, want the window", v)
90 }
91 input <- ports.PointerButton{Button: 0x110, Pressed: true}
92 if v := next(t, commands, anyOf[ports.ClosePopup]); v.ID != 9 {
93 t.Fatalf("closed %d", v.ID)
94 }
95 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 1 {
96 t.Fatalf("focus %d, want the window", f.ID)
97 }
98 input <- ports.PointerButton{Button: 0x110}
99
100 // An exclusive launcher wins over a clicked on-demand bar.
101 input <- ports.PointerMotion{X: 50, Y: 5}
102 next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
103 input <- ports.PointerButton{Button: 0x110, Pressed: true}
104 next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
105 input <- ports.PointerButton{Button: 0x110}
106 launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 20, Height: 20, Keyboard: 1}
107 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
108 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 7 {
109 t.Fatalf("focus %d, want the launcher", f.ID)
110 }
111 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
112 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 6 {
113 t.Fatalf("focus %d, want the bar back", f.ID)
114 }
115
116 // Unmapped then mapped again, the bar does not take the keyboard back.
117 client <- ports.LayerChanged{}
118 next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 1 })
119 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
120 scene(t, scenes)
121 input <- ports.PointerMotion{X: 20, Y: 50} // publishes
122 next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 1 })
123 select {
124 case v := <-commands:
125 if f, ok := v.(ports.FocusWindow); ok {
126 t.Fatalf("focus moved to %d", f.ID)
127 }
128 default:
129 }
130
131 // A window focus change by keyboard or a new window takes it back too.
132 input <- ports.PointerMotion{X: 50, Y: 5}
133 next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
134 input <- ports.PointerButton{Button: 0x110, Pressed: true}
135 next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
136 input <- ports.PointerButton{Button: 0x110}
137 client <- ports.WindowMapped{ID: 2}
138 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 2 {
139 t.Fatalf("focus %d, want the new window", f.ID)
140 }
141}
142
143// A window's menu stays with the windows: an overlay layer covers it.
144func TestWindowPopupUnderOverlay(t *testing.T) {
145 cfg := config.Defaults()
146 cfg.Border.Width = 0
147 client := make(chan ports.ClientEvent, 8)
148 input := make(chan ports.InputEvent, 8)
149 output := make(chan ports.OutputEvent, 8)
150 commands := make(chan ports.ClientCommand, 256)
151 scenes := make(chan []ports.Scene, 1)
152 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
153 if err != nil {
154 t.Fatal(err)
155 }
156 ctx, cancel := context.WithCancel(context.Background())
157 defer cancel()
158 go c.Run(ctx)
159 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
160 scene(t, scenes)
161 client <- ports.WindowMapped{ID: 1}
162 scene(t, scenes)
163 client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 0, Y: 0, W: 10, H: 10}}}
164 next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
165 client <- ports.PopupMapped{ID: 9}
166 sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 })
167 if w := sc.Windows[1]; !w.Popup || w.OverLayers {
168 t.Fatalf("window popup %+v", w)
169 }
170 osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: 15}
171 client <- ports.LayerChanged{Layers: []ports.LayerSurface{osd}}
172 scene(t, scenes)
173 input <- ports.PointerMotion{X: 5, Y: 5}
174 if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 7 {
175 t.Fatalf("hit %d, want the overlay", v.ID)
176 }
177}
178
179// A fullscreen window hides top and bottom layers from the pointer, not
180// overlay; background and bottom layers get it only where no window is.
181func TestLayerHitOrder(t *testing.T) {
182 cfg := config.Defaults()
183 cfg.Border.Width = 0
184 client := make(chan ports.ClientEvent, 8)
185 input := make(chan ports.InputEvent, 8)
186 output := make(chan ports.OutputEvent, 8)
187 commands := make(chan ports.ClientCommand, 256)
188 scenes := make(chan []ports.Scene, 1)
189 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
190 if err != nil {
191 t.Fatal(err)
192 }
193 ctx, cancel := context.WithCancel(context.Background())
194 defer cancel()
195 go c.Run(ctx)
196 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
197 scene(t, scenes)
198 wallpaper := ports.LayerSurface{ID: 5, Layer: ports.LayerBackground, Anchor: 15}
199 bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10}
200 osd := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Anchor: ports.AnchorBottom, Width: 20, Height: 10}
201 client <- ports.LayerChanged{Layers: []ports.LayerSurface{wallpaper, bar, osd}}
202 scene(t, scenes)
203 hit := func(x, y float64, want ports.WindowID) {
204 t.Helper()
205 input <- ports.PointerMotion{X: x, Y: y}
206 // A focus change is sent only when the target changes; motion always.
207 if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != want {
208 t.Fatalf("(%v,%v) hit %d, want %d", x, y, v.ID, want)
209 }
210 }
211 hit(50, 40, 5)
212 hit(50, 5, 6)
213 client <- ports.WindowMapped{ID: 1}
214 scene(t, scenes)
215 hit(50, 40, 1)
216 hit(50, 5, 6)
217 hit(50, 75, 7)
218 // A fullscreen window is exclusive: bars and notifications are hidden.
219 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
220 scene(t, scenes)
221 hit(50, 5, 1)
222 hit(50, 75, 1)
223}
224
225// The pointer moving to another output does not take the keyboard from a
226// clicked on-demand layer: keys still reach it.
227func TestLayerFocusAcrossOutputs(t *testing.T) {
228 cfg := config.Defaults()
229 cfg.Border.Width = 0
230 client := make(chan ports.ClientEvent, 8)
231 input := make(chan ports.InputEvent, 8)
232 output := make(chan ports.OutputEvent, 8)
233 commands := make(chan ports.ClientCommand, 256)
234 scenes := make(chan []ports.Scene, 1)
235 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
236 if err != nil {
237 t.Fatal(err)
238 }
239 ctx, cancel := context.WithCancel(context.Background())
240 defer cancel()
241 go c.Run(ctx)
242 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
243 scene(t, scenes)
244 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
245 scene(t, scenes)
246 client <- ports.WindowMapped{ID: 1}
247 scene(t, scenes)
248 bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, Keyboard: 2, Output: "OUT-1"}
249 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
250 scene(t, scenes)
251 input <- ports.PointerMotion{X: 50, Y: 5}
252 next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
253 input <- ports.PointerButton{Button: 0x110, Pressed: true}
254 next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
255 input <- ports.PointerButton{Button: 0x110}
256 input <- ports.PointerMotion{X: 100, Y: 40} // onto OUT-2, now focused
257 next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 0 })
258 input <- ports.KeyEvent{Keysym: "a", Pressed: true}
259 if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 {
260 t.Fatalf("key to %d, want the bar", v.ID)
261 }
262 input <- ports.KeyEvent{Keysym: "a"}
263 next(t, commands, anyOf[ports.ForwardKey])
264
265 // A window mapped on OUT-2 (focused there) takes the keyboard back.
266 client <- ports.WindowMapped{ID: 2}
267 next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 2 })
268 input <- ports.KeyEvent{Keysym: "b", Pressed: true}
269 if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 2 {
270 t.Fatalf("key to %d, want the new window", v.ID)
271 }
272}
273
274// A layer's transparent margin passes pointer focus through to the window.
275func TestLayerInputRegionPassThrough(t *testing.T) {
276 cfg := config.Defaults()
277 cfg.Border.Width = 0
278 client := make(chan ports.ClientEvent, 16)
279 input := make(chan ports.InputEvent, 16)
280 output := make(chan ports.OutputEvent, 16)
281 commands := make(chan ports.ClientCommand, 256)
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})
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 scene(t, scenes)
294 client <- ports.LayerChanged{Layers: []ports.LayerSurface{{ID: 6, Layer: ports.LayerOverlay, Width: 100, Height: 80}}}
295 scene(t, scenes)
296 client <- ports.InputRegionChanged{ID: 6, Rects: []ports.Rect{{X: 10, Y: 10, W: 80, H: 60}}}
297 scene(t, scenes)
298 input <- ports.PointerMotion{X: 5, Y: 5}
299 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 {
300 t.Fatalf("margin focus: %+v", v)
301 }
302 input <- ports.PointerMotion{X: 20, Y: 20}
303 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
304 t.Fatalf("interior focus: %+v", v)
305 }
306 client <- ports.InputRegionChanged{ID: 6}
307 scene(t, scenes)
308 input <- ports.PointerMotion{X: 20, Y: 21}
309 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 1 {
310 t.Fatalf("empty focus: %+v", v)
311 }
312 client <- ports.InputRegionChanged{ID: 6, All: true}
313 scene(t, scenes)
314 input <- ports.PointerMotion{X: 5, Y: 5}
315 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
316 t.Fatalf("reset focus: %+v", v)
317 }
318}
319
320func TestRemovedLayerInputRegionCleared(t *testing.T) {
321 cfg := config.Defaults()
322 cfg.Border.Width = 0
323 client := make(chan ports.ClientEvent, 16)
324 input := make(chan ports.InputEvent, 16)
325 output := make(chan ports.OutputEvent, 16)
326 commands := make(chan ports.ClientCommand, 256)
327 scenes := make(chan []ports.Scene, 1)
328 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
329 if err != nil {
330 t.Fatal(err)
331 }
332 ctx, cancel := context.WithCancel(context.Background())
333 defer cancel()
334 go c.Run(ctx)
335 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
336 scene(t, scenes)
337 client <- ports.WindowMapped{ID: 1}
338 scene(t, scenes)
339 client <- ports.InputRegionChanged{ID: 1}
340 scene(t, scenes) // window's empty region must survive layer changes
341 layer := ports.LayerSurface{ID: 6, Layer: ports.LayerOverlay, Width: 100, Height: 80}
342 client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
343 scene(t, scenes)
344 client <- ports.InputRegionChanged{ID: 6}
345 scene(t, scenes)
346 client <- ports.LayerChanged{}
347 scene(t, scenes)
348 client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
349 scene(t, scenes)
350 input <- ports.PointerMotion{X: 20, Y: 20}
351 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
352 t.Fatalf("remapped layer retains old region: %+v", v)
353 }
354 client <- ports.LayerChanged{}
355 scene(t, scenes)
356 input <- ports.PointerMotion{X: 21, Y: 21}
357 if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 0 {
358 t.Fatalf("window region removed with layer: %+v", v)
359 }
360}
361
362func TestPopupInputRegionPassesThroughToWindow(t *testing.T) {
363 cfg := config.Defaults()
364 cfg.Border.Width = 0
365 client := make(chan ports.ClientEvent, 16)
366 input := make(chan ports.InputEvent, 16)
367 output := make(chan ports.OutputEvent, 16)
368 commands := make(chan ports.ClientCommand, 256)
369 scenes := make(chan []ports.Scene, 1)
370 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
371 if err != nil {
372 t.Fatal(err)
373 }
374 ctx, cancel := context.WithCancel(context.Background())
375 defer cancel()
376 go c.Run(ctx)
377 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
378 scene(t, scenes)
379 client <- ports.WindowMapped{ID: 1}
380 scene(t, scenes)
381 client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 10, Y: 10, W: 10, H: 10}}}
382 next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
383 client <- ports.PopupMapped{ID: 9}
384 sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 })
385 client <- ports.InputRegionChanged{ID: 9, Rects: []ports.Rect{{X: 10, Y: 0, W: 20, H: 20}}}
386 scene(t, scenes)
387 popup := sc.Windows[1].Rect
388 input <- ports.PointerMotion{X: float64(popup.X + 2), Y: float64(popup.Y + 2)}
389 if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 1 {
390 t.Fatalf("popup margin motion: %+v, popup %+v", v, popup)
391 }
392 input <- ports.PointerMotion{X: float64(popup.X + 12), Y: float64(popup.Y + 2)}
393 if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 9 {
394 t.Fatalf("popup interior motion: %+v", v)
395 }
396}
397
398// A grabbing popup hidden with its parent under a fullscreen window is
399// closed, and the keyboard goes to the fullscreen window.
400func TestHiddenGrabPopupLosesKeyboard(t *testing.T) {
401 cfg := config.Defaults()
402 cfg.Border.Width = 0
403 client := make(chan ports.ClientEvent, 8)
404 output := make(chan ports.OutputEvent, 8)
405 commands := make(chan ports.ClientCommand, 64)
406 scenes := make(chan []ports.Scene, 1)
407 c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
408 if err != nil {
409 t.Fatal(err)
410 }
411 ctx, cancel := context.WithCancel(context.Background())
412 defer cancel()
413 go c.Run(ctx)
414 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
415 <-scenes
416 client <- ports.WindowMapped{ID: 1}
417 <-scenes
418 pos := ports.Positioner{Width: 10, Height: 10, AnchorRect: ports.Rect{W: 1, H: 1}, Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight}
419 client <- ports.PopupRequest{ID: 3, Parent: 1, Positioner: pos, Grab: true}
420 next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 3 })
421 client <- ports.PopupMapped{ID: 3}
422 if f := next(t, commands, anyOf[ports.FocusWindow]); f.ID != 3 {
423 t.Fatalf("focus %d, want the grabbing popup", f.ID)
424 }
425 // A new fullscreen window covers its parent: the popup is hidden.
426 client <- ports.WindowMapped{ID: 2}
427 client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
428 next(t, commands, func(v ports.ClosePopup) bool { return v.ID == 3 })
429 if f := next(t, commands, func(v ports.FocusWindow) bool { return v.ID != 3 }); f.ID != 2 {
430 t.Fatalf("focus %d, want the fullscreen window", f.ID)
431 }
432}