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