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