core_test.go RawHistoryFolder

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

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 23

Tags 10

↑↓ moveenter openesc close