main f2d166b

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/core"
12	"github.com/bnema/neferwl/internal/ports"
13)
14
15type multiRig struct {
16	client   chan ports.ClientEvent
17	input    chan ports.InputEvent
18	output   chan ports.OutputEvent
19	reload   chan ports.ConfigChanged
20	commands chan ports.ClientCommand
21	scenes   chan []ports.Scene
22	spawn    chan ports.SpawnRequest
23	state    chan ports.State
24	cfg      ports.Config
25}
26
27var (
28	left  = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100}
29	right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200}
30	third = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "C", Serial: "3", Width: 300, Height: 100}
31)
32
33// startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
34// the given outputs in order.
35func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
36	t.Helper()
37	return startRig(t, false, edit, outs...)
38}
39
40// startRig is startMulti; terminal enables the configured automatic-open policy.
41func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
42	t.Helper()
43	return startRigWith(t, core.Options{Terminal: terminal}, edit, outs...)
44}
45
46// startRigWith is startRig with explicit core options.
47func startRigWith(t *testing.T, opts core.Options, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
48	t.Helper()
49	cfg := altCmdDefaults()
50	cfg.Border.Width = 0
51	if edit != nil {
52		edit(&cfg)
53	}
54	r := &multiRig{
55		client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
56		output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
57		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,
58	}
59	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}, opts)
60	if err != nil {
61		t.Fatal(err)
62	}
63	ctx, cancel := context.WithCancel(context.Background())
64	done := make(chan error, 1)
65	go func() { done <- c.Run(ctx) }()
66	t.Cleanup(func() { cancel(); <-done })
67	for _, o := range outs {
68		r.plug(t, o)
69	}
70	return r
71}
72
73func (r *multiRig) plug(t *testing.T, o ports.OutputInfo) []ports.Scene {
74	t.Helper()
75	r.output <- ports.OutputAdded{Info: o}
76	return receive(t, r.scenes)
77}
78
79func (r *multiRig) key(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
80	t.Helper()
81	r.input <- ports.KeyEvent{Keysym: sym, Mods: mods, Pressed: true}
82	s := receive(t, r.scenes)
83	r.input <- ports.KeyEvent{Keysym: sym, Mods: mods}
84	return s
85}
86
87func (r *multiRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
88	t.Helper()
89	r.client <- ports.WindowMapped{ID: id}
90	return receive(t, r.scenes)
91}
92
93// shown returns the visible window IDs of each output, by connector.
94func shown(set []ports.Scene) map[string][]ports.WindowID {
95	out := map[string][]ports.WindowID{}
96	for _, s := range set {
97		out[s.Output] = []ports.WindowID{}
98		for _, w := range s.Windows {
99			if !w.Hidden {
100				out[s.Output] = append(out[s.Output], w.ID)
101			}
102		}
103	}
104	return out
105}
106
107func lastOutputs(t *testing.T, ch chan ports.ClientCommand) ports.SetOutputs {
108	t.Helper()
109	var last ports.SetOutputs
110	found := false
111	for len(ch) > 0 {
112		if v, ok := (<-ch).(ports.SetOutputs); ok {
113			last, found = v, true
114		}
115	}
116	if !found {
117		t.Fatal("no SetOutputs")
118	}
119	return last
120}
121
122func TestOutputsLeftToRight(t *testing.T) {
123	r := startMulti(t, func(c *ports.Config) {
124		c.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}, {Name: "DP-1"}}
125	}, left, right)
126	out := lastOutputs(t, r.commands)
127	// Config order wins: DP-2 (200x100 logical at scale 2), then DP-1.
128	if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-2" || out.Outputs[0].X != 0 || out.Outputs[0].Width != 200 ||
129		out.Outputs[1].Info.Name != "DP-1" || out.Outputs[1].X != 200 || out.Focused != "DP-1" {
130		t.Fatalf("%+v", out)
131	}
132}
133
134func TestLayoutPerOutput(t *testing.T) {
135	both(t, func(t *testing.T, animated bool) {
136		r := startLanding(t, animated, func(c *ports.Config) {
137			c.Layout.MaxColumns = 2
138			c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}}
139		}, left, right)
140		layoutPerOutput(t, r)
141	})
142}
143
144func layoutPerOutput(t *testing.T, r *landRig) {
145	r.mapLanded(t, 1)
146	r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
147	var set []ports.Scene
148	for id := ports.WindowID(2); id <= 6; id++ {
149		set = r.mapLanded(t, id)
150	}
151	// DP-2: four columns of 100; the fifth window splits the last one
152	// (fixed) instead of scrolling.
153	if got := shown(set); len(got["DP-2"]) != 5 {
154		t.Fatal(got)
155	}
156	for _, s := range set {
157		for _, w := range s.Windows {
158			// DP-1 keeps the default: one window of two columns fills it.
159			if want := map[string]int{"DP-1": 200, "DP-2": 100}[s.Output]; w.Rect.W != want {
160				t.Fatalf("%s: %+v", s.Output, w)
161			}
162		}
163	}
164}
165
166// widths returns the window widths on one output.
167func widths(set []ports.Scene, output string) []int {
168	var out []int
169	for _, s := range set {
170		for _, w := range s.Windows {
171			if s.Output == output {
172				out = append(out, w.Rect.W)
173			}
174		}
175	}
176	return out
177}
178
179func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {
180	both(t, func(t *testing.T, animated bool) {
181		r := startLanding(t, animated, func(c *ports.Config) {
182			c.Layout.MaxColumns = 1
183			c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}}
184		}, right)
185		layoutPerOutputFollowsMonitorAndReload(t, r)
186	})
187}
188
189func layoutPerOutputFollowsMonitorAndReload(t *testing.T, r *landRig) {
190	r.mapLanded(t, 1)
191	if got := widths(r.mapLanded(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) {
192		t.Fatal(got)
193	}
194	// Another monitor on DP-2: the key rule no longer applies.
195	other := right
196	other.Serial = "3"
197	if got := widths(r.land(r.plug(t, other)), "DP-2"); !slices.Equal(got, []int{400, 400}) {
198		t.Fatal(got)
199	}
200	// Back to the first monitor, then a reload drops the rule.
201	r.land(r.plug(t, right))
202	cfg := r.cfg
203	cfg.Layout.Outputs = nil
204	r.reload <- ports.ConfigChanged{Config: cfg}
205	if got := widths(r.land(receive(t, r.scenes)), "DP-2"); !slices.Equal(got, []int{400, 400}) {
206		t.Fatal(got)
207	}
208}
209
210func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
211	r := startMulti(t, func(c *ports.Config) {
212		c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Pos: &image.Point{X: 100, Y: 40}}}
213	}, left, right)
214	out := lastOutputs(t, r.commands)
215	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 {
216		t.Fatalf("unexpected positions: %+v", out.Outputs)
217	}
218	r.cfg.Outputs[1].Pos = &image.Point{X: -200, Y: 12}
219	r.reload <- ports.ConfigChanged{Config: r.cfg}
220	_ = receive(t, r.scenes)
221	out = lastOutputs(t, r.commands)
222	if out.Outputs[0].X != 200 || out.Outputs[1].X != -200 || out.Outputs[1].Y != 12 {
223		t.Fatalf("unexpected positions after reload: %+v", out.Outputs)
224	}
225}
226
227func TestRelativeOutputPlacement(t *testing.T) {
228	r := startMulti(t, func(c *ports.Config) {
229		c.Outputs = []ports.OutputConfig{
230			{Name: "DP-1"},
231			{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1", Offset: 30}},
232			{Name: "DP-3", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-2", Offset: -10}},
233		}
234	}, left, right, third)
235	positions := func() [][2]int {
236		t.Helper()
237		var got [][2]int
238		for _, o := range lastOutputs(t, r.commands).Outputs {
239			got = append(got, [2]int{o.X, o.Y})
240		}
241		return got
242	}
243	// DP-1 is 200x100, DP-2 400x200 logical.
244	if got, want := positions(), [][2]int{{0, 0}, {30, 100}, {430, 90}}; !slices.Equal(got, want) {
245		t.Fatalf("positions %v, want %v", got, want)
246	}
247	// A reload with another offset moves the output.
248	r.cfg.Outputs[1].Anchor.Offset = -50
249	r.reload <- ports.ConfigChanged{Config: r.cfg}
250	_ = receive(t, r.scenes)
251	if got, want := positions(), [][2]int{{0, 0}, {-50, 100}, {350, 90}}; !slices.Equal(got, want) {
252		t.Fatalf("positions after offset reload %v, want %v", got, want)
253	}
254	// Scaling the reference down moves the output below it.
255	r.cfg.Outputs[0].Scale = 2
256	r.reload <- ports.ConfigChanged{Config: r.cfg}
257	_ = receive(t, r.scenes)
258	if got, want := positions(), [][2]int{{0, 0}, {-50, 50}, {350, 40}}; !slices.Equal(got, want) {
259		t.Fatalf("positions after scale reload %v, want %v", got, want)
260	}
261}
262
263func TestRelativeOutputWithoutReferenceIsAutomatic(t *testing.T) {
264	r := startMulti(t, func(c *ports.Config) {
265		c.Outputs = []ports.OutputConfig{{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 25}}}
266	}, right)
267	if out := lastOutputs(t, r.commands).Outputs; len(out) != 1 || out[0].X != 0 || out[0].Y != 0 {
268		t.Fatalf("without reference: %+v", out)
269	}
270	// Plugging the reference restores the relation (DP-1 is 200 wide).
271	r.plug(t, left)
272	out := lastOutputs(t, r.commands).Outputs
273	if len(out) != 2 || out[0].Info.Name != "DP-2" || out[1].Info.Name != "DP-1" {
274		t.Fatalf("outputs %+v", out)
275	}
276	if out[0].X != 200 || out[0].Y != 25 || out[1].X != 0 || out[1].Y != 0 {
277		t.Fatalf("placement %+v", out)
278	}
279}
280
281func TestWindowsOpenOnFocusedOutput(t *testing.T) {
282	r := startMulti(t, nil, left, right)
283	r.mapWindow(t, 1)
284	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
285	set := r.mapWindow(t, 2)
286	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
287		t.Fatal(got)
288	}
289	// Only the focused output draws a focused window.
290	for _, s := range set {
291		for _, w := range s.Windows {
292			if w.Focused != (s.Output == "DP-2") {
293				t.Fatalf("%s: %+v", s.Output, w)
294			}
295		}
296	}
297}
298
299func TestFocusColumnCrossesOutputs(t *testing.T) {
300	r := startMulti(t, nil, left, right)
301	r.mapWindow(t, 1)
302	// The only column is the edge: focus moves to the right output.
303	r.key(t, "Right", ports.ModAlt)
304	r.mapWindow(t, 2)
305	for len(r.commands) > 0 {
306		<-r.commands
307	}
308	r.key(t, "Left", ports.ModAlt)
309	var focus ports.WindowID
310	for len(r.commands) > 0 {
311		if v, ok := (<-r.commands).(ports.FocusWindow); ok {
312			focus = v.ID
313		}
314	}
315	if focus != 1 {
316		t.Fatal(focus)
317	}
318}
319
320func TestPointerCrossesOutputs(t *testing.T) {
321	both(t, func(t *testing.T, animated bool) {
322		pointerCrossesOutputs(t, startLanding(t, animated, nil, left, right))
323	})
324}
325
326func pointerCrossesOutputs(t *testing.T, r *landRig) {
327	r.mapLanded(t, 1)
328	r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
329	r.mapLanded(t, 2)
330	for len(r.commands) > 0 {
331		<-r.commands
332	}
333	// DP-2 starts at x 200: global (250, 50) is (50, 50) on window 2.
334	r.input <- ports.PointerMotion{X: 250, Y: 50}
335	if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 50, Y: 50}) {
336		t.Fatal(v)
337	}
338	// Back on DP-1, then below it (100 high): off every output, y stays on DP-1.
339	r.input <- ports.PointerMotion{X: 10, Y: 50}
340	r.input <- ports.PointerMotion{X: 10, Y: 150}
341	for {
342		if v, ok := command(t, r.commands).(ports.PointerMotionTo); ok && v.Y == 99 {
343			if v.ID != 1 || v.X != 10 {
344				t.Fatal(v)
345			}
346			break
347		}
348	}
349	// A click focuses the output under the pointer.
350	r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
351	set := receive(t, r.scenes)
352	for _, s := range set {
353		for _, w := range s.Windows {
354			if w.Focused != (w.ID == 1) {
355				t.Fatalf("%s: %+v", s.Output, w)
356			}
357		}
358	}
359}
360
361func TestMoveWorkspaceToMonitor(t *testing.T) {
362	r := startMulti(t, nil, left, right)
363	r.mapWindow(t, 1)
364	set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
365	if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
366		t.Fatal(got)
367	}
368	// It is DP-2's now: unplugging DP-1 does not move it.
369	r.output <- ports.OutputRemoved{Name: "DP-1"}
370	set = receive(t, r.scenes)
371	if len(set) != 1 || set[0].Output != "DP-2" || len(shown(set)["DP-2"]) != 1 {
372		t.Fatal(shown(set))
373	}
374}
375
376func TestMonitorActionsFollowGeometry(t *testing.T) {
377	r := startMulti(t, func(c *ports.Config) {
378		c.Outputs = []ports.OutputConfig{
379			{Name: "DP-1"},
380			{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
381		}
382		// Without a default bind; these replace move-workspace-prev/next.
383		c.Binds["Cmd+Ctrl+Shift+Up"] = string(core.ActionMoveWorkspaceToMonitorUp)
384		c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown)
385	}, left, right)
386	// SetOutputs is sent only when the focus or layout changed.
387	current := lastOutputs(t, r.commands).Focused
388	focused := func() string {
389		for len(r.commands) > 0 {
390			if v, ok := (<-r.commands).(ports.SetOutputs); ok {
391				current = v.Focused
392			}
393		}
394		return current
395	}
396	cmdCtrl := ports.ModAlt | ports.ModCtrl
397	// DP-1 is focused and has no neighbor left, right or up.
398	r.mapWindow(t, 1)
399	for _, k := range []string{"Left", "Right", "Up"} {
400		r.key(t, k, cmdCtrl)
401		if got := focused(); got != "DP-1" {
402			t.Fatalf("%s moved focus to %s", k, got)
403		}
404	}
405	r.key(t, "Down", cmdCtrl)
406	if got := focused(); got != "DP-2" {
407		t.Fatalf("focus-monitor-down: %s", got)
408	}
409	r.key(t, "k", cmdCtrl)
410	if got := focused(); got != "DP-1" {
411		t.Fatalf("focus-monitor-up (vim twin): %s", got)
412	}
413	// The workspace goes to the monitor below and focus follows it.
414	set := r.key(t, "Down", cmdCtrl|ports.ModShift)
415	if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
416		t.Fatal(got)
417	}
418	set = r.key(t, "Up", cmdCtrl|ports.ModShift)
419	if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
420		t.Fatalf("move-workspace-to-monitor-up: %v", got)
421	}
422}
423
424func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
425	both(t, func(t *testing.T, animated bool) {
426		r := startLanding(t, animated, nil, left, right)
427		r.mapLanded(t, 1)
428		r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
429		r.mapLanded(t, 2)
430		// Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
431		r.output <- ports.OutputRemoved{Name: "DP-2"}
432		set := r.land(receive(t, r.scenes))
433		if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
434			t.Fatal(shown(set))
435		}
436		// Cmd+2 shows the guest.
437		r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
438		set = r.land(receive(t, r.scenes))
439		r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
440		if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
441			t.Fatal(got)
442		}
443		// Replugged while on screen: the guest stays until the user leaves it.
444		set = r.land(r.plug(t, right))
445		if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
446			t.Fatal(got)
447		}
448		r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
449		set = r.land(receive(t, r.scenes))
450		if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
451			t.Fatal(got)
452		}
453	})
454}
455
456func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
457	r := startMulti(t, nil, left, right)
458	r.mapWindow(t, 1)
459	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
460	r.mapWindow(t, 2)
461	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
462	r.output <- ports.OutputRemoved{Name: "DP-2"}
463	receive(t, r.scenes)
464	// Not on screen: it goes home as soon as DP-2 is back.
465	set := r.plug(t, right)
466	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
467		t.Fatal(got)
468	}
469}
470
471func TestNamedWorkspaceHomeChangeAppliesLive(t *testing.T) {
472	r := startMulti(t, func(c *ports.Config) {
473		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
474		c.Binds["Alt+w"] = "workspace web"
475	}, left, right)
476	r.key(t, "w", ports.ModAlt)
477	r.mapWindow(t, 1)
478	r.key(t, "w", ports.ModAlt)
479	r.cfg.Workspaces[0].Monitor = "DP-2"
480	r.reload <- ports.ConfigChanged{Config: r.cfg}
481	receive(t, r.scenes)
482	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
483	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
484		t.Fatal(got)
485	}
486}
487
488func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
489	r := startMulti(t, func(c *ports.Config) {
490		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2"}}
491		c.Binds["Alt+w"] = "workspace web"
492	}, left)
493	// DP-2 is not plugged: web lives on DP-1 as a guest.
494	r.key(t, "w", ports.ModAlt)
495	r.mapWindow(t, 1)
496	r.key(t, "w", ports.ModAlt)
497	// DP-2 arrives: web goes home (not on screen).
498	r.plug(t, right)
499	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
500	set := r.key(t, "w", ports.ModAlt)
501	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
502		t.Fatal(got)
503	}
504}
505
506func TestLayersStayOnTheirOutput(t *testing.T) {
507	r := startMulti(t, nil, left, right)
508	bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, ExclusiveZone: 10, Output: "DP-2"}
509	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
510	set := receive(t, r.scenes)
511	for _, s := range set {
512		if (len(s.Layers) == 1) != (s.Output == "DP-2") {
513			t.Fatalf("%s: %+v", s.Output, s.Layers)
514		}
515	}
516	if set[1].Layers[0].Rect != (ports.Rect{W: 400, H: 10}) {
517		t.Fatal(set[1].Layers)
518	}
519}
520
521func TestScaleBindTargetsFocusedOutput(t *testing.T) {
522	r := startMulti(t, func(c *ports.Config) { c.Binds["Alt+equal"] = "scale-up" }, left, right)
523	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
524	set := r.key(t, "equal", ports.ModAlt)
525	if set[0].Scale != 1 || set[1].Scale == 1 {
526		t.Fatal(set[0].Scale, set[1].Scale)
527	}
528}
529
530func TestLastOutputUnpluggedKeepsWindows(t *testing.T) {
531	r := startMulti(t, nil, left)
532	r.mapWindow(t, 1)
533	r.output <- ports.OutputRemoved{Name: "DP-1"}
534	if set := receive(t, r.scenes); len(set) != 1 || set[0].Output != "" {
535		t.Fatal(shown(set))
536	}
537	if out := lastOutputs(t, r.commands); len(out.Outputs) != 0 {
538		t.Fatalf("%+v", out)
539	}
540	// Another monitor takes the windows over.
541	set := r.plug(t, right)
542	if got := shown(set); len(got) != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
543		t.Fatal(got)
544	}
545}
546
547func TestModeChangeKeepsScreen(t *testing.T) {
548	r := startMulti(t, nil, left, right)
549	r.mapWindow(t, 1)
550	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
551	r.mapWindow(t, 2)
552	// A new mode on DP-2 is only a new size: nothing moves, focus stays.
553	bigger := right
554	bigger.Width, bigger.Height = 800, 400
555	set := r.plug(t, bigger)
556	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 || len(got["DP-1"]) != 1 {
557		t.Fatal(got)
558	}
559	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" || out.Outputs[1].Width != 800 {
560		t.Fatalf("%+v", out)
561	}
562}
563
564func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) {
565	r := startMulti(t, func(c *ports.Config) {
566		c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Primary: true}}
567	}, left, right)
568	out := lastOutputs(t, r.commands)
569	// Placed right of DP-1, but focused.
570	if out.Focused != "DP-2" || out.Outputs[1].Info.Name != "DP-2" || !out.Outputs[1].Primary || out.Outputs[0].Primary {
571		t.Fatalf("%+v", out)
572	}
573	set := r.mapWindow(t, 1)
574	if got := shown(set); len(got["DP-2"]) != 1 {
575		t.Fatal(got)
576	}
577}
578
579func TestMoveColumnCrossesOutputs(t *testing.T) {
580	r := startMulti(t, nil, left, right)
581	r.mapWindow(t, 1)
582	r.mapWindow(t, 2)
583	// Window 2 is the right column of DP-1: moving right first hits the
584	// edge, then the column goes to DP-2 and focus follows it.
585	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
586	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
587		t.Fatal(got)
588	}
589	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
590		t.Fatal(out.Focused)
591	}
592	// Back left (no terminal kept here, so a last column may move): it
593	// lands on the right side of DP-1.
594	set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
595	if got := shown(set)["DP-1"]; len(got) != 2 {
596		t.Fatal(got)
597	}
598	for _, s := range set {
599		for _, w := range s.Windows {
600			if w.ID == 2 && (s.Output != "DP-1" || !w.Focused || w.Rect.X == 0) {
601				t.Fatalf("%s %+v", s.Output, w)
602			}
603		}
604	}
605}
606
607func TestPointerFocusesOutput(t *testing.T) {
608	r := startMulti(t, nil, left, right)
609	r.input <- ports.PointerMotion{X: 250, Y: 50}
610	receive(t, r.scenes)
611	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
612		t.Fatal(out.Focused)
613	}
614	// New windows open where the pointer is.
615	if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
616		t.Fatal(got)
617	}
618}
619
620// The first terminal cannot launch before wayland receives its output layout.
621func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
622	cfg := scrollDefaults()
623	commands := make(chan ports.ClientCommand)
624	// The launcher handoff is deliberately nonblocking. A ready buffered
625	// queue tests ordering without requiring core to wait for this reader.
626	spawn := make(chan ports.SpawnRequest, 1)
627	output := make(chan ports.OutputEvent, 1)
628	scenes := make(chan []ports.Scene, 1)
629	state := make(chan ports.State, 1)
630	c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state}, core.Options{Terminal: true})
631	if err != nil {
632		t.Fatal(err)
633	}
634	ctx, cancel := context.WithCancel(context.Background())
635	done := make(chan error, 1)
636	go func() { done <- c.Run(ctx) }()
637	t.Cleanup(func() { cancel(); receive(t, done) })
638	receive(t, state) // startup finished; next command belongs to the output event
639	output <- ports.OutputAdded{Info: left}
640	select {
641	case v := <-commands:
642		if o, ok := v.(ports.SetOutputs); !ok || len(o.Outputs) != 1 || o.Outputs[0].Info.Name != left.Name {
643			t.Fatalf("first command: %v", v)
644		}
645	case req := <-spawn:
646		t.Fatalf("terminal spawned before SetOutputs: %v", req)
647	case <-time.After(time.Second):
648		t.Fatal("no output command or spawn")
649	}
650	if req := receive(t, spawn); len(req.Argv) == 0 {
651		t.Fatal(req)
652	}
653	receive(t, scenes)
654}
655
656func TestTerminalAutoOpenFirstOnly(t *testing.T) {
657	r := startRig(t, true, nil, left)
658	first := receive(t, r.spawn)
659	if first.Argv[0] != "foot" {
660		t.Fatal(first)
661	}
662	r.key(t, "Next", ports.ModAlt)
663	if len(r.spawn) != 0 {
664		t.Fatal("opened a terminal on the second workspace")
665	}
666}
667
668func TestTerminalAutoOpenOff(t *testing.T) {
669	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "off" }, left)
670	if len(r.spawn) != 0 {
671		t.Fatal("opened a terminal with auto-open off")
672	}
673	r.key(t, "Return", ports.ModAlt)
674	if req := receive(t, r.spawn); req.Argv[0] != "foot" || len(req.Env) != 0 {
675		t.Fatal(req)
676	}
677}
678
679func TestTerminalAutoOpenFirstSkipsPopulatedInitialWorkspace(t *testing.T) {
680	r := startRig(t, false, nil, left)
681	r.mapWindow(t, 1)
682	r.cfg.Terminal.AutoOpen = "first"
683	r.reload <- ports.ConfigChanged{Config: r.cfg}
684	receive(t, r.scenes)
685	r.key(t, "Next", ports.ModAlt)
686	if len(r.spawn) != 0 {
687		t.Fatal("opened a terminal after the initial workspace opportunity passed")
688	}
689}
690
691func TestTerminalAutoOpenFirstDoesNotTargetLaterWorkspace(t *testing.T) {
692	r := startRig(t, false, nil, left)
693	r.key(t, "Next", ports.ModAlt)
694	r.cfg.Terminal.AutoOpen = "first"
695	r.reload <- ports.ConfigChanged{Config: r.cfg}
696	receive(t, r.scenes)
697	if len(r.spawn) != 0 {
698		t.Fatal("opened the first terminal outside workspace 1")
699	}
700}
701
702func TestTerminalAutoOpenFirstSurvivesReloadBeforeOutput(t *testing.T) {
703	r := startRig(t, true, nil)
704	r.reload <- ports.ConfigChanged{Config: r.cfg}
705	receive(t, r.scenes)
706	r.plug(t, left)
707	if req := receive(t, r.spawn); req.Argv[0] != "foot" {
708		t.Fatal(req)
709	}
710}
711
712func TestTerminalAutoOpenAllThenFirstDoesNotOpenAgain(t *testing.T) {
713	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
714	req := receive(t, r.spawn)
715	token := req.Env[0][len(ports.SlotEnv)+1:]
716	r.client <- ports.WindowMapped{ID: 1, Slot: token}
717	receive(t, r.scenes)
718	r.cfg.Terminal.AutoOpen = "first"
719	r.reload <- ports.ConfigChanged{Config: r.cfg}
720	receive(t, r.scenes)
721	r.client <- ports.WindowUnmapped{ID: 1}
722	receive(t, r.scenes)
723	if len(r.spawn) != 0 {
724		t.Fatal("opened a second automatic terminal after all-to-first reload")
725	}
726}
727
728func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
729	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left, right)
730	// One terminal per output, each tagged for its workspace.
731	var reqs []ports.SpawnRequest
732	for len(reqs) < 2 {
733		reqs = append(reqs, receive(t, r.spawn))
734	}
735	if len(r.spawn) != 0 || reqs[0].Argv[0] != "foot" || reqs[0].Env[0] == reqs[1].Env[0] {
736		t.Fatal(reqs, len(r.spawn))
737	}
738	token := func(req ports.SpawnRequest) string { return req.Env[0][len(ports.SlotEnv)+1:] }
739	// The focused screen is DP-2 now; DP-1's terminal still lands on DP-1.
740	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
741	r.client <- ports.WindowMapped{ID: 1, Slot: token(reqs[0])}
742	r.client <- ports.WindowMapped{ID: 2, Slot: token(reqs[1])}
743	receive(t, r.scenes)
744	set := receive(t, r.scenes)
745	if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {
746		t.Fatal(got)
747	}
748	// A terminal that exits right after mapping is not respawned at once
749	// (no loop for a broken terminal command).
750	r.client <- ports.WindowUnmapped{ID: 2}
751	receive(t, r.scenes)
752	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
753	if len(r.spawn) != 0 {
754		t.Fatal("respawned at once")
755	}
756}
757
758// A terminal that exits is not respawned before termRetry has passed on
759// the injected clock, and is once it has.
760func TestTerminalRetryFollowsClock(t *testing.T) {
761	clk := newStepClock(t)
762	r := startRigWith(t, core.Options{Terminal: true, Clock: clk.clock}, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
763	first := receive(t, r.spawn)
764	r.client <- ports.WindowMapped{ID: 1, Slot: token(t, first)}
765	receive(t, r.scenes)
766	r.client <- ports.WindowUnmapped{ID: 1}
767	receive(t, r.scenes)
768	if len(r.spawn) != 0 {
769		t.Fatal("respawned at once")
770	}
771	// churn empties the workspace again, so fillEmpty runs at the clock's time.
772	churn := func(id ports.WindowID) {
773		r.client <- ports.WindowMapped{ID: id}
774		receive(t, r.scenes)
775		r.client <- ports.WindowUnmapped{ID: id}
776		receive(t, r.scenes)
777	}
778	// termRetry is private to core: stay far below and far above it.
779	clk.advance(time.Second)
780	churn(2)
781	if len(r.spawn) != 0 {
782		t.Fatal("respawned before the retry interval")
783	}
784	clk.advance(time.Minute)
785	churn(3)
786	if req := receive(t, r.spawn); req.Argv[0] != "foot" {
787		t.Fatal(req)
788	}
789}
790
791func TestLastColumnMovesToNeighborOutput(t *testing.T) {
792	r := startRig(t, true, nil, left, right)
793	r.mapWindow(t, 1)
794	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
795	if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
796		t.Fatal(got)
797	}
798}
799
800func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
801	r := startMulti(t, nil, left, right)
802	r.mapWindow(t, 1)
803	r.input <- ports.PointerMotion{X: 10, Y: 10}
804	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
805	// Jitter on DP-1 keeps DP-2 focused: the next window opens there.
806	r.input <- ports.PointerMotion{X: 11, Y: 10}
807	if got := shown(r.mapWindow(t, 2)); len(got["DP-2"]) != 1 {
808		t.Fatal(got)
809	}
810	// Entering DP-1 again focuses it.
811	r.input <- ports.PointerMotion{X: 250, Y: 10}
812	r.input <- ports.PointerMotion{X: 10, Y: 10}
813	receive(t, r.scenes)
814	if got := shown(r.mapWindow(t, 3)); len(got["DP-1"]) != 2 {
815		t.Fatal(got)
816	}
817}
818
819func TestStateSnapshot(t *testing.T) {
820	both(t, func(t *testing.T, animated bool) {
821		r := startLanding(t, animated, nil, left, right)
822		r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
823		r.land(receive(t, r.scenes))
824		r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
825		<-r.state // the channel holds the latest snapshot only
826		r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
827		r.land(receive(t, r.scenes))
828		// Snapshots from before window 2 may still be queued: wait for it.
829		st := receive(t, r.state)
830		for len(st.Windows) < 2 {
831			st = receive(t, r.state)
832		}
833		want := ports.State{
834			Output:  "DP-2",
835			Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
836			Windows: []ports.WindowState{
837				{ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true, Column: 1, Row: 1},
838				{ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true, Column: 1, Row: 1},
839			},
840		}
841		want.Workspaces = []ports.WorkspaceState{
842			{ID: 1, Number: 1, Output: "DP-1", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 200, H: 100}, Windows: []ports.TileState{{ID: 1, Rect: ports.Rect{W: 200, H: 100}}}}}},
843			{ID: 3, Number: 2, Output: "DP-1", Overflow: "scroll", Columns: []ports.ColumnState{}},
844			{ID: 2, Number: 1, Output: "DP-2", Visible: true, Overflow: "scroll", Columns: []ports.ColumnState{{Rect: ports.Rect{W: 400, H: 200}, Windows: []ports.TileState{{ID: 2, Rect: ports.Rect{W: 400, H: 200}}}}}},
845			{ID: 4, Number: 2, Output: "DP-2", Overflow: "scroll", Columns: []ports.ColumnState{}},
846		}
847		want.Window = &want.Windows[1]
848		if !reflect.DeepEqual(st, want) {
849			t.Fatalf("got  %+v\nwant %+v", st, want)
850		}
851		// Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
852		r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
853		r.land(receive(t, r.scenes))
854		st = receive(t, r.state)
855		// The window keeps its workspace ID; the output now shows the new one.
856		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 {
857			t.Fatalf("%+v", st)
858		}
859	})
860}
861
862func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
863	r := startMulti(t, func(c *ports.Config) {
864		c.Workspaces = []ports.WorkspaceConfig{{Name: "notes"}}
865		c.Binds["Alt+n"] = "workspace notes"
866	}, left)
867	r.client <- ports.WindowMapped{ID: 1, PID: 100}
868	receive(t, r.scenes)
869	<-r.state
870	// An app ID set after map reaches the state.
871	r.client <- ports.WindowAppID{ID: 1, AppID: "foot"}
872	receive(t, r.scenes)
873	// Snapshots from before the app ID may still be queued.
874	for st := receive(t, r.state); st.Windows[0].AppID != "foot"; st = receive(t, r.state) {
875	}
876	// A hidden workspace on screen: no active number, its name, window 1 off screen.
877	r.key(t, "n", ports.ModAlt)
878	st := receive(t, r.state)
879	if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
880		t.Fatalf("%+v", st)
881	}
882	// The hidden workspace has its own ID, and the window keeps the one of
883	// the workspace it is on.
884	if st.Outputs[0].WorkspaceID == 0 || st.Windows[0].WorkspaceID == 0 || st.Outputs[0].WorkspaceID == st.Windows[0].WorkspaceID {
885		t.Fatalf("workspace IDs: %+v", st)
886	}
887	// A window on the configured workspace carries its name; the ID string
888	// of ext-workspace and the state file is built from it.
889	r.client <- ports.WindowMapped{ID: 2, PID: 200}
890	receive(t, r.scenes)
891	for st = receive(t, r.state); len(st.Windows) < 2; st = receive(t, r.state) {
892	}
893	if st.Windows[0].WorkspaceName != "" || st.Windows[1].WorkspaceName != "notes" || st.Windows[1].WorkspaceID != st.Outputs[0].WorkspaceID {
894		t.Fatalf("workspace names: %+v", st.Windows)
895	}
896}
897
898func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
899	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
900	r.mapWindow(t, 1)
901	r.mapWindow(t, 2)
902	// Window 2 goes to DP-2, alone, and goes fullscreen in place.
903	r.key(t, "Right", ports.ModAlt|ports.ModShift)
904	r.key(t, "f", ports.ModAlt|ports.ModShift)
905	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
906	// Window 1 joins from DP-1: the fullscreen ends so it is visible and focused.
907	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
908	for _, s := range set {
909		for _, w := range s.Windows {
910			if w.ID == 1 && (s.Output != "DP-2" || w.Hidden || !w.Focused) {
911				t.Fatalf("%s %+v", s.Output, w)
912			}
913		}
914	}
915}
916
917// windowsOf lists the tiled windows of an output in scene order, with the
918// focused one.
919func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
920	for _, s := range set {
921		if s.Output != out {
922			continue
923		}
924		for _, w := range s.Windows {
925			if !w.Hidden {
926				ids = append(ids, w.ID)
927			}
928			if w.Focused {
929				focused = w.ID
930			}
931		}
932	}
933	return ids, focused
934}
935
936func TestExpelCrossesToFullNeighbor(t *testing.T) {
937	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
938	// DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
939	// [1] [2 3], focus on 3 at the bottom of the edge column.
940	r.mapWindow(t, 1)
941	r.mapWindow(t, 2)
942	r.mapWindow(t, 3)
943	r.key(t, "bracketleft", ports.ModAlt)
944	// DP-2: [4] [5], full too.
945	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
946	r.mapWindow(t, 4)
947	r.mapWindow(t, 5)
948	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
949	// Expel right at the edge: 3 stacks under 4, the first column of DP-2.
950	set := r.key(t, "bracketright", ports.ModAlt)
951	if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
952		t.Fatal(got)
953	}
954	got, focused := windowsOf(set, "DP-2")
955	if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
956		t.Fatal(got, focused)
957	}
958	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
959		t.Fatal(out.Focused)
960	}
961	// 3 is not in the edge column of DP-2: it stacks into [5].
962	set = r.key(t, "bracketright", ports.ModAlt)
963	if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
964		t.Fatal(got, focused)
965	}
966	// Now at the edge with no right monitor: nothing moves.
967	set = r.key(t, "bracketright", ports.ModAlt)
968	if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
969		t.Fatal(got, focused)
970	}
971}
972
973func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) {
974	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
975	// DP-1: [1] [2 3], focus on 3.
976	r.mapWindow(t, 1)
977	r.mapWindow(t, 2)
978	r.mapWindow(t, 3)
979	r.key(t, "bracketleft", ports.ModAlt)
980	// DP-2: [4] [5]; 5 goes fullscreen.
981	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
982	r.mapWindow(t, 4)
983	r.mapWindow(t, 5)
984	r.key(t, "f", ports.ModAlt|ports.ModShift)
985	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
986	// 3 joins as a tile: fullscreen ends so it is seen, focused.
987	set := r.key(t, "bracketright", ports.ModAlt)
988	got, focused := windowsOf(set, "DP-2")
989	if !slices.Contains(got, 3) || !slices.Contains(got, 4) || focused != 3 {
990		t.Fatal(got, focused)
991	}
992}
993
994func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) {
995	r := startMulti(t, func(c *ports.Config) {
996		c.Outputs = []ports.OutputConfig{{Name: "DP-1", Pos: &image.Point{X: 20, Y: -120}}, {Name: "DP-2", Pos: &image.Point{X: 20, Y: -120}}}
997	}, left, right)
998	out := lastOutputs(t, r.commands)
999	if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-1" || out.Outputs[1].Info.Name != "DP-2" {
1000		t.Fatalf("ordering: %+v", out.Outputs)
1001	}
1002	for _, o := range out.Outputs {
1003		if o.X != 20 || o.Y != -120 {
1004			t.Fatalf("explicit position: %+v", o)
1005		}
1006	}
1007}
1008
1009func TestTerminalTokenClaimedOnce(t *testing.T) {
1010	// Plug DP-1 first so its request is unambiguously first. Then plug
1011	// DP-2 and focus it before either window maps.
1012	r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
1013	first := receive(t, r.spawn)
1014	r.plug(t, right)
1015	second := receive(t, r.spawn)
1016	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1017	r.client <- ports.WindowMapped{ID: 41, Slot: token(t, first), Floating: true, Width: 20, Height: 20}
1018	receive(t, r.scenes)
1019	r.client <- ports.WindowMapped{ID: 42, Slot: token(t, first)}
1020	set := receive(t, r.scenes)
1021	if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
1022		t.Fatalf("first claim: %v", got)
1023	}
1024	// Move back to DP-1: the duplicate must follow focus, not the token.
1025	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
1026	r.client <- ports.WindowMapped{ID: 43, Slot: token(t, first)}
1027	set = receive(t, r.scenes)
1028	if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
1029		t.Fatalf("duplicate claim: %v", got)
1030	}
1031	// The other terminal's token remains available despite the first claim.
1032	r.client <- ports.WindowMapped{ID: 44, Slot: token(t, second)}
1033	set = receive(t, r.scenes)
1034	if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{44, 41}) {
1035		t.Fatalf("second claim: %v", got)
1036	}
1037}
1038
1039// From a focused float, the first column focus move leaves the float for
1040// the columns, even when the column has no neighbor on that side: it does
1041// not jump to the neighbor monitor.
1042func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
1043	r := startMulti(t, nil, left, right)
1044	r.mapWindow(t, 1)
1045	r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
1046	receive(t, r.scenes)
1047	set := r.key(t, "Right", ports.ModAlt)
1048	var focused []ports.WindowID
1049	for _, s := range set {
1050		for _, w := range s.Windows {
1051			if w.Focused {
1052				focused = append(focused, w.ID)
1053				if s.Output != "DP-1" {
1054					t.Fatalf("focused %d on %s", w.ID, s.Output)
1055				}
1056			}
1057		}
1058	}
1059	if !slices.Equal(focused, []ports.WindowID{1}) {
1060		t.Fatalf("focused %v", focused)
1061	}
1062}
1063
1064// The stash end to end: binds stash tiles, peeking neighbors are dimmed
1065// and their popups wait, a click selects a peek, and the script state
1066// tells the stash and whether it is hidden.
1067func TestStashEndToEnd(t *testing.T) {
1068	both(t, func(t *testing.T, animated bool) {
1069		r := startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
1070		stashEndToEnd(t, r)
1071	})
1072}
1073
1074func stashEndToEnd(t *testing.T, r *landRig) {
1075	for id := ports.WindowID(1); id <= 3; id++ {
1076		r.mapLanded(t, id)
1077	}
1078	r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
1079	r.keyLanded(t, "Left", ports.ModAlt)             // stays on 3: its stash's only window
1080	sc := r.keyLanded(t, "s", ports.ModAlt)
1081	if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
1082		t.Fatalf("hidden stash: %v", got)
1083	}
1084	r.keyLanded(t, "Left", ports.ModAlt) // tiles: from 2 to 1
1085	sc = r.keyLanded(t, "s", ports.ModAlt|ports.ModShift)
1086	// Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
1087	var peek, sel ports.SceneWindow
1088	for _, w := range sc[0].Windows {
1089		switch w.ID {
1090		case 3:
1091			peek = w
1092		case 1:
1093			sel = w
1094		}
1095	}
1096	if peek.Hidden || peek.Dim != 0.5 || !peek.Floating || sel.Dim != 0 || sel.Hidden || !sel.Focused || !sel.Floating {
1097		t.Fatalf("peek %+v selected %+v", peek, sel)
1098	}
1099	// A popup of the peek waits until the peek is selected.
1100	r.client <- ports.PopupRequest{ID: 9, Parent: 3, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
1101	r.client <- ports.PopupMapped{ID: 9}
1102	sc = receive(t, r.scenes)
1103	for _, w := range sc[0].Windows {
1104		if w.ID == 9 {
1105			t.Fatalf("popup of a peek drawn: %+v", w)
1106		}
1107	}
1108	// A click on the peek (x 0..19 of 200) selects it.
1109	r.input <- ports.PointerMotion{X: 5, Y: 50}
1110	r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1111	r.land(receive(t, r.scenes))
1112	st := receive(t, r.state)
1113	for st.Window == nil || st.Window.ID != 3 {
1114		st = receive(t, r.state)
1115	}
1116	byID := map[ports.WindowID]ports.WindowState{}
1117	for _, w := range st.Windows {
1118		byID[w.ID] = w
1119	}
1120	if w := byID[3]; !w.Floating || w.StashIndex != 1 || w.StashCount != 2 || w.Hidden || !w.Visible {
1121		t.Fatalf("window 3 %+v", w)
1122	}
1123	if w := byID[2]; w.Floating || w.StashIndex != 0 || w.Hidden {
1124		t.Fatalf("tile %+v", w)
1125	}
1126	r.input <- ports.PointerButton{Button: 0x110}
1127	r.keyLanded(t, "s", ports.ModAlt)
1128	st = receive(t, r.state)
1129	for !st.Windows[0].Hidden {
1130		st = receive(t, r.state)
1131	}
1132	for _, w := range st.Windows {
1133		if (w.StashIndex > 0) != w.Hidden {
1134			t.Fatalf("after hide %+v", w)
1135		}
1136	}
1137}
1138
1139// stateAfter waits for the published state that ok accepts, or fails:
1140// snapshots from before an event may still be queued.
1141func stateAfter(t *testing.T, ch <-chan ports.State, ok func(ports.State) bool) ports.State {
1142	t.Helper()
1143	deadline := time.After(time.Second)
1144	for {
1145		select {
1146		case st := <-ch:
1147			if ok(st) {
1148				return st
1149			}
1150		case <-deadline:
1151			t.Fatal("no matching state")
1152			return ports.State{}
1153		}
1154	}
1155}
1156
1157// At the ends of the stash, focus moves stay there: no hop to the
1158// neighbor monitor or workspace.
1159func TestStashKeepsFocusAtEdges(t *testing.T) {
1160	r := startMulti(t, nil, left, right)
1161	r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1162	r.mapWindow(t, 1)
1163	r.mapWindow(t, 2)
1164	r.key(t, "s", ports.ModAlt|ports.ModShift)
1165	for _, k := range []string{"Right", "Down"} {
1166		r.key(t, k, ports.ModAlt)
1167	}
1168	// A marker after the keys: its state follows theirs.
1169	r.mapWindow(t, 9)
1170	st := stateAfter(t, r.state, func(st ports.State) bool { return len(st.Windows) == 3 })
1171	if st.Output != "DP-1" || st.Outputs[0].Active != 1 {
1172		t.Fatalf("output %s active %d", st.Output, st.Outputs[0].Active)
1173	}
1174}
1175
1176// Binds act on the dialog shown over a fullscreen window, not on the window:
1177// close closes the dialog, a focus move goes back to the window and keeps it
1178// fullscreen. A dialog of a window on another output opens there.
1179func TestBindsOnDialogOverFullscreen(t *testing.T) {
1180	for _, overflow := range []string{"scroll", "fixed"} {
1181		t.Run(overflow, func(t *testing.T) {
1182			r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = overflow }, left, right)
1183			r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1184			r.mapWindow(t, 1)
1185			r.mapWindow(t, 2)
1186			r.key(t, "Left", ports.ModAlt)
1187			r.key(t, "f", ports.ModAlt|ports.ModShift) // 1 fullscreen
1188			r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 10, Height: 10, Parent: 1}
1189			receive(t, r.scenes)
1190			st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 9 })
1191			if st.Window.ID != 9 {
1192				t.Fatalf("focus %+v, want the dialog", st.Window)
1193			}
1194			for len(r.commands) > 0 {
1195				<-r.commands
1196			}
1197			r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt, Pressed: true}
1198			r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt}
1199			for {
1200				if v, ok := receive(t, r.commands).(ports.CloseWindow); ok {
1201					if v.ID != 9 {
1202						t.Fatalf("closed %d, want the dialog", v.ID)
1203					}
1204					break
1205				}
1206			}
1207			scenes := r.key(t, "Right", ports.ModAlt)
1208			st = stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID != 9 })
1209			if st.Window.ID != 1 {
1210				t.Fatalf("focus %+v, want the fullscreen window", st.Window)
1211			}
1212			for _, s := range scenes {
1213				for _, w := range s.Windows {
1214					if s.Output == "DP-1" && w.ID == 1 && !w.Fullscreen {
1215						t.Fatalf("window 1 left fullscreen: %+v", w)
1216					}
1217				}
1218			}
1219			// A dialog of the window on DP-1 opens on DP-1 while DP-2 has the focus.
1220			r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1221			r.client <- ports.WindowMapped{ID: 10, Floating: true, Width: 10, Height: 10, Parent: 1}
1222			for {
1223				seen := shown(receive(t, r.scenes))
1224				if slices.Contains(seen["DP-2"], 10) {
1225					t.Fatalf("dialog on the focused output: %v", seen)
1226				}
1227				if slices.Contains(seen["DP-1"], 10) {
1228					break
1229				}
1230			}
1231		})
1232	}
1233}
1234
1235// A focused native dialog: focus-column first leaves the dialog for the
1236// tiles below, it does not hop to the neighbor monitor.
1237func TestDialogFocusStaysOnMonitor(t *testing.T) {
1238	r := startMulti(t, nil, left, right)
1239	r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1240	r.mapWindow(t, 1)
1241	r.client <- ports.WindowMapped{ID: 3, Floating: true, Width: 10, Height: 10}
1242	receive(t, r.scenes)
1243	r.key(t, "Right", ports.ModAlt)
1244	st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window == nil || st.Window.ID != 3 })
1245	if st.Output != "DP-1" || st.Window == nil || st.Window.ID != 1 {
1246		t.Fatalf("output %s window %+v", st.Output, st.Window)
1247	}
1248}
1249
1250// A click on a tile behind the shown stash hides the stash and focuses
1251// the tile.
1252func TestStashHidesOnBackgroundClick(t *testing.T) {
1253	both(t, func(t *testing.T, animated bool) {
1254		// Gap 10 at 80%: no peeks, the margins show the tile behind.
1255		stashHidesOnBackgroundClick(t, startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap = 10 }, left))
1256	})
1257}
1258
1259func stashHidesOnBackgroundClick(t *testing.T, r *landRig) {
1260	r.mapLanded(t, 1)
1261	r.mapLanded(t, 2)
1262	r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1
1263	r.input <- ports.PointerMotion{X: 5, Y: 50}
1264	r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1265	st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })
1266	for _, w := range st.Windows {
1267		if w.ID == 2 && !w.Hidden {
1268			t.Fatalf("stash still shown: %+v", w)
1269		}
1270	}
1271}
1272
1273// A peek narrower than its border is still clickable.
1274func TestStashThinPeekClick(t *testing.T) {
1275	// 200 wide, 80%: a 20px margin, 18px of gap, 2px of the peek.
1276	r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left)
1277	r.mapWindow(t, 1)
1278	r.mapWindow(t, 2)
1279	r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
1280	r.key(t, "s", ports.ModAlt)
1281	r.key(t, "Left", ports.ModAlt)
1282	r.key(t, "s", ports.ModAlt|ports.ModShift) // stash 2 1, 2 peeks 2px from the left
1283	r.input <- ports.PointerMotion{X: 1, Y: 50}
1284	for {
1285		if v, ok := (<-r.commands).(ports.PointerFocus); ok {
1286			if v.ID != 2 {
1287				t.Fatalf("pointer focus %+v, want the peek", v)
1288			}
1289			break
1290		}
1291	}
1292}
1293
1294func TestNamedWorkspaceWithoutHomeFollowsFocus(t *testing.T) {
1295	r := startMulti(t, func(c *ports.Config) {
1296		c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}}
1297		c.Binds["Alt+w"] = "workspace web"
1298	}, left, right)
1299	// Called from DP-2, web comes to DP-2.
1300	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1301	r.key(t, "w", ports.ModAlt)
1302	if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
1303		t.Fatal(got)
1304	}
1305	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-2"]) != 0 {
1306		t.Fatal(got)
1307	}
1308	// Called from DP-1, it moves there with its window.
1309	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
1310	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 0 {
1311		t.Fatal(got)
1312	}
1313}
1314
1315func TestNamedWorkspaceWithHomeFocusesIt(t *testing.T) {
1316	r := startMulti(t, func(c *ports.Config) {
1317		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
1318		c.Binds["Alt+w"] = "workspace web"
1319	}, left, right)
1320	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1321	r.key(t, "w", ports.ModAlt)
1322	// The focus went to DP-1: the window opens there.
1323	if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
1324		t.Fatal(got)
1325	}
1326}
1327
1328func TestNamedGuestFollowsFocusAndReturnsHome(t *testing.T) {
1329	r := startMulti(t, func(c *ports.Config) {
1330		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}}
1331		c.Binds["Alt+w"] = "workspace web"
1332	}, left, right)
1333	// DP-3 is not plugged: web follows the focus to DP-2.
1334	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1335	r.key(t, "w", ports.ModAlt)
1336	if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
1337		t.Fatal(got)
1338	}
1339	r.key(t, "w", ports.ModAlt)
1340	// DP-3 arrives: web, not on screen, goes home with no bind.
1341	r.plug(t, third)
1342	st := receive(t, r.state)
1343	for len(st.Outputs) < 3 {
1344		st = receive(t, r.state)
1345	}
1346	if len(st.Windows) != 1 || st.Windows[0].Output != "DP-3" {
1347		t.Fatalf("%+v", st.Windows)
1348	}
1349}
1350
1351func TestNamedGuestOnScreenGoesHomeOnBind(t *testing.T) {
1352	r := startMulti(t, func(c *ports.Config) {
1353		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}}
1354		c.Binds["Alt+w"] = "workspace web"
1355	}, left, right)
1356	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1357	r.key(t, "w", ports.ModAlt)
1358	r.mapWindow(t, 1)
1359	// DP-3 arrives while web is on screen on DP-2: the bind takes it home.
1360	r.plug(t, third)
1361	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-3"]) != 1 || len(got["DP-2"]) != 0 {
1362		t.Fatal(got)
1363	}
1364}
1365
1366func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) {
1367	r := startMulti(t, func(c *ports.Config) {
1368		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
1369		c.Binds["Alt+w"] = "workspace web"
1370	}, left, right)
1371	r.key(t, "w", ports.ModAlt)
1372	r.mapWindow(t, 1)
1373	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1374	// Reaching web from DP-2 focuses it; it does not hide it.
1375	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 {
1376		t.Fatal(got)
1377	}
1378	if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 {
1379		t.Fatal(got)
1380	}
1381}
1382
1383// From a fullscreen window, focus-column-left/right leaves fullscreen for
1384// the column on that side; at the workspace edge it goes to the neighbor
1385// monitor and the window stays fullscreen.
1386func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) {
1387	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
1388	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1389	r.mapWindow(t, 1)
1390	r.mapWindow(t, 2)
1391	r.key(t, "f", ports.ModAlt|ports.ModShift)
1392	// Right edge: DP-2 has no neighbor there, the game stays fullscreen.
1393	if got, focused := windowsOf(r.key(t, "Right", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1394		t.Fatal("edge:", got, focused)
1395	}
1396	// Left: window 1 is there, fullscreen ends.
1397	if got, focused := windowsOf(r.key(t, "Left", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 1 {
1398		t.Fatal("left:", got, focused)
1399	}
1400	// Fullscreen on window 1, the left edge: DP-1 gets the focus and
1401	// window 1 stays fullscreen on DP-2.
1402	r.key(t, "f", ports.ModAlt|ports.ModShift)
1403	set := r.key(t, "Left", ports.ModAlt)
1404	if got, _ := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1}) {
1405		t.Fatal("monitor edge:", got)
1406	}
1407	if got, _ := windowsOf(r.mapWindow(t, 3), "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{3}) {
1408		t.Fatal("focus not on DP-1:", got)
1409	}
1410}
1411
1412// focusedOutput drains the commands and returns the focused connector,
1413// current when no SetOutputs was sent (it is sent only on a change).
1414func focusedOutput(r *multiRig, current string) string {
1415	for len(r.commands) > 0 {
1416		if v, ok := (<-r.commands).(ports.SetOutputs); ok {
1417			current = v.Focused
1418		}
1419	}
1420	return current
1421}
1422
1423// The column edge crosses to the screen on that side of the geometry, not to
1424// the next one in configuration order.
1425func TestFocusColumnEdgeFollowsGeometryNotConfigOrder(t *testing.T) {
1426	r := startMulti(t, func(c *ports.Config) {
1427		c.Outputs = []ports.OutputConfig{
1428			{Name: "DP-1", Pos: &image.Point{X: 500, Y: 0}},
1429			{Name: "DP-2", Pos: &image.Point{X: 0, Y: 0}},
1430		}
1431	}, left, right)
1432	// DP-1 (x 500..700) is focused first; DP-2 (x 0..400) lies to its left.
1433	current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1434	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
1435	if current = focusedOutput(r, current); current != "DP-2" {
1436		t.Fatalf("focus-monitor-left: %s, want DP-2", current)
1437	}
1438	r.mapWindow(t, 1)
1439	r.key(t, "Right", ports.ModAlt)
1440	if got := focusedOutput(r, current); got != "DP-1" {
1441		t.Fatalf("focus-column-right at the right edge: %s, want DP-1", got)
1442	}
1443	r.key(t, "Left", ports.ModAlt)
1444	if got := focusedOutput(r, "DP-1"); got != "DP-2" {
1445		t.Fatalf("focus-column-left at the left edge: %s, want DP-2", got)
1446	}
1447}
1448
1449// With the outputs stacked, a column edge has no neighbor on its side.
1450func TestFocusColumnEdgeStaysOnStackedOutputs(t *testing.T) {
1451	r := startMulti(t, func(c *ports.Config) {
1452		c.Outputs = []ports.OutputConfig{
1453			{Name: "DP-1"},
1454			{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
1455		}
1456	}, left, right)
1457	r.mapWindow(t, 1)
1458	current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1459	for _, k := range []string{"Left", "Right", "Left"} {
1460		r.key(t, k, ports.ModAlt)
1461		if got := focusedOutput(r, current); got != current {
1462			t.Fatalf("focus-column %s moved focus from %s to %s", k, current, got)
1463		}
1464	}
1465}
1466
1467// A screen turned off by power management is still a neighbor.
1468func TestPoweredOffScreenStaysNeighbor(t *testing.T) {
1469	r := startMulti(t, nil, left, right)
1470	// DP-1 is focused, DP-2 follows it on the right.
1471	current := focusedOutput(r, lastOutputs(t, r.commands).Focused)
1472	r.client <- ports.OutputPower{Output: "DP-2", On: false}
1473	_ = receive(t, r.scenes)
1474	if current != "DP-1" {
1475		t.Fatalf("focused %s, want DP-1", current)
1476	}
1477	if out := lastOutputs(t, r.commands); !slices.Equal(out.Off, []string{"DP-2"}) {
1478		t.Fatalf("off %v, want [DP-2]", out.Off)
1479	}
1480	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1481	if got := focusedOutput(r, current); got != "DP-2" {
1482		t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)
1483	}
1484}
1485
1486func TestRotatedOutputLayout(t *testing.T) {
1487	r := startMulti(t, func(c *ports.Config) {
1488		c.Outputs = []ports.OutputConfig{{Name: left.Name, Transform: 1}}
1489	}, left, right)
1490	out := lastOutputs(t, r.commands)
1491	if len(out.Outputs) != 2 {
1492		t.Fatalf("%+v", out.Outputs)
1493	}
1494	if o := out.Outputs[0]; o.Info.Name != "DP-1" || o.Width != 100 || o.Height != 200 || o.Transform != 1 {
1495		t.Fatalf("DP-1: %+v", o)
1496	}
1497	if o := out.Outputs[1]; o.Info.Name != "DP-2" || o.X != 100 || o.Width != 400 || o.Height != 200 || o.Transform != 0 {
1498		t.Fatalf("DP-2: %+v", o)
1499	}
1500	r.mapWindow(t, 1)
1501	set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1502	if len(set) != 2 || set[0].Output != "DP-1" || set[1].Output != "DP-2" {
1503		t.Fatalf("scenes: %+v", set)
1504	}
1505	for _, s := range set {
1506		switch s.Output {
1507		case "DP-1":
1508			if s.OutputWidth != 100 || s.OutputHeight != 200 || s.Transform != 1 {
1509				t.Fatalf("DP-1 scene: %dx%d t=%d", s.OutputWidth, s.OutputHeight, s.Transform)
1510			}
1511		case "DP-2":
1512			if s.OutputWidth != 400 || s.OutputHeight != 200 || s.Transform != 0 {
1513				t.Fatalf("DP-2 scene: %dx%d t=%d", s.OutputWidth, s.OutputHeight, s.Transform)
1514			}
1515		}
1516	}
1517}
1518
1519func TestRotatedOutputNeighbor(t *testing.T) {
1520	r := startMulti(t, func(c *ports.Config) {
1521		c.Outputs = []ports.OutputConfig{
1522			{Name: "DP-1", Transform: 1},
1523			{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
1524		}
1525	}, left, right)
1526	out := lastOutputs(t, r.commands)
1527	if o := out.Outputs[1]; o.Info.Name != "DP-2" || o.Y != 200 {
1528		t.Fatalf("DP-2: %+v", o)
1529	}
1530	r.mapWindow(t, 1)
1531	r.key(t, "Down", ports.ModAlt|ports.ModCtrl)
1532	if got := lastOutputs(t, r.commands).Focused; got != "DP-2" {
1533		t.Fatalf("focus-monitor-down: %s", got)
1534	}
1535}
1536
1537func TestTransformReload(t *testing.T) {
1538	r := startMulti(t, func(c *ports.Config) {
1539		c.Binds["Alt+equal"] = "scale-up"
1540		c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1541	}, left, right)
1542	// Scale DP-2 (focus follows the key to it) with the bind first.
1543	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1544	set := r.key(t, "equal", ports.ModAlt)
1545	scale := set[1].Scale
1546	if scale == 1 {
1547		t.Fatal("scale-up did not change the scale")
1548	}
1549	if out := lastOutputs(t, r.commands); out.Outputs[0].Width != 200 || out.Outputs[1].X != 200 {
1550		t.Fatalf("%+v", out.Outputs)
1551	}
1552
1553	r.cfg.Outputs[0].Transform = 1
1554	r.reload <- ports.ConfigChanged{Config: r.cfg}
1555	set = receive(t, r.scenes)
1556	out := lastOutputs(t, r.commands)
1557	if o := out.Outputs[0]; o.Width != 100 || o.Height != 200 || o.Transform != 1 {
1558		t.Fatalf("DP-1: %+v", o)
1559	}
1560	if o := out.Outputs[1]; o.X != 100 {
1561		t.Fatalf("DP-2 not re-placed: %+v", o)
1562	}
1563	if set[1].Scale != scale || out.Outputs[1].Scale != scale {
1564		t.Fatalf("scale %v changed to %v by a transform reload", scale, set[1].Scale)
1565	}
1566
1567	// A transform change on the scaled output itself keeps its live scale.
1568	r.cfg.Outputs[1].Transform = 1
1569	r.reload <- ports.ConfigChanged{Config: r.cfg}
1570	set = receive(t, r.scenes)
1571	if set[1].Scale != scale || set[1].Transform != 1 {
1572		t.Fatalf("scale %v transform %d", set[1].Scale, set[1].Transform)
1573	}
1574	// Back to normal.
1575	r.cfg.Outputs[0].Transform = 0
1576	r.reload <- ports.ConfigChanged{Config: r.cfg}
1577	_ = receive(t, r.scenes)
1578	if o := lastOutputs(t, r.commands).Outputs[0]; o.Width != 200 || o.Transform != 0 {
1579		t.Fatalf("DP-1: %+v", o)
1580	}
1581}
1582
1583// A reload that shrinks the layout under a still pointer brings it back onto
1584// an output.
1585func TestTransformReloadClampsPointer(t *testing.T) {
1586	both(t, func(t *testing.T, animated bool) {
1587		transformReloadClampsPointer(t, startLanding(t, animated, func(c *ports.Config) {
1588			c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1589		}, left, right))
1590	})
1591}
1592
1593func transformReloadClampsPointer(t *testing.T, r *landRig) {
1594	r.mapLanded(t, 1)
1595	r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
1596	r.mapLanded(t, 2)
1597	// DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
1598	for len(r.commands) > 0 {
1599		<-r.commands
1600	}
1601	r.input <- ports.PointerMotion{X: 550, Y: 50}
1602	if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 350, Y: 50}) {
1603		t.Fatal(v)
1604	}
1605	// Rotating DP-1 moves DP-2 to x 100..500: 550 is off every output.
1606	r.cfg.Outputs[0].Transform = 1
1607	r.reload <- ports.ConfigChanged{Config: r.cfg}
1608	receive(t, r.scenes)
1609	for len(r.commands) > 0 {
1610		<-r.commands
1611	}
1612	// A rehit (here from a swipe end) points at what lies under the pointer:
1613	// clamped onto DP-1's right edge, window 1, not nothing.
1614	r.input <- ports.SwipeEnd{}
1615	for {
1616		if v, ok := command(t, r.commands).(ports.PointerFocus); ok {
1617			if v.ID != 1 {
1618				t.Fatalf("pointer after reload: %+v", v)
1619			}
1620			break
1621		}
1622	}
1623}

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