1package core_test
2
3import (
4 "context"
5 "image"
6 "reflect"
7 "slices"
8 "testing"
9 "time"
10
11 "github.com/bnema/neferwl/internal/adapters/config"
12 "github.com/bnema/neferwl/internal/core"
13 "github.com/bnema/neferwl/internal/ports"
14)
15
16type multiRig struct {
17 client chan ports.ClientEvent
18 input chan ports.InputEvent
19 output chan ports.OutputEvent
20 reload chan ports.ConfigChanged
21 commands chan ports.ClientCommand
22 scenes chan []ports.Scene
23 spawn chan ports.SpawnRequest
24 state chan ports.State
25 cfg ports.Config
26}
27
28var (
29 left = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100}
30 right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200}
31 third = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "C", Serial: "3", Width: 300, Height: 100}
32)
33
34// startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
35// the given outputs in order.
36func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
37 t.Helper()
38 return startRig(t, false, edit, outs...)
39}
40
41// startRig is startMulti; terminal enables the configured automatic-open policy.
42func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
43 t.Helper()
44 cfg := altCmdDefaults()
45 cfg.Border.Width = 0
46 if edit != nil {
47 edit(&cfg)
48 }
49 r := &multiRig{
50 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
51 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
52 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
53 }
54 c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})
55 if err != nil {
56 t.Fatal(err)
57 }
58 ctx, cancel := context.WithCancel(context.Background())
59 done := make(chan error, 1)
60 go func() { done <- c.Run(ctx) }()
61 t.Cleanup(func() { cancel(); <-done })
62 for _, o := range outs {
63 r.plug(t, o)
64 }
65 return r
66}
67
68func (r *multiRig) plug(t *testing.T, o ports.OutputInfo) []ports.Scene {
69 t.Helper()
70 r.output <- ports.OutputAdded{Info: o}
71 return receive(t, r.scenes)
72}
73
74func (r *multiRig) key(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
75 t.Helper()
76 r.input <- ports.KeyEvent{Keysym: sym, Mods: mods, Pressed: true}
77 s := receive(t, r.scenes)
78 r.input <- ports.KeyEvent{Keysym: sym, Mods: mods}
79 return s
80}
81
82func (r *multiRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
83 t.Helper()
84 r.client <- ports.WindowMapped{ID: id}
85 return receive(t, r.scenes)
86}
87
88// shown returns the visible window IDs of each output, by connector.
89func shown(set []ports.Scene) map[string][]ports.WindowID {
90 out := map[string][]ports.WindowID{}
91 for _, s := range set {
92 out[s.Output] = []ports.WindowID{}
93 for _, w := range s.Windows {
94 if !w.Hidden {
95 out[s.Output] = append(out[s.Output], w.ID)
96 }
97 }
98 }
99 return out
100}
101
102func lastOutputs(t *testing.T, ch chan ports.ClientCommand) ports.SetOutputs {
103 t.Helper()
104 var last ports.SetOutputs
105 found := false
106 for len(ch) > 0 {
107 if v, ok := (<-ch).(ports.SetOutputs); ok {
108 last, found = v, true
109 }
110 }
111 if !found {
112 t.Fatal("no SetOutputs")
113 }
114 return last
115}
116
117func TestOutputsLeftToRight(t *testing.T) {
118 r := startMulti(t, func(c *ports.Config) {
119 c.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}, {Name: "DP-1"}}
120 }, left, right)
121 out := lastOutputs(t, r.commands)
122 // Config order wins: DP-2 (200x100 logical at scale 2), then DP-1.
123 if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-2" || out.Outputs[0].X != 0 || out.Outputs[0].Width != 200 ||
124 out.Outputs[1].Info.Name != "DP-1" || out.Outputs[1].X != 200 || out.Focused != "DP-1" {
125 t.Fatalf("%+v", out)
126 }
127}
128
129func TestLayoutPerOutput(t *testing.T) {
130 r := startMulti(t, func(c *ports.Config) {
131 c.Layout.MaxColumns = 2
132 c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}}
133 }, left, right)
134 r.mapWindow(t, 1)
135 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
136 var set []ports.Scene
137 for id := ports.WindowID(2); id <= 6; id++ {
138 set = r.mapWindow(t, id)
139 }
140 // DP-2: four columns of 100; the fifth window splits the last one
141 // (fixed) instead of scrolling.
142 if got := shown(set); len(got["DP-2"]) != 5 {
143 t.Fatal(got)
144 }
145 for _, s := range set {
146 for _, w := range s.Windows {
147 // DP-1 keeps the default: one window of two columns fills it.
148 if want := map[string]int{"DP-1": 200, "DP-2": 100}[s.Output]; w.Rect.W != want {
149 t.Fatalf("%s: %+v", s.Output, w)
150 }
151 }
152 }
153}
154
155// widths returns the window widths on one output.
156func widths(set []ports.Scene, output string) []int {
157 var out []int
158 for _, s := range set {
159 for _, w := range s.Windows {
160 if s.Output == output {
161 out = append(out, w.Rect.W)
162 }
163 }
164 }
165 return out
166}
167
168func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {
169 r := startMulti(t, func(c *ports.Config) {
170 c.Layout.MaxColumns = 1
171 c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}}
172 }, right)
173 r.mapWindow(t, 1)
174 if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) {
175 t.Fatal(got)
176 }
177 // Another monitor on DP-2: the key rule no longer applies.
178 other := right
179 other.Serial = "3"
180 if got := widths(r.plug(t, other), "DP-2"); !slices.Equal(got, []int{400, 400}) {
181 t.Fatal(got)
182 }
183 // Back to the first monitor, then a reload drops the rule.
184 r.plug(t, right)
185 cfg := r.cfg
186 cfg.Layout.Outputs = nil
187 r.reload <- ports.ConfigChanged{Config: cfg}
188 if got := widths(receive(t, r.scenes), "DP-2"); !slices.Equal(got, []int{400, 400}) {
189 t.Fatal(got)
190 }
191}
192
193func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
194 r := startMulti(t, func(c *ports.Config) {
195 c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Pos: &image.Point{X: 100, Y: 40}}}
196 }, left, right)
197 out := lastOutputs(t, r.commands)
198 if len(out.Outputs) != 2 || out.Outputs[0].X != 500 || out.Outputs[0].Y != 0 || out.Outputs[1].X != 100 || out.Outputs[1].Y != 40 {
199 t.Fatalf("unexpected positions: %+v", out.Outputs)
200 }
201 r.cfg.Outputs[1].Pos = &image.Point{X: -200, Y: 12}
202 r.reload <- ports.ConfigChanged{Config: r.cfg}
203 _ = receive(t, r.scenes)
204 out = lastOutputs(t, r.commands)
205 if out.Outputs[0].X != 200 || out.Outputs[1].X != -200 || out.Outputs[1].Y != 12 {
206 t.Fatalf("unexpected positions after reload: %+v", out.Outputs)
207 }
208}
209
210func TestWindowsOpenOnFocusedOutput(t *testing.T) {
211 r := startMulti(t, nil, left, right)
212 r.mapWindow(t, 1)
213 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
214 set := r.mapWindow(t, 2)
215 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
216 t.Fatal(got)
217 }
218 // Only the focused output draws a focused window.
219 for _, s := range set {
220 for _, w := range s.Windows {
221 if w.Focused != (s.Output == "DP-2") {
222 t.Fatalf("%s: %+v", s.Output, w)
223 }
224 }
225 }
226}
227
228func TestFocusColumnCrossesOutputs(t *testing.T) {
229 r := startMulti(t, nil, left, right)
230 r.mapWindow(t, 1)
231 // The only column is the edge: focus moves to the right output.
232 r.key(t, "Right", ports.ModAlt)
233 r.mapWindow(t, 2)
234 for len(r.commands) > 0 {
235 <-r.commands
236 }
237 r.key(t, "Left", ports.ModAlt)
238 var focus ports.WindowID
239 for len(r.commands) > 0 {
240 if v, ok := (<-r.commands).(ports.FocusWindow); ok {
241 focus = v.ID
242 }
243 }
244 if focus != 1 {
245 t.Fatal(focus)
246 }
247}
248
249func TestPointerCrossesOutputs(t *testing.T) {
250 r := startMulti(t, nil, left, right)
251 r.mapWindow(t, 1)
252 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
253 r.mapWindow(t, 2)
254 for len(r.commands) > 0 {
255 <-r.commands
256 }
257 // DP-2 starts at x 200: global (250, 50) is (50, 50) on window 2.
258 r.input <- ports.PointerMotion{X: 250, Y: 50}
259 if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 50, Y: 50}) {
260 t.Fatal(v)
261 }
262 // Back on DP-1, then below it (100 high): off every output, y stays on DP-1.
263 r.input <- ports.PointerMotion{X: 10, Y: 50}
264 r.input <- ports.PointerMotion{X: 10, Y: 150}
265 for {
266 if v, ok := command(t, r.commands).(ports.PointerMotionTo); ok && v.Y == 99 {
267 if v.ID != 1 || v.X != 10 {
268 t.Fatal(v)
269 }
270 break
271 }
272 }
273 // A click focuses the output under the pointer.
274 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
275 set := receive(t, r.scenes)
276 for _, s := range set {
277 for _, w := range s.Windows {
278 if w.Focused != (w.ID == 1) {
279 t.Fatalf("%s: %+v", s.Output, w)
280 }
281 }
282 }
283}
284
285func TestMoveWorkspaceToMonitor(t *testing.T) {
286 r := startMulti(t, nil, left, right)
287 r.mapWindow(t, 1)
288 set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
289 if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
290 t.Fatal(got)
291 }
292 // It is DP-2's now: unplugging DP-1 does not move it.
293 r.output <- ports.OutputRemoved{Name: "DP-1"}
294 set = receive(t, r.scenes)
295 if len(set) != 1 || set[0].Output != "DP-2" || len(shown(set)["DP-2"]) != 1 {
296 t.Fatal(shown(set))
297 }
298}
299
300func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
301 r := startMulti(t, nil, left, right)
302 r.mapWindow(t, 1)
303 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
304 r.mapWindow(t, 2)
305 // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
306 r.output <- ports.OutputRemoved{Name: "DP-2"}
307 set := receive(t, r.scenes)
308 if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
309 t.Fatal(shown(set))
310 }
311 // Cmd+2 shows the guest.
312 r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
313 set = receive(t, r.scenes)
314 r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
315 if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
316 t.Fatal(got)
317 }
318 // Replugged while on screen: the guest stays until the user leaves it.
319 set = r.plug(t, right)
320 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
321 t.Fatal(got)
322 }
323 r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
324 set = receive(t, r.scenes)
325 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
326 t.Fatal(got)
327 }
328}
329
330func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
331 r := startMulti(t, nil, left, right)
332 r.mapWindow(t, 1)
333 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
334 r.mapWindow(t, 2)
335 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
336 r.output <- ports.OutputRemoved{Name: "DP-2"}
337 receive(t, r.scenes)
338 // Not on screen: it goes home as soon as DP-2 is back.
339 set := r.plug(t, right)
340 if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
341 t.Fatal(got)
342 }
343}
344
345func TestNamedWorkspaceHomeChangeAppliesLive(t *testing.T) {
346 r := startMulti(t, func(c *ports.Config) {
347 c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
348 c.Binds["Alt+w"] = "workspace web"
349 }, left, right)
350 r.key(t, "w", ports.ModAlt)
351 r.mapWindow(t, 1)
352 r.key(t, "w", ports.ModAlt)
353 r.cfg.Workspaces[0].Monitor = "DP-2"
354 r.reload <- ports.ConfigChanged{Config: r.cfg}
355 receive(t, r.scenes)
356 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
357 if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
358 t.Fatal(got)
359 }
360}
361
362func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
363 r := startMulti(t, func(c *ports.Config) {
364 c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2"}}
365 c.Binds["Alt+w"] = "workspace web"
366 }, left)
367 // DP-2 is not plugged: web lives on DP-1 as a guest.
368 r.key(t, "w", ports.ModAlt)
369 r.mapWindow(t, 1)
370 r.key(t, "w", ports.ModAlt)
371 // DP-2 arrives: web goes home (not on screen).
372 r.plug(t, right)
373 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
374 set := r.key(t, "w", ports.ModAlt)
375 if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
376 t.Fatal(got)
377 }
378}
379
380func TestLayersStayOnTheirOutput(t *testing.T) {
381 r := startMulti(t, nil, left, right)
382 bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, ExclusiveZone: 10, Output: "DP-2"}
383 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
384 set := receive(t, r.scenes)
385 for _, s := range set {
386 if (len(s.Layers) == 1) != (s.Output == "DP-2") {
387 t.Fatalf("%s: %+v", s.Output, s.Layers)
388 }
389 }
390 if set[1].Layers[0].Rect != (ports.Rect{W: 400, H: 10}) {
391 t.Fatal(set[1].Layers)
392 }
393}
394
395func TestScaleBindTargetsFocusedOutput(t *testing.T) {
396 r := startMulti(t, func(c *ports.Config) { c.Binds["Alt+equal"] = "scale-up" }, left, right)
397 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
398 set := r.key(t, "equal", ports.ModAlt)
399 if set[0].Scale != 1 || set[1].Scale == 1 {
400 t.Fatal(set[0].Scale, set[1].Scale)
401 }
402}
403
404func TestLastOutputUnpluggedKeepsWindows(t *testing.T) {
405 r := startMulti(t, nil, left)
406 r.mapWindow(t, 1)
407 r.output <- ports.OutputRemoved{Name: "DP-1"}
408 if set := receive(t, r.scenes); len(set) != 1 || set[0].Output != "" {
409 t.Fatal(shown(set))
410 }
411 if out := lastOutputs(t, r.commands); len(out.Outputs) != 0 {
412 t.Fatalf("%+v", out)
413 }
414 // Another monitor takes the windows over.
415 set := r.plug(t, right)
416 if got := shown(set); len(got) != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
417 t.Fatal(got)
418 }
419}
420
421func TestModeChangeKeepsScreen(t *testing.T) {
422 r := startMulti(t, nil, left, right)
423 r.mapWindow(t, 1)
424 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
425 r.mapWindow(t, 2)
426 // A new mode on DP-2 is only a new size: nothing moves, focus stays.
427 bigger := right
428 bigger.Width, bigger.Height = 800, 400
429 set := r.plug(t, bigger)
430 if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 || len(got["DP-1"]) != 1 {
431 t.Fatal(got)
432 }
433 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" || out.Outputs[1].Width != 800 {
434 t.Fatalf("%+v", out)
435 }
436}
437
438func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) {
439 r := startMulti(t, func(c *ports.Config) {
440 c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Primary: true}}
441 }, left, right)
442 out := lastOutputs(t, r.commands)
443 // Placed right of DP-1, but focused.
444 if out.Focused != "DP-2" || out.Outputs[1].Info.Name != "DP-2" || !out.Outputs[1].Primary || out.Outputs[0].Primary {
445 t.Fatalf("%+v", out)
446 }
447 set := r.mapWindow(t, 1)
448 if got := shown(set); len(got["DP-2"]) != 1 {
449 t.Fatal(got)
450 }
451}
452
453func TestMoveColumnCrossesOutputs(t *testing.T) {
454 r := startMulti(t, nil, left, right)
455 r.mapWindow(t, 1)
456 r.mapWindow(t, 2)
457 // Window 2 is the right column of DP-1: moving right first hits the
458 // edge, then the column goes to DP-2 and focus follows it.
459 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
460 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
461 t.Fatal(got)
462 }
463 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
464 t.Fatal(out.Focused)
465 }
466 // Back left (no terminal kept here, so a last column may move): it
467 // lands on the right side of DP-1.
468 set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
469 if got := shown(set)["DP-1"]; len(got) != 2 {
470 t.Fatal(got)
471 }
472 for _, s := range set {
473 for _, w := range s.Windows {
474 if w.ID == 2 && (s.Output != "DP-1" || !w.Focused || w.Rect.X == 0) {
475 t.Fatalf("%s %+v", s.Output, w)
476 }
477 }
478 }
479}
480
481func TestPointerFocusesOutput(t *testing.T) {
482 r := startMulti(t, nil, left, right)
483 r.input <- ports.PointerMotion{X: 250, Y: 50}
484 receive(t, r.scenes)
485 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
486 t.Fatal(out.Focused)
487 }
488 // New windows open where the pointer is.
489 if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
490 t.Fatal(got)
491 }
492}
493
494// The first terminal cannot launch before wayland receives its output layout.
495func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
496 cfg := config.Defaults()
497 commands := make(chan ports.ClientCommand)
498 // The launcher handoff is deliberately nonblocking. A ready buffered
499 // queue tests ordering without requiring core to wait for this reader.
500 spawn := make(chan ports.SpawnRequest, 1)
501 output := make(chan ports.OutputEvent, 1)
502 scenes := make(chan []ports.Scene, 1)
503 state := make(chan ports.State, 1)
504 c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state, Terminal: true})
505 if err != nil {
506 t.Fatal(err)
507 }
508 ctx, cancel := context.WithCancel(context.Background())
509 done := make(chan error, 1)
510 go func() { done <- c.Run(ctx) }()
511 t.Cleanup(func() { cancel(); receive(t, done) })
512 receive(t, state) // startup finished; next command belongs to the output event
513 output <- ports.OutputAdded{Info: left}
514 select {
515 case v := <-commands:
516 if o, ok := v.(ports.SetOutputs); !ok || len(o.Outputs) != 1 || o.Outputs[0].Info.Name != left.Name {
517 t.Fatalf("first command: %v", v)
518 }
519 case req := <-spawn:
520 t.Fatalf("terminal spawned before SetOutputs: %v", req)
521 case <-time.After(time.Second):
522 t.Fatal("no output command or spawn")
523 }
524 if v, ok := receive(t, commands).(ports.SlotsPending); !ok || !v.Pending {
525 t.Fatalf("expected pending before spawn, got %v", v)
526 }
527 if req := receive(t, spawn); len(req.Argv) == 0 {
528 t.Fatal(req)
529 }
530 receive(t, scenes)
531}
532
533func TestTerminalAutoOpenFirstOnly(t *testing.T) {
534 r := startRig(t, true, nil, left)
535 first := receive(t, r.spawn)
536 if first.Argv[0] != "foot" {
537 t.Fatal(first)
538 }
539 r.key(t, "Next", ports.ModAlt)
540 if len(r.spawn) != 0 {
541 t.Fatal("opened a terminal on the second workspace")
542 }
543}
544
545func TestTerminalAutoOpenOff(t *testing.T) {
546 r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "off" }, left)
547 if len(r.spawn) != 0 {
548 t.Fatal("opened a terminal with auto-open off")
549 }
550 r.key(t, "Return", ports.ModAlt)
551 if req := receive(t, r.spawn); req.Argv[0] != "foot" || len(req.Env) != 0 {
552 t.Fatal(req)
553 }
554}
555
556func TestTerminalAutoOpenFirstSkipsPopulatedInitialWorkspace(t *testing.T) {
557 r := startRig(t, false, nil, left)
558 r.mapWindow(t, 1)
559 r.cfg.Terminal.AutoOpen = "first"
560 r.reload <- ports.ConfigChanged{Config: r.cfg}
561 receive(t, r.scenes)
562 r.key(t, "Next", ports.ModAlt)
563 if len(r.spawn) != 0 {
564 t.Fatal("opened a terminal after the initial workspace opportunity passed")
565 }
566}
567
568func TestTerminalAutoOpenFirstDoesNotTargetLaterWorkspace(t *testing.T) {
569 r := startRig(t, false, nil, left)
570 r.key(t, "Next", ports.ModAlt)
571 r.cfg.Terminal.AutoOpen = "first"
572 r.reload <- ports.ConfigChanged{Config: r.cfg}
573 receive(t, r.scenes)
574 if len(r.spawn) != 0 {
575 t.Fatal("opened the first terminal outside workspace 1")
576 }
577}
578
579func TestTerminalAutoOpenFirstSurvivesReloadBeforeOutput(t *testing.T) {
580 r := startRig(t, true, nil)
581 r.reload <- ports.ConfigChanged{Config: r.cfg}
582 receive(t, r.scenes)
583 r.plug(t, left)
584 if req := receive(t, r.spawn); req.Argv[0] != "foot" {
585 t.Fatal(req)
586 }
587}
588
589func TestTerminalAutoOpenAllThenFirstDoesNotOpenAgain(t *testing.T) {
590 r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
591 req := receive(t, r.spawn)
592 token := req.Env[0][len(ports.SlotEnv)+1:]
593 r.client <- ports.WindowMapped{ID: 1, Slot: token}
594 receive(t, r.scenes)
595 r.cfg.Terminal.AutoOpen = "first"
596 r.reload <- ports.ConfigChanged{Config: r.cfg}
597 receive(t, r.scenes)
598 r.client <- ports.WindowUnmapped{ID: 1}
599 receive(t, r.scenes)
600 if len(r.spawn) != 0 {
601 t.Fatal("opened a second automatic terminal after all-to-first reload")
602 }
603}
604
605func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
606 r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left, right)
607 // One terminal per output, each tagged for its workspace.
608 var reqs []ports.SpawnRequest
609 for len(reqs) < 2 {
610 reqs = append(reqs, receive(t, r.spawn))
611 }
612 if len(r.spawn) != 0 || reqs[0].Argv[0] != "foot" || reqs[0].Env[0] == reqs[1].Env[0] {
613 t.Fatal(reqs, len(r.spawn))
614 }
615 token := func(req ports.SpawnRequest) string { return req.Env[0][len(ports.SlotEnv)+1:] }
616 // The focused screen is DP-2 now; DP-1's terminal still lands on DP-1.
617 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
618 r.client <- ports.WindowMapped{ID: 1, Slot: token(reqs[0])}
619 r.client <- ports.WindowMapped{ID: 2, Slot: token(reqs[1])}
620 receive(t, r.scenes)
621 set := receive(t, r.scenes)
622 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {
623 t.Fatal(got)
624 }
625 // A terminal that exits right after mapping is not respawned at once
626 // (no loop for a broken terminal command).
627 r.client <- ports.WindowUnmapped{ID: 2}
628 receive(t, r.scenes)
629 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
630 if len(r.spawn) != 0 {
631 t.Fatal("respawned at once")
632 }
633}
634
635func TestLastColumnMovesToNeighborOutput(t *testing.T) {
636 r := startRig(t, true, nil, left, right)
637 r.mapWindow(t, 1)
638 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
639 if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
640 t.Fatal(got)
641 }
642}
643
644func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
645 r := startMulti(t, nil, left, right)
646 r.mapWindow(t, 1)
647 r.input <- ports.PointerMotion{X: 10, Y: 10}
648 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
649 // Jitter on DP-1 keeps DP-2 focused: the next window opens there.
650 r.input <- ports.PointerMotion{X: 11, Y: 10}
651 if got := shown(r.mapWindow(t, 2)); len(got["DP-2"]) != 1 {
652 t.Fatal(got)
653 }
654 // Entering DP-1 again focuses it.
655 r.input <- ports.PointerMotion{X: 250, Y: 10}
656 r.input <- ports.PointerMotion{X: 10, Y: 10}
657 receive(t, r.scenes)
658 if got := shown(r.mapWindow(t, 3)); len(got["DP-1"]) != 2 {
659 t.Fatal(got)
660 }
661}
662
663func TestStateSnapshot(t *testing.T) {
664 r := startMulti(t, nil, left, right)
665 r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
666 receive(t, r.scenes)
667 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
668 <-r.state // the channel holds the latest snapshot only
669 r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
670 receive(t, r.scenes)
671 // Snapshots from before window 2 may still be queued: wait for it.
672 st := receive(t, r.state)
673 for len(st.Windows) < 2 {
674 st = receive(t, r.state)
675 }
676 want := ports.State{
677 Output: "DP-2",
678 Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
679 Windows: []ports.WindowState{
680 {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
681 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
682 },
683 }
684 want.Window = &want.Windows[1]
685 if !reflect.DeepEqual(st, want) {
686 t.Fatalf("got %+v\nwant %+v", st, want)
687 }
688 // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
689 r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
690 receive(t, r.scenes)
691 st = receive(t, r.state)
692 // The window keeps its workspace ID; the output now shows the new one.
693 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 {
694 t.Fatalf("%+v", st)
695 }
696}
697
698func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
699 r := startMulti(t, func(c *ports.Config) {
700 c.Workspaces = []ports.WorkspaceConfig{{Name: "notes"}}
701 c.Binds["Alt+n"] = "workspace notes"
702 }, left)
703 r.client <- ports.WindowMapped{ID: 1, PID: 100}
704 receive(t, r.scenes)
705 <-r.state
706 // An app ID set after map reaches the state.
707 r.client <- ports.WindowAppID{ID: 1, AppID: "foot"}
708 receive(t, r.scenes)
709 // Snapshots from before the app ID may still be queued.
710 for st := receive(t, r.state); st.Windows[0].AppID != "foot"; st = receive(t, r.state) {
711 }
712 // A hidden workspace on screen: no active number, its name, window 1 off screen.
713 r.key(t, "n", ports.ModAlt)
714 st := receive(t, r.state)
715 if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
716 t.Fatalf("%+v", st)
717 }
718 // The hidden workspace has its own ID, and the window keeps the one of
719 // the workspace it is on.
720 if st.Outputs[0].WorkspaceID == 0 || st.Windows[0].WorkspaceID == 0 || st.Outputs[0].WorkspaceID == st.Windows[0].WorkspaceID {
721 t.Fatalf("workspace IDs: %+v", st)
722 }
723 // A window on the configured workspace carries its name; the ID string
724 // of ext-workspace and the state file is built from it.
725 r.client <- ports.WindowMapped{ID: 2, PID: 200}
726 receive(t, r.scenes)
727 for st = receive(t, r.state); len(st.Windows) < 2; st = receive(t, r.state) {
728 }
729 if st.Windows[0].WorkspaceName != "" || st.Windows[1].WorkspaceName != "notes" || st.Windows[1].WorkspaceID != st.Outputs[0].WorkspaceID {
730 t.Fatalf("workspace names: %+v", st.Windows)
731 }
732}
733
734func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
735 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
736 r.mapWindow(t, 1)
737 r.mapWindow(t, 2)
738 // Window 2 goes to DP-2, alone, and goes fullscreen in place.
739 r.key(t, "Right", ports.ModAlt|ports.ModShift)
740 r.key(t, "f", ports.ModAlt|ports.ModShift)
741 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
742 // Window 1 joins from DP-1: the fullscreen ends so it is visible and focused.
743 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
744 for _, s := range set {
745 for _, w := range s.Windows {
746 if w.ID == 1 && (s.Output != "DP-2" || w.Hidden || !w.Focused) {
747 t.Fatalf("%s %+v", s.Output, w)
748 }
749 }
750 }
751}
752
753// windowsOf lists the tiled windows of an output in scene order, with the
754// focused one.
755func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
756 for _, s := range set {
757 if s.Output != out {
758 continue
759 }
760 for _, w := range s.Windows {
761 if !w.Hidden {
762 ids = append(ids, w.ID)
763 }
764 if w.Focused {
765 focused = w.ID
766 }
767 }
768 }
769 return ids, focused
770}
771
772func TestExpelCrossesToFullNeighbor(t *testing.T) {
773 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
774 // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
775 // [1] [2 3], focus on 3 at the bottom of the edge column.
776 r.mapWindow(t, 1)
777 r.mapWindow(t, 2)
778 r.mapWindow(t, 3)
779 r.key(t, "bracketleft", ports.ModAlt)
780 // DP-2: [4] [5], full too.
781 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
782 r.mapWindow(t, 4)
783 r.mapWindow(t, 5)
784 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
785 // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
786 set := r.key(t, "bracketright", ports.ModAlt)
787 if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
788 t.Fatal(got)
789 }
790 got, focused := windowsOf(set, "DP-2")
791 if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
792 t.Fatal(got, focused)
793 }
794 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
795 t.Fatal(out.Focused)
796 }
797 // 3 is not in the edge column of DP-2: it stacks into [5].
798 set = r.key(t, "bracketright", ports.ModAlt)
799 if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
800 t.Fatal(got, focused)
801 }
802 // Now at the edge with no right monitor: nothing moves.
803 set = r.key(t, "bracketright", ports.ModAlt)
804 if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
805 t.Fatal(got, focused)
806 }
807}
808
809func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) {
810 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
811 // DP-1: [1] [2 3], focus on 3.
812 r.mapWindow(t, 1)
813 r.mapWindow(t, 2)
814 r.mapWindow(t, 3)
815 r.key(t, "bracketleft", ports.ModAlt)
816 // DP-2: [4] [5]; 5 goes fullscreen.
817 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
818 r.mapWindow(t, 4)
819 r.mapWindow(t, 5)
820 r.key(t, "f", ports.ModAlt|ports.ModShift)
821 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
822 // 3 joins as a tile: fullscreen ends so it is seen, focused.
823 set := r.key(t, "bracketright", ports.ModAlt)
824 got, focused := windowsOf(set, "DP-2")
825 if !slices.Contains(got, 3) || !slices.Contains(got, 4) || focused != 3 {
826 t.Fatal(got, focused)
827 }
828}
829
830func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) {
831 r := startMulti(t, func(c *ports.Config) {
832 c.Outputs = []ports.OutputConfig{{Name: "DP-1", Pos: &image.Point{X: 20, Y: -120}}, {Name: "DP-2", Pos: &image.Point{X: 20, Y: -120}}}
833 }, left, right)
834 out := lastOutputs(t, r.commands)
835 if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-1" || out.Outputs[1].Info.Name != "DP-2" {
836 t.Fatalf("ordering: %+v", out.Outputs)
837 }
838 for _, o := range out.Outputs {
839 if o.X != 20 || o.Y != -120 {
840 t.Fatalf("explicit position: %+v", o)
841 }
842 }
843}
844
845func TestTerminalTokenClaimedOnce(t *testing.T) {
846 // Plug DP-1 first so its request is unambiguously first. Then plug
847 // DP-2 and focus it before either window maps.
848 r := startRig(t, true, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
849 first := receive(t, r.spawn)
850 r.plug(t, right)
851 second := receive(t, r.spawn)
852 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
853 r.client <- ports.WindowMapped{ID: 41, Slot: token(t, first), Floating: true, Width: 20, Height: 20}
854 receive(t, r.scenes)
855 r.client <- ports.WindowMapped{ID: 42, Slot: token(t, first)}
856 set := receive(t, r.scenes)
857 if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
858 t.Fatalf("first claim: %v", got)
859 }
860 // Move back to DP-1: the duplicate must follow focus, not the token.
861 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
862 r.client <- ports.WindowMapped{ID: 43, Slot: token(t, first)}
863 set = receive(t, r.scenes)
864 if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{41}) {
865 t.Fatalf("duplicate claim: %v", got)
866 }
867 // The other terminal's token remains available despite the first claim.
868 r.client <- ports.WindowMapped{ID: 44, Slot: token(t, second)}
869 set = receive(t, r.scenes)
870 if got := shown(set); !slices.Equal(got["DP-1"], []ports.WindowID{42, 43}) || !slices.Equal(got["DP-2"], []ports.WindowID{44, 41}) {
871 t.Fatalf("second claim: %v", got)
872 }
873}
874
875// From a focused float, the first column focus move leaves the float for
876// the columns, even when the column has no neighbor on that side: it does
877// not jump to the neighbor monitor.
878func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
879 r := startMulti(t, nil, left, right)
880 r.mapWindow(t, 1)
881 r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10}
882 receive(t, r.scenes)
883 set := r.key(t, "Right", ports.ModAlt)
884 var focused []ports.WindowID
885 for _, s := range set {
886 for _, w := range s.Windows {
887 if w.Focused {
888 focused = append(focused, w.ID)
889 if s.Output != "DP-1" {
890 t.Fatalf("focused %d on %s", w.ID, s.Output)
891 }
892 }
893 }
894 }
895 if !slices.Equal(focused, []ports.WindowID{1}) {
896 t.Fatalf("focused %v", focused)
897 }
898}
899
900// The stash end to end: binds stash tiles, peeking neighbors are dimmed
901// and their popups wait, a click selects a peek, and the script state
902// tells the stash and whether it is hidden.
903func TestStashEndToEnd(t *testing.T) {
904 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
905 for id := ports.WindowID(1); id <= 3; id++ {
906 r.mapWindow(t, id)
907 }
908 r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
909 r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
910 sc := r.key(t, "s", ports.ModAlt)
911 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
912 t.Fatalf("hidden stash: %v", got)
913 }
914 r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1
915 sc = r.key(t, "s", ports.ModAlt|ports.ModShift)
916 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
917 var peek, sel ports.SceneWindow
918 for _, w := range sc[0].Windows {
919 switch w.ID {
920 case 3:
921 peek = w
922 case 1:
923 sel = w
924 }
925 }
926 if peek.Hidden || peek.Dim != 0.5 || !peek.Floating || sel.Dim != 0 || sel.Hidden || !sel.Focused || !sel.Floating {
927 t.Fatalf("peek %+v selected %+v", peek, sel)
928 }
929 // A popup of the peek waits until the peek is selected.
930 r.client <- ports.PopupRequest{ID: 9, Parent: 3, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
931 r.client <- ports.PopupMapped{ID: 9}
932 sc = receive(t, r.scenes)
933 for _, w := range sc[0].Windows {
934 if w.ID == 9 {
935 t.Fatalf("popup of a peek drawn: %+v", w)
936 }
937 }
938 // A click on the peek (x 0..19 of 200) selects it.
939 r.input <- ports.PointerMotion{X: 5, Y: 50}
940 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
941 receive(t, r.scenes)
942 st := receive(t, r.state)
943 for st.Window == nil || st.Window.ID != 3 {
944 st = receive(t, r.state)
945 }
946 byID := map[ports.WindowID]ports.WindowState{}
947 for _, w := range st.Windows {
948 byID[w.ID] = w
949 }
950 if w := byID[3]; !w.Floating || w.StashIndex != 1 || w.StashCount != 2 || w.Hidden || !w.Visible {
951 t.Fatalf("window 3 %+v", w)
952 }
953 if w := byID[2]; w.Floating || w.StashIndex != 0 || w.Hidden {
954 t.Fatalf("tile %+v", w)
955 }
956 r.input <- ports.PointerButton{Button: 0x110}
957 r.key(t, "s", ports.ModAlt)
958 st = receive(t, r.state)
959 for !st.Windows[0].Hidden {
960 st = receive(t, r.state)
961 }
962 for _, w := range st.Windows {
963 if (w.StashIndex > 0) != w.Hidden {
964 t.Fatalf("after hide %+v", w)
965 }
966 }
967}
968
969// stateAfter waits for the published state that ok accepts, or fails:
970// snapshots from before an event may still be queued.
971func stateAfter(t *testing.T, ch <-chan ports.State, ok func(ports.State) bool) ports.State {
972 t.Helper()
973 deadline := time.After(time.Second)
974 for {
975 select {
976 case st := <-ch:
977 if ok(st) {
978 return st
979 }
980 case <-deadline:
981 t.Fatal("no matching state")
982 return ports.State{}
983 }
984 }
985}
986
987// At the ends of the stash, focus moves stay there: no hop to the
988// neighbor monitor or workspace.
989func TestStashKeepsFocusAtEdges(t *testing.T) {
990 r := startMulti(t, nil, left, right)
991 r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
992 r.mapWindow(t, 1)
993 r.mapWindow(t, 2)
994 r.key(t, "s", ports.ModAlt|ports.ModShift)
995 for _, k := range []string{"Right", "Down"} {
996 r.key(t, k, ports.ModAlt)
997 }
998 // A marker after the keys: its state follows theirs.
999 r.mapWindow(t, 9)
1000 st := stateAfter(t, r.state, func(st ports.State) bool { return len(st.Windows) == 3 })
1001 if st.Output != "DP-1" || st.Outputs[0].Active != 1 {
1002 t.Fatalf("output %s active %d", st.Output, st.Outputs[0].Active)
1003 }
1004}
1005
1006// A focused native dialog: focus-column first leaves the dialog for the
1007// tiles below, it does not hop to the neighbor monitor.
1008func TestDialogFocusStaysOnMonitor(t *testing.T) {
1009 r := startMulti(t, nil, left, right)
1010 r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1011 r.mapWindow(t, 1)
1012 r.client <- ports.WindowMapped{ID: 3, Floating: true, Width: 10, Height: 10}
1013 receive(t, r.scenes)
1014 r.key(t, "Right", ports.ModAlt)
1015 st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window == nil || st.Window.ID != 3 })
1016 if st.Output != "DP-1" || st.Window == nil || st.Window.ID != 1 {
1017 t.Fatalf("output %s window %+v", st.Output, st.Window)
1018 }
1019}
1020
1021// A click on a tile behind the shown stash hides the stash and focuses
1022// the tile.
1023func TestStashHidesOnBackgroundClick(t *testing.T) {
1024 // Gap 10 at 80%: no peeks, the margins show the tile behind.
1025 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left)
1026 r.mapWindow(t, 1)
1027 r.mapWindow(t, 2)
1028 r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1
1029 r.input <- ports.PointerMotion{X: 5, Y: 50}
1030 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1031 st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })
1032 for _, w := range st.Windows {
1033 if w.ID == 2 && !w.Hidden {
1034 t.Fatalf("stash still shown: %+v", w)
1035 }
1036 }
1037}
1038
1039// A peek narrower than its border is still clickable.
1040func TestStashThinPeekClick(t *testing.T) {
1041 // 200 wide, 80%: a 20px margin, 18px of gap, 2px of the peek.
1042 r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left)
1043 r.mapWindow(t, 1)
1044 r.mapWindow(t, 2)
1045 r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
1046 r.key(t, "s", ports.ModAlt)
1047 r.key(t, "Left", ports.ModAlt)
1048 r.key(t, "s", ports.ModAlt|ports.ModShift) // stash 2 1, 2 peeks 2px from the left
1049 r.input <- ports.PointerMotion{X: 1, Y: 50}
1050 for {
1051 if v, ok := (<-r.commands).(ports.PointerFocus); ok {
1052 if v.ID != 2 {
1053 t.Fatalf("pointer focus %+v, want the peek", v)
1054 }
1055 break
1056 }
1057 }
1058}
1059
1060func TestNamedWorkspaceWithoutHomeFollowsFocus(t *testing.T) {
1061 r := startMulti(t, func(c *ports.Config) {
1062 c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}}
1063 c.Binds["Alt+w"] = "workspace web"
1064 }, left, right)
1065 // Called from DP-2, web comes to DP-2.
1066 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1067 r.key(t, "w", ports.ModAlt)
1068 if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
1069 t.Fatal(got)
1070 }
1071 if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-2"]) != 0 {
1072 t.Fatal(got)
1073 }
1074 // Called from DP-1, it moves there with its window.
1075 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
1076 if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 0 {
1077 t.Fatal(got)
1078 }
1079}
1080
1081func TestNamedWorkspaceWithHomeFocusesIt(t *testing.T) {
1082 r := startMulti(t, func(c *ports.Config) {
1083 c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
1084 c.Binds["Alt+w"] = "workspace web"
1085 }, left, right)
1086 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1087 r.key(t, "w", ports.ModAlt)
1088 // The focus went to DP-1: the window opens there.
1089 if got := shown(r.mapWindow(t, 1)); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
1090 t.Fatal(got)
1091 }
1092}
1093
1094func TestNamedGuestFollowsFocusAndReturnsHome(t *testing.T) {
1095 r := startMulti(t, func(c *ports.Config) {
1096 c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}}
1097 c.Binds["Alt+w"] = "workspace web"
1098 }, left, right)
1099 // DP-3 is not plugged: web follows the focus to DP-2.
1100 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1101 r.key(t, "w", ports.ModAlt)
1102 if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
1103 t.Fatal(got)
1104 }
1105 r.key(t, "w", ports.ModAlt)
1106 // DP-3 arrives: web, not on screen, goes home with no bind.
1107 r.plug(t, third)
1108 st := receive(t, r.state)
1109 for len(st.Outputs) < 3 {
1110 st = receive(t, r.state)
1111 }
1112 if len(st.Windows) != 1 || st.Windows[0].Output != "DP-3" {
1113 t.Fatalf("%+v", st.Windows)
1114 }
1115}
1116
1117func TestNamedGuestOnScreenGoesHomeOnBind(t *testing.T) {
1118 r := startMulti(t, func(c *ports.Config) {
1119 c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-3"}}
1120 c.Binds["Alt+w"] = "workspace web"
1121 }, left, right)
1122 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1123 r.key(t, "w", ports.ModAlt)
1124 r.mapWindow(t, 1)
1125 // DP-3 arrives while web is on screen on DP-2: the bind takes it home.
1126 r.plug(t, third)
1127 if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-3"]) != 1 || len(got["DP-2"]) != 0 {
1128 t.Fatal(got)
1129 }
1130}
1131
1132func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) {
1133 r := startMulti(t, func(c *ports.Config) {
1134 c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-1"}}
1135 c.Binds["Alt+w"] = "workspace web"
1136 }, left, right)
1137 r.key(t, "w", ports.ModAlt)
1138 r.mapWindow(t, 1)
1139 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1140 // Reaching web from DP-2 focuses it; it does not hide it.
1141 if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 1 {
1142 t.Fatal(got)
1143 }
1144 if got := shown(r.key(t, "w", ports.ModAlt)); len(got["DP-1"]) != 0 {
1145 t.Fatal(got)
1146 }
1147}
1148
1149// From a fullscreen window, focus-column-left/right leaves fullscreen for
1150// the column on that side; at the workspace edge it goes to the neighbor
1151// monitor and the window stays fullscreen.
1152func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) {
1153 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
1154 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1155 r.mapWindow(t, 1)
1156 r.mapWindow(t, 2)
1157 r.key(t, "f", ports.ModAlt|ports.ModShift)
1158 // Right edge: DP-2 has no neighbor there, the game stays fullscreen.
1159 if got, focused := windowsOf(r.key(t, "Right", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1160 t.Fatal("edge:", got, focused)
1161 }
1162 // Left: window 1 is there, fullscreen ends.
1163 if got, focused := windowsOf(r.key(t, "Left", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 1 {
1164 t.Fatal("left:", got, focused)
1165 }
1166 // Fullscreen on window 1, the left edge: DP-1 gets the focus and
1167 // window 1 stays fullscreen on DP-2.
1168 r.key(t, "f", ports.ModAlt|ports.ModShift)
1169 set := r.key(t, "Left", ports.ModAlt)
1170 if got, _ := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1}) {
1171 t.Fatal("monitor edge:", got)
1172 }
1173 if got, _ := windowsOf(r.mapWindow(t, 3), "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{3}) {
1174 t.Fatal("focus not on DP-1:", got)
1175 }
1176}