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