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