v0.5.0 ccce658

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

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