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 overflow == OverflowCascade {
367 // The maximized column has its own band: it stays.
368 if id, _ := w.Focused(); w.Columns[w.columnOf(id)].FullWidth || !w.Columns[a].FullWidth {
369 t.Fatalf("cascade: maximization moved: %+v", w.Columns)
370 }
371 return
372 }
373 if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth {
374 t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth)
375 }
376 if w.policy().equalCells && !w.hiddenByMaximized(a) {
377 t.Fatal("source not hidden by the maximized target")
378 }
379 })
380 }
381}
382
383func TestSwitchCommitLeavesPinnedFullscreen(t *testing.T) {
384 m, w := switcherMonitor(OverflowFixed, 2)
385 src := w.Columns[w.Focus].Windows[0]
386 w.SetFullscreen(src, true)
387 if w.fullscreen != src {
388 t.Fatal("setup: no fullscreen")
389 }
390 openSwitcher(m, 1)
391 target := m.sw.order[1]
392 m.commitSwitcher()
393 if w.fullscreen != 0 {
394 t.Fatalf("fullscreen %d kept", w.fullscreen)
395 }
396 if id, _ := w.Focused(); id != target {
397 t.Fatalf("focus %d, want %d", id, target)
398 }
399}
400
401func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) {
402 m, w := switcherMonitor(OverflowScroll, 3)
403 before, _ := w.Focused()
404 openSwitcher(m, 1)
405 m.sw.order[1] = 99 // not in the workspace any more
406 m.commitSwitcher()
407 if id, _ := w.Focused(); id != before || m.sw.ws != nil {
408 t.Fatalf("focus %d, want %d; open %v", id, before, m.sw.ws != nil)
409 }
410 openSwitcher(m, 1)
411 m.sw.ws = &Workspace{}
412 m.commitSwitcher()
413 if id, _ := w.Focused(); id != before || m.sw.ws != nil {
414 t.Fatalf("focus %d for a foreign workspace, want %d", id, before)
415 }
416}
417
418// --- P2.2: actions and step ---
419
420func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
421 c, ic, _ := switcherCore(t, 3)
422 c.mods = ports.ModSuper
423 before := switcherFocus(c)
424 if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
425 t.Fatal(err)
426 }
427 sw := &c.cur().mon.sw
428 if sw.ws == nil || sw.at != 1 || switcherFocus(c) != before {
429 t.Fatalf("open %v at %d focus %d (was %d)", sw.ws != nil, sw.at, switcherFocus(c), before)
430 }
431 if len(ic.timers) != 1 || ic.timers[0] != switcherDelay {
432 t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay)
433 }
434 bind(t, c, ActionSwitchColumnNext)
435 if sw.at != 2 {
436 t.Fatalf("at %d", sw.at)
437 }
438 bind(t, c, ActionSwitchColumnNext)
439 if sw.at != 0 {
440 t.Fatalf("at %d, want wrap to 0", sw.at)
441 }
442 bind(t, c, ActionSwitchColumnPrev)
443 if sw.at != 2 {
444 t.Fatalf("at %d, want wrap to 2", sw.at)
445 }
446 bind(t, c, ActionSwitchColumnPrev)
447 if sw.at != 1 {
448 t.Fatalf("at %d", sw.at)
449 }
450 if len(ic.timers) != 1 {
451 t.Fatalf("timers %v: stepping must not re-arm", ic.timers)
452 }
453}
454
455func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
456 c, ic, _ := switcherCore(t, 3)
457 start := switcherFocus(c)
458 // F9 is bound to switch-column-next with no Cmd held.
459 f9 := func(pressed bool) ports.KeyEvent {
460 return ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: pressed}
461 }
462 sendKeys(t, c, f9(true), f9(false))
463 requireClosed(t, c)
464 if len(ic.timers) != 0 {
465 t.Fatalf("timers %v armed for a switch with no Cmd", ic.timers)
466 }
467 if switcherFocus(c) == start {
468 t.Fatal("focus did not move")
469 }
470 sendKeys(t, c, f9(true), f9(false))
471 if switcherFocus(c) != start {
472 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
473 }
474}
475
476func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
477 c, _, _ := switcherCore(t, 1)
478 c.mods = ports.ModSuper
479 bind(t, c, ActionSwitchColumnNext)
480 requireClosed(t, c)
481 c, _, _ = switcherCore(t, 3)
482 c.cur().mon.ToggleOverview()
483 before := switcherFocus(c)
484 bind(t, c, ActionSwitchColumnNext)
485 requireClosed(t, c)
486 if switcherFocus(c) != before {
487 t.Fatal("switcher acted in the overview")
488 }
489}
490
491// --- P2.3: release commit and timer ---
492
493func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
494 c, _, _ := switcherCore(t, 3)
495 start := switcherFocus(c)
496 for len(c.ch.Scenes) > 0 {
497 <-c.ch.Scenes
498 }
499 tap := func() {
500 t.Helper()
501 sendKeys(t, c,
502 superKey(ports.ModSuper, true),
503 tab(ports.ModSuper, true),
504 tab(ports.ModSuper, false),
505 superKey(0, false),
506 )
507 for len(c.ch.Scenes) > 0 {
508 if p := scenePreviews((<-c.ch.Scenes)[0]); len(p) > 0 {
509 t.Fatalf("cards published during a quick tap: %+v", p)
510 }
511 }
512 requireClosed(t, c)
513 }
514 tap()
515 if switcherFocus(c) == start {
516 t.Fatal("tap did not swap")
517 }
518 tap()
519 if switcherFocus(c) != start {
520 t.Fatalf("second tap focus %d, want back on %d", switcherFocus(c), start)
521 }
522}
523
524func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
525 c, _, cmds := switcherCore(t, 3)
526 start := switcherFocus(c)
527 order := c.cur().mon.Current().switchOrder()
528 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
529 if switcherFocus(c) != start || c.cur().mon.sw.ws == nil {
530 t.Fatal("focus moved or switcher closed before the release")
531 }
532 forwarded(cmds)
533 sendKeys(t, c, superKey(0, false))
534 requireClosed(t, c)
535 if switcherFocus(c) != order[1] {
536 t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
537 }
538 fk := forwarded(cmds)
539 if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {
540 t.Fatalf("release not forwarded to the new focus: %+v", fk)
541 }
542}
543
544// --- P3.1: keys and gestures ---
545
546func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
547 c, _, cmds := switcherCore(t, 3)
548 start := switcherFocus(c)
549 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
550 forwarded(cmds)
551 esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true}
552 sendKeys(t, c, esc)
553 esc.Pressed = false
554 sendKeys(t, c, esc, superKey(0, false))
555 requireClosed(t, c)
556 if switcherFocus(c) != start {
557 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
558 }
559 for _, k := range forwarded(cmds) {
560 if k.Key.Keysym == "Escape" {
561 t.Fatalf("Escape forwarded: %+v", k)
562 }
563 }
564}
565
566func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
567 c, _, _ := switcherCore(t, 3)
568 start := switcherFocus(c)
569 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
570 sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
571 requireClosed(t, c)
572 if switcherFocus(c) != start {
573 t.Fatalf("focus %d moved", switcherFocus(c))
574 }
575 if c.cur().mon.Current().fullscreen != start {
576 t.Fatalf("bind not applied: fullscreen %d", c.cur().mon.Current().fullscreen)
577 }
578}
579
580func TestSwitchShiftTabGoesBackward(t *testing.T) {
581 c, _, _ := switcherCore(t, 3)
582 sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true))
583 sw := c.cur().mon.sw
584 if sw.ws == nil || sw.at != len(sw.order)-1 {
585 t.Fatalf("open %v at %d of %d", sw.ws != nil, sw.at, len(sw.order))
586 }
587 sendKeys(t, c, shiftTab(false), tab(ports.ModSuper, true))
588 if c.cur().mon.sw.at != 0 {
589 t.Fatalf("at %d, want 0 after next", c.cur().mon.sw.at)
590 }
591}
592
593func TestSwitchSwipeBeginCancels(t *testing.T) {
594 c, _, _ := switcherCore(t, 3)
595 start := switcherFocus(c)
596 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
597 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
598 requireClosed(t, c)
599 if switcherFocus(c) != start {
600 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
601 }
602}
603
604func TestSwitchSecurityChangeCancels(t *testing.T) {
605 c, _, _ := switcherCore(t, 3)
606 state := ports.SecurityState{Generation: 1}
607 gate := portsmocks.NewMockSessionSecurity(t)
608 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
609 c.opts.Security = gate
610 start := switcherFocus(c)
611 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
612 state = ports.SecurityState{Generation: 2}
613 if !c.syncSecurity() {
614 t.Fatal("security change not seen")
615 }
616 requireClosed(t, c)
617 if switcherFocus(c) != start {
618 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
619 }
620}
621
622func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
623 c, _, _ := switcherCore(t, 3)
624 cfg := c.cfg
625 cfg.Keyboard.CmdKey = "alt"
626 if err := c.apply(cfg); err != nil {
627 t.Fatal(err)
628 }
629 order := c.cur().mon.Current().switchOrder()
630 sendKeys(t, c, tab(ports.ModAlt, true))
631 if c.cur().mon.sw.ws == nil {
632 t.Fatal("Alt+Tab did not open the switcher")
633 }
634 sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false})
635 requireClosed(t, c)
636 if switcherFocus(c) != order[1] {
637 t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
638 }
639}
640
641// --- P3.2: layout ---
642
643func TestSwitcherLayoutCards(t *testing.T) {
644 c, _, _ := switcherCore(t, 3)
645 c.cfg.Floating.Dim = 0.4
646 // A wider focused column: the cards have different aspect ratios.
647 c.cur().mon.Current().GrowColumn(false)
648 indicatorScene(t, c)
649 area := c.cur().mon.Current().overviewArea()
650 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
651 if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 || c.cur().mon.sw.shown {
652 t.Fatalf("cards before the delay: %+v", s.Windows)
653 }
654 order := c.cur().mon.sw.order
655 if !c.switcherTick() || !c.cur().mon.sw.shown || c.switcher.timerC != nil {
656 t.Fatal("the tick did not show the cards and clear the timer")
657 }
658 s := indicatorScene(t, c)
659 cards := scenePreviews(s)
660 if len(cards) != 3 {
661 t.Fatalf("%d cards: %+v", len(cards), s.Windows)
662 }
663 focused := 0
664 distinct := false
665 var centers []int
666 for k, card := range cards {
667 if card.ID != order[k] {
668 t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k])
669 }
670 // The boxes are equal: the cards' centres are evenly spaced.
671 centers = append(centers, card.Rect.X+card.Rect.W/2)
672 // A card is its window's client size, scaled down uniformly.
673 sent := c.configures.sent[card.ID]
674 if sent.Width <= 0 || sent.Height <= 0 || card.Preview <= 0 || card.Preview >= 1 {
675 t.Fatalf("card %d: client %dx%d, preview %v", card.ID, sent.Width, sent.Height, card.Preview)
676 }
677 if d := card.Rect.W - int(math.Round(float64(sent.Width)*card.Preview)); d < -1 || d > 1 {
678 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)
679 }
680 if d := card.Rect.H - int(math.Round(float64(sent.Height)*card.Preview)); d < -1 || d > 1 {
681 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)
682 }
683 if first := c.configures.sent[order[0]]; sent.Width != first.Width {
684 distinct = true
685 }
686 if card.Focused {
687 focused++
688 if card.ID != order[c.cur().mon.sw.at] {
689 t.Fatalf("focused card %d is not the selected %d", card.ID, order[c.cur().mon.sw.at])
690 }
691 }
692 }
693 if !distinct {
694 t.Fatal("setup: every window has the same size, so the aspect ratios prove nothing")
695 }
696 if d := (centers[1] - centers[0]) - (centers[2] - centers[1]); d < -2 || d > 2 {
697 t.Fatalf("boxes not equal: card centres %v", centers)
698 }
699 if d := centers[0] + centers[2] - 2*(area.X+area.W/2); d < -2 || d > 2 {
700 t.Fatalf("row not centered in %+v: card centres %v", area, centers)
701 }
702 if focused != 1 {
703 t.Fatalf("%d focused cards", focused)
704 }
705 if len(s.Separators) != 4 {
706 t.Fatalf("separators %+v, want the 4 lines of the outline", s.Separators)
707 }
708 if s.Dim != 0.4 {
709 t.Fatalf("dim %v", s.Dim)
710 }
711 if s.TileInset != (ports.Insets{}) {
712 t.Fatalf("tile inset %+v", s.TileInset)
713 }
714 for _, w := range s.Windows {
715 if w.Preview == 0 && !w.Hidden {
716 t.Fatalf("window %d drawn under the cards", w.ID)
717 }
718 }
719 // A commit gives the workspace back.
720 sendKeys(t, c, superKey(0, false))
721 if s := lastScene(t, c); len(scenePreviews(s)) != 0 {
722 t.Fatalf("cards after the commit: %+v", s.Windows)
723 }
724}
725
726func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
727 // Windows 10 and 11 are on a second workspace.
728 c, _ := twoWorkspaces(t)
729 m := c.cur().mon
730 m.AddWindow(4)
731 w := m.Current()
732 w.ConsumeOrExpel(-1)
733 m.AddFloating(9, 100, 80)
734 if len(w.Columns) != 3 || w.columnWindows() != 4 || len(w.Floats) != 1 {
735 t.Fatalf("setup: %d columns, %d windows, %d floats", len(w.Columns), w.columnWindows(), len(w.Floats))
736 }
737 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
738 c.switcherTick()
739 s := indicatorScene(t, c)
740 reps := map[WindowID]bool{}
741 for _, id := range m.sw.order {
742 reps[id] = true
743 }
744 seen := map[WindowID]bool{}
745 for _, sw := range s.Windows {
746 seen[sw.ID] = true
747 if reps[sw.ID] {
748 if sw.Preview == 0 || sw.Hidden {
749 t.Fatalf("representative %d not a card: %+v", sw.ID, sw)
750 }
751 } else if !sw.Hidden {
752 t.Fatalf("window %d not hidden: %+v", sw.ID, sw)
753 }
754 }
755 // The other window of a column, the float and the other workspace's
756 // windows are all in the scene, hidden.
757 for _, id := range []WindowID{4, 9, 10, 11} {
758 if !seen[id] {
759 t.Fatalf("window %d missing from the scene (hidden windows must be listed): %+v", id, s.Windows)
760 }
761 }
762}
763
764// While the cards show, the screen draws previews: the tiles do not stop at
765// the panels, a card takes no pointer, nothing is
766// dragged, and a window mapping or unmapping behind them does not animate.
767func TestSwitcherShownIsAPreview(t *testing.T) {
768 c, ic, _ := switcherCore(t, 3)
769 c.cfg.Animations.On = true
770 m := c.cur().mon
771 // A bar reserves the top of the output: tiles stop below it.
772 c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 300, Height: 20, ExclusiveZone: 20}})
773 if s := indicatorScene(t, c); s.TileInset.Top != 20 {
774 t.Fatalf("setup: tile inset %+v, want the bar's 20", s.TileInset)
775 }
776 sizes := map[WindowID]ports.ConfigureWindow{}
777 for id, v := range c.configures.sent {
778 sizes[id] = v
779 }
780 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
781 c.switcherTick()
782 indicatorScene(t, c)
783 // The cards fly in, and the motions survive a publish.
784 if len(c.cur().rects) == 0 {
785 t.Fatal("no card motion after the show")
786 }
787 settle := func() {
788 t.Helper()
789 for range 200 {
790 ic.now = ic.now.Add(16 * time.Millisecond)
791 if err := c.step(context.Background(), nil); err != nil {
792 t.Fatal(err)
793 }
794 }
795 lastScene(t, c)
796 if len(c.cur().rects) != 0 || c.animating() {
797 t.Fatalf("motions did not settle: %d rects", len(c.cur().rects))
798 }
799 }
800 settle()
801 if !m.switcherShown() {
802 t.Fatal("setup: cards not shown")
803 }
804 s := indicatorScene(t, c)
805 if len(scenePreviews(s)) != 3 {
806 t.Fatalf("%d cards", len(scenePreviews(s)))
807 }
808 // A card keeps its client's real size.
809 for _, id := range m.sw.order {
810 if got := c.configures.sent[id]; got.Width != sizes[id].Width || got.Height != sizes[id].Height {
811 t.Fatalf("window %d configured %dx%d under its card, was %dx%d", id, got.Width, got.Height, sizes[id].Width, sizes[id].Height)
812 }
813 }
814 if s.TileInset != (ports.Insets{}) {
815 t.Fatalf("tile inset %+v while the cards show", s.TileInset)
816 }
817 // The pointer finds no window under a card.
818 card := m.sw.order[1]
819 x, y := cardCenter(t, c, card)
820 if id, _, _ := c.hit(x, y); id != 0 {
821 t.Fatalf("hit window %d under a card", id)
822 }
823 if _, _, ok := c.draggable(card); ok {
824 t.Fatal("a card can be dragged")
825 }
826 if tgt := c.dropAt(card, x, y); tgt.kind != dropNone {
827 t.Fatalf("a drop target %+v under the cards", tgt)
828 }
829 // A map and an unmap behind the cards do not animate.
830 c.mapWindow(ports.WindowMapped{ID: 20})
831 if len(c.cur().rects) != 0 {
832 t.Fatalf("a map behind the cards started motions: %+v", c.cur().rects)
833 }
834 gone := m.sw.order[2]
835 c.unmapWindow(ports.WindowUnmapped{ID: gone})
836 if len(c.cur().rects) != 0 {
837 t.Fatalf("an unmap behind the cards started motions: %+v", c.cur().rects)
838 }
839 if m.Current().columnOf(gone) >= 0 || len(m.sw.order) != 2 {
840 t.Fatalf("window %d not removed from the cards: %v", gone, m.sw.order)
841 }
842 // The workspace comes back with the panel inset.
843 sendKeys(t, c, superKey(0, false))
844 settle()
845 if s := indicatorScene(t, c); s.TileInset.Top != 20 || len(scenePreviews(s)) != 0 {
846 t.Fatalf("after the commit: tile inset %+v, %d cards", s.TileInset, len(scenePreviews(s)))
847 }
848}
849
850// The predicates that choose between the workspace and its previews follow
851// the cards as they follow the overview: the frame, a covering fullscreen
852// and the capture state.
853func TestSwitcherShownPreviewPredicates(t *testing.T) {
854 c, _, _ := switcherCore(t, 3)
855 sc := c.cur()
856 m := sc.mon
857 w := m.Current()
858 w.Overflow = OverflowFixed // a fullscreen window covers whatever the scroll
859 w.SetSize(100, 100) // the workspace frame is smaller than the output
860 tile := w.Columns[w.Focus].Windows[0]
861 w.SetFullscreen(tile, true)
862 indicatorScene(t, c)
863 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: w.ID}}}
864 type state struct {
865 frameIsOutput, covers, captureHidden bool
866 captureShown uint64
867 }
868 read := func() state {
869 r, reason := c.capResolve(capTarget{workspace: w.ID})
870 if reason != ports.CaptureReasonNone {
871 t.Fatalf("capture target: %v", reason)
872 }
873 var shown uint64
874 if cs := c.captureSceneFor(sc, &capView{}); cs != nil {
875 shown = cs.Shown
876 }
877 return state{m.Frame() == m.Output(), hasFullscreen(sc), r.hidden, shown}
878 }
879 if got, want := read(), (state{false, true, false, w.ID}); got != want {
880 t.Fatalf("closed: %+v, want %+v", got, want)
881 }
882 c.mods = ports.ModSuper
883 bind(t, c, ActionSwitchColumnNext)
884 c.switcherTick()
885 if !m.switcherShown() {
886 t.Fatal("setup: cards not shown")
887 }
888 if got, want := read(), (state{true, false, true, 0}); got != want {
889 t.Fatalf("shown: %+v, want %+v", got, want)
890 }
891}
892
893// A card's window with no configure sent yet (it mapped since the last
894// publish) is sized from its real client rect, not from the card.
895func TestSwitcherCardOfUnconfiguredWindowKeepsRealSize(t *testing.T) {
896 c, _, _ := switcherCore(t, 3)
897 m := c.cur().mon
898 id := m.Current().switchOrder()[1]
899 c.configures.forget(id) // the card falls back to the usable size
900 c.mods = ports.ModSuper
901 bind(t, c, ActionSwitchColumnNext)
902 c.configures.forget(id) // the bind's publish configured it again
903 c.switcherTick()
904 indicatorScene(t, c)
905 var real Placement
906 for _, p := range m.Current().Layout() {
907 if p.ID == id {
908 real = p
909 }
910 }
911 want := c.clientRect(real)
912 if got := c.configures.sent[id]; got.Width != want.W || got.Height != want.H || want.W == 0 {
913 t.Fatalf("window %d configured %dx%d under its card, real client %dx%d", id, got.Width, got.Height, want.W, want.H)
914 }
915}
916
917// A focus change behind the cards (the real focus, not the selection) is not
918// recorded as a use of the column until the cards are gone.
919func TestSwitcherShownDoesNotNoteFocus(t *testing.T) {
920 c, _, _ := switcherCore(t, 3)
921 w := c.cur().mon.Current()
922 c.mods = ports.ModSuper
923 bind(t, c, ActionSwitchColumnNext)
924 c.switcherTick()
925 before := slices.Clone(w.recent)
926 w.FocusID(c.cur().mon.sw.order[2])
927 indicatorScene(t, c)
928 if !slices.Equal(w.recent, before) {
929 t.Fatalf("recent %v, was %v: noted while the cards show", w.recent, before)
930 }
931}
932
933// Showing the cards drops the motions of windows they hide.
934func TestSwitcherShowDropsMotionsOfHiddenWindows(t *testing.T) {
935 c, _, _ := switcherCore(t, 3)
936 c.cfg.Animations.On = true
937 sc := c.cur()
938 c.mapWindow(ports.WindowMapped{ID: 4})
939 w := sc.mon.Current()
940 w.ConsumeOrExpel(-1) // 4 joins 3's column
941 w.FocusID(3) // 3 stands for the column; 4 is hidden
942 if _, ok := sc.rects[4]; !ok {
943 t.Fatal("setup: no entrance motion for window 4")
944 }
945 c.mods = ports.ModSuper
946 bind(t, c, ActionSwitchColumnNext)
947 if _, ok := sc.rects[4]; !ok {
948 t.Fatal("setup: motion dropped by the bind")
949 }
950 c.switcherTick()
951 if _, ok := sc.rects[4]; ok {
952 t.Fatalf("a hidden window keeps its motion: %+v", sc.rects[4])
953 }
954}
955
956func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
957 c, _, cmds := switcherCore(t, 3)
958 focus := switcherFocus(c)
959 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
960 c.switcherTick()
961 indicatorScene(t, c)
962 forwarded(cmds)
963 if c.keyboardFocus() != focus {
964 t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus)
965 }
966 sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true})
967 // An unbound key goes to the real focus (Cmd+x has no bind here).
968 fk := forwarded(cmds)
969 if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" {
970 t.Fatalf("forwarded %+v, want x to %d", fk, focus)
971 }
972}
973
974// --- P3.3: click ---
975
976func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) {
977 t.Helper()
978 for _, p := range c.cur().shownLayout() {
979 if p.ID == id && p.Preview > 0 {
980 return float64(p.Rect.X + p.Rect.W/2), float64(p.Rect.Y + p.Rect.H/2)
981 }
982 }
983 t.Fatalf("no card for %d", id)
984 return 0, 0
985}
986
987func TestSwitcherClickOnCardCommits(t *testing.T) {
988 c, _, cmds := switcherCore(t, 3)
989 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
990 c.switcherTick()
991 indicatorScene(t, c)
992 target := c.cur().mon.sw.order[2]
993 c.cursorX, c.cursorY = cardCenter(t, c, target)
994 forwarded(cmds)
995 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
996 t.Fatal(err)
997 }
998 requireClosed(t, c)
999 if switcherFocus(c) != target {
1000 t.Fatalf("focus %d, want %d", switcherFocus(c), target)
1001 }
1002 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
1003 t.Fatal(err)
1004 }
1005 for len(cmds) > 0 {
1006 if v, ok := (<-cmds).(ports.PointerButtonTo); ok {
1007 t.Fatalf("click reached a client: %+v", v)
1008 }
1009 }
1010}
1011
1012// A click lands on a card where it is drawn, while its motion still runs.
1013func TestSwitcherClickOnMovingCardCommits(t *testing.T) {
1014 c, ic, _ := switcherCore(t, 3)
1015 c.cfg.Animations.On = true
1016 sc := c.cur()
1017 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1018 c.switcherTick()
1019 indicatorScene(t, c)
1020 ic.now = ic.now.Add(16 * time.Millisecond)
1021 if err := c.step(context.Background(), nil); err != nil {
1022 t.Fatal(err)
1023 }
1024 lastScene(t, c)
1025 target := sc.mon.sw.order[2]
1026 if len(sc.rects) == 0 || !c.animating() {
1027 t.Fatalf("setup: %d card motions, animating %v", len(sc.rects), c.animating())
1028 }
1029 var settled, shown Rect
1030 for _, p := range sc.settledLayout {
1031 if p.ID == target {
1032 settled = p.Rect
1033 }
1034 }
1035 for _, p := range sc.shownLayout() {
1036 if p.ID == target {
1037 shown = p.Rect
1038 }
1039 }
1040 if settled == shown {
1041 t.Fatalf("setup: card %d shown at its settled rect %+v", target, shown)
1042 }
1043 c.cursorX, c.cursorY = cardCenter(t, c, target)
1044 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1045 t.Fatal(err)
1046 }
1047 requireClosed(t, c)
1048 if switcherFocus(c) != target {
1049 t.Fatalf("focus %d, want %d", switcherFocus(c), target)
1050 }
1051}
1052
1053// Cancelling while the switch is held leaves nothing for the delayed show.
1054func TestSwitcherTickAfterCancelDoesNothing(t *testing.T) {
1055 c, _, _ := switcherCore(t, 3)
1056 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1057 if !c.switching() {
1058 t.Fatal("setup: no switcher open")
1059 }
1060 c.cancelSwitcher()
1061 if c.switcherTick() {
1062 t.Fatal("switcherTick reports a switcher after the cancel")
1063 }
1064 requireClosed(t, c)
1065}
1066
1067func TestSwitcherClickElsewhereCancels(t *testing.T) {
1068 c, _, _ := switcherCore(t, 3)
1069 start := switcherFocus(c)
1070 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1071 c.switcherTick()
1072 indicatorScene(t, c)
1073 c.cursorX, c.cursorY = 1, 1
1074 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1075 t.Fatal(err)
1076 }
1077 requireClosed(t, c)
1078 if switcherFocus(c) != start {
1079 t.Fatalf("focus %d, want %d", switcherFocus(c), start)
1080 }
1081}
1082
1083func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
1084 c, _, _ := switcherCore(t, 3)
1085 start := switcherFocus(c)
1086 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1087 handled, err := c.switcherClick(context.Background(), 0x110)
1088 if err != nil || handled {
1089 t.Fatalf("handled %v err %v", handled, err)
1090 }
1091 requireClosed(t, c)
1092 if switcherFocus(c) != start {
1093 t.Fatal("press did not cancel")
1094 }
1095}
1096
1097// --- P3.4: robustness ---
1098
1099func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
1100 c, _, _ := switcherCore(t, 3)
1101 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1102 c.switcherTick()
1103 sw := &c.cur().mon.sw
1104 gone := sw.order[sw.at]
1105 c.cur().mon.RemoveWindow(gone)
1106 if sw.ws == nil || len(sw.order) != 2 || sw.at < 0 || sw.at >= len(sw.order) || slices.Contains(sw.order, gone) {
1107 t.Fatalf("open %v order %v at %d", sw.ws != nil, sw.order, sw.at)
1108 }
1109 s := indicatorScene(t, c)
1110 if n := len(scenePreviews(s)); n != 2 {
1111 t.Fatalf("%d cards after a close", n)
1112 }
1113 want := sw.order[sw.at]
1114 sendKeys(t, c, superKey(0, false))
1115 if switcherFocus(c) != want {
1116 t.Fatalf("focus %d, want neighbour %d", switcherFocus(c), want)
1117 }
1118}
1119
1120// Closing a window ahead of the selection shifts the cards left: the
1121// selection follows its card, so it still stands on the same window.
1122func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
1123 c, _, _ := switcherCore(t, 4)
1124 c.mods = ports.ModSuper
1125 bind(t, c, ActionSwitchColumnNext)
1126 sw := &c.cur().mon.sw
1127 if sw.at != 1 || len(sw.order) != 4 {
1128 t.Fatalf("setup: at %d of %d", sw.at, len(sw.order))
1129 }
1130 selected := sw.order[sw.at]
1131 c.cur().mon.RemoveWindow(sw.order[0])
1132 if sw.ws == nil || len(sw.order) != 3 {
1133 t.Fatalf("order %v after the close", sw.order)
1134 }
1135 if sw.at != 0 || sw.order[sw.at] != selected {
1136 t.Fatalf("selection moved from %d: order %v at %d, want at 0", selected, sw.order, sw.at)
1137 }
1138 sendKeys(t, c, superKey(0, false))
1139 if switcherFocus(c) != selected {
1140 t.Fatalf("focus %d after the commit, want %d", switcherFocus(c), selected)
1141 }
1142}
1143
1144func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
1145 c, _, _ := switcherCore(t, 2)
1146 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1147 c.switcherTick()
1148 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
1149 if sw := c.cur().mon.sw; sw.ws != nil || sw.shown {
1150 t.Fatal("switcher still open with one column")
1151 }
1152 if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {
1153 t.Fatalf("cards left: %+v", s.Windows)
1154 }
1155 // The timer fires on a closed switcher: harmless, and it clears the
1156 // core's side.
1157 if c.switcherTick() {
1158 t.Fatal("tick on a closed switcher")
1159 }
1160 requireClosed(t, c)
1161 sendKeys(t, c, superKey(0, false))
1162}
1163
1164func TestSwitcherOutputRemovalCancels(t *testing.T) {
1165 c, _, _ := switcherCore(t, 3)
1166 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1167 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1168 c.switcherTick()
1169 sc := c.switcher.sc
1170 if sc == nil {
1171 t.Fatal("no switcher screen")
1172 }
1173 c.removeScreen(sc.name())
1174 requireClosed(t, c)
1175 if sc.mon.sw.ws != nil {
1176 t.Fatal("switcher survived its output")
1177 }
1178 if err := c.publish(context.Background()); err != nil {
1179 t.Fatal(err)
1180 }
1181 if c.switcherTick() {
1182 t.Fatal("tick after the output went")
1183 }
1184}
1185
1186func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
1187 c, _, _ := switcherCore(t, 3)
1188 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1189 first := c.cur()
1190 for id := WindowID(11); id <= 13; id++ {
1191 c.screens[c.screenIndex("B")].mon.AddWindow(id)
1192 }
1193 indicatorScene(t, c)
1194 c.mods = ports.ModSuper
1195 bind(t, c, ActionSwitchColumnNext)
1196 if c.switcher.sc != first {
1197 t.Fatal("switcher not on the focused screen")
1198 }
1199 c.focusScreen = c.screenIndex("B")
1200 bind(t, c, ActionSwitchColumnNext)
1201 if first.mon.sw.ws != nil {
1202 t.Fatal("old switcher left open")
1203 }
1204 if c.switcher.sc != c.cur() || c.cur().mon.sw.ws == nil || c.cur().mon.sw.at != 1 {
1205 t.Fatalf("new switcher on %v open %v", c.switcher.sc == c.cur(), c.cur().mon.sw.ws != nil)
1206 }
1207}
1208
1209// With animations on, the cards fly in and the commit flies them back; the
1210// windows end where the layout puts them, with no card left.
1211func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
1212 c, ic, _ := switcherCore(t, 3)
1213 c.cfg.Animations.On = true
1214 target := c.cur().mon.Current().switchOrder()[1]
1215 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1216 c.switcherTick()
1217 if err := c.publish(context.Background()); err != nil {
1218 t.Fatal(err)
1219 }
1220 if len(scenePreviews(lastScene(t, c))) != 3 {
1221 t.Fatal("cards not shown")
1222 }
1223 sendKeys(t, c, superKey(0, false))
1224 for range 200 {
1225 ic.now = ic.now.Add(16 * time.Millisecond)
1226 if err := c.step(context.Background(), nil); err != nil {
1227 t.Fatal(err)
1228 }
1229 }
1230 s := lastScene(t, c)
1231 if len(scenePreviews(s)) != 0 || switcherFocus(c) != target {
1232 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
1233 }
1234 if n := len(c.cur().rects); n != 0 || c.animating() {
1235 t.Fatalf("motions left: %d rect motions, animating %v", n, c.animating())
1236 }
1237}
1238
1239// --- review fixes ---
1240
1241// twoWorkspaces is a switcher core whose second workspace holds two
1242// windows (10, 11) and is not on screen.
1243func twoWorkspaces(t *testing.T) (*Core, *Workspace) {
1244 t.Helper()
1245 c, _, _ := switcherCore(t, 3)
1246 c.cfg.Floating.Dim = 0.4
1247 m := c.cur().mon
1248 first := m.Current()
1249 m.Focus(1)
1250 m.AddWindow(10)
1251 m.AddWindow(11)
1252 other := m.Current()
1253 m.show(first)
1254 if other == first || m.Current() != first {
1255 t.Fatal("workspaces not set up")
1256 }
1257 indicatorScene(t, c)
1258 return c, other
1259}
1260
1261func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
1262 c, other := twoWorkspaces(t)
1263 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1264 c.switcherTick()
1265 if s := indicatorScene(t, c); len(scenePreviews(s)) != 3 {
1266 t.Fatalf("cards not shown: %+v", s.Windows)
1267 }
1268 // An xdg-activation request for a window of the other workspace.
1269 if err := c.activate(context.Background(), 10); err != nil {
1270 t.Fatal(err)
1271 }
1272 m := c.cur().mon
1273 if m.Current() != other || m.previewing() {
1274 t.Fatalf("on screen %v, previewing %v", m.Current() == other, m.previewing())
1275 }
1276 s := indicatorScene(t, c)
1277 if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
1278 t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
1279 }
1280 requireClosed(t, c)
1281 var any bool
1282 for _, p := range c.cur().shownLayout() {
1283 if p.ID == 10 || p.ID == 11 {
1284 x, y := float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)
1285 if id, _, _ := c.hit(x, y); id != p.ID {
1286 t.Fatalf("hit %d at window %d", id, p.ID)
1287 }
1288 any = true
1289 }
1290 }
1291 if !any {
1292 t.Fatal("workspace windows not laid out")
1293 }
1294 // The next switch opens fresh on the new workspace.
1295 c.mods = ports.ModSuper
1296 bind(t, c, ActionSwitchColumnNext)
1297 if sw := m.sw; sw.ws == nil || sw.ws != other || sw.at != 1 {
1298 t.Fatalf("open %v on other %v at %d", sw.ws != nil, sw.ws == other, sw.at)
1299 }
1300}
1301
1302func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
1303 c, other := twoWorkspaces(t)
1304 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1305 c.switcherTick()
1306 // Shown on screen, then the workspace changes with no publish between.
1307 c.cur().mon.show(other)
1308 if c.cur().mon.previewing() {
1309 t.Fatal("stale cards still count as previews")
1310 }
1311 sendKeys(t, c, tab(ports.ModSuper, true))
1312 sw := c.cur().mon.sw
1313 if sw.ws == nil || sw.ws != other || sw.shown || sw.at != 1 {
1314 t.Fatalf("open %v on other %v shown %v at %d", sw.ws != nil, sw.ws == other, sw.shown, sw.at)
1315 }
1316 // A release on a stale switcher commits nothing and closes it.
1317 c.cur().mon.show(c.cur().mon.Workspaces[0])
1318 focus := switcherFocus(c)
1319 sendKeys(t, c, superKey(0, false))
1320 requireClosed(t, c)
1321 if switcherFocus(c) != focus {
1322 t.Fatal("stale switcher survived the release")
1323 }
1324}
1325
1326// The delay passes after another workspace came on screen with no publish
1327// in between: the tick drops the switcher instead of showing stale cards.
1328func TestSwitcherTickOnStaleWorkspaceCancels(t *testing.T) {
1329 c, other := twoWorkspaces(t)
1330 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1331 m := c.cur().mon
1332 if m.sw.ws == nil {
1333 t.Fatal("switcher not open")
1334 }
1335 m.show(other)
1336 if !c.switcherTick() {
1337 t.Fatal("tick on a stale workspace asks for no scene")
1338 }
1339 requireClosed(t, c)
1340}
1341
1342func TestSwitchIgnoredDuringDrag(t *testing.T) {
1343 c, _, _ := switcherCore(t, 3)
1344 start := switcherFocus(c)
1345 d := &dragState{id: start, button: btnLeft}
1346 c.drag = d
1347 c.buttons[btnLeft] = true
1348 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1349 requireClosed(t, c)
1350 if c.drag != d || switcherFocus(c) != start {
1351 t.Fatal("drag disturbed")
1352 }
1353 sendKeys(t, c, ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true})
1354 if c.drag != nil {
1355 t.Fatal("Escape did not cancel the drag")
1356 }
1357}
1358
1359// Another bind closing the shown cards still flies them back: the snapshot
1360// is taken while they are drawn, so the windows move from the card rects.
1361func TestSwitchBindOverShownCardsFliesBack(t *testing.T) {
1362 c, ic, _ := switcherCore(t, 3)
1363 c.cfg.Animations.On = true
1364 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1365 c.switcherTick()
1366 for range 200 {
1367 ic.now = ic.now.Add(16 * time.Millisecond)
1368 if err := c.step(context.Background(), nil); err != nil {
1369 t.Fatal(err)
1370 }
1371 }
1372 sc := c.cur()
1373 cards := map[WindowID]Rect{}
1374 for _, p := range sc.shownLayout() {
1375 if p.Preview > 0 {
1376 cards[p.ID] = p.Rect
1377 }
1378 }
1379 if len(cards) != 3 || len(sc.rects) != 0 {
1380 t.Fatalf("%d cards, %d motions: the cards did not settle", len(cards), len(sc.rects))
1381 }
1382 bind(t, c, ActionFocusColumnLeft)
1383 if c.switching() || sc.mon.sw.shown {
1384 t.Fatal("the bind left the switcher open")
1385 }
1386 settled := map[WindowID]Rect{}
1387 for _, p := range sc.mon.Layout() {
1388 settled[p.ID] = p.Rect
1389 }
1390 for id, card := range cards {
1391 rm, ok := sc.rects[id]
1392 if !ok || !rm.scale {
1393 t.Fatalf("window %d has no fly-back motion", id)
1394 }
1395 if got := rm.apply(settled[id]); got != card {
1396 t.Fatalf("window %d flies back from %+v, want its card %+v", id, got, card)
1397 }
1398 }
1399}
1400
1401func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1402 m := NewMonitor()
1403 m.SetOutput(300, 200)
1404 m.SetMaxColumns(3)
1405 for id := WindowID(1); id <= 4; id++ {
1406 m.AddWindow(id)
1407 }
1408 w := m.Current()
1409 w.FocusID(3)
1410 w.ConsumeOrExpel(-1) // columns [1] [2 3] [4]
1411 if len(w.Columns) != 3 || len(w.Columns[1].Windows) != 2 {
1412 t.Fatalf("columns %+v", w.Columns)
1413 }
1414 w.FocusID(1)
1415 openSwitcher(m, 0)
1416 at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
1417 m.sw.at = at
1418 gone := m.sw.order[at]
1419 size := Rect{W: 120, H: 90}
1420 m.sw.sizes = map[WindowID]Rect{gone: size}
1421 other := w.Columns[1].Windows[0]
1422 if other == gone {
1423 other = w.Columns[1].Windows[1]
1424 }
1425 m.RemoveWindow(gone)
1426 if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
1427 t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
1428 }
1429 if m.sw.sizes[other] != size {
1430 t.Fatalf("sizes %v: the new card keeps no size of the closed window's", m.sw.sizes)
1431 }
1432 m.commitSwitcher()
1433 if id, _ := w.Focused(); id != other {
1434 t.Fatalf("focus %d, want %d", id, other)
1435 }
1436}
1437
1438func TestSwitchCommitFocusesTileUnderFloat(t *testing.T) {
1439 c, _, _ := switcherCore(t, 3)
1440 m := c.cur().mon
1441 m.AddFloating(9, 100, 80)
1442 w := m.Current()
1443 if !w.floatFocus {
1444 t.Fatal("float not focused")
1445 }
1446 tile := w.Columns[w.Focus].Windows[0]
1447 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1448 bind(t, c, ActionSwitchColumnPrev) // back to card 0: the column under the float
1449 if m.sw.at != 0 || m.sw.order[0] != tile {
1450 t.Fatalf("at %d order %v", m.sw.at, m.sw.order)
1451 }
1452 sendKeys(t, c, superKey(0, false))
1453 if w.floatFocus || switcherFocus(c) != tile {
1454 t.Fatalf("float focused %v, focus %d, want tile %d", w.floatFocus, switcherFocus(c), tile)
1455 }
1456}
1457
1458func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
1459 c, _, _ := switcherCore(t, 3)
1460 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1461 first := c.focusScreen
1462 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1463 want := c.cur().mon.sw.order[1]
1464 // The pointer crosses to the other output meanwhile.
1465 c.focusScreen = c.screenIndex("B")
1466 sendKeys(t, c, superKey(0, false))
1467 if c.focusScreen != first || switcherFocus(c) != want {
1468 t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
1469 }
1470}