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