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 window that asks for fullscreen as it maps (Wine at a remembered
801// monitor size) stays in its column: the windows it opens next are seen.
802// A later request is honoured.
803func TestFullscreenAtMapIgnored(t *testing.T) {
804	cfg := config.Defaults()
805	cfg.Layout.Overflow = "fixed"
806	cfg.Focus.Animation = ports.FocusAnimationOff
807	client := make(chan ports.ClientEvent, 8)
808	output := make(chan ports.OutputEvent, 8)
809	commands := make(chan ports.ClientCommand, 64)
810	scenes := make(chan []ports.Scene, 1)
811	// Core reads the time on its goroutine.
812	var now atomic.Int64
813	clock := portsmocks.NewMockClock(t)
814	clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) })
815	c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock})
816	if err != nil {
817		t.Fatal(err)
818	}
819	ctx, cancel := context.WithCancel(context.Background())
820	defer cancel()
821	go c.Run(ctx)
822	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
823	scene(t, scenes)
824	client <- ports.WindowMapped{ID: 1}
825	scene(t, scenes)
826	client <- ports.WindowMapped{ID: 2}
827	scene(t, scenes)
828	client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
829	client <- ports.WindowMapped{ID: 3, Floating: true, Width: 20, Height: 10}
830	s := scene(t, scenes)
831	for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) {
832		s = scene(t, scenes)
833	}
834	for _, w := range s.Windows {
835		if w.Fullscreen || w.Hidden {
836			t.Fatalf("startup fullscreen applied: %+v", s.Windows)
837		}
838	}
839	now.Add(1)
840	// Only the focused window may cover the screen (ADR 011).
841	client <- ports.WindowUnmapped{ID: 3}
842	scene(t, scenes)
843	client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
844	s = scene(t, scenes)
845	// Honoured, in place.
846	if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && !w.Hidden }) {
847		t.Fatalf("later fullscreen ignored: %+v", s.Windows)
848	}
849}
850
851// Under fixed overflow, a window opening over a fullscreen game waits
852// hidden: the game keeps the screen and the keyboard. An activation (a
853// user action in the new client) leaves fullscreen and shows both tiles;
854// a focus move back to the game and the bind make it fullscreen again.
855func TestFixedFullscreenArrivalWaits(t *testing.T) {
856	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
857	r.mapWindow(t, 1)
858	r.key(t, "f", ports.ModAlt|ports.ModShift)
859	set := r.mapWindow(t, 2)
860	for len(r.commands) > 0 {
861		<-r.commands
862	}
863	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
864		t.Fatal("game disturbed:", got, focused)
865	}
866	r.client <- ports.WindowActivate{ID: 2}
867	set = receive(t, r.scenes)
868	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 {
869		t.Fatal("after activate:", got, focused)
870	}
871	for {
872		if f, ok := command(t, r.commands).(ports.FocusWindow); ok && f.ID == 2 {
873			break
874		}
875	}
876	r.key(t, "Left", ports.ModAlt)
877	set = r.key(t, "f", ports.ModAlt|ports.ModShift)
878	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
879		t.Fatal("game again:", got, focused)
880	}
881}
882
883// A taskbar's fullscreen request is the user's: it applies at once.
884func TestExternalFullscreenAtMapApplies(t *testing.T) {
885	cfg := config.Defaults()
886	cfg.Focus.Animation = ports.FocusAnimationOff
887	client := make(chan ports.ClientEvent, 8)
888	output := make(chan ports.OutputEvent, 8)
889	commands := make(chan ports.ClientCommand, 64)
890	scenes := make(chan []ports.Scene, 1)
891	clock := portsmocks.NewMockClock(t)
892	clock.EXPECT().Now().Return(time.Unix(0, 0))
893	c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock})
894	if err != nil {
895		t.Fatal(err)
896	}
897	ctx, cancel := context.WithCancel(context.Background())
898	defer cancel()
899	go c.Run(ctx)
900	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
901	scene(t, scenes)
902	client <- ports.WindowMapped{ID: 1}
903	scene(t, scenes)
904	client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
905	s := scene(t, scenes)
906	if len(s.Windows) != 1 || !s.Windows[0].Fullscreen {
907		t.Fatalf("taskbar fullscreen ignored: %+v", s.Windows)
908	}
909}
910
911// Under fixed overflow, a taskbar's fullscreen on an unfocused window is
912// the user's choice: the window takes the focus and covers the screen.
913func TestExternalFullscreenFocusesFixed(t *testing.T) {
914	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
915	r.mapWindow(t, 1)
916	r.mapWindow(t, 2)
917	r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
918	set := receive(t, r.scenes)
919	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
920		t.Fatal("taskbar fullscreen:", got, focused)
921	}
922	// Over another covering window too: it leaves fullscreen.
923	r.client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true, External: true}
924	set = receive(t, r.scenes)
925	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
926		t.Fatal("taskbar fullscreen over another:", got, focused)
927	}
928	// Leaving from the taskbar keeps the focus.
929	r.client <- ports.WindowFullscreenRequest{ID: 2, External: true}
930	set = receive(t, r.scenes)
931	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 {
932		t.Fatal("taskbar leave:", got, focused)
933	}
934}
935
936// A taskbar fullscreen on a window of a workspace off screen, on another
937// output, brings it on screen with the focus, as an activation.
938func TestExternalFullscreenOffScreen(t *testing.T) {
939	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
940	r.mapWindow(t, 1)
941	r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2, not followed
942	r.key(t, "Right", ports.ModAlt|ports.ModCtrl) // focus DP-2
943	r.mapWindow(t, 2)
944	r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
945	set := receive(t, r.scenes)
946	found := false
947	for _, s := range set {
948		for _, w := range s.Windows {
949			if w.ID != 1 {
950				continue
951			}
952			found = true
953			if s.Output != "DP-1" || w.Hidden || !w.Fullscreen || !w.Focused {
954				t.Fatalf("window 1 on %s: %+v", s.Output, w)
955			}
956		}
957	}
958	if !found {
959		t.Fatal("window 1 in no scene")
960	}
961}
962
963// In the overview, a taskbar fullscreen on a window of another workspace
964// selects its row: closing the overview lands on it, fullscreen.
965func TestExternalFullscreenInOverview(t *testing.T) {
966	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
967	r.mapWindow(t, 1)
968	r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2
969	r.mapWindow(t, 2)
970	r.key(t, "o", ports.ModAlt)
971	r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
972	receive(t, r.scenes)
973	set := r.key(t, "o", ports.ModAlt)
974	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
975		t.Fatal("after the overview:", got, focused)
976	}
977}
978
979// focus-window-down from a fullscreen tile at the bottom of its column
980// goes to the next workspace and fullscreen stays.
981func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
982	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
983	r.mapWindow(t, 1)
984	r.mapWindow(t, 2)
985	r.key(t, "f", ports.ModAlt|ports.ModShift)
986	set := r.key(t, "Down", ports.ModAlt)
987	if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
988		t.Fatal("still on the first workspace:", got)
989	}
990	set = r.key(t, "Up", ports.ModAlt)
991	if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
992		t.Fatal("fullscreen lost:", got, focused)
993	}
994}
995
996// A refused fullscreen request still gets a configure, unchanged: clients
997// wait for it (xdg-shell).
998func TestRefusedFullscreenConfigured(t *testing.T) {
999	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1000	r.mapWindow(t, 1)
1001	r.mapWindow(t, 2)
1002	for len(r.commands) > 0 {
1003		<-r.commands
1004	}
1005	r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
1006	receive(t, r.scenes)
1007	for len(r.commands) > 0 {
1008		if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 {
1009			if v.Fullscreen {
1010				t.Fatalf("refused request applied: %+v", v)
1011			}
1012			return
1013		}
1014	}
1015	t.Fatal("no configure answered the request")
1016}
1017
1018// An activated window on another workspace comes on screen with focus.
1019func TestWindowActivateShowsAndFocuses(t *testing.T) {
1020	cfg := config.Defaults()
1021	client := make(chan ports.ClientEvent, 8)
1022	input := make(chan ports.InputEvent, 8)
1023	output := make(chan ports.OutputEvent, 8)
1024	commands := make(chan ports.ClientCommand, 64)
1025	scenes := make(chan []ports.Scene, 1)
1026	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
1027	if err != nil {
1028		t.Fatal(err)
1029	}
1030	ctx, cancel := context.WithCancel(context.Background())
1031	defer cancel()
1032	go c.Run(ctx)
1033	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
1034	scene(t, scenes)
1035	client <- ports.WindowMapped{ID: 1}
1036	scene(t, scenes)
1037	// Window 2 opens on workspace 2, then the user goes back to 1.
1038	input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
1039	scene(t, scenes)
1040	client <- ports.WindowMapped{ID: 2}
1041	scene(t, scenes)
1042	input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true}
1043	visible := func(sc ports.Scene) (shown []ports.SceneWindow) {
1044		for _, w := range sc.Windows {
1045			if !w.Hidden {
1046				shown = append(shown, w)
1047			}
1048		}
1049		return shown
1050	}
1051	if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 {
1052		t.Fatalf("workspace 1: %+v", v)
1053	}
1054	client <- ports.WindowActivate{ID: 2}
1055	v := visible(scene(t, scenes))
1056	for len(v) != 1 || v[0].ID != 2 {
1057		v = visible(scene(t, scenes))
1058	}
1059	if !v[0].Focused {
1060		t.Fatalf("after activate: %+v", v)
1061	}
1062	for {
1063		if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
1064			break
1065		}
1066	}
1067
1068}

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