v0.2.0 13c6a90

screens_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"image"
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
16type multiRig struct {
17	client   chan ports.ClientEvent
18	input    chan ports.InputEvent
19	output   chan ports.OutputEvent
20	reload   chan ports.ConfigChanged
21	commands chan ports.ClientCommand
22	scenes   chan []ports.Scene
23	spawn    chan ports.SpawnRequest
24	state    chan ports.State
25	cfg      ports.Config
26}
27
28var (
29	left  = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100}
30	right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200}
31	third = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "C", Serial: "3", Width: 300, Height: 100}
32)
33
34// startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
35// the given outputs in order.
36func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
37	t.Helper()
38	return startRig(t, false, edit, outs...)
39}
40
41// startRig is startMulti; terminal enables the configured automatic-open policy.
42func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
43	t.Helper()
44	cfg := altCmdDefaults()
45	cfg.Border.Width = 0
46	if edit != nil {
47		edit(&cfg)
48	}
49	r := &multiRig{
50		client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
51		output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
52		commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
53	}
54	c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})
55	if err != nil {
56		t.Fatal(err)
57	}
58	ctx, cancel := context.WithCancel(context.Background())
59	done := make(chan error, 1)
60	go func() { done <- c.Run(ctx) }()
61	t.Cleanup(func() { cancel(); <-done })
62	for _, o := range outs {
63		r.plug(t, o)
64	}
65	return r
66}
67
68func (r *multiRig) plug(t *testing.T, o ports.OutputInfo) []ports.Scene {
69	t.Helper()
70	r.output <- ports.OutputAdded{Info: o}
71	return receive(t, r.scenes)
72}
73
74func (r *multiRig) key(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
75	t.Helper()
76	r.input <- ports.KeyEvent{Keysym: sym, Mods: mods, Pressed: true}
77	s := receive(t, r.scenes)
78	r.input <- ports.KeyEvent{Keysym: sym, Mods: mods}
79	return s
80}
81
82func (r *multiRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
83	t.Helper()
84	r.client <- ports.WindowMapped{ID: id}
85	return receive(t, r.scenes)
86}
87
88// shown returns the visible window IDs of each output, by connector.
89func shown(set []ports.Scene) map[string][]ports.WindowID {
90	out := map[string][]ports.WindowID{}
91	for _, s := range set {
92		out[s.Output] = []ports.WindowID{}
93		for _, w := range s.Windows {
94			if !w.Hidden {
95				out[s.Output] = append(out[s.Output], w.ID)
96			}
97		}
98	}
99	return out
100}
101
102func lastOutputs(t *testing.T, ch chan ports.ClientCommand) ports.SetOutputs {
103	t.Helper()
104	var last ports.SetOutputs
105	found := false
106	for len(ch) > 0 {
107		if v, ok := (<-ch).(ports.SetOutputs); ok {
108			last, found = v, true
109		}
110	}
111	if !found {
112		t.Fatal("no SetOutputs")
113	}
114	return last
115}
116
117func TestOutputsLeftToRight(t *testing.T) {
118	r := startMulti(t, func(c *ports.Config) {
119		c.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}, {Name: "DP-1"}}
120	}, left, right)
121	out := lastOutputs(t, r.commands)
122	// Config order wins: DP-2 (200x100 logical at scale 2), then DP-1.
123	if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-2" || out.Outputs[0].X != 0 || out.Outputs[0].Width != 200 ||
124		out.Outputs[1].Info.Name != "DP-1" || out.Outputs[1].X != 200 || out.Focused != "DP-1" {
125		t.Fatalf("%+v", out)
126	}
127}
128
129func TestLayoutPerOutput(t *testing.T) {
130	r := startMulti(t, func(c *ports.Config) {
131		c.Layout.MaxColumns = 2
132		c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}}
133	}, left, right)
134	r.mapWindow(t, 1)
135	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
136	var set []ports.Scene
137	for id := ports.WindowID(2); id <= 6; id++ {
138		set = r.mapWindow(t, id)
139	}
140	// DP-2: four columns of 100; the fifth window splits the last one
141	// (fixed) instead of scrolling.
142	if got := shown(set); len(got["DP-2"]) != 5 {
143		t.Fatal(got)
144	}
145	for _, s := range set {
146		for _, w := range s.Windows {
147			// DP-1 keeps the default: one window of two columns fills it.
148			if want := map[string]int{"DP-1": 200, "DP-2": 100}[s.Output]; w.Rect.W != want {
149				t.Fatalf("%s: %+v", s.Output, w)
150			}
151		}
152	}
153}
154
155// widths returns the window widths on one output.
156func widths(set []ports.Scene, output string) []int {
157	var out []int
158	for _, s := range set {
159		for _, w := range s.Windows {
160			if s.Output == output {
161				out = append(out, w.Rect.W)
162			}
163		}
164	}
165	return out
166}
167
168func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {
169	r := startMulti(t, func(c *ports.Config) {
170		c.Layout.MaxColumns = 1
171		c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}}
172	}, right)
173	r.mapWindow(t, 1)
174	if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) {
175		t.Fatal(got)
176	}
177	// Another monitor on DP-2: the key rule no longer applies.
178	other := right
179	other.Serial = "3"
180	if got := widths(r.plug(t, other), "DP-2"); !slices.Equal(got, []int{400, 400}) {
181		t.Fatal(got)
182	}
183	// Back to the first monitor, then a reload drops the rule.
184	r.plug(t, right)
185	cfg := r.cfg
186	cfg.Layout.Outputs = nil
187	r.reload <- ports.ConfigChanged{Config: cfg}
188	if got := widths(receive(t, r.scenes), "DP-2"); !slices.Equal(got, []int{400, 400}) {
189		t.Fatal(got)
190	}
191}
192
193func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
194	r := startMulti(t, func(c *ports.Config) {
195		c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Pos: &image.Point{X: 100, Y: 40}}}
196	}, left, right)
197	out := lastOutputs(t, r.commands)
198	if len(out.Outputs) != 2 || out.Outputs[0].X != 500 || out.Outputs[0].Y != 0 || out.Outputs[1].X != 100 || out.Outputs[1].Y != 40 {
199		t.Fatalf("unexpected positions: %+v", out.Outputs)
200	}
201	r.cfg.Outputs[1].Pos = &image.Point{X: -200, Y: 12}
202	r.reload <- ports.ConfigChanged{Config: r.cfg}
203	_ = receive(t, r.scenes)
204	out = lastOutputs(t, r.commands)
205	if out.Outputs[0].X != 200 || out.Outputs[1].X != -200 || out.Outputs[1].Y != 12 {
206		t.Fatalf("unexpected positions after reload: %+v", out.Outputs)
207	}
208}
209
210func TestWindowsOpenOnFocusedOutput(t *testing.T) {
211	r := startMulti(t, nil, left, right)
212	r.mapWindow(t, 1)
213	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
214	set := r.mapWindow(t, 2)
215	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
216		t.Fatal(got)
217	}
218	// Only the focused output draws a focused window.
219	for _, s := range set {
220		for _, w := range s.Windows {
221			if w.Focused != (s.Output == "DP-2") {
222				t.Fatalf("%s: %+v", s.Output, w)
223			}
224		}
225	}
226}
227
228func TestFocusColumnCrossesOutputs(t *testing.T) {
229	r := startMulti(t, nil, left, right)
230	r.mapWindow(t, 1)
231	// The only column is the edge: focus moves to the right output.
232	r.key(t, "Right", ports.ModAlt)
233	r.mapWindow(t, 2)
234	for len(r.commands) > 0 {
235		<-r.commands
236	}
237	r.key(t, "Left", ports.ModAlt)
238	var focus ports.WindowID
239	for len(r.commands) > 0 {
240		if v, ok := (<-r.commands).(ports.FocusWindow); ok {
241			focus = v.ID
242		}
243	}
244	if focus != 1 {
245		t.Fatal(focus)
246	}
247}
248
249func TestPointerCrossesOutputs(t *testing.T) {
250	r := startMulti(t, nil, left, right)
251	r.mapWindow(t, 1)
252	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
253	r.mapWindow(t, 2)
254	for len(r.commands) > 0 {
255		<-r.commands
256	}
257	// DP-2 starts at x 200: global (250, 50) is (50, 50) on window 2.
258	r.input <- ports.PointerMotion{X: 250, Y: 50}
259	if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 50, Y: 50}) {
260		t.Fatal(v)
261	}
262	// Back on DP-1, then below it (100 high): off every output, y stays on DP-1.
263	r.input <- ports.PointerMotion{X: 10, Y: 50}
264	r.input <- ports.PointerMotion{X: 10, Y: 150}
265	for {
266		if v, ok := command(t, r.commands).(ports.PointerMotionTo); ok && v.Y == 99 {
267			if v.ID != 1 || v.X != 10 {
268				t.Fatal(v)
269			}
270			break
271		}
272	}
273	// A click focuses the output under the pointer.
274	r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
275	set := receive(t, r.scenes)
276	for _, s := range set {
277		for _, w := range s.Windows {
278			if w.Focused != (w.ID == 1) {
279				t.Fatalf("%s: %+v", s.Output, w)
280			}
281		}
282	}
283}
284
285func TestMoveWorkspaceToMonitor(t *testing.T) {
286	r := startMulti(t, nil, left, right)
287	r.mapWindow(t, 1)
288	set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
289	if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
290		t.Fatal(got)
291	}
292	// It is DP-2's now: unplugging DP-1 does not move it.
293	r.output <- ports.OutputRemoved{Name: "DP-1"}
294	set = receive(t, r.scenes)
295	if len(set) != 1 || set[0].Output != "DP-2" || len(shown(set)["DP-2"]) != 1 {
296		t.Fatal(shown(set))
297	}
298}
299
300func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
301	r := startMulti(t, nil, left, right)
302	r.mapWindow(t, 1)
303	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
304	r.mapWindow(t, 2)
305	// Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
306	r.output <- ports.OutputRemoved{Name: "DP-2"}
307	set := receive(t, r.scenes)
308	if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
309		t.Fatal(shown(set))
310	}
311	// Cmd+2 shows the guest.
312	r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
313	set = receive(t, r.scenes)
314	r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
315	if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
316		t.Fatal(got)
317	}
318	// Replugged while on screen: the guest stays until the user leaves it.
319	set = r.plug(t, right)
320	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
321		t.Fatal(got)
322	}
323	r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
324	set = receive(t, r.scenes)
325	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
326		t.Fatal(got)
327	}
328}
329
330func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
331	r := startMulti(t, nil, left, right)
332	r.mapWindow(t, 1)
333	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
334	r.mapWindow(t, 2)
335	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
336	r.output <- ports.OutputRemoved{Name: "DP-2"}
337	receive(t, r.scenes)
338	// Not on screen: it goes home as soon as DP-2 is back.
339	set := r.plug(t, right)
340	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
341		t.Fatal(got)
342	}
343}
344
345func TestNamedWorkspaceHomeChangeAppliesLive(t *testing.T) {
346	r := startMulti(t, func(c *ports.Config) {
347		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
348		c.Binds["Alt+w"] = "workspace web"
349	}, left, right)
350	r.key(t, "w", ports.ModAlt)
351	r.mapWindow(t, 1)
352	r.key(t, "w", ports.ModAlt)
353	r.cfg.Workspaces[0].Monitor = "DP-2"
354	r.reload <- ports.ConfigChanged{Config: r.cfg}
355	receive(t, r.scenes)
356	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
357	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
358		t.Fatal(got)
359	}
360}
361
362func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
363	r := startMulti(t, func(c *ports.Config) {
364		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2"}}
365		c.Binds["Alt+w"] = "workspace web"
366	}, left)
367	// DP-2 is not plugged: web lives on DP-1 as a guest.
368	r.key(t, "w", ports.ModAlt)
369	r.mapWindow(t, 1)
370	r.key(t, "w", ports.ModAlt)
371	// DP-2 arrives: web goes home (not on screen).
372	r.plug(t, right)
373	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
374	set := r.key(t, "w", ports.ModAlt)
375	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
376		t.Fatal(got)
377	}
378}
379
380func TestLayersStayOnTheirOutput(t *testing.T) {
381	r := startMulti(t, nil, left, right)
382	bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, ExclusiveZone: 10, Output: "DP-2"}
383	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
384	set := receive(t, r.scenes)
385	for _, s := range set {
386		if (len(s.Layers) == 1) != (s.Output == "DP-2") {
387			t.Fatalf("%s: %+v", s.Output, s.Layers)
388		}
389	}
390	if set[1].Layers[0].Rect != (ports.Rect{W: 400, H: 10}) {
391		t.Fatal(set[1].Layers)
392	}
393}
394
395func TestScaleBindTargetsFocusedOutput(t *testing.T) {
396	r := startMulti(t, func(c *ports.Config) { c.Binds["Alt+equal"] = "scale-up" }, left, right)
397	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
398	set := r.key(t, "equal", ports.ModAlt)
399	if set[0].Scale != 1 || set[1].Scale == 1 {
400		t.Fatal(set[0].Scale, set[1].Scale)
401	}
402}
403
404func TestLastOutputUnpluggedKeepsWindows(t *testing.T) {
405	r := startMulti(t, nil, left)
406	r.mapWindow(t, 1)
407	r.output <- ports.OutputRemoved{Name: "DP-1"}
408	if set := receive(t, r.scenes); len(set) != 1 || set[0].Output != "" {
409		t.Fatal(shown(set))
410	}
411	if out := lastOutputs(t, r.commands); len(out.Outputs) != 0 {
412		t.Fatalf("%+v", out)
413	}
414	// Another monitor takes the windows over.
415	set := r.plug(t, right)
416	if got := shown(set); len(got) != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
417		t.Fatal(got)
418	}
419}
420
421func TestModeChangeKeepsScreen(t *testing.T) {
422	r := startMulti(t, nil, left, right)
423	r.mapWindow(t, 1)
424	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
425	r.mapWindow(t, 2)
426	// A new mode on DP-2 is only a new size: nothing moves, focus stays.
427	bigger := right
428	bigger.Width, bigger.Height = 800, 400
429	set := r.plug(t, bigger)
430	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 || len(got["DP-1"]) != 1 {
431		t.Fatal(got)
432	}
433	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" || out.Outputs[1].Width != 800 {
434		t.Fatalf("%+v", out)
435	}
436}
437
438func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) {
439	r := startMulti(t, func(c *ports.Config) {
440		c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Primary: true}}
441	}, left, right)
442	out := lastOutputs(t, r.commands)
443	// Placed right of DP-1, but focused.
444	if out.Focused != "DP-2" || out.Outputs[1].Info.Name != "DP-2" || !out.Outputs[1].Primary || out.Outputs[0].Primary {
445		t.Fatalf("%+v", out)
446	}
447	set := r.mapWindow(t, 1)
448	if got := shown(set); len(got["DP-2"]) != 1 {
449		t.Fatal(got)
450	}
451}
452
453func TestMoveColumnCrossesOutputs(t *testing.T) {
454	r := startMulti(t, nil, left, right)
455	r.mapWindow(t, 1)
456	r.mapWindow(t, 2)
457	// Window 2 is the right column of DP-1: moving right first hits the
458	// edge, then the column goes to DP-2 and focus follows it.
459	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
460	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
461		t.Fatal(got)
462	}
463	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
464		t.Fatal(out.Focused)
465	}
466	// Back left (no terminal kept here, so a last column may move): it
467	// lands on the right side of DP-1.
468	set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
469	if got := shown(set)["DP-1"]; len(got) != 2 {
470		t.Fatal(got)
471	}
472	for _, s := range set {
473		for _, w := range s.Windows {
474			if w.ID == 2 && (s.Output != "DP-1" || !w.Focused || w.Rect.X == 0) {
475				t.Fatalf("%s %+v", s.Output, w)
476			}
477		}
478	}
479}
480
481func TestPointerFocusesOutput(t *testing.T) {
482	r := startMulti(t, nil, left, right)
483	r.input <- ports.PointerMotion{X: 250, Y: 50}
484	receive(t, r.scenes)
485	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
486		t.Fatal(out.Focused)
487	}
488	// New windows open where the pointer is.
489	if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
490		t.Fatal(got)
491	}
492}
493
494// The first terminal cannot launch before wayland receives its output layout.
495func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
496	cfg := config.Defaults()
497	commands := make(chan ports.ClientCommand)
498	// The launcher handoff is deliberately nonblocking. A ready buffered
499	// queue tests ordering without requiring core to wait for this reader.
500	spawn := make(chan ports.SpawnRequest, 1)
501	output := make(chan ports.OutputEvent, 1)
502	scenes := make(chan []ports.Scene, 1)
503	state := make(chan ports.State, 1)
504	c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state, Terminal: true})
505	if err != nil {
506		t.Fatal(err)
507	}
508	ctx, cancel := context.WithCancel(context.Background())
509	done := make(chan error, 1)
510	go func() { done <- c.Run(ctx) }()
511	t.Cleanup(func() { cancel(); receive(t, done) })
512	receive(t, state) // startup finished; next command belongs to the output event
513	output <- ports.OutputAdded{Info: left}
514	select {
515	case v := <-commands:
516		if o, ok := v.(ports.SetOutputs); !ok || len(o.Outputs) != 1 || o.Outputs[0].Info.Name != left.Name {
517			t.Fatalf("first command: %v", v)
518		}
519	case req := <-spawn:
520		t.Fatalf("terminal spawned before SetOutputs: %v", req)
521	case <-time.After(time.Second):
522		t.Fatal("no output command or spawn")
523	}
524	if v, ok := receive(t, commands).(ports.SlotsPending); !ok || !v.Pending {
525		t.Fatalf("expected pending before spawn, got %v", v)
526	}
527	if req := receive(t, spawn); len(req.Argv) == 0 {
528		t.Fatal(req)
529	}
530	receive(t, scenes)
531}
532
533func TestTerminalAutoOpenFirstOnly(t *testing.T) {
534	r := startRig(t, true, nil, left)
535	first := receive(t, r.spawn)
536	if first.Argv[0] != "foot" {
537		t.Fatal(first)
538	}
539	r.key(t, "Next", ports.ModAlt)
540	if len(r.spawn) != 0 {
541		t.Fatal("opened a terminal on the second workspace")
542	}
543}
544
545func TestTerminalAutoOpenOff(t *testing.T) {
546	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "off" }, left)
547	if len(r.spawn) != 0 {
548		t.Fatal("opened a terminal with auto-open off")
549	}
550	r.key(t, "Return", ports.ModAlt)
551	if req := receive(t, r.spawn); req.Argv[0] != "foot" || len(req.Env) != 0 {
552		t.Fatal(req)
553	}
554}
555
556func TestTerminalAutoOpenFirstSkipsPopulatedInitialWorkspace(t *testing.T) {
557	r := startRig(t, false, nil, left)
558	r.mapWindow(t, 1)
559	r.cfg.Terminal.AutoOpen = "first"
560	r.reload <- ports.ConfigChanged{Config: r.cfg}
561	receive(t, r.scenes)
562	r.key(t, "Next", ports.ModAlt)
563	if len(r.spawn) != 0 {
564		t.Fatal("opened a terminal after the initial workspace opportunity passed")
565	}
566}
567
568func TestTerminalAutoOpenFirstDoesNotTargetLaterWorkspace(t *testing.T) {
569	r := startRig(t, false, nil, left)
570	r.key(t, "Next", ports.ModAlt)
571	r.cfg.Terminal.AutoOpen = "first"
572	r.reload <- ports.ConfigChanged{Config: r.cfg}
573	receive(t, r.scenes)
574	if len(r.spawn) != 0 {
575		t.Fatal("opened the first terminal outside workspace 1")
576	}
577}
578
579func TestTerminalAutoOpenFirstSurvivesReloadBeforeOutput(t *testing.T) {
580	r := startRig(t, true, nil)
581	r.reload <- ports.ConfigChanged{Config: r.cfg}
582	receive(t, r.scenes)
583	r.plug(t, left)
584	if req := receive(t, r.spawn); req.Argv[0] != "foot" {
585		t.Fatal(req)
586	}
587}
588
589func TestTerminalAutoOpenAllThenFirstDoesNotOpenAgain(t *testing.T) {
590	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
591	req := receive(t, r.spawn)
592	token := req.Env[0][len(ports.SlotEnv)+1:]
593	r.client <- ports.WindowMapped{ID: 1, Slot: token}
594	receive(t, r.scenes)
595	r.cfg.Terminal.AutoOpen = "first"
596	r.reload <- ports.ConfigChanged{Config: r.cfg}
597	receive(t, r.scenes)
598	r.client <- ports.WindowUnmapped{ID: 1}
599	receive(t, r.scenes)
600	if len(r.spawn) != 0 {
601		t.Fatal("opened a second automatic terminal after all-to-first reload")
602	}
603}
604
605func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
606	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left, right)
607	// One terminal per output, each tagged for its workspace.
608	var reqs []ports.SpawnRequest
609	for len(reqs) < 2 {
610		reqs = append(reqs, receive(t, r.spawn))
611	}
612	if len(r.spawn) != 0 || reqs[0].Argv[0] != "foot" || reqs[0].Env[0] == reqs[1].Env[0] {
613		t.Fatal(reqs, len(r.spawn))
614	}
615	token := func(req ports.SpawnRequest) string { return req.Env[0][len(ports.SlotEnv)+1:] }
616	// The focused screen is DP-2 now; DP-1's terminal still lands on DP-1.
617	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
618	r.client <- ports.WindowMapped{ID: 1, Slot: token(reqs[0])}
619	r.client <- ports.WindowMapped{ID: 2, Slot: token(reqs[1])}
620	receive(t, r.scenes)
621	set := receive(t, r.scenes)
622	if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {
623		t.Fatal(got)
624	}
625	// A terminal that exits right after mapping is not respawned at once
626	// (no loop for a broken terminal command).
627	r.client <- ports.WindowUnmapped{ID: 2}
628	receive(t, r.scenes)
629	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
630	if len(r.spawn) != 0 {
631		t.Fatal("respawned at once")
632	}
633}
634
635func TestLastColumnMovesToNeighborOutput(t *testing.T) {
636	r := startRig(t, true, nil, left, right)
637	r.mapWindow(t, 1)
638	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
639	if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
640		t.Fatal(got)
641	}
642}
643
644func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
645	r := startMulti(t, nil, left, right)
646	r.mapWindow(t, 1)
647	r.input <- ports.PointerMotion{X: 10, Y: 10}
648	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
649	// Jitter on DP-1 keeps DP-2 focused: the next window opens there.
650	r.input <- ports.PointerMotion{X: 11, Y: 10}
651	if got := shown(r.mapWindow(t, 2)); len(got["DP-2"]) != 1 {
652		t.Fatal(got)
653	}
654	// Entering DP-1 again focuses it.
655	r.input <- ports.PointerMotion{X: 250, Y: 10}
656	r.input <- ports.PointerMotion{X: 10, Y: 10}
657	receive(t, r.scenes)
658	if got := shown(r.mapWindow(t, 3)); len(got["DP-1"]) != 2 {
659		t.Fatal(got)
660	}
661}
662
663func TestStateSnapshot(t *testing.T) {
664	r := startMulti(t, nil, left, right)
665	r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
666	receive(t, r.scenes)
667	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
668	<-r.state // the channel holds the latest snapshot only
669	r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
670	receive(t, r.scenes)
671	// Snapshots from before window 2 may still be queued: wait for it.
672	st := receive(t, r.state)
673	for len(st.Windows) < 2 {
674		st = receive(t, r.state)
675	}
676	want := ports.State{
677		Output:  "DP-2",
678		Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
679		Windows: []ports.WindowState{
680			{ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
681			{ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
682		},
683	}
684	want.Window = &want.Windows[1]
685	if !reflect.DeepEqual(st, want) {
686		t.Fatalf("got  %+v\nwant %+v", st, want)
687	}
688	// Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
689	r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
690	receive(t, r.scenes)
691	st = receive(t, r.state)
692	// The window keeps its workspace ID; the output now shows the new one.
693	if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
694		t.Fatalf("%+v", st)
695	}
696}
697
698func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
699	r := startMulti(t, func(c *ports.Config) {
700		c.Workspaces = []ports.WorkspaceConfig{{Name: "notes"}}
701		c.Binds["Alt+n"] = "workspace notes"
702	}, left)
703	r.client <- ports.WindowMapped{ID: 1, PID: 100}
704	receive(t, r.scenes)
705	<-r.state
706	// An app ID set after map reaches the state.
707	r.client <- ports.WindowAppID{ID: 1, AppID: "foot"}
708	receive(t, r.scenes)
709	// Snapshots from before the app ID may still be queued.
710	for st := receive(t, r.state); st.Windows[0].AppID != "foot"; st = receive(t, r.state) {
711	}
712	// A hidden workspace on screen: no active number, its name, window 1 off screen.
713	r.key(t, "n", ports.ModAlt)
714	st := receive(t, r.state)
715	if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
716		t.Fatalf("%+v", st)
717	}
718	// The hidden workspace has its own ID, and the window keeps the one of
719	// the workspace it is on.
720	if st.Outputs[0].WorkspaceID == 0 || st.Windows[0].WorkspaceID == 0 || st.Outputs[0].WorkspaceID == st.Windows[0].WorkspaceID {
721		t.Fatalf("workspace IDs: %+v", st)
722	}
723	// A window on the configured workspace carries its name; the ID string
724	// of ext-workspace and the state file is built from it.
725	r.client <- ports.WindowMapped{ID: 2, PID: 200}
726	receive(t, r.scenes)
727	for st = receive(t, r.state); len(st.Windows) < 2; st = receive(t, r.state) {
728	}
729	if st.Windows[0].WorkspaceName != "" || st.Windows[1].WorkspaceName != "notes" || st.Windows[1].WorkspaceID != st.Outputs[0].WorkspaceID {
730		t.Fatalf("workspace names: %+v", st.Windows)
731	}
732}
733
734func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
735	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
736	r.mapWindow(t, 1)
737	r.mapWindow(t, 2)
738	// Window 2 goes to DP-2, alone, and goes fullscreen in place.
739	r.key(t, "Right", ports.ModAlt|ports.ModShift)
740	r.key(t, "f", ports.ModAlt|ports.ModShift)
741	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
742	// Window 1 joins from DP-1: the fullscreen ends so it is visible and focused.
743	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
744	for _, s := range set {
745		for _, w := range s.Windows {
746			if w.ID == 1 && (s.Output != "DP-2" || w.Hidden || !w.Focused) {
747				t.Fatalf("%s %+v", s.Output, w)
748			}
749		}
750	}
751}
752
753// windowsOf lists the tiled windows of an output in scene order, with the
754// focused one.
755func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
756	for _, s := range set {
757		if s.Output != out {
758			continue
759		}
760		for _, w := range s.Windows {
761			if !w.Hidden {
762				ids = append(ids, w.ID)
763			}
764			if w.Focused {
765				focused = w.ID
766			}
767		}
768	}
769	return ids, focused
770}
771
772func TestExpelCrossesToFullNeighbor(t *testing.T) {
773	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
774	// DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
775	// [1] [2 3], focus on 3 at the bottom of the edge column.
776	r.mapWindow(t, 1)
777	r.mapWindow(t, 2)
778	r.mapWindow(t, 3)
779	r.key(t, "bracketleft", ports.ModAlt)
780	// DP-2: [4] [5], full too.
781	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
782	r.mapWindow(t, 4)
783	r.mapWindow(t, 5)
784	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
785	// Expel right at the edge: 3 stacks under 4, the first column of DP-2.
786	set := r.key(t, "bracketright", ports.ModAlt)
787	if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
788		t.Fatal(got)
789	}
790	got, focused := windowsOf(set, "DP-2")
791	if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
792		t.Fatal(got, focused)
793	}
794	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
795		t.Fatal(out.Focused)
796	}
797	// 3 is not in the edge column of DP-2: it stacks into [5].
798	set = r.key(t, "bracketright", ports.ModAlt)
799	if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
800		t.Fatal(got, focused)
801	}
802	// Now at the edge with no right monitor: nothing moves.
803	set = r.key(t, "bracketright", ports.ModAlt)
804	if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
805		t.Fatal(got, focused)
806	}
807}
808
809func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) {
810	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
811	// DP-1: [1] [2 3], focus on 3.
812	r.mapWindow(t, 1)
813	r.mapWindow(t, 2)
814	r.mapWindow(t, 3)
815	r.key(t, "bracketleft", ports.ModAlt)
816	// DP-2: [4] [5]; 5 goes fullscreen.
817	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
818	r.mapWindow(t, 4)
819	r.mapWindow(t, 5)
820	r.key(t, "f", ports.ModAlt|ports.ModShift)
821	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
822	// 3 joins as a tile: fullscreen ends so it is seen, focused.
823	set := r.key(t, "bracketright", ports.ModAlt)
824	got, focused := windowsOf(set, "DP-2")
825	if !slices.Contains(got, 3) || !slices.Contains(got, 4) || focused != 3 {
826		t.Fatal(got, focused)
827	}
828}
829
830func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) {
831	r := startMulti(t, func(c *ports.Config) {
832		c.Outputs = []ports.OutputConfig{{Name: "DP-1", Pos: &image.Point{X: 20, Y: -120}}, {Name: "DP-2", Pos: &image.Point{X: 20, Y: -120}}}
833	}, left, right)
834	out := lastOutputs(t, r.commands)
835	if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-1" || out.Outputs[1].Info.Name != "DP-2" {
836		t.Fatalf("ordering: %+v", out.Outputs)
837	}
838	for _, o := range out.Outputs {
839		if o.X != 20 || o.Y != -120 {
840			t.Fatalf("explicit position: %+v", o)
841		}
842	}
843}
844
845func TestTerminalTokenClaimedOnce(t *testing.T) {
846	// Plug DP-1 first so its request is unambiguously first. Then plug
847	// DP-2 and focus it before either window maps.
848	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
849	first := receive(t, r.spawn)
850	r.plug(t, right)
851	second := receive(t, r.spawn)
852	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
853	r.client <- ports.WindowMapped{ID: 41, Slot: token(t, first), Floating: true, Width: 20, Height: 20}
854	receive(t, r.scenes)
855	r.client <- ports.WindowMapped{ID: 42, Slot: token(t, first)}
856	set := receive(t, r.scenes)
857	if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
858		t.Fatalf("first claim: %v", got)
859	}
860	// Move back to DP-1: the duplicate must follow focus, not the token.
861	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
862	r.client <- ports.WindowMapped{ID: 43, Slot: token(t, first)}
863	set = receive(t, r.scenes)
864	if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
865		t.Fatalf("duplicate claim: %v", got)
866	}
867	// The other terminal's token remains available despite the first claim.
868	r.client <- ports.WindowMapped{ID: 44, Slot: token(t, second)}
869	set = receive(t, r.scenes)
870	if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{44, 41}) {
871		t.Fatalf("second claim: %v", got)
872	}
873}
874
875// From a focused float, the first column focus move leaves the float for
876// the columns, even when the column has no neighbor on that side: it does
877// not jump to the neighbor monitor.
878func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
879	r := startMulti(t, nil, left, right)
880	r.mapWindow(t, 1)
881	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
882	receive(t, r.scenes)
883	set := r.key(t, "Right", ports.ModAlt)
884	var focused []ports.WindowID
885	for _, s := range set {
886		for _, w := range s.Windows {
887			if w.Focused {
888				focused = append(focused, w.ID)
889				if s.Output != "DP-1" {
890					t.Fatalf("focused %d on %s", w.ID, s.Output)
891				}
892			}
893		}
894	}
895	if !slices.Equal(focused, []ports.WindowID{1}) {
896		t.Fatalf("focused %v", focused)
897	}
898}
899
900// The stash end to end: binds stash tiles, peeking neighbors are dimmed
901// and their popups wait, a click selects a peek, and the script state
902// tells the stash and whether it is hidden.
903func TestStashEndToEnd(t *testing.T) {
904	r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
905	for id := ports.WindowID(1); id <= 3; id++ {
906		r.mapWindow(t, id)
907	}
908	r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
909	r.key(t, "Left", ports.ModAlt)             // stays on 3: its stash's only window
910	sc := r.key(t, "s", ports.ModAlt)
911	if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
912		t.Fatalf("hidden stash: %v", got)
913	}
914	r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1
915	sc = r.key(t, "s", ports.ModAlt|ports.ModShift)
916	// Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
917	var peek, sel ports.SceneWindow
918	for _, w := range sc[0].Windows {
919		switch w.ID {
920		case 3:
921			peek = w
922		case 1:
923			sel = w
924		}
925	}
926	if peek.Hidden || peek.Dim != 0.5 || !peek.Floating || sel.Dim != 0 || sel.Hidden || !sel.Focused || !sel.Floating {
927		t.Fatalf("peek %+v selected %+v", peek, sel)
928	}
929	// A popup of the peek waits until the peek is selected.
930	r.client <- ports.PopupRequest{ID: 9, Parent: 3, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
931	r.client <- ports.PopupMapped{ID: 9}
932	sc = receive(t, r.scenes)
933	for _, w := range sc[0].Windows {
934		if w.ID == 9 {
935			t.Fatalf("popup of a peek drawn: %+v", w)
936		}
937	}
938	// A click on the peek (x 0..19 of 200) selects it.
939	r.input <- ports.PointerMotion{X: 5, Y: 50}
940	r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
941	receive(t, r.scenes)
942	st := receive(t, r.state)
943	for st.Window == nil || st.Window.ID != 3 {
944		st = receive(t, r.state)
945	}
946	byID := map[ports.WindowID]ports.WindowState{}
947	for _, w := range st.Windows {
948		byID[w.ID] = w
949	}
950	if w := byID[3]; !w.Floating || w.StashIndex != 1 || w.StashCount != 2 || w.Hidden || !w.Visible {
951		t.Fatalf("window 3 %+v", w)
952	}
953	if w := byID[2]; w.Floating || w.StashIndex != 0 || w.Hidden {
954		t.Fatalf("tile %+v", w)
955	}
956	r.input <- ports.PointerButton{Button: 0x110}
957	r.key(t, "s", ports.ModAlt)
958	st = receive(t, r.state)
959	for !st.Windows[0].Hidden {
960		st = receive(t, r.state)
961	}
962	for _, w := range st.Windows {
963		if (w.StashIndex > 0) != w.Hidden {
964			t.Fatalf("after hide %+v", w)
965		}
966	}
967}
968
969// stateAfter waits for the published state that ok accepts, or fails:
970// snapshots from before an event may still be queued.
971func stateAfter(t *testing.T, ch <-chan ports.State, ok func(ports.State) bool) ports.State {
972	t.Helper()
973	deadline := time.After(time.Second)
974	for {
975		select {
976		case st := <-ch:
977			if ok(st) {
978				return st
979			}
980		case <-deadline:
981			t.Fatal("no matching state")
982			return ports.State{}
983		}
984	}
985}
986
987// At the ends of the stash, focus moves stay there: no hop to the
988// neighbor monitor or workspace.
989func TestStashKeepsFocusAtEdges(t *testing.T) {
990	r := startMulti(t, nil, left, right)
991	r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
992	r.mapWindow(t, 1)
993	r.mapWindow(t, 2)
994	r.key(t, "s", ports.ModAlt|ports.ModShift)
995	for _, k := range []string{"Right", "Down"} {
996		r.key(t, k, ports.ModAlt)
997	}
998	// A marker after the keys: its state follows theirs.
999	r.mapWindow(t, 9)
1000	st := stateAfter(t, r.state, func(st ports.State) bool { return len(st.Windows) == 3 })
1001	if st.Output != "DP-1" || st.Outputs[0].Active != 1 {
1002		t.Fatalf("output %s active %d", st.Output, st.Outputs[0].Active)
1003	}
1004}
1005
1006// A focused native dialog: focus-column first leaves the dialog for the
1007// tiles below, it does not hop to the neighbor monitor.
1008func TestDialogFocusStaysOnMonitor(t *testing.T) {
1009	r := startMulti(t, nil, left, right)
1010	r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1011	r.mapWindow(t, 1)
1012	r.client <- ports.WindowMapped{ID: 3, Floating: true, Width: 10, Height: 10}
1013	receive(t, r.scenes)
1014	r.key(t, "Right", ports.ModAlt)
1015	st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window == nil || st.Window.ID != 3 })
1016	if st.Output != "DP-1" || st.Window == nil || st.Window.ID != 1 {
1017		t.Fatalf("output %s window %+v", st.Output, st.Window)
1018	}
1019}
1020
1021// A click on a tile behind the shown stash hides the stash and focuses
1022// the tile.
1023func TestStashHidesOnBackgroundClick(t *testing.T) {
1024	// Gap 10 at 80%: no peeks, the margins show the tile behind.
1025	r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left)
1026	r.mapWindow(t, 1)
1027	r.mapWindow(t, 2)
1028	r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1
1029	r.input <- ports.PointerMotion{X: 5, Y: 50}
1030	r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1031	st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })
1032	for _, w := range st.Windows {
1033		if w.ID == 2 && !w.Hidden {
1034			t.Fatalf("stash still shown: %+v", w)
1035		}
1036	}
1037}
1038
1039// A peek narrower than its border is still clickable.
1040func TestStashThinPeekClick(t *testing.T) {
1041	// 200 wide, 80%: a 20px margin, 18px of gap, 2px of the peek.
1042	r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left)
1043	r.mapWindow(t, 1)
1044	r.mapWindow(t, 2)
1045	r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
1046	r.key(t, "s", ports.ModAlt)
1047	r.key(t, "Left", ports.ModAlt)
1048	r.key(t, "s", ports.ModAlt|ports.ModShift) // stash 2 1, 2 peeks 2px from the left
1049	r.input <- ports.PointerMotion{X: 1, Y: 50}
1050	for {
1051		if v, ok := (<-r.commands).(ports.PointerFocus); ok {
1052			if v.ID != 2 {
1053				t.Fatalf("pointer focus %+v, want the peek", v)
1054			}
1055			break
1056		}
1057	}
1058}
1059
1060func TestNamedWorkspaceWithoutHomeFollowsFocus(t *testing.T) {
1061	r := startMulti(t, func(c *ports.Config) {
1062		c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}}
1063		c.Binds["Alt+w"] = "workspace web"
1064	}, left, right)
1065	// Called from DP-2, web comes to DP-2.
1066	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1067	r.key(t, "w", ports.ModAlt)
1068	if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
1069		t.Fatal(got)
1070	}
1071	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-2"]) != 0 {
1072		t.Fatal(got)
1073	}
1074	// Called from DP-1, it moves there with its window.
1075	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
1076	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 0 {
1077		t.Fatal(got)
1078	}
1079}
1080
1081func TestNamedWorkspaceWithHomeFocusesIt(t *testing.T) {
1082	r := startMulti(t, func(c *ports.Config) {
1083		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
1084		c.Binds["Alt+w"] = "workspace web"
1085	}, left, right)
1086	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1087	r.key(t, "w", ports.ModAlt)
1088	// The focus went to DP-1: the window opens there.
1089	if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
1090		t.Fatal(got)
1091	}
1092}
1093
1094func TestNamedGuestFollowsFocusAndReturnsHome(t *testing.T) {
1095	r := startMulti(t, func(c *ports.Config) {
1096		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}}
1097		c.Binds["Alt+w"] = "workspace web"
1098	}, left, right)
1099	// DP-3 is not plugged: web follows the focus to DP-2.
1100	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1101	r.key(t, "w", ports.ModAlt)
1102	if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
1103		t.Fatal(got)
1104	}
1105	r.key(t, "w", ports.ModAlt)
1106	// DP-3 arrives: web, not on screen, goes home with no bind.
1107	r.plug(t, third)
1108	st := receive(t, r.state)
1109	for len(st.Outputs) < 3 {
1110		st = receive(t, r.state)
1111	}
1112	if len(st.Windows) != 1 || st.Windows[0].Output != "DP-3" {
1113		t.Fatalf("%+v", st.Windows)
1114	}
1115}
1116
1117func TestNamedGuestOnScreenGoesHomeOnBind(t *testing.T) {
1118	r := startMulti(t, func(c *ports.Config) {
1119		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}}
1120		c.Binds["Alt+w"] = "workspace web"
1121	}, left, right)
1122	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1123	r.key(t, "w", ports.ModAlt)
1124	r.mapWindow(t, 1)
1125	// DP-3 arrives while web is on screen on DP-2: the bind takes it home.
1126	r.plug(t, third)
1127	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-3"]) != 1 || len(got["DP-2"]) != 0 {
1128		t.Fatal(got)
1129	}
1130}
1131
1132func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) {
1133	r := startMulti(t, func(c *ports.Config) {
1134		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
1135		c.Binds["Alt+w"] = "workspace web"
1136	}, left, right)
1137	r.key(t, "w", ports.ModAlt)
1138	r.mapWindow(t, 1)
1139	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1140	// Reaching web from DP-2 focuses it; it does not hide it.
1141	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 {
1142		t.Fatal(got)
1143	}
1144	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 {
1145		t.Fatal(got)
1146	}
1147}
1148
1149// From a fullscreen window, focus-column-left/right leaves fullscreen for
1150// the column on that side; at the workspace edge it goes to the neighbor
1151// monitor and the window stays fullscreen.
1152func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) {
1153	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
1154	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1155	r.mapWindow(t, 1)
1156	r.mapWindow(t, 2)
1157	r.key(t, "f", ports.ModAlt|ports.ModShift)
1158	// Right edge: DP-2 has no neighbor there, the game stays fullscreen.
1159	if got, focused := windowsOf(r.key(t, "Right", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1160		t.Fatal("edge:", got, focused)
1161	}
1162	// Left: window 1 is there, fullscreen ends.
1163	if got, focused := windowsOf(r.key(t, "Left", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 1 {
1164		t.Fatal("left:", got, focused)
1165	}
1166	// Fullscreen on window 1, the left edge: DP-1 gets the focus and
1167	// window 1 stays fullscreen on DP-2.
1168	r.key(t, "f", ports.ModAlt|ports.ModShift)
1169	set := r.key(t, "Left", ports.ModAlt)
1170	if got, _ := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1}) {
1171		t.Fatal("monitor edge:", got)
1172	}
1173	if got, _ := windowsOf(r.mapWindow(t, 3), "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{3}) {
1174		t.Fatal("focus not on DP-1:", got)
1175	}
1176}

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