1package core
2
3import (
4 "context"
5 "math"
6 "slices"
7 "testing"
8 "time"
9
10 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11 "github.com/bnema/neferwl/internal/ports"
12)
13
14// threeColumns is a workspace of three one-window columns, windows 1..3.
15func threeColumns() *Workspace {
16 w := workspace()
17 w.SetMaxColumns(3)
18 for id := WindowID(1); id <= 3; id++ {
19 w.AddWindow(id)
20 }
21 return w
22}
23
24func focusNote(w *Workspace, id WindowID) {
25 w.FocusID(id)
26 w.noteFocus()
27}
28
29func TestMRUOrder(t *testing.T) {
30 tests := []struct {
31 name string
32 run func(w *Workspace)
33 want []WindowID
34 }{
35 {"follows focus", func(w *Workspace) {
36 // Columns 0, 2, 1 in turn: the last one used goes first.
37 focusNote(w, 1)
38 focusNote(w, 3)
39 focusNote(w, 2)
40 }, []WindowID{2, 3, 1}},
41 {"back to the oldest", func(w *Workspace) {
42 focusNote(w, 1)
43 focusNote(w, 3)
44 focusNote(w, 2)
45 focusNote(w, 1)
46 }, []WindowID{1, 2, 3}},
47 {"never focused follow by position", func(w *Workspace) {
48 focusNote(w, 3)
49 }, []WindowID{3, 1, 2}},
50 {"closed windows are pruned", func(w *Workspace) {
51 focusNote(w, 1)
52 focusNote(w, 3)
53 focusNote(w, 2)
54 w.RemoveWindow(3)
55 }, []WindowID{2, 1}},
56 {"a moved column keeps its rank", func(w *Workspace) {
57 focusNote(w, 1)
58 focusNote(w, 3)
59 focusNote(w, 2)
60 w.MoveColumn(1)
61 w.noteFocus()
62 }, []WindowID{2, 3, 1}},
63 {"a column taken to another workspace leaves recent", func(w *Workspace) {
64 focusNote(w, 1)
65 focusNote(w, 3)
66 focusNote(w, 2)
67 if _, ok := w.takeColumn(); !ok {
68 t.Fatal("no column taken")
69 }
70 if slices.Contains(w.recent, 2) {
71 t.Fatalf("recent %v keeps the taken window", w.recent)
72 }
73 w.noteFocus()
74 }, []WindowID{3, 1}},
75 }
76 for _, tt := range tests {
77 t.Run(tt.name, func(t *testing.T) {
78 w := threeColumns()
79 tt.run(w)
80 if got := w.switchOrder(); !slices.Equal(got, tt.want) {
81 t.Fatalf("order %v, want %v", got, tt.want)
82 }
83 // recent never keeps a closed window nor two of one column.
84 seen := map[int]bool{}
85 for _, id := range w.recent {
86 i := w.columnOf(id)
87 if i < 0 || seen[i] {
88 t.Fatalf("recent %v keeps window %d (column %d)", w.recent, id, i)
89 }
90 seen[i] = true
91 }
92 })
93 }
94}
95
96func TestMRUOneWindowPerColumn(t *testing.T) {
97 w := threeColumns()
98 w.AddWindow(4)
99 w.FocusColumn(-1)
100 w.ConsumeOrExpel(-1)
101 // Whatever the columns became, each appears once.
102 for range 3 {
103 w.noteFocus()
104 w.FocusColumn(-1)
105 }
106 order := w.switchOrder()
107 if len(order) != len(w.Columns) {
108 t.Fatalf("order %v for %d columns", order, len(w.Columns))
109 }
110 seen := map[int]bool{}
111 for _, id := range order {
112 if seen[w.columnOf(id)] {
113 t.Fatalf("order %v lists a column twice", order)
114 }
115 seen[w.columnOf(id)] = true
116 }
117}
118
119func TestMRUSwitchOrderNeedsTwoColumns(t *testing.T) {
120 w := workspace()
121 if w.switchOrder() != nil {
122 t.Fatal("order of an empty workspace")
123 }
124 w.AddWindow(1)
125 w.noteFocus()
126 if w.switchOrder() != nil {
127 t.Fatal("order of one column")
128 }
129}
130
131func TestMRUCapped(t *testing.T) {
132 w := workspace()
133 w.SetMaxColumns(3)
134 for id := WindowID(1); id <= switcherRecentMax+10; id++ {
135 w.AddWindow(id)
136 w.noteFocus()
137 }
138 if len(w.recent) != switcherRecentMax {
139 t.Fatalf("recent holds %d", len(w.recent))
140 }
141}
142
143func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
144 w := threeColumns()
145 focusNote(w, 2)
146 if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {
147 t.Fatalf("noteFocus allocates %v", n)
148 }
149 // A focus change inside the latest column updates its entry in place.
150 w.AddWindow(4)
151 w.ConsumeOrExpel(-1)
152 w.FocusID(2)
153 w.noteFocus()
154 if len(w.recent) == 0 || w.recent[0] != 2 {
155 t.Fatalf("recent %v, want window 2 first", w.recent)
156 }
157 n := len(w.recent)
158 col := w.Columns[w.Focus]
159 if len(col.Windows) < 2 {
160 t.Fatalf("setup: column %v has one window", col.Windows)
161 }
162 other := col.Windows[0]
163 if other == 2 {
164 other = col.Windows[1]
165 }
166 w.FocusID(other)
167 if got := testing.AllocsPerRun(100, w.noteFocus); got != 0 {
168 t.Fatalf("noteFocus allocates %v after a same-column change", got)
169 }
170 if len(w.recent) != n || w.recent[0] != other {
171 t.Fatalf("recent %v, want window %d first in place of 2 (%d entries)", w.recent, other, n)
172 }
173}
174
175// Browsing the overview focuses nothing: the selection moving to another
176// column, then Escape, leave the recent order as it was.
177func TestOverviewBrowsingKeepsMRU(t *testing.T) {
178 c, _, _ := switcherCore(t, 3)
179 m := c.cur().mon
180 w := m.Current()
181 focusNote(w, 1)
182 focusNote(w, 3)
183 focusNote(w, 2)
184 indicatorScene(t, c)
185 order, recent := w.switchOrder(), slices.Clone(w.recent)
186 focus := switcherFocus(c)
187 m.ToggleOverview()
188 m.OverviewMove(1, 0)
189 m.OverviewMove(1, 0)
190 if switcherFocus(c) == focus {
191 t.Fatal("setup: the overview selection is still on the focused window")
192 }
193 indicatorScene(t, c)
194 // Cancelling would note the original column again: look while browsing.
195 if !slices.Equal(w.recent, recent) {
196 t.Fatalf("recent %v while browsing, was %v", w.recent, recent)
197 }
198 m.CancelOverview()
199 indicatorScene(t, c)
200 if got := w.switchOrder(); !slices.Equal(got, order) || !slices.Equal(w.recent, recent) {
201 t.Fatalf("order %v recent %v, were %v %v", got, w.recent, order, recent)
202 }
203 if switcherFocus(c) != focus {
204 t.Fatalf("focus %d, want %d", switcherFocus(c), focus)
205 }
206}
207
208// switcherBinds returns the binds of the switcher tests: the defaults, a
209// fullscreen bind to interrupt a switch and F9, a switch with no Cmd.
210func switcherBinds() map[string]string {
211 return map[string]string{
212 "Cmd+Tab": string(ActionSwitchColumnNext),
213 "Cmd+Shift+Tab": string(ActionSwitchColumnPrev),
214 "Cmd+f": string(ActionToggleFullscreen),
215 "F9": string(ActionSwitchColumnNext),
216 }
217}
218
219const (
220 keyTab = 15
221 keySuper = 133
222)
223
224// switcherCore is indicatorCore with the switcher binds, up to three
225// columns on screen and n one-window columns (windows 1..n, n focused),
226// published once so the configures carry the windows' sizes.
227func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.ClientCommand) {
228 t.Helper()
229 commands := make(chan ports.ClientCommand, 64)
230 c, ic := indicatorCoreWith(t, commands)
231 cfg := c.cfg
232 cfg.Layout.MaxColumns = 3
233 cfg.Binds = switcherBinds()
234 if err := c.apply(cfg); err != nil {
235 t.Fatal(err)
236 }
237 for id := WindowID(1); id <= WindowID(n); id++ {
238 c.cur().mon.AddWindow(id)
239 }
240 indicatorScene(t, c)
241 return c, ic, commands
242}
243
244// requireClosed fails unless no switcher is open: no monitor holds one, and
245// the core has neither a switcher screen nor a timer.
246func requireClosed(t *testing.T, c *Core) {
247 t.Helper()
248 for _, sc := range c.screens {
249 if sc.mon.sw.ws != nil || sc.mon.sw.shown {
250 t.Fatalf("screen %q still holds a switcher: %+v", sc.name(), sc.mon.sw)
251 }
252 }
253 if c.switcher.sc != nil || c.switcher.timerC != nil || c.switcher.timerStop != nil {
254 t.Fatalf("switcher screen %v, timer %v", c.switcher.sc != nil, c.switcher.timerC != nil)
255 }
256}
257
258func tab(mods ports.Mods, pressed bool) ports.KeyEvent {
259 return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed}
260}
261
262func shiftTab(pressed bool) ports.KeyEvent {
263 return ports.KeyEvent{Keysym: "ISO_Left_Tab", Base: "Tab", Keycode: keyTab, Mods: ports.ModSuper | ports.ModShift, Pressed: pressed}
264}
265
266func superKey(mods ports.Mods, pressed bool) ports.KeyEvent {
267 return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed}
268}
269
270func bind(t *testing.T, c *Core, a Action) {
271 t.Helper()
272 if err := c.runBind(context.Background(), a); err != nil {
273 t.Fatal(err)
274 }
275}
276
277func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) {
278 t.Helper()
279 for _, k := range keys {
280 if err := c.keyEvent(context.Background(), k); err != nil {
281 t.Fatal(err)
282 }
283 }
284}
285
286// lastScene returns the latest published scene.
287func lastScene(t *testing.T, c *Core) ports.Scene {
288 t.Helper()
289 var s []ports.Scene
290 for len(c.ch.Scenes) > 0 {
291 s = <-c.ch.Scenes
292 }
293 if s == nil {
294 t.Fatal("no scene published")
295 }
296 return s[0]
297}
298
299func switcherFocus(c *Core) WindowID {
300 id, _ := c.cur().mon.Focused()
301 return id
302}
303
304// forwarded drains the commands and returns the forwarded keys.
305func forwarded(ch <-chan ports.ClientCommand) []ports.ForwardKey {
306 var out []ports.ForwardKey
307 for len(ch) > 0 {
308 if v, ok := (<-ch).(ports.ForwardKey); ok {
309 out = append(out, v)
310 }
311 }
312 return out
313}
314
315func scenePreviews(s ports.Scene) []ports.SceneWindow {
316 var out []ports.SceneWindow
317 for _, w := range s.Windows {
318 if w.Preview > 0 {
319 out = append(out, w)
320 }
321 }
322 return out
323}
324
325// --- P2.1: commit ---
326
327func switcherMonitor(overflow Overflow, n int) (*Monitor, *Workspace) {
328 m := NewMonitor()
329 m.SetOutput(300, 200)
330 m.SetMaxColumns(3)
331 m.SetOverflow(overflow)
332 for id := WindowID(1); id <= WindowID(n); id++ {
333 m.AddWindow(id)
334 }
335 return m, m.Current()
336}
337
338func openSwitcher(m *Monitor, at int) {
339 w := m.Current()
340 m.sw = switcherState{ws: w, order: w.switchOrder(), at: at}
341}
342
343func TestSwitchCommitFocusesSelectedColumn(t *testing.T) {
344 m, w := switcherMonitor(OverflowScroll, 3)
345 openSwitcher(m, 1)
346 want := m.sw.order[1]
347 m.commitSwitcher()
348 if id, _ := w.Focused(); id != want || m.sw.ws != nil {
349 t.Fatalf("focus %d (want %d), switcher open %v", id, want, m.sw.ws != nil)
350 }
351}
352
353func TestSwitchCommitTransfersMaximization(t *testing.T) {
354 for _, overflow := range []Overflow{OverflowCascade, OverflowFixed, OverflowScroll} {
355 t.Run(string(overflow), func(t *testing.T) {
356 m, w := switcherMonitor(overflow, 2)
357 a, b := w.Focus, 1-w.Focus
358 w.FocusID(w.Columns[a].Windows[0])
359 w.ToggleFullWidth()
360 w.noteFocus()
361 if !w.Columns[a].FullWidth {
362 t.Fatal("setup: source not maximized")
363 }
364 openSwitcher(m, 1)
365 m.commitSwitcher()
366 if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth {
367 t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth)
368 }
369 if w.policy().equalCells && !w.hiddenByMaximized(a) {
370 t.Fatal("source not hidden by the maximized target")
371 }
372 })
373 }
374}
375
376func TestSwitchCommitLeavesPinnedFullscreen(t *testing.T) {
377 m, w := switcherMonitor(OverflowFixed, 2)
378 src := w.Columns[w.Focus].Windows[0]
379 w.SetFullscreen(src, true)
380 if w.fullscreen != src {
381 t.Fatal("setup: no fullscreen")
382 }
383 openSwitcher(m, 1)
384 target := m.sw.order[1]
385 m.commitSwitcher()
386 if w.fullscreen != 0 {
387 t.Fatalf("fullscreen %d kept", w.fullscreen)
388 }
389 if id, _ := w.Focused(); id != target {
390 t.Fatalf("focus %d, want %d", id, target)
391 }
392}
393
394func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) {
395 m, w := switcherMonitor(OverflowScroll, 3)
396 before, _ := w.Focused()
397 openSwitcher(m, 1)
398 m.sw.order[1] = 99 // not in the workspace any more
399 m.commitSwitcher()
400 if id, _ := w.Focused(); id != before || m.sw.ws != nil {
401 t.Fatalf("focus %d, want %d; open %v", id, before, m.sw.ws != nil)
402 }
403 openSwitcher(m, 1)
404 m.sw.ws = &Workspace{}
405 m.commitSwitcher()
406 if id, _ := w.Focused(); id != before || m.sw.ws != nil {
407 t.Fatalf("focus %d for a foreign workspace, want %d", id, before)
408 }
409}
410
411// --- P2.2: actions and step ---
412
413func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
414 c, ic, _ := switcherCore(t, 3)
415 c.mods = ports.ModSuper
416 before := switcherFocus(c)
417 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
418 t.Fatal(err)
419 }
420 sw := &c.cur().mon.sw
421 if sw.ws == nil || sw.at != 1 || switcherFocus(c) != before {
422 t.Fatalf("open %v at %d focus %d (was %d)", sw.ws != nil, sw.at, switcherFocus(c), before)
423 }
424 if len(ic.timers) != 1 || ic.timers[0] != switcherDelay {
425 t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay)
426 }
427 bind(t, c, ActionSwitchColumnNext)
428 if sw.at != 2 {
429 t.Fatalf("at %d", sw.at)
430 }
431 bind(t, c, ActionSwitchColumnNext)
432 if sw.at != 0 {
433 t.Fatalf("at %d, want wrap to 0", sw.at)
434 }
435 bind(t, c, ActionSwitchColumnPrev)
436 if sw.at != 2 {
437 t.Fatalf("at %d, want wrap to 2", sw.at)
438 }
439 bind(t, c, ActionSwitchColumnPrev)
440 if sw.at != 1 {
441 t.Fatalf("at %d", sw.at)
442 }
443 if len(ic.timers) != 1 {
444 t.Fatalf("timers %v: stepping must not re-arm", ic.timers)
445 }
446}
447
448func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
449 c, ic, _ := switcherCore(t, 3)
450 start := switcherFocus(c)
451 // F9 is bound to switch-column-next with no Cmd held.
452 f9 := func(pressed bool) ports.KeyEvent {
453 return ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: pressed}
454 }
455 sendKeys(t, c, f9(true), f9(false))
456 requireClosed(t, c)
457 if len(ic.timers) != 0 {
458 t.Fatalf("timers %v armed for a switch with no Cmd", ic.timers)
459 }
460 if switcherFocus(c) == start {
461 t.Fatal("focus did not move")
462 }
463 sendKeys(t, c, f9(true), f9(false))
464 if switcherFocus(c) != start {
465 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
466 }
467}
468
469func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
470 c, _, _ := switcherCore(t, 1)
471 c.mods = ports.ModSuper
472 bind(t, c, ActionSwitchColumnNext)
473 requireClosed(t, c)
474 c, _, _ = switcherCore(t, 3)
475 c.cur().mon.ToggleOverview()
476 before := switcherFocus(c)
477 bind(t, c, ActionSwitchColumnNext)
478 requireClosed(t, c)
479 if switcherFocus(c) != before {
480 t.Fatal("switcher acted in the overview")
481 }
482}
483
484// --- P2.3: release commit and timer ---
485
486func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
487 c, _, _ := switcherCore(t, 3)
488 start := switcherFocus(c)
489 for len(c.ch.Scenes) > 0 {
490 <-c.ch.Scenes
491 }
492 tap := func() {
493 t.Helper()
494 sendKeys(t, c,
495 superKey(ports.ModSuper, true),
496 tab(ports.ModSuper, true),
497 tab(ports.ModSuper, false),
498 superKey(0, false),
499 )
500 for len(c.ch.Scenes) > 0 {
501 if p := scenePreviews((<-c.ch.Scenes)[0]); len(p) > 0 {
502 t.Fatalf("cards published during a quick tap: %+v", p)
503 }
504 }
505 requireClosed(t, c)
506 }
507 tap()
508 if switcherFocus(c) == start {
509 t.Fatal("tap did not swap")
510 }
511 tap()
512 if switcherFocus(c) != start {
513 t.Fatalf("second tap focus %d, want back on %d", switcherFocus(c), start)
514 }
515}
516
517func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
518 c, _, cmds := switcherCore(t, 3)
519 start := switcherFocus(c)
520 order := c.cur().mon.Current().switchOrder()
521 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
522 if switcherFocus(c) != start || c.cur().mon.sw.ws == nil {
523 t.Fatal("focus moved or switcher closed before the release")
524 }
525 forwarded(cmds)
526 sendKeys(t, c, superKey(0, false))
527 requireClosed(t, c)
528 if switcherFocus(c) != order[1] {
529 t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
530 }
531 fk := forwarded(cmds)
532 if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {
533 t.Fatalf("release not forwarded to the new focus: %+v", fk)
534 }
535}
536
537// --- P3.1: keys and gestures ---
538
539func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
540 c, _, cmds := switcherCore(t, 3)
541 start := switcherFocus(c)
542 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
543 forwarded(cmds)
544 esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true}
545 sendKeys(t, c, esc)
546 esc.Pressed = false
547 sendKeys(t, c, esc, superKey(0, false))
548 requireClosed(t, c)
549 if switcherFocus(c) != start {
550 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
551 }
552 for _, k := range forwarded(cmds) {
553 if k.Key.Keysym == "Escape" {
554 t.Fatalf("Escape forwarded: %+v", k)
555 }
556 }
557}
558
559func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
560 c, _, _ := switcherCore(t, 3)
561 start := switcherFocus(c)
562 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
563 sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
564 requireClosed(t, c)
565 if switcherFocus(c) != start {
566 t.Fatalf("focus %d moved", switcherFocus(c))
567 }
568 if c.cur().mon.Current().fullscreen != start {
569 t.Fatalf("bind not applied: fullscreen %d", c.cur().mon.Current().fullscreen)
570 }
571}
572
573func TestSwitchShiftTabGoesBackward(t *testing.T) {
574 c, _, _ := switcherCore(t, 3)
575 sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true))
576 sw := c.cur().mon.sw
577 if sw.ws == nil || sw.at != len(sw.order)-1 {
578 t.Fatalf("open %v at %d of %d", sw.ws != nil, sw.at, len(sw.order))
579 }
580 sendKeys(t, c, shiftTab(false), tab(ports.ModSuper, true))
581 if c.cur().mon.sw.at != 0 {
582 t.Fatalf("at %d, want 0 after next", c.cur().mon.sw.at)
583 }
584}
585
586func TestSwitchSwipeBeginCancels(t *testing.T) {
587 c, _, _ := switcherCore(t, 3)
588 start := switcherFocus(c)
589 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
590 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
591 requireClosed(t, c)
592 if switcherFocus(c) != start {
593 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
594 }
595}
596
597func TestSwitchSecurityChangeCancels(t *testing.T) {
598 c, _, _ := switcherCore(t, 3)
599 state := ports.SecurityState{Generation: 1}
600 gate := portsmocks.NewMockSessionSecurity(t)
601 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
602 c.opts.Security = gate
603 start := switcherFocus(c)
604 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
605 state = ports.SecurityState{Generation: 2}
606 if !c.syncSecurity() {
607 t.Fatal("security change not seen")
608 }
609 requireClosed(t, c)
610 if switcherFocus(c) != start {
611 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
612 }
613}
614
615func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
616 c, _, _ := switcherCore(t, 3)
617 cfg := c.cfg
618 cfg.Keyboard.CmdKey = "alt"
619 if err := c.apply(cfg); err != nil {
620 t.Fatal(err)
621 }
622 order := c.cur().mon.Current().switchOrder()
623 sendKeys(t, c, tab(ports.ModAlt, true))
624 if c.cur().mon.sw.ws == nil {
625 t.Fatal("Alt+Tab did not open the switcher")
626 }
627 sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false})
628 requireClosed(t, c)
629 if switcherFocus(c) != order[1] {
630 t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
631 }
632}
633
634// --- P3.2: layout ---
635
636func TestSwitcherLayoutCards(t *testing.T) {
637 c, _, _ := switcherCore(t, 3)
638 c.cfg.Floating.Dim = 0.4
639 // A wider focused column: the cards have different aspect ratios.
640 c.cur().mon.Current().ResizeColumn(30)
641 indicatorScene(t, c)
642 area := c.cur().mon.Current().overviewArea()
643 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
644 if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 || c.cur().mon.sw.shown {
645 t.Fatalf("cards before the delay: %+v", s.Windows)
646 }
647 order := c.cur().mon.sw.order
648 if !c.switcherTick() || !c.cur().mon.sw.shown || c.switcher.timerC != nil {
649 t.Fatal("the tick did not show the cards and clear the timer")
650 }
651 s := indicatorScene(t, c)
652 cards := scenePreviews(s)
653 if len(cards) != 3 {
654 t.Fatalf("%d cards: %+v", len(cards), s.Windows)
655 }
656 focused := 0
657 distinct := false
658 var centers []int
659 for k, card := range cards {
660 if card.ID != order[k] {
661 t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k])
662 }
663 // The boxes are equal: the cards' centres are evenly spaced.
664 centers = append(centers, card.Rect.X+card.Rect.W/2)
665 // A card is its window's client size, scaled down uniformly.
666 sent := c.configures.sent[card.ID]
667 if sent.Width <= 0 || sent.Height <= 0 || card.Preview <= 0 || card.Preview >= 1 {
668 t.Fatalf("card %d: client %dx%d, preview %v", card.ID, sent.Width, sent.Height, card.Preview)
669 }
670 if d := card.Rect.W - int(math.Round(float64(sent.Width)*card.Preview)); d < -1 || d > 1 {
671 t.Fatalf("card %d is %dx%d for a %dx%d client at %v", card.ID, card.Rect.W, card.Rect.H, sent.Width, sent.Height, card.Preview)
672 }
673 if d := card.Rect.H - int(math.Round(float64(sent.Height)*card.Preview)); d < -1 || d > 1 {
674 t.Fatalf("card %d is %dx%d for a %dx%d client at %v", card.ID, card.Rect.W, card.Rect.H, sent.Width, sent.Height, card.Preview)
675 }
676 if first := c.configures.sent[order[0]]; sent.Width != first.Width {
677 distinct = true
678 }
679 if card.Focused {
680 focused++
681 if card.ID != order[c.cur().mon.sw.at] {
682 t.Fatalf("focused card %d is not the selected %d", card.ID, order[c.cur().mon.sw.at])
683 }
684 }
685 }
686 if !distinct {
687 t.Fatal("setup: every window has the same size, so the aspect ratios prove nothing")
688 }
689 if d := (centers[1] - centers[0]) - (centers[2] - centers[1]); d < -2 || d > 2 {
690 t.Fatalf("boxes not equal: card centres %v", centers)
691 }
692 if d := centers[0] + centers[2] - 2*(area.X+area.W/2); d < -2 || d > 2 {
693 t.Fatalf("row not centered in %+v: card centres %v", area, centers)
694 }
695 if focused != 1 {
696 t.Fatalf("%d focused cards", focused)
697 }
698 if len(s.Separators) != 4 {
699 t.Fatalf("separators %+v, want the 4 lines of the outline", s.Separators)
700 }
701 if s.Dim != 0.4 {
702 t.Fatalf("dim %v", s.Dim)
703 }
704 if s.TileInset != (ports.Insets{}) {
705 t.Fatalf("tile inset %+v", s.TileInset)
706 }
707 for _, w := range s.Windows {
708 if w.Preview == 0 && !w.Hidden {
709 t.Fatalf("window %d drawn under the cards", w.ID)
710 }
711 }
712 // A commit gives the workspace back.
713 sendKeys(t, c, superKey(0, false))
714 if s := lastScene(t, c); len(scenePreviews(s)) != 0 {
715 t.Fatalf("cards after the commit: %+v", s.Windows)
716 }
717}
718
719func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
720 // Windows 10 and 11 are on a second workspace.
721 c, _ := twoWorkspaces(t)
722 m := c.cur().mon
723 m.AddWindow(4)
724 w := m.Current()
725 w.ConsumeOrExpel(-1)
726 m.AddFloating(9, 100, 80)
727 if len(w.Columns) != 3 || w.columnWindows() != 4 || len(w.Floats) != 1 {
728 t.Fatalf("setup: %d columns, %d windows, %d floats", len(w.Columns), w.columnWindows(), len(w.Floats))
729 }
730 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
731 c.switcherTick()
732 s := indicatorScene(t, c)
733 reps := map[WindowID]bool{}
734 for _, id := range m.sw.order {
735 reps[id] = true
736 }
737 seen := map[WindowID]bool{}
738 for _, sw := range s.Windows {
739 seen[sw.ID] = true
740 if reps[sw.ID] {
741 if sw.Preview == 0 || sw.Hidden {
742 t.Fatalf("representative %d not a card: %+v", sw.ID, sw)
743 }
744 } else if !sw.Hidden {
745 t.Fatalf("window %d not hidden: %+v", sw.ID, sw)
746 }
747 }
748 // The other window of a column, the float and the other workspace's
749 // windows are all in the scene, hidden.
750 for _, id := range []WindowID{4, 9, 10, 11} {
751 if !seen[id] {
752 t.Fatalf("window %d missing from the scene (hidden windows must be listed): %+v", id, s.Windows)
753 }
754 }
755}
756
757// While the cards show, the screen draws previews: the tiles do not stop at
758// the panels, a card takes no pointer, nothing is
759// dragged, and a window mapping or unmapping behind them does not animate.
760func TestSwitcherShownIsAPreview(t *testing.T) {
761 c, ic, _ := switcherCore(t, 3)
762 c.cfg.Animations.On = true
763 m := c.cur().mon
764 // A bar reserves the top of the output: tiles stop below it.
765 c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 300, Height: 20, ExclusiveZone: 20}})
766 if s := indicatorScene(t, c); s.TileInset.Top != 20 {
767 t.Fatalf("setup: tile inset %+v, want the bar's 20", s.TileInset)
768 }
769 sizes := map[WindowID]ports.ConfigureWindow{}
770 for id, v := range c.configures.sent {
771 sizes[id] = v
772 }
773 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
774 c.switcherTick()
775 indicatorScene(t, c)
776 // The cards fly in, and the motions survive a publish.
777 if len(c.cur().rects) == 0 {
778 t.Fatal("no card motion after the show")
779 }
780 settle := func() {
781 t.Helper()
782 for range 200 {
783 ic.now = ic.now.Add(16 * time.Millisecond)
784 if err := c.step(context.Background(), nil); err != nil {
785 t.Fatal(err)
786 }
787 }
788 lastScene(t, c)
789 if len(c.cur().rects) != 0 || c.animating() {
790 t.Fatalf("motions did not settle: %d rects", len(c.cur().rects))
791 }
792 }
793 settle()
794 if !m.switcherShown() {
795 t.Fatal("setup: cards not shown")
796 }
797 s := indicatorScene(t, c)
798 if len(scenePreviews(s)) != 3 {
799 t.Fatalf("%d cards", len(scenePreviews(s)))
800 }
801 // A card keeps its client's real size.
802 for _, id := range m.sw.order {
803 if got := c.configures.sent[id]; got.Width != sizes[id].Width || got.Height != sizes[id].Height {
804 t.Fatalf("window %d configured %dx%d under its card, was %dx%d", id, got.Width, got.Height, sizes[id].Width, sizes[id].Height)
805 }
806 }
807 if s.TileInset != (ports.Insets{}) {
808 t.Fatalf("tile inset %+v while the cards show", s.TileInset)
809 }
810 // The pointer finds no window under a card.
811 card := m.sw.order[1]
812 x, y := cardCenter(t, c, card)
813 if id, _, _ := c.hit(x, y); id != 0 {
814 t.Fatalf("hit window %d under a card", id)
815 }
816 if _, _, ok := c.draggable(card); ok {
817 t.Fatal("a card can be dragged")
818 }
819 if tgt := c.dropAt(card, x, y); tgt.kind != dropNone {
820 t.Fatalf("a drop target %+v under the cards", tgt)
821 }
822 // A map and an unmap behind the cards do not animate.
823 c.mapWindow(ports.WindowMapped{ID: 20})
824 if len(c.cur().rects) != 0 {
825 t.Fatalf("a map behind the cards started motions: %+v", c.cur().rects)
826 }
827 gone := m.sw.order[2]
828 c.unmapWindow(ports.WindowUnmapped{ID: gone})
829 if len(c.cur().rects) != 0 {
830 t.Fatalf("an unmap behind the cards started motions: %+v", c.cur().rects)
831 }
832 if m.Current().columnOf(gone) >= 0 || len(m.sw.order) != 2 {
833 t.Fatalf("window %d not removed from the cards: %v", gone, m.sw.order)
834 }
835 // The workspace comes back with the panel inset.
836 sendKeys(t, c, superKey(0, false))
837 settle()
838 if s := indicatorScene(t, c); s.TileInset.Top != 20 || len(scenePreviews(s)) != 0 {
839 t.Fatalf("after the commit: tile inset %+v, %d cards", s.TileInset, len(scenePreviews(s)))
840 }
841}
842
843// The predicates that choose between the workspace and its previews follow
844// the cards as they follow the overview: the frame, a covering fullscreen
845// and the capture state.
846func TestSwitcherShownPreviewPredicates(t *testing.T) {
847 c, _, _ := switcherCore(t, 3)
848 sc := c.cur()
849 m := sc.mon
850 w := m.Current()
851 w.Overflow = OverflowFixed // a fullscreen window covers whatever the scroll
852 w.SetSize(100, 100) // the workspace frame is smaller than the output
853 tile := w.Columns[w.Focus].Windows[0]
854 w.SetFullscreen(tile, true)
855 indicatorScene(t, c)
856 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: w.ID}}}
857 type state struct {
858 frameIsOutput, covers, captureHidden bool
859 captureShown uint64
860 }
861 read := func() state {
862 r, reason := c.capResolve(capTarget{workspace: w.ID})
863 if reason != ports.CaptureReasonNone {
864 t.Fatalf("capture target: %v", reason)
865 }
866 var shown uint64
867 if cs := c.captureSceneFor(sc, &capView{}); cs != nil {
868 shown = cs.Shown
869 }
870 return state{m.Frame() == m.Output(), hasFullscreen(sc), r.hidden, shown}
871 }
872 if got, want := read(), (state{false, true, false, w.ID}); got != want {
873 t.Fatalf("closed: %+v, want %+v", got, want)
874 }
875 c.mods = ports.ModSuper
876 bind(t, c, ActionSwitchColumnNext)
877 c.switcherTick()
878 if !m.switcherShown() {
879 t.Fatal("setup: cards not shown")
880 }
881 if got, want := read(), (state{true, false, true, 0}); got != want {
882 t.Fatalf("shown: %+v, want %+v", got, want)
883 }
884}
885
886// A card's window with no configure sent yet (it mapped since the last
887// publish) is sized from its real client rect, not from the card.
888func TestSwitcherCardOfUnconfiguredWindowKeepsRealSize(t *testing.T) {
889 c, _, _ := switcherCore(t, 3)
890 m := c.cur().mon
891 id := m.Current().switchOrder()[1]
892 c.configures.forget(id) // the card falls back to the usable size
893 c.mods = ports.ModSuper
894 bind(t, c, ActionSwitchColumnNext)
895 c.configures.forget(id) // the bind's publish configured it again
896 c.switcherTick()
897 indicatorScene(t, c)
898 var real Placement
899 for _, p := range m.Current().Layout() {
900 if p.ID == id {
901 real = p
902 }
903 }
904 want := c.clientRect(real)
905 if got := c.configures.sent[id]; got.Width != want.W || got.Height != want.H || want.W == 0 {
906 t.Fatalf("window %d configured %dx%d under its card, real client %dx%d", id, got.Width, got.Height, want.W, want.H)
907 }
908}
909
910// A focus change behind the cards (the real focus, not the selection) is not
911// recorded as a use of the column until the cards are gone.
912func TestSwitcherShownDoesNotNoteFocus(t *testing.T) {
913 c, _, _ := switcherCore(t, 3)
914 w := c.cur().mon.Current()
915 c.mods = ports.ModSuper
916 bind(t, c, ActionSwitchColumnNext)
917 c.switcherTick()
918 before := slices.Clone(w.recent)
919 w.FocusID(c.cur().mon.sw.order[2])
920 indicatorScene(t, c)
921 if !slices.Equal(w.recent, before) {
922 t.Fatalf("recent %v, was %v: noted while the cards show", w.recent, before)
923 }
924}
925
926// Showing the cards drops the motions of windows they hide.
927func TestSwitcherShowDropsMotionsOfHiddenWindows(t *testing.T) {
928 c, _, _ := switcherCore(t, 3)
929 c.cfg.Animations.On = true
930 sc := c.cur()
931 c.mapWindow(ports.WindowMapped{ID: 4})
932 w := sc.mon.Current()
933 w.ConsumeOrExpel(-1) // 4 joins 3's column
934 w.FocusID(3) // 3 stands for the column; 4 is hidden
935 if _, ok := sc.rects[4]; !ok {
936 t.Fatal("setup: no entrance motion for window 4")
937 }
938 c.mods = ports.ModSuper
939 bind(t, c, ActionSwitchColumnNext)
940 if _, ok := sc.rects[4]; !ok {
941 t.Fatal("setup: motion dropped by the bind")
942 }
943 c.switcherTick()
944 if _, ok := sc.rects[4]; ok {
945 t.Fatalf("a hidden window keeps its motion: %+v", sc.rects[4])
946 }
947}
948
949func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
950 c, _, cmds := switcherCore(t, 3)
951 focus := switcherFocus(c)
952 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
953 c.switcherTick()
954 indicatorScene(t, c)
955 forwarded(cmds)
956 if c.keyboardFocus() != focus {
957 t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus)
958 }
959 sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true})
960 // An unbound key goes to the real focus (Cmd+x has no bind here).
961 fk := forwarded(cmds)
962 if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" {
963 t.Fatalf("forwarded %+v, want x to %d", fk, focus)
964 }
965}
966
967// --- P3.3: click ---
968
969func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) {
970 t.Helper()
971 for _, p := range c.cur().shownLayout() {
972 if p.ID == id && p.Preview > 0 {
973 return float64(p.Rect.X + p.Rect.W/2), float64(p.Rect.Y + p.Rect.H/2)
974 }
975 }
976 t.Fatalf("no card for %d", id)
977 return 0, 0
978}
979
980func TestSwitcherClickOnCardCommits(t *testing.T) {
981 c, _, cmds := switcherCore(t, 3)
982 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
983 c.switcherTick()
984 indicatorScene(t, c)
985 target := c.cur().mon.sw.order[2]
986 c.cursorX, c.cursorY = cardCenter(t, c, target)
987 forwarded(cmds)
988 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
989 t.Fatal(err)
990 }
991 requireClosed(t, c)
992 if switcherFocus(c) != target {
993 t.Fatalf("focus %d, want %d", switcherFocus(c), target)
994 }
995 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
996 t.Fatal(err)
997 }
998 for len(cmds) > 0 {
999 if v, ok := (<-cmds).(ports.PointerButtonTo); ok {
1000 t.Fatalf("click reached a client: %+v", v)
1001 }
1002 }
1003}
1004
1005// A click lands on a card where it is drawn, while its motion still runs.
1006func TestSwitcherClickOnMovingCardCommits(t *testing.T) {
1007 c, ic, _ := switcherCore(t, 3)
1008 c.cfg.Animations.On = true
1009 sc := c.cur()
1010 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1011 c.switcherTick()
1012 indicatorScene(t, c)
1013 ic.now = ic.now.Add(16 * time.Millisecond)
1014 if err := c.step(context.Background(), nil); err != nil {
1015 t.Fatal(err)
1016 }
1017 lastScene(t, c)
1018 target := sc.mon.sw.order[2]
1019 if len(sc.rects) == 0 || !c.animating() {
1020 t.Fatalf("setup: %d card motions, animating %v", len(sc.rects), c.animating())
1021 }
1022 var settled, shown Rect
1023 for _, p := range sc.settledLayout {
1024 if p.ID == target {
1025 settled = p.Rect
1026 }
1027 }
1028 for _, p := range sc.shownLayout() {
1029 if p.ID == target {
1030 shown = p.Rect
1031 }
1032 }
1033 if settled == shown {
1034 t.Fatalf("setup: card %d shown at its settled rect %+v", target, shown)
1035 }
1036 c.cursorX, c.cursorY = cardCenter(t, c, target)
1037 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1038 t.Fatal(err)
1039 }
1040 requireClosed(t, c)
1041 if switcherFocus(c) != target {
1042 t.Fatalf("focus %d, want %d", switcherFocus(c), target)
1043 }
1044}
1045
1046// Cancelling while the switch is held leaves nothing for the delayed show.
1047func TestSwitcherTickAfterCancelDoesNothing(t *testing.T) {
1048 c, _, _ := switcherCore(t, 3)
1049 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1050 if !c.switching() {
1051 t.Fatal("setup: no switcher open")
1052 }
1053 c.cancelSwitcher()
1054 if c.switcherTick() {
1055 t.Fatal("switcherTick reports a switcher after the cancel")
1056 }
1057 requireClosed(t, c)
1058}
1059
1060func TestSwitcherClickElsewhereCancels(t *testing.T) {
1061 c, _, _ := switcherCore(t, 3)
1062 start := switcherFocus(c)
1063 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1064 c.switcherTick()
1065 indicatorScene(t, c)
1066 c.cursorX, c.cursorY = 1, 1
1067 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1068 t.Fatal(err)
1069 }
1070 requireClosed(t, c)
1071 if switcherFocus(c) != start {
1072 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
1073 }
1074}
1075
1076func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
1077 c, _, _ := switcherCore(t, 3)
1078 start := switcherFocus(c)
1079 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1080 handled, err := c.switcherClick(context.Background(), 0x110)
1081 if err != nil || handled {
1082 t.Fatalf("handled %v err %v", handled, err)
1083 }
1084 requireClosed(t, c)
1085 if switcherFocus(c) != start {
1086 t.Fatal("press did not cancel")
1087 }
1088}
1089
1090// --- P3.4: robustness ---
1091
1092func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
1093 c, _, _ := switcherCore(t, 3)
1094 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1095 c.switcherTick()
1096 sw := &c.cur().mon.sw
1097 gone := sw.order[sw.at]
1098 c.cur().mon.RemoveWindow(gone)
1099 if sw.ws == nil || len(sw.order) != 2 || sw.at < 0 || sw.at >= len(sw.order) || slices.Contains(sw.order, gone) {
1100 t.Fatalf("open %v order %v at %d", sw.ws != nil, sw.order, sw.at)
1101 }
1102 s := indicatorScene(t, c)
1103 if n := len(scenePreviews(s)); n != 2 {
1104 t.Fatalf("%d cards after a close", n)
1105 }
1106 want := sw.order[sw.at]
1107 sendKeys(t, c, superKey(0, false))
1108 if switcherFocus(c) != want {
1109 t.Fatalf("focus %d, want neighbour %d", switcherFocus(c), want)
1110 }
1111}
1112
1113// Closing a window ahead of the selection shifts the cards left: the
1114// selection follows its card, so it still stands on the same window.
1115func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
1116 c, _, _ := switcherCore(t, 4)
1117 c.mods = ports.ModSuper
1118 bind(t, c, ActionSwitchColumnNext)
1119 sw := &c.cur().mon.sw
1120 if sw.at != 1 || len(sw.order) != 4 {
1121 t.Fatalf("setup: at %d of %d", sw.at, len(sw.order))
1122 }
1123 selected := sw.order[sw.at]
1124 c.cur().mon.RemoveWindow(sw.order[0])
1125 if sw.ws == nil || len(sw.order) != 3 {
1126 t.Fatalf("order %v after the close", sw.order)
1127 }
1128 if sw.at != 0 || sw.order[sw.at] != selected {
1129 t.Fatalf("selection moved from %d: order %v at %d, want at 0", selected, sw.order, sw.at)
1130 }
1131 sendKeys(t, c, superKey(0, false))
1132 if switcherFocus(c) != selected {
1133 t.Fatalf("focus %d after the commit, want %d", switcherFocus(c), selected)
1134 }
1135}
1136
1137func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
1138 c, _, _ := switcherCore(t, 2)
1139 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1140 c.switcherTick()
1141 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
1142 if sw := c.cur().mon.sw; sw.ws != nil || sw.shown {
1143 t.Fatal("switcher still open with one column")
1144 }
1145 if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {
1146 t.Fatalf("cards left: %+v", s.Windows)
1147 }
1148 // The timer fires on a closed switcher: harmless, and it clears the
1149 // core's side.
1150 if c.switcherTick() {
1151 t.Fatal("tick on a closed switcher")
1152 }
1153 requireClosed(t, c)
1154 sendKeys(t, c, superKey(0, false))
1155}
1156
1157func TestSwitcherOutputRemovalCancels(t *testing.T) {
1158 c, _, _ := switcherCore(t, 3)
1159 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1160 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1161 c.switcherTick()
1162 sc := c.switcher.sc
1163 if sc == nil {
1164 t.Fatal("no switcher screen")
1165 }
1166 c.removeScreen(sc.name())
1167 requireClosed(t, c)
1168 if sc.mon.sw.ws != nil {
1169 t.Fatal("switcher survived its output")
1170 }
1171 if err := c.publish(context.Background()); err != nil {
1172 t.Fatal(err)
1173 }
1174 if c.switcherTick() {
1175 t.Fatal("tick after the output went")
1176 }
1177}
1178
1179func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
1180 c, _, _ := switcherCore(t, 3)
1181 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1182 first := c.cur()
1183 for id := WindowID(11); id <= 13; id++ {
1184 c.screens[c.screenIndex("B")].mon.AddWindow(id)
1185 }
1186 indicatorScene(t, c)
1187 c.mods = ports.ModSuper
1188 bind(t, c, ActionSwitchColumnNext)
1189 if c.switcher.sc != first {
1190 t.Fatal("switcher not on the focused screen")
1191 }
1192 c.focusScreen = c.screenIndex("B")
1193 bind(t, c, ActionSwitchColumnNext)
1194 if first.mon.sw.ws != nil {
1195 t.Fatal("old switcher left open")
1196 }
1197 if c.switcher.sc != c.cur() || c.cur().mon.sw.ws == nil || c.cur().mon.sw.at != 1 {
1198 t.Fatalf("new switcher on %v open %v", c.switcher.sc == c.cur(), c.cur().mon.sw.ws != nil)
1199 }
1200}
1201
1202// With animations on, the cards fly in and the commit flies them back; the
1203// windows end where the layout puts them, with no card left.
1204func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
1205 c, ic, _ := switcherCore(t, 3)
1206 c.cfg.Animations.On = true
1207 target := c.cur().mon.Current().switchOrder()[1]
1208 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1209 c.switcherTick()
1210 if err := c.publish(context.Background()); err != nil {
1211 t.Fatal(err)
1212 }
1213 if len(scenePreviews(lastScene(t, c))) != 3 {
1214 t.Fatal("cards not shown")
1215 }
1216 sendKeys(t, c, superKey(0, false))
1217 for range 200 {
1218 ic.now = ic.now.Add(16 * time.Millisecond)
1219 if err := c.step(context.Background(), nil); err != nil {
1220 t.Fatal(err)
1221 }
1222 }
1223 s := lastScene(t, c)
1224 if len(scenePreviews(s)) != 0 || switcherFocus(c) != target {
1225 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
1226 }
1227 if n := len(c.cur().rects); n != 0 || c.animating() {
1228 t.Fatalf("motions left: %d rect motions, animating %v", n, c.animating())
1229 }
1230}
1231
1232// --- review fixes ---
1233
1234// twoWorkspaces is a switcher core whose second workspace holds two
1235// windows (10, 11) and is not on screen.
1236func twoWorkspaces(t *testing.T) (*Core, *Workspace) {
1237 t.Helper()
1238 c, _, _ := switcherCore(t, 3)
1239 c.cfg.Floating.Dim = 0.4
1240 m := c.cur().mon
1241 first := m.Current()
1242 m.Focus(1)
1243 m.AddWindow(10)
1244 m.AddWindow(11)
1245 other := m.Current()
1246 m.show(first)
1247 if other == first || m.Current() != first {
1248 t.Fatal("workspaces not set up")
1249 }
1250 indicatorScene(t, c)
1251 return c, other
1252}
1253
1254func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
1255 c, other := twoWorkspaces(t)
1256 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1257 c.switcherTick()
1258 if s := indicatorScene(t, c); len(scenePreviews(s)) != 3 {
1259 t.Fatalf("cards not shown: %+v", s.Windows)
1260 }
1261 // An xdg-activation request for a window of the other workspace.
1262 if err := c.activate(context.Background(), 10); err != nil {
1263 t.Fatal(err)
1264 }
1265 m := c.cur().mon
1266 if m.Current() != other || m.previewing() {
1267 t.Fatalf("on screen %v, previewing %v", m.Current() == other, m.previewing())
1268 }
1269 s := indicatorScene(t, c)
1270 if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
1271 t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
1272 }
1273 requireClosed(t, c)
1274 var any bool
1275 for _, p := range c.cur().shownLayout() {
1276 if p.ID == 10 || p.ID == 11 {
1277 x, y := float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)
1278 if id, _, _ := c.hit(x, y); id != p.ID {
1279 t.Fatalf("hit %d at window %d", id, p.ID)
1280 }
1281 any = true
1282 }
1283 }
1284 if !any {
1285 t.Fatal("workspace windows not laid out")
1286 }
1287 // The next switch opens fresh on the new workspace.
1288 c.mods = ports.ModSuper
1289 bind(t, c, ActionSwitchColumnNext)
1290 if sw := m.sw; sw.ws == nil || sw.ws != other || sw.at != 1 {
1291 t.Fatalf("open %v on other %v at %d", sw.ws != nil, sw.ws == other, sw.at)
1292 }
1293}
1294
1295func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
1296 c, other := twoWorkspaces(t)
1297 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1298 c.switcherTick()
1299 // Shown on screen, then the workspace changes with no publish between.
1300 c.cur().mon.show(other)
1301 if c.cur().mon.previewing() {
1302 t.Fatal("stale cards still count as previews")
1303 }
1304 sendKeys(t, c, tab(ports.ModSuper, true))
1305 sw := c.cur().mon.sw
1306 if sw.ws == nil || sw.ws != other || sw.shown || sw.at != 1 {
1307 t.Fatalf("open %v on other %v shown %v at %d", sw.ws != nil, sw.ws == other, sw.shown, sw.at)
1308 }
1309 // A release on a stale switcher commits nothing and closes it.
1310 c.cur().mon.show(c.cur().mon.Workspaces[0])
1311 focus := switcherFocus(c)
1312 sendKeys(t, c, superKey(0, false))
1313 requireClosed(t, c)
1314 if switcherFocus(c) != focus {
1315 t.Fatal("stale switcher survived the release")
1316 }
1317}
1318
1319// The delay passes after another workspace came on screen with no publish
1320// in between: the tick drops the switcher instead of showing stale cards.
1321func TestSwitcherTickOnStaleWorkspaceCancels(t *testing.T) {
1322 c, other := twoWorkspaces(t)
1323 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1324 m := c.cur().mon
1325 if m.sw.ws == nil {
1326 t.Fatal("switcher not open")
1327 }
1328 m.show(other)
1329 if !c.switcherTick() {
1330 t.Fatal("tick on a stale workspace asks for no scene")
1331 }
1332 requireClosed(t, c)
1333}
1334
1335func TestSwitchIgnoredDuringDrag(t *testing.T) {
1336 c, _, _ := switcherCore(t, 3)
1337 start := switcherFocus(c)
1338 d := &dragState{id: start, button: btnLeft}
1339 c.drag = d
1340 c.buttons[btnLeft] = true
1341 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1342 requireClosed(t, c)
1343 if c.drag != d || switcherFocus(c) != start {
1344 t.Fatal("drag disturbed")
1345 }
1346 sendKeys(t, c, ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true})
1347 if c.drag != nil {
1348 t.Fatal("Escape did not cancel the drag")
1349 }
1350}
1351
1352// Another bind closing the shown cards still flies them back: the snapshot
1353// is taken while they are drawn, so the windows move from the card rects.
1354func TestSwitchBindOverShownCardsFliesBack(t *testing.T) {
1355 c, ic, _ := switcherCore(t, 3)
1356 c.cfg.Animations.On = true
1357 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1358 c.switcherTick()
1359 for range 200 {
1360 ic.now = ic.now.Add(16 * time.Millisecond)
1361 if err := c.step(context.Background(), nil); err != nil {
1362 t.Fatal(err)
1363 }
1364 }
1365 sc := c.cur()
1366 cards := map[WindowID]Rect{}
1367 for _, p := range sc.shownLayout() {
1368 if p.Preview > 0 {
1369 cards[p.ID] = p.Rect
1370 }
1371 }
1372 if len(cards) != 3 || len(sc.rects) != 0 {
1373 t.Fatalf("%d cards, %d motions: the cards did not settle", len(cards), len(sc.rects))
1374 }
1375 bind(t, c, ActionFocusColumnLeft)
1376 if c.switching() || sc.mon.sw.shown {
1377 t.Fatal("the bind left the switcher open")
1378 }
1379 settled := map[WindowID]Rect{}
1380 for _, p := range sc.mon.Layout() {
1381 settled[p.ID] = p.Rect
1382 }
1383 for id, card := range cards {
1384 rm, ok := sc.rects[id]
1385 if !ok || !rm.scale {
1386 t.Fatalf("window %d has no fly-back motion", id)
1387 }
1388 if got := rm.apply(settled[id]); got != card {
1389 t.Fatalf("window %d flies back from %+v, want its card %+v", id, got, card)
1390 }
1391 }
1392}
1393
1394func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1395 m := NewMonitor()
1396 m.SetOutput(300, 200)
1397 m.SetMaxColumns(3)
1398 for id := WindowID(1); id <= 4; id++ {
1399 m.AddWindow(id)
1400 }
1401 w := m.Current()
1402 w.FocusID(3)
1403 w.ConsumeOrExpel(-1) // columns [1] [2 3] [4]
1404 if len(w.Columns) != 3 || len(w.Columns[1].Windows) != 2 {
1405 t.Fatalf("columns %+v", w.Columns)
1406 }
1407 w.FocusID(1)
1408 openSwitcher(m, 0)
1409 at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
1410 m.sw.at = at
1411 gone := m.sw.order[at]
1412 size := Rect{W: 120, H: 90}
1413 m.sw.sizes = map[WindowID]Rect{gone: size}
1414 other := w.Columns[1].Windows[0]
1415 if other == gone {
1416 other = w.Columns[1].Windows[1]
1417 }
1418 m.RemoveWindow(gone)
1419 if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
1420 t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
1421 }
1422 if m.sw.sizes[other] != size {
1423 t.Fatalf("sizes %v: the new card keeps no size of the closed window's", m.sw.sizes)
1424 }
1425 m.commitSwitcher()
1426 if id, _ := w.Focused(); id != other {
1427 t.Fatalf("focus %d, want %d", id, other)
1428 }
1429}
1430
1431func TestSwitchCommitFocusesTileUnderFloat(t *testing.T) {
1432 c, _, _ := switcherCore(t, 3)
1433 m := c.cur().mon
1434 m.AddFloating(9, 100, 80)
1435 w := m.Current()
1436 if !w.floatFocus {
1437 t.Fatal("float not focused")
1438 }
1439 tile := w.Columns[w.Focus].Windows[0]
1440 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1441 bind(t, c, ActionSwitchColumnPrev) // back to card 0: the column under the float
1442 if m.sw.at != 0 || m.sw.order[0] != tile {
1443 t.Fatalf("at %d order %v", m.sw.at, m.sw.order)
1444 }
1445 sendKeys(t, c, superKey(0, false))
1446 if w.floatFocus || switcherFocus(c) != tile {
1447 t.Fatalf("float focused %v, focus %d, want tile %d", w.floatFocus, switcherFocus(c), tile)
1448 }
1449}
1450
1451func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
1452 c, _, _ := switcherCore(t, 3)
1453 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1454 first := c.focusScreen
1455 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1456 want := c.cur().mon.sw.order[1]
1457 // The pointer crosses to the other output meanwhile.
1458 c.focusScreen = c.screenIndex("B")
1459 sendKeys(t, c, superKey(0, false))
1460 if c.focusScreen != first || switcherFocus(c) != want {
1461 t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
1462 }
1463}