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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close