v0.4.0 b6be3a8

core_test.go RawHistoryFolder

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

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