main f2d166b

core_test.go RawHistoryFolder

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

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