v0.1.0 d9218d6

core_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"errors"
6	"github.com/bnema/neferwl/internal/adapters/config"
7	"github.com/bnema/neferwl/internal/core"
8	"github.com/bnema/neferwl/internal/ports"
9	"testing"
10	"time"
11)
12
13func receive[T any](t *testing.T, ch <-chan T) T {
14	t.Helper()
15	select {
16	case v := <-ch:
17		return v
18	case <-time.After(time.Second):
19		t.Fatal("timed out")
20		var zero T
21		return zero
22	}
23}
24
25// scene receives the next scene set and returns the first output's scene.
26func scene(t *testing.T, ch <-chan []ports.Scene) ports.Scene {
27	t.Helper()
28	return receive(t, ch)[0]
29}
30
31// command receives the next client command, skipping output scale updates.
32func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
33	t.Helper()
34	for {
35		if v := receive(t, ch); v != nil {
36			if _, ok := v.(ports.SetOutputs); !ok {
37				return v
38			}
39		}
40	}
41}
42
43func TestOwner(t *testing.T) {
44	cfg := config.Defaults()
45	cfg.Keyboard.CmdKey = "alt"
46	cfg.Border.Width = 0
47	cfg.Layout.Gaps = 8
48	client := make(chan ports.ClientEvent, 128)
49	input := make(chan ports.InputEvent, 32)
50	output := make(chan ports.OutputEvent, 8)
51	reload := make(chan ports.ConfigChanged, 8)
52	commands := make(chan ports.ClientCommand, 256)
53	spawn := make(chan ports.SpawnRequest, 8)
54	scenes := make(chan []ports.Scene, 1)
55	errs := make(chan error, 8)
56	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs})
57	if err != nil {
58		t.Fatal(err)
59	}
60	ctx, cancel := context.WithCancel(context.Background())
61	defer cancel()
62	done := make(chan error, 1)
63	go func() { done <- c.Run(ctx) }()
64	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
65	s := scene(t, scenes)
66	if s.OutputWidth != 100 {
67		t.Fatal(s)
68	}
69	client <- ports.WindowMapped{ID: 1}
70	s = scene(t, scenes)
71	// A single column fills the usable width.
72	if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
73		t.Fatal(s)
74	}
75	if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {
76		t.Fatal(v)
77	}
78	command(t, commands)
79	client <- ports.WindowMapped{ID: 2}
80	s = scene(t, scenes)
81	if len(s.Windows) != 2 {
82		t.Fatal(s)
83	}
84	// Commands for focus changes and second mapping.
85	for len(commands) > 0 {
86		<-commands
87	}
88	client <- ports.WindowMapped{ID: 2}
89	scene(t, scenes)
90	if len(commands) != 0 {
91		t.Fatal("duplicate configure")
92	}
93	input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true}
94	scene(t, scenes)
95	if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" {
96		t.Fatal(v)
97	}
98	input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt}
99	input <- ports.KeyEvent{Keysym: "x", Pressed: true}
100	if v := command(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {
101		t.Fatal(v)
102	}
103	input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true}
104	scene(t, scenes)
105	if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) {
106		t.Fatal(v)
107	}
108	client <- ports.WindowUnmapped{ID: 2}
109	scene(t, scenes)
110	for len(commands) > 0 {
111		<-commands
112	}
113	cfg.Layout.Gaps = 4
114	reload <- ports.ConfigChanged{Config: cfg}
115	s = scene(t, scenes)
116	if s.Windows[0].Rect.W != 92 {
117		t.Fatal(s)
118	}
119	if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1"}) {
120		t.Fatal(v)
121	}
122	cfg.Layout.MaxColumns = 0
123	reload <- ports.ConfigChanged{Config: cfg}
124	receive(t, errs)
125	for i := 0; i < 100; i++ {
126		client <- ports.WindowMapped{ID: ports.WindowID(i + 3)}
127	}
128	input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
129	if err := receive(t, done); !errors.Is(err, core.ErrQuit) {
130		t.Fatal(err)
131	}
132}
133func TestCancelAndSceneCapacity(t *testing.T) {
134	cfg := config.Defaults()
135	if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}); err == nil {
136		t.Fatal("capacity")
137	}
138	ctx, cancel := context.WithCancel(context.Background())
139	c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)})
140	if err != nil {
141		t.Fatal(err)
142	}
143	done := make(chan error, 1)
144	go func() { done <- c.Run(ctx) }()
145	cancel()
146	if err := receive(t, done); err != nil {
147		t.Fatal(err)
148	}
149}
150
151func TestKeyPressState(t *testing.T) {
152	cfg := config.Defaults()
153	client := make(chan ports.ClientEvent, 8)
154	input := make(chan ports.InputEvent, 8)
155	commands := make(chan ports.ClientCommand, 16)
156	scenes := make(chan []ports.Scene, 1)
157	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes})
158	if err != nil {
159		t.Fatal(err)
160	}
161	ctx, cancel := context.WithCancel(context.Background())
162	done := make(chan error, 1)
163	go func() { done <- c.Run(ctx) }()
164	defer func() { cancel(); receive(t, done) }()
165	client <- ports.WindowMapped{ID: 1}
166	scene(t, scenes)
167	command(t, commands)
168	command(t, commands)
169	press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
170	input <- press
171	scene(t, scenes)
172	input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper}
173	input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
174	if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) {
175		t.Fatal(v)
176	}
177	input <- ports.KeyEvent{Keysym: "Left"}
178	if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) {
179		t.Fatal(v)
180	}
181	input <- press
182	scene(t, scenes)
183	input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
184	command(t, commands)
185	input <- ports.KeyEvent{Keysym: "Left"}
186	if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) {
187		t.Fatal(v)
188	}
189}
190
191func TestBoundReleaseSwallowed(t *testing.T) {
192	cfg := config.Defaults()
193	input := make(chan ports.InputEvent, 4)
194	client := make(chan ports.ClientEvent, 2)
195	commands := make(chan ports.ClientCommand, 8)
196	scenes := make(chan []ports.Scene, 1)
197	c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
198	if err != nil {
199		t.Fatal(err)
200	}
201	ctx, cancel := context.WithCancel(context.Background())
202	done := make(chan error, 1)
203	go func() { done <- c.Run(ctx) }()
204	defer func() { cancel(); receive(t, done) }()
205	client <- ports.WindowMapped{ID: 1}
206	scene(t, scenes)
207	command(t, commands)
208	command(t, commands)
209	input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
210	scene(t, scenes)
211	input <- ports.KeyEvent{Keysym: "Left"}
212	input <- ports.KeyEvent{Keysym: "x", Pressed: true}
213	if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {
214		t.Fatal(v)
215	}
216}
217
218func TestPointerFocusAndGrab(t *testing.T) {
219	cfg := config.Defaults()
220	cfg.Border.Width = 0
221	cfg.Layout.Gaps = 8
222	client := make(chan ports.ClientEvent, 8)
223	input := make(chan ports.InputEvent, 8)
224	output := make(chan ports.OutputEvent, 8)
225	commands := make(chan ports.ClientCommand, 64)
226	scenes := make(chan []ports.Scene, 1)
227	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
228	if err != nil {
229		t.Fatal(err)
230	}
231	ctx, cancel := context.WithCancel(context.Background())
232	defer cancel()
233	go c.Run(ctx)
234	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
235	scene(t, scenes)
236	client <- ports.WindowMapped{ID: 1}
237	first := scene(t, scenes).Windows[0].Rect
238	client <- ports.WindowMapped{ID: 2}
239	scene(t, scenes)
240	for len(commands) > 0 {
241		<-commands
242	}
243	input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1}
244	if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) {
245		t.Fatal(v)
246	}
247	if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) {
248		t.Fatal(v)
249	}
250	input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
251	if v := command(t, commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}}) {
252		t.Fatal(v)
253	}
254	input <- ports.PointerButton{Button: 0x110, Pressed: true}
255	if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
256		t.Fatal(v)
257	}
258	scene(t, scenes)
259	found := false
260	for len(commands) > 0 {
261		if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 {
262			found = true
263		}
264	}
265	if !found {
266		t.Fatal("missing keyboard focus")
267	}
268	input <- ports.PointerMotion{X: 0, Y: 0}
269	if v := command(t, commands); v != (ports.PointerFocus{}) {
270		t.Fatal(v)
271	}
272	// Mid-drag off the window, scroll goes to what is under the pointer:
273	// nothing here, not the grab window that got a leave.
274	input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
275	input <- ports.PointerButton{Button: 0x110}
276	if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {
277		t.Fatal(v)
278	}
279}
280
281func TestBorderInset(t *testing.T) {
282	cfg := config.Defaults()
283	cfg.Border.Width = 2
284	client := make(chan ports.ClientEvent, 8)
285	input := make(chan ports.InputEvent, 8)
286	output := make(chan ports.OutputEvent, 8)
287	commands := make(chan ports.ClientCommand, 64)
288	scenes := make(chan []ports.Scene, 1)
289	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
290	if err != nil {
291		t.Fatal(err)
292	}
293	ctx, cancel := context.WithCancel(context.Background())
294	defer cancel()
295	go c.Run(ctx)
296	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
297	scene(t, scenes)
298	// Alone, the window is borderless and gets the full rect.
299	client <- ports.WindowMapped{ID: 1}
300	r := scene(t, scenes).Windows[0].Rect
301	if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {
302		t.Fatalf("%v for outer %v", v, r)
303	}
304	// With a second column, the left window owns the shared separator: its
305	// client is 2px narrower on the right; the right window keeps its rect.
306	client <- ports.WindowMapped{ID: 2}
307	s := scene(t, scenes)
308	r = s.Windows[1].Rect
309	if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
310		t.Fatal(s)
311	}
312	want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}
313	found := false
314	for len(commands) > 0 {
315		if v := <-commands; v == want {
316			found = true
317		}
318	}
319	if !found {
320		t.Fatalf("no %v for outer %v", want, r)
321	}
322	input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
323	if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 2, Y: 5}) {
324		t.Fatal(v)
325	}
326}
327
328func TestLayerKeyboardFocus(t *testing.T) {
329	cfg := config.Defaults()
330	client := make(chan ports.ClientEvent, 8)
331	input := make(chan ports.InputEvent, 8)
332	output := make(chan ports.OutputEvent, 8)
333	commands := make(chan ports.ClientCommand, 64)
334	scenes := make(chan []ports.Scene, 1)
335	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
336	if err != nil {
337		t.Fatal(err)
338	}
339	ctx, cancel := context.WithCancel(context.Background())
340	defer cancel()
341	go c.Run(ctx)
342	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
343	scene(t, scenes)
344	client <- ports.WindowMapped{ID: 1}
345	scene(t, scenes)
346	focusOf := func() ports.WindowID {
347		t.Helper()
348		for {
349			if v, ok := command(t, commands).(ports.FocusWindow); ok {
350				return v.ID
351			}
352		}
353	}
354	if id := focusOf(); id != 1 {
355		t.Fatalf("window focus %d", id)
356	}
357	// An on-demand bar does not steal focus; an exclusive launcher does.
358	launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1}
359	bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2}
360	client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
361	scene(t, scenes)
362	if id := focusOf(); id != 7 {
363		t.Fatalf("layer focus %d", id)
364	}
365	input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}
366	for {
367		if v, ok := command(t, commands).(ports.ForwardKey); ok {
368			if v.ID != 7 {
369				t.Fatalf("key went to %d", v.ID)
370			}
371			break
372		}
373	}
374	client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
375	scene(t, scenes)
376	if id := focusOf(); id != 1 {
377		t.Fatalf("focus not restored: %d", id)
378	}
379}
380
381func TestWorkspaceSwitch(t *testing.T) {
382	cfg := config.Defaults()
383	client := make(chan ports.ClientEvent, 8)
384	input := make(chan ports.InputEvent, 8)
385	output := make(chan ports.OutputEvent, 8)
386	commands := make(chan ports.ClientCommand, 64)
387	scenes := make(chan []ports.Scene, 1)
388	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
389	if err != nil {
390		t.Fatal(err)
391	}
392	ctx, cancel := context.WithCancel(context.Background())
393	defer cancel()
394	go c.Run(ctx)
395	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
396	scene(t, scenes)
397	client <- ports.WindowMapped{ID: 1}
398	scene(t, scenes)
399	for len(commands) > 0 {
400		<-commands
401	}
402	// AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
403	input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
404	s := scene(t, scenes)
405	if len(s.Windows) != 1 || !s.Windows[0].Hidden {
406		t.Fatal(s)
407	}
408	// Window 1 is deactivated and loses keyboard focus.
409	var sawDeactivate, sawFocus bool
410	for len(commands) > 0 {
411		switch v := (<-commands).(type) {
412		case ports.ConfigureWindow:
413			sawDeactivate = sawDeactivate || (v.ID == 1 && !v.Activated)
414		case ports.FocusWindow:
415			sawFocus = sawFocus || v.ID == 0
416		}
417	}
418	if !sawDeactivate || !sawFocus {
419		t.Fatal(sawDeactivate, sawFocus)
420	}
421	// A new window opens on workspace 2, alone and borderless.
422	client <- ports.WindowMapped{ID: 2}
423	s = scene(t, scenes)
424	if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
425		t.Fatal(s)
426	}
427}
428
429func TestClickAfterWorkspaceSwitch(t *testing.T) {
430	cfg := config.Defaults()
431	client := make(chan ports.ClientEvent, 8)
432	input := make(chan ports.InputEvent, 8)
433	output := make(chan ports.OutputEvent, 8)
434	commands := make(chan ports.ClientCommand, 64)
435	scenes := make(chan []ports.Scene, 1)
436	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
437	if err != nil {
438		t.Fatal(err)
439	}
440	ctx, cancel := context.WithCancel(context.Background())
441	defer cancel()
442	go c.Run(ctx)
443	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
444	scene(t, scenes)
445	client <- ports.WindowMapped{ID: 1}
446	scene(t, scenes)
447	input <- ports.PointerMotion{X: 50, Y: 40}
448	for len(commands) < 3 {
449		time.Sleep(time.Millisecond)
450	}
451	for len(commands) > 0 {
452		<-commands
453	}
454	// Cmd+2 hides window 1; the pointer must leave it.
455	input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
456	scene(t, scenes)
457	var cleared bool
458	for len(commands) > 0 {
459		if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
460			cleared = true
461		}
462	}
463	if !cleared {
464		t.Fatal("pointer focus kept on hidden window")
465	}
466	// A click without moving does not reach window 1 nor switch back.
467	input <- ports.PointerButton{Button: 0x110, Pressed: true}
468	input <- ports.PointerButton{Button: 0x110}
469	client <- ports.WindowMapped{ID: 2}
470	s := scene(t, scenes)
471	if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
472		t.Fatal(s)
473	}
474	for len(commands) > 0 {
475		if v, ok := (<-commands).(ports.PointerButtonTo); ok {
476			t.Fatal(v)
477		}
478	}
479}
480
481func TestShiftReleasedFirst(t *testing.T) {
482	cfg := config.Defaults()
483	input := make(chan ports.InputEvent, 8)
484	client := make(chan ports.ClientEvent, 2)
485	commands := make(chan ports.ClientCommand, 16)
486	scenes := make(chan []ports.Scene, 1)
487	c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
488	if err != nil {
489		t.Fatal(err)
490	}
491	ctx, cancel := context.WithCancel(context.Background())
492	defer cancel()
493	go c.Run(ctx)
494	client <- ports.WindowMapped{ID: 1}
495	scene(t, scenes)
496	for len(commands) > 0 {
497		<-commands
498	}
499	// US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-column-to-workspace 1.
500	input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true}
501	scene(t, scenes)
502	// Shift goes up first, so the release reports "1": still swallowed.
503	input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper}
504	input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
505	if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}}) {
506		t.Fatal(v)
507	}
508}
509
510func TestOutputScale(t *testing.T) {
511	cfg := config.Defaults()
512	cfg.Border.Width = 0
513	cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
514	cfg.Binds["Cmd+equal"] = "scale-up"
515	cfg.Binds["Cmd+minus"] = "scale-down"
516	client := make(chan ports.ClientEvent, 8)
517	input := make(chan ports.InputEvent, 8)
518	output := make(chan ports.OutputEvent, 8)
519	reload := make(chan ports.ConfigChanged, 1)
520	commands := make(chan ports.ClientCommand, 64)
521	scenes := make(chan []ports.Scene, 1)
522	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes})
523	if err != nil {
524		t.Fatal(err)
525	}
526	ctx, cancel := context.WithCancel(context.Background())
527	defer cancel()
528	go c.Run(ctx)
529	// 5120x2160 at scale 2 is 2560x1080 logical.
530	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
531	s := scene(t, scenes)
532	if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 {
533		t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
534	}
535	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" {
536		t.Fatal(v)
537	}
538	client <- ports.WindowMapped{ID: 1}
539	scene(t, scenes)
540	if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2"}) {
541		t.Fatal(v)
542	}
543	for len(commands) > 0 {
544		<-commands
545	}
546	// The pointer arrives in global logical pixels.
547	input <- ports.PointerMotion{X: 500, Y: 200}
548	if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) {
549		t.Fatal(v)
550	}
551	// Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.
552	input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
553	s = scene(t, scenes)
554	if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 {
555		t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
556	}
557	input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper}
558	input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
559	if s = scene(t, scenes); s.Scale != 2 {
560		t.Fatal(s.Scale)
561	}
562	// A reload that keeps the config scale keeps the live one too.
563	input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper}
564	input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
565	if s = scene(t, scenes); s.Scale != 2.5 {
566		t.Fatal(s.Scale)
567	}
568	cfg.Layout.Gaps = 1
569	reload <- ports.ConfigChanged{Config: cfg}
570	if s = scene(t, scenes); s.Scale != 2.5 {
571		t.Fatal(s.Scale)
572	}
573	// Changing the config scale wins.
574	cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}}
575	reload <- ports.ConfigChanged{Config: cfg}
576	if s = scene(t, scenes); s.Scale != 1.25 || s.OutputWidth != 4096 {
577		t.Fatal(s.Scale, s.OutputWidth)
578	}
579}
580
581func TestPointerConstraint(t *testing.T) {
582	cfg := config.Defaults()
583	cfg.Border.Width = 0
584	cfg.Layout.Gaps = 0
585	client := make(chan ports.ClientEvent, 8)
586	input := make(chan ports.InputEvent, 8)
587	output := make(chan ports.OutputEvent, 8)
588	commands := make(chan ports.ClientCommand, 64)
589	scenes := make(chan []ports.Scene, 1)
590	constraints := make(chan ports.PointerConstraint, 1)
591	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints})
592	if err != nil {
593		t.Fatal(err)
594	}
595	ctx, cancel := context.WithCancel(context.Background())
596	defer cancel()
597	go c.Run(ctx)
598	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
599	scene(t, scenes)
600	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
601	scene(t, scenes)
602	input <- ports.PointerMotion{X: 150, Y: 40}
603	scene(t, scenes) // the pointer moves the focus to B
604	client <- ports.WindowMapped{ID: 1}
605	r := receive(t, scenes)[1].Windows[0].Rect
606	// The confine region is window-local and clipped to the window.
607	client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
608	scene(t, scenes)
609	want := ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 100 + r.X + 10, Y: r.Y + 10, W: r.W - 10, H: 20}}
610	// Input gets core's cursor, clamped into the region.
611	if got := receive(t, constraints); got.Mode != want.Mode || got.Rect != want.Rect || got.X != 150 || got.Y != 29 {
612		t.Fatalf("got %+v, want %+v", got, want)
613	}
614	for len(commands) > 0 {
615		<-commands
616	}
617	// Motion is clamped into the region; deltas pass through.
618	input <- ports.PointerMotion{X: 199, Y: 79, DX: 5, DY: 6}
619	for {
620		if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
621			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 {
622				t.Fatalf("motion %+v", v)
623			}
624			break
625		}
626	}
627	// A lock keeps the pointer still.
628	client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintLock}}
629	scene(t, scenes)
630	if got := receive(t, constraints); got.Mode != ports.ConstraintLock {
631		t.Fatalf("lock %+v", got)
632	}
633	for len(commands) > 0 {
634		<-commands
635	}
636	input <- ports.PointerMotion{X: 101, Y: 1, DX: -50}
637	for {
638		if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
639			if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.DX != -50 {
640				t.Fatalf("locked motion %+v", v)
641			}
642			break
643		}
644	}
645	client <- ports.PointerConstrained{}
646	scene(t, scenes)
647	if got := receive(t, constraints); got.Mode != ports.ConstraintNone {
648		t.Fatalf("release %+v", got)
649	}
650}
651
652// A click outside a grabbing menu chain closes it, the topmost popup first.
653func TestPopupChainDismissOrder(t *testing.T) {
654	cfg := config.Defaults()
655	cfg.Border.Width = 0
656	client := make(chan ports.ClientEvent, 8)
657	input := make(chan ports.InputEvent, 8)
658	output := make(chan ports.OutputEvent, 8)
659	commands := make(chan ports.ClientCommand, 64)
660	scenes := make(chan []ports.Scene, 1)
661	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
662	if err != nil {
663		t.Fatal(err)
664	}
665	ctx, cancel := context.WithCancel(context.Background())
666	defer cancel()
667	go c.Run(ctx)
668	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
669	scene(t, scenes)
670	client <- ports.WindowMapped{ID: 1}
671	scene(t, scenes)
672	pos := ports.Positioner{Width: 10, Height: 10, AnchorRect: ports.Rect{W: 1, H: 1}, Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight}
673	for _, v := range []struct{ id, parent ports.WindowID }{{2, 1}, {3, 2}} {
674		client <- ports.PopupRequest{ID: v.id, Parent: v.parent, Positioner: pos, Grab: true}
675		for {
676			if cp, ok := command(t, commands).(ports.ConfigurePopup); ok && cp.ID == v.id {
677				break
678			}
679		}
680		client <- ports.PopupMapped{ID: v.id}
681		scene(t, scenes)
682	}
683	for len(commands) > 0 {
684		<-commands
685	}
686	// The window's far corner is outside both popups.
687	input <- ports.PointerMotion{X: 90, Y: 70}
688	input <- ports.PointerButton{Button: 0x110, Pressed: true}
689	var closed []ports.WindowID
690	for len(closed) < 2 {
691		if v, ok := command(t, commands).(ports.ClosePopup); ok {
692			closed = append(closed, v.ID)
693		}
694	}
695	if closed[0] != 3 || closed[1] != 2 {
696		t.Fatalf("close order %v, want [3 2]", closed)
697	}
698}
699
700// A dialog over a fullscreen window gets the pointer.
701func TestFloatOverFullscreenHit(t *testing.T) {
702	cfg := config.Defaults()
703	cfg.Border.Width = 0
704	client := make(chan ports.ClientEvent, 8)
705	input := make(chan ports.InputEvent, 8)
706	output := make(chan ports.OutputEvent, 8)
707	commands := make(chan ports.ClientCommand, 64)
708	scenes := make(chan []ports.Scene, 1)
709	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
710	if err != nil {
711		t.Fatal(err)
712	}
713	ctx, cancel := context.WithCancel(context.Background())
714	defer cancel()
715	go c.Run(ctx)
716	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
717	scene(t, scenes)
718	client <- ports.WindowMapped{ID: 1}
719	scene(t, scenes)
720	client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
721	scene(t, scenes)
722	client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
723	scene(t, scenes)
724	for len(commands) > 0 {
725		<-commands
726	}
727	input <- ports.PointerMotion{X: 50, Y: 40}
728	if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
729		t.Fatalf("pointer went to %v, want the dialog", v)
730	}
731}
732
733// An activated window on another workspace comes on screen with focus.
734func TestWindowActivateShowsAndFocuses(t *testing.T) {
735	cfg := config.Defaults()
736	client := make(chan ports.ClientEvent, 8)
737	input := make(chan ports.InputEvent, 8)
738	output := make(chan ports.OutputEvent, 8)
739	commands := make(chan ports.ClientCommand, 64)
740	scenes := make(chan []ports.Scene, 1)
741	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
742	if err != nil {
743		t.Fatal(err)
744	}
745	ctx, cancel := context.WithCancel(context.Background())
746	defer cancel()
747	go c.Run(ctx)
748	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
749	scene(t, scenes)
750	client <- ports.WindowMapped{ID: 1}
751	scene(t, scenes)
752	// Window 2 opens on workspace 2, then the user goes back to 1.
753	input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
754	scene(t, scenes)
755	client <- ports.WindowMapped{ID: 2}
756	scene(t, scenes)
757	input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true}
758	visible := func(sc ports.Scene) (shown []ports.SceneWindow) {
759		for _, w := range sc.Windows {
760			if !w.Hidden {
761				shown = append(shown, w)
762			}
763		}
764		return shown
765	}
766	if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 {
767		t.Fatalf("workspace 1: %+v", v)
768	}
769	client <- ports.WindowActivate{ID: 2}
770	v := visible(scene(t, scenes))
771	for len(v) != 1 || v[0].ID != 2 {
772		v = visible(scene(t, scenes))
773	}
774	if !v[0].Focused {
775		t.Fatalf("after activate: %+v", v)
776	}
777	for {
778		if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
779			break
780		}
781	}
782
783}

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