core_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close