screens_test.go RawHistoryFolder

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

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 26

Tags 10

↑↓ moveenter openesc close