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

Tags 10

↑↓ moveenter openesc close