detached 79b4f2d

test(core): tighten column switcher coverage

Cover the previewing() sites, card sizes, hidden windows, the MRU in place and the overview, add a switcher subcase to the publish allocation guard, merge and drop redundant tests, and share requireClosed.
commit
79b4f2dd1944c32810de238a65a7e79a52bedc58
author
brice · · 2026-10-08 18:45 UTC
parent
f6ed4f4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +489 -171
M internal/core/publish_alloc_test.go +45 -0
@@ -247,6 +247,51 @@ func TestPublishOverviewAllocations(t *testing.T) {
247247 })
248248 }
249249 }
250+ // The column switcher's settled cards are previews too: a held switch
251+ // shows them, and a publish while they stay costs a plain frame.
252+ t.Run("switcher", func(t *testing.T) {
253+ c := publishRig(t, 1)
254+ c.cfg.Animations.Slowdown = 1 // settle fast: only the settled frame is measured
255+ c.cmdMod = ports.ModSuper
256+ c.mods = ports.ModSuper
257+ sc := c.cur()
258+ m := sc.mon
259+ m.SetMaxColumns(3)
260+ sc.arrange()
261+ c.refreshShown()
262+ ctx := context.Background()
263+ if err := c.runBind(ctx, ActionSwitchColumnNext); err != nil {
264+ t.Fatal(err)
265+ }
266+ if !c.switching() || !c.switcherTick() || !m.switcherShown() {
267+ t.Fatalf("setup: switching %v, shown %v", c.switching(), m.switcherShown())
268+ }
269+ // Let the card motions settle (the rig slows every spring).
270+ step := publishStep(t, c, nil)
271+ step()
272+ for start := time.Now(); c.animating() && time.Since(start) < 30*time.Second; {
273+ time.Sleep(50 * time.Millisecond)
274+ step()
275+ }
276+ if c.animating() || len(sc.rects) != 0 || len(c.overviewReal) == 0 {
277+ t.Fatalf("setup: %d rect motions, animating %v, real layouts %d", len(sc.rects), c.animating(), len(c.overviewReal))
278+ }
279+ publish := func() {
280+ if err := c.publish(ctx); err != nil {
281+ t.Fatal(err)
282+ }
283+ select {
284+ case <-c.ch.Scenes:
285+ default:
286+ t.Fatal("no scene published")
287+ }
288+ }
289+ n := testing.AllocsPerRun(50, publish)
290+ t.Logf("switcher allocs per publish = %v", n)
291+ if n > publishAllocBudget {
292+ t.Errorf("switcher publish allocs per frame = %v, budget %d", n, publishAllocBudget)
293+ }
294+ })
250295 }
251296
252297 // TestPublishLeavingAllocations pins the cost of a frame with leaving
@@ -247,6 +247,51 @@ func TestPublishOverviewAllocations(t *testing.T) {
247 })247 })
248 }248 }
249 }249 }
250+ // The column switcher's settled cards are previews too: a held switch
251+ // shows them, and a publish while they stay costs a plain frame.
252+ t.Run("switcher", func(t *testing.T) {
253+ c := publishRig(t, 1)
254+ c.cfg.Animations.Slowdown = 1 // settle fast: only the settled frame is measured
255+ c.cmdMod = ports.ModSuper
256+ c.mods = ports.ModSuper
257+ sc := c.cur()
258+ m := sc.mon
259+ m.SetMaxColumns(3)
260+ sc.arrange()
261+ c.refreshShown()
262+ ctx := context.Background()
263+ if err := c.runBind(ctx, ActionSwitchColumnNext); err != nil {
264+ t.Fatal(err)
265+ }
266+ if !c.switching() || !c.switcherTick() || !m.switcherShown() {
267+ t.Fatalf("setup: switching %v, shown %v", c.switching(), m.switcherShown())
268+ }
269+ // Let the card motions settle (the rig slows every spring).
270+ step := publishStep(t, c, nil)
271+ step()
272+ for start := time.Now(); c.animating() && time.Since(start) < 30*time.Second; {
273+ time.Sleep(50 * time.Millisecond)
274+ step()
275+ }
276+ if c.animating() || len(sc.rects) != 0 || len(c.overviewReal) == 0 {
277+ t.Fatalf("setup: %d rect motions, animating %v, real layouts %d", len(sc.rects), c.animating(), len(c.overviewReal))
278+ }
279+ publish := func() {
280+ if err := c.publish(ctx); err != nil {
281+ t.Fatal(err)
282+ }
283+ select {
284+ case <-c.ch.Scenes:
285+ default:
286+ t.Fatal("no scene published")
287+ }
288+ }
289+ n := testing.AllocsPerRun(50, publish)
290+ t.Logf("switcher allocs per publish = %v", n)
291+ if n > publishAllocBudget {
292+ t.Errorf("switcher publish allocs per frame = %v, budget %d", n, publishAllocBudget)
293+ }
294+ })
250 }295 }
251 296
252 // TestPublishLeavingAllocations pins the cost of a frame with leaving297 // TestPublishLeavingAllocations pins the cost of a frame with leaving
M internal/core/switcher_test.go +444 -171
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "math"
56 "slices"
67 "testing"
78 "time"
@@ -25,56 +26,58 @@ func focusNote(w *Workspace, id WindowID) {
2526 w.noteFocus()
2627 }
2728
28-func TestMRUOrderFollowsFocus(t *testing.T) {
29- w := threeColumns()
30- // Columns 0, 2, 1 in turn: the last one used goes first.
31- focusNote(w, 1)
32- focusNote(w, 3)
33- focusNote(w, 2)
34- if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {
35- t.Fatalf("order %v, want %v", got, want)
36- }
37- focusNote(w, 1)
38- if got, want := w.switchOrder(), []WindowID{1, 2, 3}; !slices.Equal(got, want) {
39- t.Fatalf("order %v, want %v", got, want)
40- }
41-}
42-
43-func TestMRUColumnsNeverFocusedFollowByPosition(t *testing.T) {
44- w := threeColumns()
45- focusNote(w, 3)
46- if got, want := w.switchOrder(), []WindowID{3, 1, 2}; !slices.Equal(got, want) {
47- t.Fatalf("order %v, want %v", got, want)
48- }
49-}
50-
51-func TestMRUPrunesClosedWindows(t *testing.T) {
52- w := threeColumns()
53- focusNote(w, 1)
54- focusNote(w, 3)
55- focusNote(w, 2)
56- w.RemoveWindow(3)
57- if got, want := w.switchOrder(), []WindowID{2, 1}; !slices.Equal(got, want) {
58- t.Fatalf("order %v, want %v", got, want)
59- }
60- if slices.Contains(w.recent, 3) {
61- t.Fatalf("recent %v keeps a closed window", w.recent)
62- }
63-}
64-
65-func TestMRUKeepsRankWhenColumnMoves(t *testing.T) {
66- w := threeColumns()
67- focusNote(w, 1)
68- focusNote(w, 3)
69- focusNote(w, 2)
70- // Window 2 moves to the right: its column is the same, so is its rank.
71- w.MoveColumn(1)
72- w.noteFocus()
73- if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {
74- t.Fatalf("order %v, want %v", got, want)
75- }
76- if len(w.recent) != 3 {
77- t.Fatalf("recent %v", w.recent)
29+func 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+ }
64+ for _, tt := range tests {
65+ t.Run(tt.name, func(t *testing.T) {
66+ w := threeColumns()
67+ tt.run(w)
68+ if got := w.switchOrder(); !slices.Equal(got, tt.want) {
69+ t.Fatalf("order %v, want %v", got, tt.want)
70+ }
71+ // recent never keeps a closed window nor two of one column.
72+ seen := map[int]bool{}
73+ for _, id := range w.recent {
74+ i := w.columnOf(id)
75+ if i < 0 || seen[i] {
76+ t.Fatalf("recent %v keeps window %d (column %d)", w.recent, id, i)
77+ }
78+ seen[i] = true
79+ }
80+ })
7881 }
7982 }
8083
@@ -131,14 +134,74 @@ func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
131134 if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {
132135 t.Fatalf("noteFocus allocates %v", n)
133136 }
137+ // A focus change inside the latest column updates its entry in place.
138+ w.AddWindow(4)
139+ w.ConsumeOrExpel(-1)
140+ w.FocusID(2)
141+ w.noteFocus()
142+ if len(w.recent) == 0 || w.recent[0] != 2 {
143+ t.Fatalf("recent %v, want window 2 first", w.recent)
144+ }
145+ n := len(w.recent)
146+ col := w.Columns[w.Focus]
147+ other := col.Windows[0]
148+ if other == 2 {
149+ other = col.Windows[1]
150+ }
151+ if len(col.Windows) < 2 {
152+ t.Fatalf("setup: column %v has one window", col.Windows)
153+ }
154+ w.FocusID(other)
155+ if got := testing.AllocsPerRun(100, w.noteFocus); got != 0 {
156+ t.Fatalf("noteFocus allocates %v after a same-column change", got)
157+ }
158+ if len(w.recent) != n || w.recent[0] != other {
159+ t.Fatalf("recent %v, want window %d first in place of 2 (%d entries)", w.recent, other, n)
160+ }
161+}
162+
163+// Browsing the overview focuses nothing: the selection moving to another
164+// column, then Escape, leave the recent order as it was.
165+func TestOverviewBrowsingKeepsMRU(t *testing.T) {
166+ c, _, _ := switcherCore(t, 3)
167+ m := c.cur().mon
168+ w := m.Current()
169+ focusNote(w, 1)
170+ focusNote(w, 3)
171+ focusNote(w, 2)
172+ indicatorScene(t, c)
173+ order, recent := w.switchOrder(), slices.Clone(w.recent)
174+ focus := switcherFocus(c)
175+ m.ToggleOverview()
176+ m.OverviewMove(1, 0)
177+ m.OverviewMove(1, 0)
178+ if switcherFocus(c) == focus {
179+ t.Fatal("setup: the overview selection is still on the focused window")
180+ }
181+ indicatorScene(t, c)
182+ // Cancelling would note the original column again: look while browsing.
183+ if !slices.Equal(w.recent, recent) {
184+ t.Fatalf("recent %v while browsing, was %v", w.recent, recent)
185+ }
186+ m.CancelOverview()
187+ indicatorScene(t, c)
188+ if got := w.switchOrder(); !slices.Equal(got, order) || !slices.Equal(w.recent, recent) {
189+ t.Fatalf("order %v recent %v, were %v %v", got, w.recent, order, recent)
190+ }
191+ if switcherFocus(c) != focus {
192+ t.Fatalf("focus %d, want %d", switcherFocus(c), focus)
193+ }
134194 }
135195
136-// switcherBinds are the default binds of the switcher.
137-var switcherBinds = map[string]string{
138- "Cmd+Tab": string(ActionSwitchColumnNext),
139- "Cmd+Shift+Tab": string(ActionSwitchColumnPrev),
140- "Cmd+f": string(ActionToggleFullscreen),
141- "F9": string(ActionSwitchColumnNext),
196+// switcherBinds returns the binds of the switcher tests: the defaults, a
197+// fullscreen bind to interrupt a switch and F9, a switch with no Cmd.
198+func switcherBinds() map[string]string {
199+ return map[string]string{
200+ "Cmd+Tab": string(ActionSwitchColumnNext),
201+ "Cmd+Shift+Tab": string(ActionSwitchColumnPrev),
202+ "Cmd+f": string(ActionToggleFullscreen),
203+ "F9": string(ActionSwitchColumnNext),
204+ }
142205 }
143206
144207 const (
@@ -155,7 +218,7 @@ func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.Cli
155218 c, ic := indicatorCoreWith(t, commands)
156219 cfg := c.cfg
157220 cfg.Layout.MaxColumns = 3
158- cfg.Binds = switcherBinds
221+ cfg.Binds = switcherBinds()
159222 if err := c.apply(cfg); err != nil {
160223 t.Fatal(err)
161224 }
@@ -166,6 +229,20 @@ func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.Cli
166229 return c, ic, commands
167230 }
168231
232+// requireClosed fails unless no switcher is open: no monitor holds one, and
233+// the core has neither a switcher screen nor a timer.
234+func requireClosed(t *testing.T, c *Core) {
235+ t.Helper()
236+ for _, sc := range c.screens {
237+ if sc.mon.sw.ws != nil || sc.mon.sw.shown {
238+ t.Fatalf("screen %q still holds a switcher: %+v", sc.name(), sc.mon.sw)
239+ }
240+ }
241+ if c.switcher.sc != nil || c.switcher.timerC != nil || c.switcher.timerStop != nil {
242+ t.Fatalf("switcher screen %v, timer %v", c.switcher.sc != nil, c.switcher.timerC != nil)
243+ }
244+}
245+
169246 func tab(mods ports.Mods, pressed bool) ports.KeyEvent {
170247 return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed}
171248 }
@@ -356,29 +433,22 @@ func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
356433 }
357434 }
358435
359-func TestSwitchPrevOpensOnOldestColumn(t *testing.T) {
360- c, _, _ := switcherCore(t, 3)
361- c.mods = ports.ModSuper
362- bind(t, c, ActionSwitchColumnPrev)
363- if sw := c.cur().mon.sw; sw.ws == nil || sw.at != len(sw.order)-1 {
364- t.Fatalf("at %d of %d", sw.at, len(sw.order))
365- }
366-}
367-
368436 func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
369437 c, ic, _ := switcherCore(t, 3)
370- c.mods = 0
371438 start := switcherFocus(c)
372- if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
373- t.Fatal(err)
439+ // F9 is bound to switch-column-next with no Cmd held.
440+ f9 := func(pressed bool) ports.KeyEvent {
441+ return ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: pressed}
374442 }
375- if c.cur().mon.sw.ws != nil || c.switcher.timerC != nil || len(ic.timers) != 0 {
376- t.Fatalf("open %v, timer %v %v", c.cur().mon.sw.ws != nil, c.switcher.timerC != nil, ic.timers)
443+ sendKeys(t, c, f9(true), f9(false))
444+ requireClosed(t, c)
445+ if len(ic.timers) != 0 {
446+ t.Fatalf("timers %v armed for a switch with no Cmd", ic.timers)
377447 }
378448 if switcherFocus(c) == start {
379449 t.Fatal("focus did not move")
380450 }
381- bind(t, c, ActionSwitchColumnNext)
451+ sendKeys(t, c, f9(true), f9(false))
382452 if switcherFocus(c) != start {
383453 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
384454 }
@@ -388,14 +458,13 @@ func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
388458 c, _, _ := switcherCore(t, 1)
389459 c.mods = ports.ModSuper
390460 bind(t, c, ActionSwitchColumnNext)
391- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil {
392- t.Fatal("switcher opened with one column")
393- }
461+ requireClosed(t, c)
394462 c, _, _ = switcherCore(t, 3)
395463 c.cur().mon.ToggleOverview()
396464 before := switcherFocus(c)
397465 bind(t, c, ActionSwitchColumnNext)
398- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != before {
466+ requireClosed(t, c)
467+ if switcherFocus(c) != before {
399468 t.Fatal("switcher acted in the overview")
400469 }
401470 }
@@ -421,9 +490,7 @@ func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
421490 t.Fatalf("cards published during a quick tap: %+v", p)
422491 }
423492 }
424- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {
425- t.Fatal("switcher left open")
426- }
493+ requireClosed(t, c)
427494 }
428495 tap()
429496 if switcherFocus(c) == start {
@@ -445,8 +512,9 @@ func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
445512 }
446513 forwarded(cmds)
447514 sendKeys(t, c, superKey(0, false))
448- if switcherFocus(c) != order[1] || c.cur().mon.sw.ws != nil || c.switcher.timerC != nil {
449- t.Fatalf("focus %d (want %d), open %v", switcherFocus(c), order[1], c.cur().mon.sw.ws != nil)
515+ requireClosed(t, c)
516+ if switcherFocus(c) != order[1] {
517+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
450518 }
451519 fk := forwarded(cmds)
452520 if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {
@@ -454,30 +522,6 @@ func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
454522 }
455523 }
456524
457-func TestSwitcherTickShowsCards(t *testing.T) {
458- c, _, _ := switcherCore(t, 3)
459- sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
460- if c.cur().mon.sw.shown {
461- t.Fatal("shown before the delay")
462- }
463- if !c.switcherTick() || !c.cur().mon.sw.shown {
464- t.Fatal("tick did not show the switcher")
465- }
466- if c.switcher.timerC != nil {
467- t.Fatal("timer left")
468- }
469-}
470-
471-func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) {
472- c, _, _ := switcherCore(t, 3)
473- c.mods = ports.ModSuper
474- bind(t, c, ActionSwitchColumnNext)
475- c.cancelSwitcher()
476- if c.switcherTick() {
477- t.Fatal("tick on a closed switcher asks for a scene")
478- }
479-}
480-
481525 // --- P3.1: keys and gestures ---
482526
483527 func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
@@ -489,8 +533,9 @@ func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
489533 sendKeys(t, c, esc)
490534 esc.Pressed = false
491535 sendKeys(t, c, esc, superKey(0, false))
492- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != start {
493- t.Fatalf("open %v focus %d (want %d)", c.cur().mon.sw.ws != nil, switcherFocus(c), start)
536+ requireClosed(t, c)
537+ if switcherFocus(c) != start {
538+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
494539 }
495540 for _, k := range forwarded(cmds) {
496541 if k.Key.Keysym == "Escape" {
@@ -504,9 +549,7 @@ func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
504549 start := switcherFocus(c)
505550 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
506551 sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
507- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {
508- t.Fatal("switcher still open")
509- }
552+ requireClosed(t, c)
510553 if switcherFocus(c) != start {
511554 t.Fatalf("focus %d moved", switcherFocus(c))
512555 }
@@ -533,8 +576,9 @@ func TestSwitchSwipeBeginCancels(t *testing.T) {
533576 start := switcherFocus(c)
534577 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
535578 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
536- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start {
537- t.Fatal("swipe did not cancel the switcher")
579+ requireClosed(t, c)
580+ if switcherFocus(c) != start {
581+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
538582 }
539583 }
540584
@@ -550,8 +594,9 @@ func TestSwitchSecurityChangeCancels(t *testing.T) {
550594 if !c.syncSecurity() {
551595 t.Fatal("security change not seen")
552596 }
553- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start {
554- t.Fatal("security change did not cancel the switcher")
597+ requireClosed(t, c)
598+ if switcherFocus(c) != start {
599+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
555600 }
556601 }
557602
@@ -568,8 +613,9 @@ func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
568613 t.Fatal("Alt+Tab did not open the switcher")
569614 }
570615 sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false})
571- if c.cur().mon.sw.ws != nil || switcherFocus(c) != order[1] {
572- t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.ws != nil, switcherFocus(c), order[1])
616+ requireClosed(t, c)
617+ if switcherFocus(c) != order[1] {
618+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
573619 }
574620 }
575621
@@ -578,28 +624,46 @@ func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
578624 func TestSwitcherLayoutCards(t *testing.T) {
579625 c, _, _ := switcherCore(t, 3)
580626 c.cfg.Floating.Dim = 0.4
627+ // A wider focused column: the cards have different aspect ratios.
628+ c.cur().mon.Current().ResizeColumn(30)
629+ indicatorScene(t, c)
581630 area := c.cur().mon.Current().overviewArea()
582631 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
583- if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {
632+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 || c.cur().mon.sw.shown {
584633 t.Fatalf("cards before the delay: %+v", s.Windows)
585634 }
586635 order := c.cur().mon.sw.order
587- c.switcherTick()
636+ if !c.switcherTick() || !c.cur().mon.sw.shown || c.switcher.timerC != nil {
637+ t.Fatal("the tick did not show the cards and clear the timer")
638+ }
588639 s := indicatorScene(t, c)
589640 cards := scenePreviews(s)
590641 if len(cards) != 3 {
591642 t.Fatalf("%d cards: %+v", len(cards), s.Windows)
592643 }
593- minX, maxX := 1<<30, 0
594644 focused := 0
645+ distinct := false
646+ var centers []int
595647 for k, card := range cards {
596648 if card.ID != order[k] {
597649 t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k])
598650 }
599- if card.Rect.W < cards[0].Rect.W-1 || card.Rect.W > cards[0].Rect.W+1 {
600- t.Fatalf("cards of different widths: %+v", cards)
651+ // The boxes are equal: the cards' centres are evenly spaced.
652+ centers = append(centers, card.Rect.X+card.Rect.W/2)
653+ // A card is its window's client size, scaled down uniformly.
654+ sent := c.configures.sent[card.ID]
655+ if sent.Width <= 0 || sent.Height <= 0 || card.Preview <= 0 || card.Preview >= 1 {
656+ t.Fatalf("card %d: client %dx%d, preview %v", card.ID, sent.Width, sent.Height, card.Preview)
657+ }
658+ if d := card.Rect.W - int(math.Round(float64(sent.Width)*card.Preview)); d < -1 || d > 1 {
659+ 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)
660+ }
661+ if d := card.Rect.H - int(math.Round(float64(sent.Height)*card.Preview)); d < -1 || d > 1 {
662+ 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)
663+ }
664+ if first := c.configures.sent[order[0]]; sent.Width != first.Width {
665+ distinct = true
601666 }
602- minX, maxX = min(minX, card.Rect.X), max(maxX, card.Rect.X+card.Rect.W)
603667 if card.Focused {
604668 focused++
605669 if card.ID != order[c.cur().mon.sw.at] {
@@ -607,9 +671,14 @@ func TestSwitcherLayoutCards(t *testing.T) {
607671 }
608672 }
609673 }
610- left, right := minX-area.X, area.X+area.W-maxX
611- if d := left - right; d < -1 || d > 1 {
612- t.Fatalf("row not centered: left gap %d, right gap %d", left, right)
674+ if !distinct {
675+ t.Fatal("setup: every window has the same size, so the aspect ratios prove nothing")
676+ }
677+ if d := (centers[1] - centers[0]) - (centers[2] - centers[1]); d < -2 || d > 2 {
678+ t.Fatalf("boxes not equal: card centres %v", centers)
679+ }
680+ if d := centers[0] + centers[2] - 2*(area.X+area.W/2); d < -2 || d > 2 {
681+ t.Fatalf("row not centered in %+v: card centres %v", area, centers)
613682 }
614683 if focused != 1 {
615684 t.Fatalf("%d focused cards", focused)
@@ -628,10 +697,6 @@ func TestSwitcherLayoutCards(t *testing.T) {
628697 t.Fatalf("window %d drawn under the cards", w.ID)
629698 }
630699 }
631- // The keyboard stays on the real focus.
632- if c.keyboardFocus() != focusedIDReal(c) {
633- t.Fatalf("keyboard focus %d moved", c.keyboardFocus())
634- }
635700 // A commit gives the workspace back.
636701 sendKeys(t, c, superKey(0, false))
637702 if s := lastScene(t, c); len(scenePreviews(s)) != 0 {
@@ -639,27 +704,27 @@ func TestSwitcherLayoutCards(t *testing.T) {
639704 }
640705 }
641706
642-func focusedIDReal(c *Core) WindowID {
643- id, _ := c.cur().mon.Current().Focused()
644- return id
645-}
646-
647707 func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
648- c, _, _ := switcherCore(t, 3)
649- c.cur().mon.AddWindow(4)
650- w := c.cur().mon.Current()
708+ // Windows 10 and 11 are on a second workspace.
709+ c, _ := twoWorkspaces(t)
710+ m := c.cur().mon
711+ m.AddWindow(4)
712+ w := m.Current()
651713 w.ConsumeOrExpel(-1)
652- if len(w.Columns) != 3 || w.columnWindows() != 4 {
653- t.Fatalf("setup: %d columns, %d windows", len(w.Columns), w.columnWindows())
714+ m.AddFloating(9, 100, 80)
715+ if len(w.Columns) != 3 || w.columnWindows() != 4 || len(w.Floats) != 1 {
716+ t.Fatalf("setup: %d columns, %d windows, %d floats", len(w.Columns), w.columnWindows(), len(w.Floats))
654717 }
655718 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
656719 c.switcherTick()
657720 s := indicatorScene(t, c)
658721 reps := map[WindowID]bool{}
659- for _, id := range c.cur().mon.sw.order {
722+ for _, id := range m.sw.order {
660723 reps[id] = true
661724 }
725+ seen := map[WindowID]bool{}
662726 for _, sw := range s.Windows {
727+ seen[sw.ID] = true
663728 if reps[sw.ID] {
664729 if sw.Preview == 0 || sw.Hidden {
665730 t.Fatalf("representative %d not a card: %+v", sw.ID, sw)
@@ -668,6 +733,202 @@ func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
668733 t.Fatalf("window %d not hidden: %+v", sw.ID, sw)
669734 }
670735 }
736+ // The other window of a column, the float and the other workspace's
737+ // windows are all in the scene, hidden.
738+ for _, id := range []WindowID{4, 9, 10, 11} {
739+ if !seen[id] {
740+ t.Fatalf("window %d missing from the scene (hidden windows must be listed): %+v", id, s.Windows)
741+ }
742+ }
743+}
744+
745+// While the cards show, the screen draws previews: the tiles do not stop at
746+// the panels, a card takes no pointer, nothing is
747+// dragged, and a window mapping or unmapping behind them does not animate.
748+func TestSwitcherShownIsAPreview(t *testing.T) {
749+ c, ic, _ := switcherCore(t, 3)
750+ c.cfg.Animations.On = true
751+ m := c.cur().mon
752+ // A bar reserves the top of the output: tiles stop below it.
753+ c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 300, Height: 20, ExclusiveZone: 20}})
754+ if s := indicatorScene(t, c); s.TileInset.Top != 20 {
755+ t.Fatalf("setup: tile inset %+v, want the bar's 20", s.TileInset)
756+ }
757+ sizes := map[WindowID]ports.ConfigureWindow{}
758+ for id, v := range c.configures.sent {
759+ sizes[id] = v
760+ }
761+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
762+ c.switcherTick()
763+ indicatorScene(t, c)
764+ // The cards fly in, and the motions survive a publish.
765+ if len(c.cur().rects) == 0 {
766+ t.Fatal("no card motion after the show")
767+ }
768+ settle := func() {
769+ t.Helper()
770+ for range 200 {
771+ ic.now = ic.now.Add(16 * time.Millisecond)
772+ if err := c.step(context.Background(), nil); err != nil {
773+ t.Fatal(err)
774+ }
775+ }
776+ lastScene(t, c)
777+ if len(c.cur().rects) != 0 || c.animating() {
778+ t.Fatalf("motions did not settle: %d rects", len(c.cur().rects))
779+ }
780+ }
781+ settle()
782+ if !m.switcherShown() {
783+ t.Fatal("setup: cards not shown")
784+ }
785+ s := indicatorScene(t, c)
786+ if len(scenePreviews(s)) != 3 {
787+ t.Fatalf("%d cards", len(scenePreviews(s)))
788+ }
789+ // A card keeps its client's real size.
790+ for _, id := range m.sw.order {
791+ if got := c.configures.sent[id]; got.Width != sizes[id].Width || got.Height != sizes[id].Height {
792+ t.Fatalf("window %d configured %dx%d under its card, was %dx%d", id, got.Width, got.Height, sizes[id].Width, sizes[id].Height)
793+ }
794+ }
795+ if s.TileInset != (ports.Insets{}) {
796+ t.Fatalf("tile inset %+v while the cards show", s.TileInset)
797+ }
798+ // The pointer finds no window under a card.
799+ card := m.sw.order[1]
800+ x, y := cardCenter(t, c, card)
801+ if id, _, _ := c.hit(x, y); id != 0 {
802+ t.Fatalf("hit window %d under a card", id)
803+ }
804+ if _, _, ok := c.draggable(card); ok {
805+ t.Fatal("a card can be dragged")
806+ }
807+ if tgt := c.dropAt(card, x, y); tgt.kind != dropNone {
808+ t.Fatalf("a drop target %+v under the cards", tgt)
809+ }
810+ // A map and an unmap behind the cards do not animate.
811+ c.mapWindow(ports.WindowMapped{ID: 20})
812+ if len(c.cur().rects) != 0 {
813+ t.Fatalf("a map behind the cards started motions: %+v", c.cur().rects)
814+ }
815+ gone := m.sw.order[2]
816+ c.unmapWindow(ports.WindowUnmapped{ID: gone})
817+ if len(c.cur().rects) != 0 {
818+ t.Fatalf("an unmap behind the cards started motions: %+v", c.cur().rects)
819+ }
820+ if m.Current().columnOf(gone) >= 0 || len(m.sw.order) != 2 {
821+ t.Fatalf("window %d not removed from the cards: %v", gone, m.sw.order)
822+ }
823+ // The workspace comes back with the panel inset.
824+ sendKeys(t, c, superKey(0, false))
825+ settle()
826+ if s := indicatorScene(t, c); s.TileInset.Top != 20 || len(scenePreviews(s)) != 0 {
827+ t.Fatalf("after the commit: tile inset %+v, %d cards", s.TileInset, len(scenePreviews(s)))
828+ }
829+}
830+
831+// The predicates that choose between the workspace and its previews follow
832+// the cards as they follow the overview: the frame, a covering fullscreen
833+// and the capture state.
834+func TestSwitcherShownPreviewPredicates(t *testing.T) {
835+ c, _, _ := switcherCore(t, 3)
836+ sc := c.cur()
837+ m := sc.mon
838+ w := m.Current()
839+ w.Overflow = OverflowFixed // a fullscreen window covers whatever the scroll
840+ w.SetSize(100, 100) // the workspace frame is smaller than the output
841+ tile := w.Columns[w.Focus].Windows[0]
842+ w.SetFullscreen(tile, true)
843+ indicatorScene(t, c)
844+ c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: w.ID}}}
845+ type state struct {
846+ frameIsOutput, covers, captureHidden bool
847+ captureShown uint64
848+ }
849+ read := func() state {
850+ r, reason := c.capResolve(capTarget{workspace: w.ID})
851+ if reason != ports.CaptureReasonNone {
852+ t.Fatalf("capture target: %v", reason)
853+ }
854+ var shown uint64
855+ if cs := c.captureSceneFor(sc, &capView{}); cs != nil {
856+ shown = cs.Shown
857+ }
858+ return state{m.Frame() == m.Output(), hasFullscreen(sc), r.hidden, shown}
859+ }
860+ if got, want := read(), (state{false, true, false, w.ID}); got != want {
861+ t.Fatalf("closed: %+v, want %+v", got, want)
862+ }
863+ c.mods = ports.ModSuper
864+ bind(t, c, ActionSwitchColumnNext)
865+ c.switcherTick()
866+ if !m.switcherShown() {
867+ t.Fatal("setup: cards not shown")
868+ }
869+ if got, want := read(), (state{true, false, true, 0}); got != want {
870+ t.Fatalf("shown: %+v, want %+v", got, want)
871+ }
872+}
873+
874+// A card's window with no configure sent yet (it mapped since the last
875+// publish) is sized from its real client rect, not from the card.
876+func TestSwitcherCardOfUnconfiguredWindowKeepsRealSize(t *testing.T) {
877+ c, _, _ := switcherCore(t, 3)
878+ m := c.cur().mon
879+ id := m.Current().switchOrder()[1]
880+ c.configures.forget(id) // the card falls back to the usable size
881+ c.mods = ports.ModSuper
882+ bind(t, c, ActionSwitchColumnNext)
883+ c.configures.forget(id) // the bind's publish configured it again
884+ c.switcherTick()
885+ indicatorScene(t, c)
886+ var real Placement
887+ for _, p := range m.Current().Layout() {
888+ if p.ID == id {
889+ real = p
890+ }
891+ }
892+ want := c.clientRect(real)
893+ if got := c.configures.sent[id]; got.Width != want.W || got.Height != want.H || want.W == 0 {
894+ t.Fatalf("window %d configured %dx%d under its card, real client %dx%d", id, got.Width, got.Height, want.W, want.H)
895+ }
896+}
897+
898+// A focus change behind the cards (the real focus, not the selection) is not
899+// recorded as a use of the column until the cards are gone.
900+func TestSwitcherShownDoesNotNoteFocus(t *testing.T) {
901+ c, _, _ := switcherCore(t, 3)
902+ w := c.cur().mon.Current()
903+ c.mods = ports.ModSuper
904+ bind(t, c, ActionSwitchColumnNext)
905+ c.switcherTick()
906+ before := slices.Clone(w.recent)
907+ w.FocusID(c.cur().mon.sw.order[2])
908+ indicatorScene(t, c)
909+ if !slices.Equal(w.recent, before) {
910+ t.Fatalf("recent %v, was %v: noted while the cards show", w.recent, before)
911+ }
912+}
913+
914+// Showing the cards drops the motions of windows they hide.
915+func TestSwitcherShowDropsMotionsOfHiddenWindows(t *testing.T) {
916+ c, _, _ := switcherCore(t, 3)
917+ c.cfg.Animations.On = true
918+ sc := c.cur()
919+ c.mapWindow(ports.WindowMapped{ID: 4})
920+ w := sc.mon.Current()
921+ w.ConsumeOrExpel(-1) // 4 joins 3's column
922+ w.FocusID(3) // 3 stands for the column; 4 is hidden
923+ if _, ok := sc.rects[4]; !ok {
924+ t.Fatal("setup: no entrance motion for window 4")
925+ }
926+ c.mods = ports.ModSuper
927+ bind(t, c, ActionSwitchColumnNext)
928+ c.switcherTick()
929+ if _, ok := sc.rects[4]; ok {
930+ t.Fatalf("a hidden window keeps its motion: %+v", sc.rects[4])
931+ }
671932 }
672933
673934 func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
@@ -712,8 +973,9 @@ func TestSwitcherClickOnCardCommits(t *testing.T) {
712973 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
713974 t.Fatal(err)
714975 }
715- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != target {
716- t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.ws != nil, switcherFocus(c), target)
976+ requireClosed(t, c)
977+ if switcherFocus(c) != target {
978+ t.Fatalf("focus %d, want %d", switcherFocus(c), target)
717979 }
718980 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
719981 t.Fatal(err)
@@ -735,8 +997,9 @@ func TestSwitcherClickElsewhereCancels(t *testing.T) {
735997 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
736998 t.Fatal(err)
737999 }
738- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != start {
739- t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.ws != nil, switcherFocus(c), start)
1000+ requireClosed(t, c)
1001+ if switcherFocus(c) != start {
1002+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
7401003 }
7411004 }
7421005
@@ -748,7 +1011,8 @@ func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
7481011 if err != nil || handled {
7491012 t.Fatalf("handled %v err %v", handled, err)
7501013 }
751- if c.cur().mon.sw.ws != nil || c.switcher.timerC != nil || switcherFocus(c) != start {
1014+ requireClosed(t, c)
1015+ if switcherFocus(c) != start {
7521016 t.Fatal("press did not cancel")
7531017 }
7541018 }
@@ -776,15 +1040,27 @@ func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
7761040 }
7771041 }
7781042
1043+// Closing a window ahead of the selection shifts the cards left: the
1044+// selection follows its card, so it still stands on the same window.
7791045 func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
780- c, _, _ := switcherCore(t, 3)
1046+ c, _, _ := switcherCore(t, 4)
7811047 c.mods = ports.ModSuper
782- bind(t, c, ActionSwitchColumnPrev)
1048+ bind(t, c, ActionSwitchColumnNext)
7831049 sw := &c.cur().mon.sw
1050+ if sw.at != 1 || len(sw.order) != 4 {
1051+ t.Fatalf("setup: at %d of %d", sw.at, len(sw.order))
1052+ }
7841053 selected := sw.order[sw.at]
7851054 c.cur().mon.RemoveWindow(sw.order[0])
786- if sw.ws == nil || sw.order[sw.at] != selected {
787- t.Fatalf("selection moved from %d: order %v at %d", selected, sw.order, sw.at)
1055+ if sw.ws == nil || len(sw.order) != 3 {
1056+ t.Fatalf("order %v after the close", sw.order)
1057+ }
1058+ if sw.at != 0 || sw.order[sw.at] != selected {
1059+ t.Fatalf("selection moved from %d: order %v at %d, want at 0", selected, sw.order, sw.at)
1060+ }
1061+ sendKeys(t, c, superKey(0, false))
1062+ if switcherFocus(c) != selected {
1063+ t.Fatalf("focus %d after the commit, want %d", switcherFocus(c), selected)
7881064 }
7891065 }
7901066
@@ -793,16 +1069,18 @@ func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
7931069 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
7941070 c.switcherTick()
7951071 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
796- if c.cur().mon.sw.ws != nil || c.cur().mon.sw.shown {
1072+ if sw := c.cur().mon.sw; sw.ws != nil || sw.shown {
7971073 t.Fatal("switcher still open with one column")
7981074 }
7991075 if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {
8001076 t.Fatalf("cards left: %+v", s.Windows)
8011077 }
802- // The timer fires on a closed switcher: harmless.
1078+ // The timer fires on a closed switcher: harmless, and it clears the
1079+ // core's side.
8031080 if c.switcherTick() {
8041081 t.Fatal("tick on a closed switcher")
8051082 }
1083+ requireClosed(t, c)
8061084 sendKeys(t, c, superKey(0, false))
8071085 }
8081086
@@ -816,7 +1094,8 @@ func TestSwitcherOutputRemovalCancels(t *testing.T) {
8161094 t.Fatal("no switcher screen")
8171095 }
8181096 c.removeScreen(sc.name())
819- if c.switcher.sc != nil || c.switcher.timerC != nil || sc.mon.sw.ws != nil {
1097+ requireClosed(t, c)
1098+ if sc.mon.sw.ws != nil {
8201099 t.Fatal("switcher survived its output")
8211100 }
8221101 if err := c.publish(context.Background()); err != nil {
@@ -875,6 +1154,9 @@ func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
8751154 if len(scenePreviews(s)) != 0 || switcherFocus(c) != target {
8761155 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
8771156 }
1157+ if n := len(c.cur().rects); n != 0 || c.animating() {
1158+ t.Fatalf("motions left: %d rect motions, animating %v", n, c.animating())
1159+ }
8781160 }
8791161
8801162 // --- review fixes ---
@@ -918,9 +1200,7 @@ func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
9181200 if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
9191201 t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
9201202 }
921- if m.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {
922- t.Fatal("stale switcher left open")
923- }
1203+ requireClosed(t, c)
9241204 var any bool
9251205 for _, p := range c.cur().shownLayout() {
9261206 if p.ID == 10 || p.ID == 11 {
@@ -960,7 +1240,8 @@ func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
9601240 c.cur().mon.show(c.cur().mon.Workspaces[0])
9611241 focus := switcherFocus(c)
9621242 sendKeys(t, c, superKey(0, false))
963- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != focus {
1243+ requireClosed(t, c)
1244+ if switcherFocus(c) != focus {
9641245 t.Fatal("stale switcher survived the release")
9651246 }
9661247 }
@@ -978,9 +1259,7 @@ func TestSwitcherTickOnStaleWorkspaceCancels(t *testing.T) {
9781259 if !c.switcherTick() {
9791260 t.Fatal("tick on a stale workspace asks for no scene")
9801261 }
981- if m.sw.ws != nil || m.sw.shown || c.switcher.sc != nil || c.switcher.timerC != nil {
982- t.Fatal("stale switcher survived the tick")
983- }
1262+ requireClosed(t, c)
9841263 }
9851264
9861265 func TestSwitchIgnoredDuringDrag(t *testing.T) {
@@ -990,9 +1269,7 @@ func TestSwitchIgnoredDuringDrag(t *testing.T) {
9901269 c.drag = d
9911270 c.buttons[btnLeft] = true
9921271 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
993- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {
994- t.Fatal("switcher opened during a drag")
995- }
1272+ requireClosed(t, c)
9961273 if c.drag != d || switcherFocus(c) != start {
9971274 t.Fatal("drag disturbed")
9981275 }
@@ -1062,6 +1339,8 @@ func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
10621339 at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
10631340 m.sw.at = at
10641341 gone := m.sw.order[at]
1342+ size := Rect{W: 120, H: 90}
1343+ m.sw.sizes = map[WindowID]Rect{gone: size}
10651344 other := w.Columns[1].Windows[0]
10661345 if other == gone {
10671346 other = w.Columns[1].Windows[1]
@@ -1070,6 +1349,9 @@ func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
10701349 if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
10711350 t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
10721351 }
1352+ if m.sw.sizes[other] != size {
1353+ t.Fatalf("sizes %v: the new card keeps no size of the closed window's", m.sw.sizes)
1354+ }
10731355 m.commitSwitcher()
10741356 if id, _ := w.Focused(); id != other {
10751357 t.Fatalf("focus %d, want %d", id, other)
@@ -1109,12 +1391,3 @@ func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
11091391 t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
11101392 }
11111393 }
1112-
1113-func TestSwitchUnmodifiedBindThroughKeyEvent(t *testing.T) {
1114- c, ic, _ := switcherCore(t, 3)
1115- start := switcherFocus(c)
1116- sendKeys(t, c, ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: true})
1117- if c.cur().mon.sw.ws != nil || len(ic.timers) != 0 || switcherFocus(c) == start {
1118- t.Fatalf("open %v timers %v focus %d (was %d)", c.cur().mon.sw.ws != nil, ic.timers, switcherFocus(c), start)
1119- }
1120-}
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "math"
5 "slices"6 "slices"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -25,56 +26,58 @@ func focusNote(w *Workspace, id WindowID) {
25 w.noteFocus()26 w.noteFocus()
26 }27 }
27 28
28-func TestMRUOrderFollowsFocus(t *testing.T) {29+func TestMRUOrder(t *testing.T) {
29- w := threeColumns()30+ tests := []struct {
30- // Columns 0, 2, 1 in turn: the last one used goes first.31+ name string
31- focusNote(w, 1)32+ run func(w *Workspace)
32- focusNote(w, 3)33+ want []WindowID
33- focusNote(w, 2)34+ }{
34- if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {35+ {"follows focus", func(w *Workspace) {
35- t.Fatalf("order %v, want %v", got, want)36+ // Columns 0, 2, 1 in turn: the last one used goes first.
36- }37+ focusNote(w, 1)
37- focusNote(w, 1)38+ focusNote(w, 3)
38- if got, want := w.switchOrder(), []WindowID{1, 2, 3}; !slices.Equal(got, want) {39+ focusNote(w, 2)
39- t.Fatalf("order %v, want %v", got, want)40+ }, []WindowID{2, 3, 1}},
40- }41+ {"back to the oldest", func(w *Workspace) {
41-}42+ focusNote(w, 1)
42-43+ focusNote(w, 3)
43-func TestMRUColumnsNeverFocusedFollowByPosition(t *testing.T) {44+ focusNote(w, 2)
44- w := threeColumns()45+ focusNote(w, 1)
45- focusNote(w, 3)46+ }, []WindowID{1, 2, 3}},
46- if got, want := w.switchOrder(), []WindowID{3, 1, 2}; !slices.Equal(got, want) {47+ {"never focused follow by position", func(w *Workspace) {
47- t.Fatalf("order %v, want %v", got, want)48+ focusNote(w, 3)
48- }49+ }, []WindowID{3, 1, 2}},
49-}50+ {"closed windows are pruned", func(w *Workspace) {
50-51+ focusNote(w, 1)
51-func TestMRUPrunesClosedWindows(t *testing.T) {52+ focusNote(w, 3)
52- w := threeColumns()53+ focusNote(w, 2)
53- focusNote(w, 1)54+ w.RemoveWindow(3)
54- focusNote(w, 3)55+ }, []WindowID{2, 1}},
55- focusNote(w, 2)56+ {"a moved column keeps its rank", func(w *Workspace) {
56- w.RemoveWindow(3)57+ focusNote(w, 1)
57- if got, want := w.switchOrder(), []WindowID{2, 1}; !slices.Equal(got, want) {58+ focusNote(w, 3)
58- t.Fatalf("order %v, want %v", got, want)59+ focusNote(w, 2)
59- }60+ w.MoveColumn(1)
60- if slices.Contains(w.recent, 3) {61+ w.noteFocus()
61- t.Fatalf("recent %v keeps a closed window", w.recent)62+ }, []WindowID{2, 3, 1}},
62- }63+ }
63-}64+ for _, tt := range tests {
64-65+ t.Run(tt.name, func(t *testing.T) {
65-func TestMRUKeepsRankWhenColumnMoves(t *testing.T) {66+ w := threeColumns()
66- w := threeColumns()67+ tt.run(w)
67- focusNote(w, 1)68+ if got := w.switchOrder(); !slices.Equal(got, tt.want) {
68- focusNote(w, 3)69+ t.Fatalf("order %v, want %v", got, tt.want)
69- focusNote(w, 2)70+ }
70- // Window 2 moves to the right: its column is the same, so is its rank.71+ // recent never keeps a closed window nor two of one column.
71- w.MoveColumn(1)72+ seen := map[int]bool{}
72- w.noteFocus()73+ for _, id := range w.recent {
73- if got, want := w.switchOrder(), []WindowID{2, 3, 1}; !slices.Equal(got, want) {74+ i := w.columnOf(id)
74- t.Fatalf("order %v, want %v", got, want)75+ if i < 0 || seen[i] {
75- }76+ t.Fatalf("recent %v keeps window %d (column %d)", w.recent, id, i)
76- if len(w.recent) != 3 {77+ }
77- t.Fatalf("recent %v", w.recent)78+ seen[i] = true
79+ }
80+ })
78 }81 }
79 }82 }
80 83
@@ -131,14 +134,74 @@ func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
131 if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {134 if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {
132 t.Fatalf("noteFocus allocates %v", n)135 t.Fatalf("noteFocus allocates %v", n)
133 }136 }
137+ // A focus change inside the latest column updates its entry in place.
138+ w.AddWindow(4)
139+ w.ConsumeOrExpel(-1)
140+ w.FocusID(2)
141+ w.noteFocus()
142+ if len(w.recent) == 0 || w.recent[0] != 2 {
143+ t.Fatalf("recent %v, want window 2 first", w.recent)
144+ }
145+ n := len(w.recent)
146+ col := w.Columns[w.Focus]
147+ other := col.Windows[0]
148+ if other == 2 {
149+ other = col.Windows[1]
150+ }
151+ if len(col.Windows) < 2 {
152+ t.Fatalf("setup: column %v has one window", col.Windows)
153+ }
154+ w.FocusID(other)
155+ if got := testing.AllocsPerRun(100, w.noteFocus); got != 0 {
156+ t.Fatalf("noteFocus allocates %v after a same-column change", got)
157+ }
158+ if len(w.recent) != n || w.recent[0] != other {
159+ t.Fatalf("recent %v, want window %d first in place of 2 (%d entries)", w.recent, other, n)
160+ }
161+}
162+
163+// Browsing the overview focuses nothing: the selection moving to another
164+// column, then Escape, leave the recent order as it was.
165+func TestOverviewBrowsingKeepsMRU(t *testing.T) {
166+ c, _, _ := switcherCore(t, 3)
167+ m := c.cur().mon
168+ w := m.Current()
169+ focusNote(w, 1)
170+ focusNote(w, 3)
171+ focusNote(w, 2)
172+ indicatorScene(t, c)
173+ order, recent := w.switchOrder(), slices.Clone(w.recent)
174+ focus := switcherFocus(c)
175+ m.ToggleOverview()
176+ m.OverviewMove(1, 0)
177+ m.OverviewMove(1, 0)
178+ if switcherFocus(c) == focus {
179+ t.Fatal("setup: the overview selection is still on the focused window")
180+ }
181+ indicatorScene(t, c)
182+ // Cancelling would note the original column again: look while browsing.
183+ if !slices.Equal(w.recent, recent) {
184+ t.Fatalf("recent %v while browsing, was %v", w.recent, recent)
185+ }
186+ m.CancelOverview()
187+ indicatorScene(t, c)
188+ if got := w.switchOrder(); !slices.Equal(got, order) || !slices.Equal(w.recent, recent) {
189+ t.Fatalf("order %v recent %v, were %v %v", got, w.recent, order, recent)
190+ }
191+ if switcherFocus(c) != focus {
192+ t.Fatalf("focus %d, want %d", switcherFocus(c), focus)
193+ }
134 }194 }
135 195
136-// switcherBinds are the default binds of the switcher.196+// switcherBinds returns the binds of the switcher tests: the defaults, a
137-var switcherBinds = map[string]string{197+// fullscreen bind to interrupt a switch and F9, a switch with no Cmd.
138- "Cmd+Tab": string(ActionSwitchColumnNext),198+func switcherBinds() map[string]string {
139- "Cmd+Shift+Tab": string(ActionSwitchColumnPrev),199+ return map[string]string{
140- "Cmd+f": string(ActionToggleFullscreen),200+ "Cmd+Tab": string(ActionSwitchColumnNext),
141- "F9": string(ActionSwitchColumnNext),201+ "Cmd+Shift+Tab": string(ActionSwitchColumnPrev),
202+ "Cmd+f": string(ActionToggleFullscreen),
203+ "F9": string(ActionSwitchColumnNext),
204+ }
142 }205 }
143 206
144 const (207 const (
@@ -155,7 +218,7 @@ func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.Cli
155 c, ic := indicatorCoreWith(t, commands)218 c, ic := indicatorCoreWith(t, commands)
156 cfg := c.cfg219 cfg := c.cfg
157 cfg.Layout.MaxColumns = 3220 cfg.Layout.MaxColumns = 3
158- cfg.Binds = switcherBinds221+ cfg.Binds = switcherBinds()
159 if err := c.apply(cfg); err != nil {222 if err := c.apply(cfg); err != nil {
160 t.Fatal(err)223 t.Fatal(err)
161 }224 }
@@ -166,6 +229,20 @@ func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.Cli
166 return c, ic, commands229 return c, ic, commands
167 }230 }
168 231
232+// requireClosed fails unless no switcher is open: no monitor holds one, and
233+// the core has neither a switcher screen nor a timer.
234+func requireClosed(t *testing.T, c *Core) {
235+ t.Helper()
236+ for _, sc := range c.screens {
237+ if sc.mon.sw.ws != nil || sc.mon.sw.shown {
238+ t.Fatalf("screen %q still holds a switcher: %+v", sc.name(), sc.mon.sw)
239+ }
240+ }
241+ if c.switcher.sc != nil || c.switcher.timerC != nil || c.switcher.timerStop != nil {
242+ t.Fatalf("switcher screen %v, timer %v", c.switcher.sc != nil, c.switcher.timerC != nil)
243+ }
244+}
245+
169 func tab(mods ports.Mods, pressed bool) ports.KeyEvent {246 func tab(mods ports.Mods, pressed bool) ports.KeyEvent {
170 return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed}247 return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed}
171 }248 }
@@ -356,29 +433,22 @@ func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
356 }433 }
357 }434 }
358 435
359-func TestSwitchPrevOpensOnOldestColumn(t *testing.T) {
360- c, _, _ := switcherCore(t, 3)
361- c.mods = ports.ModSuper
362- bind(t, c, ActionSwitchColumnPrev)
363- if sw := c.cur().mon.sw; sw.ws == nil || sw.at != len(sw.order)-1 {
364- t.Fatalf("at %d of %d", sw.at, len(sw.order))
365- }
366-}
367-
368 func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {436 func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
369 c, ic, _ := switcherCore(t, 3)437 c, ic, _ := switcherCore(t, 3)
370- c.mods = 0
371 start := switcherFocus(c)438 start := switcherFocus(c)
372- if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {439+ // F9 is bound to switch-column-next with no Cmd held.
373- t.Fatal(err)440+ f9 := func(pressed bool) ports.KeyEvent {
441+ return ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: pressed}
374 }442 }
375- if c.cur().mon.sw.ws != nil || c.switcher.timerC != nil || len(ic.timers) != 0 {443+ sendKeys(t, c, f9(true), f9(false))
376- t.Fatalf("open %v, timer %v %v", c.cur().mon.sw.ws != nil, c.switcher.timerC != nil, ic.timers)444+ requireClosed(t, c)
445+ if len(ic.timers) != 0 {
446+ t.Fatalf("timers %v armed for a switch with no Cmd", ic.timers)
377 }447 }
378 if switcherFocus(c) == start {448 if switcherFocus(c) == start {
379 t.Fatal("focus did not move")449 t.Fatal("focus did not move")
380 }450 }
381- bind(t, c, ActionSwitchColumnNext)451+ sendKeys(t, c, f9(true), f9(false))
382 if switcherFocus(c) != start {452 if switcherFocus(c) != start {
383 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)453 t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
384 }454 }
@@ -388,14 +458,13 @@ func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
388 c, _, _ := switcherCore(t, 1)458 c, _, _ := switcherCore(t, 1)
389 c.mods = ports.ModSuper459 c.mods = ports.ModSuper
390 bind(t, c, ActionSwitchColumnNext)460 bind(t, c, ActionSwitchColumnNext)
391- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil {461+ requireClosed(t, c)
392- t.Fatal("switcher opened with one column")
393- }
394 c, _, _ = switcherCore(t, 3)462 c, _, _ = switcherCore(t, 3)
395 c.cur().mon.ToggleOverview()463 c.cur().mon.ToggleOverview()
396 before := switcherFocus(c)464 before := switcherFocus(c)
397 bind(t, c, ActionSwitchColumnNext)465 bind(t, c, ActionSwitchColumnNext)
398- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != before {466+ requireClosed(t, c)
467+ if switcherFocus(c) != before {
399 t.Fatal("switcher acted in the overview")468 t.Fatal("switcher acted in the overview")
400 }469 }
401 }470 }
@@ -421,9 +490,7 @@ func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
421 t.Fatalf("cards published during a quick tap: %+v", p)490 t.Fatalf("cards published during a quick tap: %+v", p)
422 }491 }
423 }492 }
424- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {493+ requireClosed(t, c)
425- t.Fatal("switcher left open")
426- }
427 }494 }
428 tap()495 tap()
429 if switcherFocus(c) == start {496 if switcherFocus(c) == start {
@@ -445,8 +512,9 @@ func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
445 }512 }
446 forwarded(cmds)513 forwarded(cmds)
447 sendKeys(t, c, superKey(0, false))514 sendKeys(t, c, superKey(0, false))
448- if switcherFocus(c) != order[1] || c.cur().mon.sw.ws != nil || c.switcher.timerC != nil {515+ requireClosed(t, c)
449- t.Fatalf("focus %d (want %d), open %v", switcherFocus(c), order[1], c.cur().mon.sw.ws != nil)516+ if switcherFocus(c) != order[1] {
517+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
450 }518 }
451 fk := forwarded(cmds)519 fk := forwarded(cmds)
452 if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {520 if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {
@@ -454,30 +522,6 @@ func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
454 }522 }
455 }523 }
456 524
457-func TestSwitcherTickShowsCards(t *testing.T) {
458- c, _, _ := switcherCore(t, 3)
459- sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
460- if c.cur().mon.sw.shown {
461- t.Fatal("shown before the delay")
462- }
463- if !c.switcherTick() || !c.cur().mon.sw.shown {
464- t.Fatal("tick did not show the switcher")
465- }
466- if c.switcher.timerC != nil {
467- t.Fatal("timer left")
468- }
469-}
470-
471-func TestSwitcherTickAfterCloseIsHarmless(t *testing.T) {
472- c, _, _ := switcherCore(t, 3)
473- c.mods = ports.ModSuper
474- bind(t, c, ActionSwitchColumnNext)
475- c.cancelSwitcher()
476- if c.switcherTick() {
477- t.Fatal("tick on a closed switcher asks for a scene")
478- }
479-}
480-
481 // --- P3.1: keys and gestures ---525 // --- P3.1: keys and gestures ---
482 526
483 func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {527 func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
@@ -489,8 +533,9 @@ func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
489 sendKeys(t, c, esc)533 sendKeys(t, c, esc)
490 esc.Pressed = false534 esc.Pressed = false
491 sendKeys(t, c, esc, superKey(0, false))535 sendKeys(t, c, esc, superKey(0, false))
492- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != start {536+ requireClosed(t, c)
493- t.Fatalf("open %v focus %d (want %d)", c.cur().mon.sw.ws != nil, switcherFocus(c), start)537+ if switcherFocus(c) != start {
538+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
494 }539 }
495 for _, k := range forwarded(cmds) {540 for _, k := range forwarded(cmds) {
496 if k.Key.Keysym == "Escape" {541 if k.Key.Keysym == "Escape" {
@@ -504,9 +549,7 @@ func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
504 start := switcherFocus(c)549 start := switcherFocus(c)
505 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))550 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
506 sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})551 sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
507- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {552+ requireClosed(t, c)
508- t.Fatal("switcher still open")
509- }
510 if switcherFocus(c) != start {553 if switcherFocus(c) != start {
511 t.Fatalf("focus %d moved", switcherFocus(c))554 t.Fatalf("focus %d moved", switcherFocus(c))
512 }555 }
@@ -533,8 +576,9 @@ func TestSwitchSwipeBeginCancels(t *testing.T) {
533 start := switcherFocus(c)576 start := switcherFocus(c)
534 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))577 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
535 c.swipeBegin(ports.SwipeBegin{Fingers: 3})578 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
536- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start {579+ requireClosed(t, c)
537- t.Fatal("swipe did not cancel the switcher")580+ if switcherFocus(c) != start {
581+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
538 }582 }
539 }583 }
540 584
@@ -550,8 +594,9 @@ func TestSwitchSecurityChangeCancels(t *testing.T) {
550 if !c.syncSecurity() {594 if !c.syncSecurity() {
551 t.Fatal("security change not seen")595 t.Fatal("security change not seen")
552 }596 }
553- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil || switcherFocus(c) != start {597+ requireClosed(t, c)
554- t.Fatal("security change did not cancel the switcher")598+ if switcherFocus(c) != start {
599+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
555 }600 }
556 }601 }
557 602
@@ -568,8 +613,9 @@ func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
568 t.Fatal("Alt+Tab did not open the switcher")613 t.Fatal("Alt+Tab did not open the switcher")
569 }614 }
570 sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false})615 sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false})
571- if c.cur().mon.sw.ws != nil || switcherFocus(c) != order[1] {616+ requireClosed(t, c)
572- t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.ws != nil, switcherFocus(c), order[1])617+ if switcherFocus(c) != order[1] {
618+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
573 }619 }
574 }620 }
575 621
@@ -578,28 +624,46 @@ func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
578 func TestSwitcherLayoutCards(t *testing.T) {624 func TestSwitcherLayoutCards(t *testing.T) {
579 c, _, _ := switcherCore(t, 3)625 c, _, _ := switcherCore(t, 3)
580 c.cfg.Floating.Dim = 0.4626 c.cfg.Floating.Dim = 0.4
627+ // A wider focused column: the cards have different aspect ratios.
628+ c.cur().mon.Current().ResizeColumn(30)
629+ indicatorScene(t, c)
581 area := c.cur().mon.Current().overviewArea()630 area := c.cur().mon.Current().overviewArea()
582 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))631 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
583- if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {632+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 || c.cur().mon.sw.shown {
584 t.Fatalf("cards before the delay: %+v", s.Windows)633 t.Fatalf("cards before the delay: %+v", s.Windows)
585 }634 }
586 order := c.cur().mon.sw.order635 order := c.cur().mon.sw.order
587- c.switcherTick()636+ if !c.switcherTick() || !c.cur().mon.sw.shown || c.switcher.timerC != nil {
637+ t.Fatal("the tick did not show the cards and clear the timer")
638+ }
588 s := indicatorScene(t, c)639 s := indicatorScene(t, c)
589 cards := scenePreviews(s)640 cards := scenePreviews(s)
590 if len(cards) != 3 {641 if len(cards) != 3 {
591 t.Fatalf("%d cards: %+v", len(cards), s.Windows)642 t.Fatalf("%d cards: %+v", len(cards), s.Windows)
592 }643 }
593- minX, maxX := 1<<30, 0
594 focused := 0644 focused := 0
645+ distinct := false
646+ var centers []int
595 for k, card := range cards {647 for k, card := range cards {
596 if card.ID != order[k] {648 if card.ID != order[k] {
597 t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k])649 t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k])
598 }650 }
599- if card.Rect.W < cards[0].Rect.W-1 || card.Rect.W > cards[0].Rect.W+1 {651+ // The boxes are equal: the cards' centres are evenly spaced.
600- t.Fatalf("cards of different widths: %+v", cards)652+ centers = append(centers, card.Rect.X+card.Rect.W/2)
653+ // A card is its window's client size, scaled down uniformly.
654+ sent := c.configures.sent[card.ID]
655+ if sent.Width <= 0 || sent.Height <= 0 || card.Preview <= 0 || card.Preview >= 1 {
656+ t.Fatalf("card %d: client %dx%d, preview %v", card.ID, sent.Width, sent.Height, card.Preview)
657+ }
658+ if d := card.Rect.W - int(math.Round(float64(sent.Width)*card.Preview)); d < -1 || d > 1 {
659+ 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)
660+ }
661+ if d := card.Rect.H - int(math.Round(float64(sent.Height)*card.Preview)); d < -1 || d > 1 {
662+ 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)
663+ }
664+ if first := c.configures.sent[order[0]]; sent.Width != first.Width {
665+ distinct = true
601 }666 }
602- minX, maxX = min(minX, card.Rect.X), max(maxX, card.Rect.X+card.Rect.W)
603 if card.Focused {667 if card.Focused {
604 focused++668 focused++
605 if card.ID != order[c.cur().mon.sw.at] {669 if card.ID != order[c.cur().mon.sw.at] {
@@ -607,9 +671,14 @@ func TestSwitcherLayoutCards(t *testing.T) {
607 }671 }
608 }672 }
609 }673 }
610- left, right := minX-area.X, area.X+area.W-maxX674+ if !distinct {
611- if d := left - right; d < -1 || d > 1 {675+ t.Fatal("setup: every window has the same size, so the aspect ratios prove nothing")
612- t.Fatalf("row not centered: left gap %d, right gap %d", left, right)676+ }
677+ if d := (centers[1] - centers[0]) - (centers[2] - centers[1]); d < -2 || d > 2 {
678+ t.Fatalf("boxes not equal: card centres %v", centers)
679+ }
680+ if d := centers[0] + centers[2] - 2*(area.X+area.W/2); d < -2 || d > 2 {
681+ t.Fatalf("row not centered in %+v: card centres %v", area, centers)
613 }682 }
614 if focused != 1 {683 if focused != 1 {
615 t.Fatalf("%d focused cards", focused)684 t.Fatalf("%d focused cards", focused)
@@ -628,10 +697,6 @@ func TestSwitcherLayoutCards(t *testing.T) {
628 t.Fatalf("window %d drawn under the cards", w.ID)697 t.Fatalf("window %d drawn under the cards", w.ID)
629 }698 }
630 }699 }
631- // The keyboard stays on the real focus.
632- if c.keyboardFocus() != focusedIDReal(c) {
633- t.Fatalf("keyboard focus %d moved", c.keyboardFocus())
634- }
635 // A commit gives the workspace back.700 // A commit gives the workspace back.
636 sendKeys(t, c, superKey(0, false))701 sendKeys(t, c, superKey(0, false))
637 if s := lastScene(t, c); len(scenePreviews(s)) != 0 {702 if s := lastScene(t, c); len(scenePreviews(s)) != 0 {
@@ -639,27 +704,27 @@ func TestSwitcherLayoutCards(t *testing.T) {
639 }704 }
640 }705 }
641 706
642-func focusedIDReal(c *Core) WindowID {
643- id, _ := c.cur().mon.Current().Focused()
644- return id
645-}
646-
647 func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {707 func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
648- c, _, _ := switcherCore(t, 3)708+ // Windows 10 and 11 are on a second workspace.
649- c.cur().mon.AddWindow(4)709+ c, _ := twoWorkspaces(t)
650- w := c.cur().mon.Current()710+ m := c.cur().mon
711+ m.AddWindow(4)
712+ w := m.Current()
651 w.ConsumeOrExpel(-1)713 w.ConsumeOrExpel(-1)
652- if len(w.Columns) != 3 || w.columnWindows() != 4 {714+ m.AddFloating(9, 100, 80)
653- t.Fatalf("setup: %d columns, %d windows", len(w.Columns), w.columnWindows())715+ if len(w.Columns) != 3 || w.columnWindows() != 4 || len(w.Floats) != 1 {
716+ t.Fatalf("setup: %d columns, %d windows, %d floats", len(w.Columns), w.columnWindows(), len(w.Floats))
654 }717 }
655 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))718 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
656 c.switcherTick()719 c.switcherTick()
657 s := indicatorScene(t, c)720 s := indicatorScene(t, c)
658 reps := map[WindowID]bool{}721 reps := map[WindowID]bool{}
659- for _, id := range c.cur().mon.sw.order {722+ for _, id := range m.sw.order {
660 reps[id] = true723 reps[id] = true
661 }724 }
725+ seen := map[WindowID]bool{}
662 for _, sw := range s.Windows {726 for _, sw := range s.Windows {
727+ seen[sw.ID] = true
663 if reps[sw.ID] {728 if reps[sw.ID] {
664 if sw.Preview == 0 || sw.Hidden {729 if sw.Preview == 0 || sw.Hidden {
665 t.Fatalf("representative %d not a card: %+v", sw.ID, sw)730 t.Fatalf("representative %d not a card: %+v", sw.ID, sw)
@@ -668,6 +733,202 @@ func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
668 t.Fatalf("window %d not hidden: %+v", sw.ID, sw)733 t.Fatalf("window %d not hidden: %+v", sw.ID, sw)
669 }734 }
670 }735 }
736+ // The other window of a column, the float and the other workspace's
737+ // windows are all in the scene, hidden.
738+ for _, id := range []WindowID{4, 9, 10, 11} {
739+ if !seen[id] {
740+ t.Fatalf("window %d missing from the scene (hidden windows must be listed): %+v", id, s.Windows)
741+ }
742+ }
743+}
744+
745+// While the cards show, the screen draws previews: the tiles do not stop at
746+// the panels, a card takes no pointer, nothing is
747+// dragged, and a window mapping or unmapping behind them does not animate.
748+func TestSwitcherShownIsAPreview(t *testing.T) {
749+ c, ic, _ := switcherCore(t, 3)
750+ c.cfg.Animations.On = true
751+ m := c.cur().mon
752+ // A bar reserves the top of the output: tiles stop below it.
753+ c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 300, Height: 20, ExclusiveZone: 20}})
754+ if s := indicatorScene(t, c); s.TileInset.Top != 20 {
755+ t.Fatalf("setup: tile inset %+v, want the bar's 20", s.TileInset)
756+ }
757+ sizes := map[WindowID]ports.ConfigureWindow{}
758+ for id, v := range c.configures.sent {
759+ sizes[id] = v
760+ }
761+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
762+ c.switcherTick()
763+ indicatorScene(t, c)
764+ // The cards fly in, and the motions survive a publish.
765+ if len(c.cur().rects) == 0 {
766+ t.Fatal("no card motion after the show")
767+ }
768+ settle := func() {
769+ t.Helper()
770+ for range 200 {
771+ ic.now = ic.now.Add(16 * time.Millisecond)
772+ if err := c.step(context.Background(), nil); err != nil {
773+ t.Fatal(err)
774+ }
775+ }
776+ lastScene(t, c)
777+ if len(c.cur().rects) != 0 || c.animating() {
778+ t.Fatalf("motions did not settle: %d rects", len(c.cur().rects))
779+ }
780+ }
781+ settle()
782+ if !m.switcherShown() {
783+ t.Fatal("setup: cards not shown")
784+ }
785+ s := indicatorScene(t, c)
786+ if len(scenePreviews(s)) != 3 {
787+ t.Fatalf("%d cards", len(scenePreviews(s)))
788+ }
789+ // A card keeps its client's real size.
790+ for _, id := range m.sw.order {
791+ if got := c.configures.sent[id]; got.Width != sizes[id].Width || got.Height != sizes[id].Height {
792+ t.Fatalf("window %d configured %dx%d under its card, was %dx%d", id, got.Width, got.Height, sizes[id].Width, sizes[id].Height)
793+ }
794+ }
795+ if s.TileInset != (ports.Insets{}) {
796+ t.Fatalf("tile inset %+v while the cards show", s.TileInset)
797+ }
798+ // The pointer finds no window under a card.
799+ card := m.sw.order[1]
800+ x, y := cardCenter(t, c, card)
801+ if id, _, _ := c.hit(x, y); id != 0 {
802+ t.Fatalf("hit window %d under a card", id)
803+ }
804+ if _, _, ok := c.draggable(card); ok {
805+ t.Fatal("a card can be dragged")
806+ }
807+ if tgt := c.dropAt(card, x, y); tgt.kind != dropNone {
808+ t.Fatalf("a drop target %+v under the cards", tgt)
809+ }
810+ // A map and an unmap behind the cards do not animate.
811+ c.mapWindow(ports.WindowMapped{ID: 20})
812+ if len(c.cur().rects) != 0 {
813+ t.Fatalf("a map behind the cards started motions: %+v", c.cur().rects)
814+ }
815+ gone := m.sw.order[2]
816+ c.unmapWindow(ports.WindowUnmapped{ID: gone})
817+ if len(c.cur().rects) != 0 {
818+ t.Fatalf("an unmap behind the cards started motions: %+v", c.cur().rects)
819+ }
820+ if m.Current().columnOf(gone) >= 0 || len(m.sw.order) != 2 {
821+ t.Fatalf("window %d not removed from the cards: %v", gone, m.sw.order)
822+ }
823+ // The workspace comes back with the panel inset.
824+ sendKeys(t, c, superKey(0, false))
825+ settle()
826+ if s := indicatorScene(t, c); s.TileInset.Top != 20 || len(scenePreviews(s)) != 0 {
827+ t.Fatalf("after the commit: tile inset %+v, %d cards", s.TileInset, len(scenePreviews(s)))
828+ }
829+}
830+
831+// The predicates that choose between the workspace and its previews follow
832+// the cards as they follow the overview: the frame, a covering fullscreen
833+// and the capture state.
834+func TestSwitcherShownPreviewPredicates(t *testing.T) {
835+ c, _, _ := switcherCore(t, 3)
836+ sc := c.cur()
837+ m := sc.mon
838+ w := m.Current()
839+ w.Overflow = OverflowFixed // a fullscreen window covers whatever the scroll
840+ w.SetSize(100, 100) // the workspace frame is smaller than the output
841+ tile := w.Columns[w.Focus].Windows[0]
842+ w.SetFullscreen(tile, true)
843+ indicatorScene(t, c)
844+ c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: w.ID}}}
845+ type state struct {
846+ frameIsOutput, covers, captureHidden bool
847+ captureShown uint64
848+ }
849+ read := func() state {
850+ r, reason := c.capResolve(capTarget{workspace: w.ID})
851+ if reason != ports.CaptureReasonNone {
852+ t.Fatalf("capture target: %v", reason)
853+ }
854+ var shown uint64
855+ if cs := c.captureSceneFor(sc, &capView{}); cs != nil {
856+ shown = cs.Shown
857+ }
858+ return state{m.Frame() == m.Output(), hasFullscreen(sc), r.hidden, shown}
859+ }
860+ if got, want := read(), (state{false, true, false, w.ID}); got != want {
861+ t.Fatalf("closed: %+v, want %+v", got, want)
862+ }
863+ c.mods = ports.ModSuper
864+ bind(t, c, ActionSwitchColumnNext)
865+ c.switcherTick()
866+ if !m.switcherShown() {
867+ t.Fatal("setup: cards not shown")
868+ }
869+ if got, want := read(), (state{true, false, true, 0}); got != want {
870+ t.Fatalf("shown: %+v, want %+v", got, want)
871+ }
872+}
873+
874+// A card's window with no configure sent yet (it mapped since the last
875+// publish) is sized from its real client rect, not from the card.
876+func TestSwitcherCardOfUnconfiguredWindowKeepsRealSize(t *testing.T) {
877+ c, _, _ := switcherCore(t, 3)
878+ m := c.cur().mon
879+ id := m.Current().switchOrder()[1]
880+ c.configures.forget(id) // the card falls back to the usable size
881+ c.mods = ports.ModSuper
882+ bind(t, c, ActionSwitchColumnNext)
883+ c.configures.forget(id) // the bind's publish configured it again
884+ c.switcherTick()
885+ indicatorScene(t, c)
886+ var real Placement
887+ for _, p := range m.Current().Layout() {
888+ if p.ID == id {
889+ real = p
890+ }
891+ }
892+ want := c.clientRect(real)
893+ if got := c.configures.sent[id]; got.Width != want.W || got.Height != want.H || want.W == 0 {
894+ t.Fatalf("window %d configured %dx%d under its card, real client %dx%d", id, got.Width, got.Height, want.W, want.H)
895+ }
896+}
897+
898+// A focus change behind the cards (the real focus, not the selection) is not
899+// recorded as a use of the column until the cards are gone.
900+func TestSwitcherShownDoesNotNoteFocus(t *testing.T) {
901+ c, _, _ := switcherCore(t, 3)
902+ w := c.cur().mon.Current()
903+ c.mods = ports.ModSuper
904+ bind(t, c, ActionSwitchColumnNext)
905+ c.switcherTick()
906+ before := slices.Clone(w.recent)
907+ w.FocusID(c.cur().mon.sw.order[2])
908+ indicatorScene(t, c)
909+ if !slices.Equal(w.recent, before) {
910+ t.Fatalf("recent %v, was %v: noted while the cards show", w.recent, before)
911+ }
912+}
913+
914+// Showing the cards drops the motions of windows they hide.
915+func TestSwitcherShowDropsMotionsOfHiddenWindows(t *testing.T) {
916+ c, _, _ := switcherCore(t, 3)
917+ c.cfg.Animations.On = true
918+ sc := c.cur()
919+ c.mapWindow(ports.WindowMapped{ID: 4})
920+ w := sc.mon.Current()
921+ w.ConsumeOrExpel(-1) // 4 joins 3's column
922+ w.FocusID(3) // 3 stands for the column; 4 is hidden
923+ if _, ok := sc.rects[4]; !ok {
924+ t.Fatal("setup: no entrance motion for window 4")
925+ }
926+ c.mods = ports.ModSuper
927+ bind(t, c, ActionSwitchColumnNext)
928+ c.switcherTick()
929+ if _, ok := sc.rects[4]; ok {
930+ t.Fatalf("a hidden window keeps its motion: %+v", sc.rects[4])
931+ }
671 }932 }
672 933
673 func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {934 func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
@@ -712,8 +973,9 @@ func TestSwitcherClickOnCardCommits(t *testing.T) {
712 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {973 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
713 t.Fatal(err)974 t.Fatal(err)
714 }975 }
715- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != target {976+ requireClosed(t, c)
716- t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.ws != nil, switcherFocus(c), target)977+ if switcherFocus(c) != target {
978+ t.Fatalf("focus %d, want %d", switcherFocus(c), target)
717 }979 }
718 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {980 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
719 t.Fatal(err)981 t.Fatal(err)
@@ -735,8 +997,9 @@ func TestSwitcherClickElsewhereCancels(t *testing.T) {
735 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {997 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
736 t.Fatal(err)998 t.Fatal(err)
737 }999 }
738- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != start {1000+ requireClosed(t, c)
739- t.Fatalf("open %v focus %d, want %d", c.cur().mon.sw.ws != nil, switcherFocus(c), start)1001+ if switcherFocus(c) != start {
1002+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
740 }1003 }
741 }1004 }
742 1005
@@ -748,7 +1011,8 @@ func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
748 if err != nil || handled {1011 if err != nil || handled {
749 t.Fatalf("handled %v err %v", handled, err)1012 t.Fatalf("handled %v err %v", handled, err)
750 }1013 }
751- if c.cur().mon.sw.ws != nil || c.switcher.timerC != nil || switcherFocus(c) != start {1014+ requireClosed(t, c)
1015+ if switcherFocus(c) != start {
752 t.Fatal("press did not cancel")1016 t.Fatal("press did not cancel")
753 }1017 }
754 }1018 }
@@ -776,15 +1040,27 @@ func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
776 }1040 }
777 }1041 }
778 1042
1043+// Closing a window ahead of the selection shifts the cards left: the
1044+// selection follows its card, so it still stands on the same window.
779 func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {1045 func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
780- c, _, _ := switcherCore(t, 3)1046+ c, _, _ := switcherCore(t, 4)
781 c.mods = ports.ModSuper1047 c.mods = ports.ModSuper
782- bind(t, c, ActionSwitchColumnPrev)1048+ bind(t, c, ActionSwitchColumnNext)
783 sw := &c.cur().mon.sw1049 sw := &c.cur().mon.sw
1050+ if sw.at != 1 || len(sw.order) != 4 {
1051+ t.Fatalf("setup: at %d of %d", sw.at, len(sw.order))
1052+ }
784 selected := sw.order[sw.at]1053 selected := sw.order[sw.at]
785 c.cur().mon.RemoveWindow(sw.order[0])1054 c.cur().mon.RemoveWindow(sw.order[0])
786- if sw.ws == nil || sw.order[sw.at] != selected {1055+ if sw.ws == nil || len(sw.order) != 3 {
787- t.Fatalf("selection moved from %d: order %v at %d", selected, sw.order, sw.at)1056+ t.Fatalf("order %v after the close", sw.order)
1057+ }
1058+ if sw.at != 0 || sw.order[sw.at] != selected {
1059+ t.Fatalf("selection moved from %d: order %v at %d, want at 0", selected, sw.order, sw.at)
1060+ }
1061+ sendKeys(t, c, superKey(0, false))
1062+ if switcherFocus(c) != selected {
1063+ t.Fatalf("focus %d after the commit, want %d", switcherFocus(c), selected)
788 }1064 }
789 }1065 }
790 1066
@@ -793,16 +1069,18 @@ func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
793 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))1069 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
794 c.switcherTick()1070 c.switcherTick()
795 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])1071 c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
796- if c.cur().mon.sw.ws != nil || c.cur().mon.sw.shown {1072+ if sw := c.cur().mon.sw; sw.ws != nil || sw.shown {
797 t.Fatal("switcher still open with one column")1073 t.Fatal("switcher still open with one column")
798 }1074 }
799 if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {1075 if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {
800 t.Fatalf("cards left: %+v", s.Windows)1076 t.Fatalf("cards left: %+v", s.Windows)
801 }1077 }
802- // The timer fires on a closed switcher: harmless.1078+ // The timer fires on a closed switcher: harmless, and it clears the
1079+ // core's side.
803 if c.switcherTick() {1080 if c.switcherTick() {
804 t.Fatal("tick on a closed switcher")1081 t.Fatal("tick on a closed switcher")
805 }1082 }
1083+ requireClosed(t, c)
806 sendKeys(t, c, superKey(0, false))1084 sendKeys(t, c, superKey(0, false))
807 }1085 }
808 1086
@@ -816,7 +1094,8 @@ func TestSwitcherOutputRemovalCancels(t *testing.T) {
816 t.Fatal("no switcher screen")1094 t.Fatal("no switcher screen")
817 }1095 }
818 c.removeScreen(sc.name())1096 c.removeScreen(sc.name())
819- if c.switcher.sc != nil || c.switcher.timerC != nil || sc.mon.sw.ws != nil {1097+ requireClosed(t, c)
1098+ if sc.mon.sw.ws != nil {
820 t.Fatal("switcher survived its output")1099 t.Fatal("switcher survived its output")
821 }1100 }
822 if err := c.publish(context.Background()); err != nil {1101 if err := c.publish(context.Background()); err != nil {
@@ -875,6 +1154,9 @@ func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
875 if len(scenePreviews(s)) != 0 || switcherFocus(c) != target {1154 if len(scenePreviews(s)) != 0 || switcherFocus(c) != target {
876 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)1155 t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
877 }1156 }
1157+ if n := len(c.cur().rects); n != 0 || c.animating() {
1158+ t.Fatalf("motions left: %d rect motions, animating %v", n, c.animating())
1159+ }
878 }1160 }
879 1161
880 // --- review fixes ---1162 // --- review fixes ---
@@ -918,9 +1200,7 @@ func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
918 if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {1200 if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
919 t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))1201 t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
920 }1202 }
921- if m.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {1203+ requireClosed(t, c)
922- t.Fatal("stale switcher left open")
923- }
924 var any bool1204 var any bool
925 for _, p := range c.cur().shownLayout() {1205 for _, p := range c.cur().shownLayout() {
926 if p.ID == 10 || p.ID == 11 {1206 if p.ID == 10 || p.ID == 11 {
@@ -960,7 +1240,8 @@ func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
960 c.cur().mon.show(c.cur().mon.Workspaces[0])1240 c.cur().mon.show(c.cur().mon.Workspaces[0])
961 focus := switcherFocus(c)1241 focus := switcherFocus(c)
962 sendKeys(t, c, superKey(0, false))1242 sendKeys(t, c, superKey(0, false))
963- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || switcherFocus(c) != focus {1243+ requireClosed(t, c)
1244+ if switcherFocus(c) != focus {
964 t.Fatal("stale switcher survived the release")1245 t.Fatal("stale switcher survived the release")
965 }1246 }
966 }1247 }
@@ -978,9 +1259,7 @@ func TestSwitcherTickOnStaleWorkspaceCancels(t *testing.T) {
978 if !c.switcherTick() {1259 if !c.switcherTick() {
979 t.Fatal("tick on a stale workspace asks for no scene")1260 t.Fatal("tick on a stale workspace asks for no scene")
980 }1261 }
981- if m.sw.ws != nil || m.sw.shown || c.switcher.sc != nil || c.switcher.timerC != nil {1262+ requireClosed(t, c)
982- t.Fatal("stale switcher survived the tick")
983- }
984 }1263 }
985 1264
986 func TestSwitchIgnoredDuringDrag(t *testing.T) {1265 func TestSwitchIgnoredDuringDrag(t *testing.T) {
@@ -990,9 +1269,7 @@ func TestSwitchIgnoredDuringDrag(t *testing.T) {
990 c.drag = d1269 c.drag = d
991 c.buttons[btnLeft] = true1270 c.buttons[btnLeft] = true
992 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))1271 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
993- if c.cur().mon.sw.ws != nil || c.switcher.sc != nil || c.switcher.timerC != nil {1272+ requireClosed(t, c)
994- t.Fatal("switcher opened during a drag")
995- }
996 if c.drag != d || switcherFocus(c) != start {1273 if c.drag != d || switcherFocus(c) != start {
997 t.Fatal("drag disturbed")1274 t.Fatal("drag disturbed")
998 }1275 }
@@ -1062,6 +1339,8 @@ func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1062 at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })1339 at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
1063 m.sw.at = at1340 m.sw.at = at
1064 gone := m.sw.order[at]1341 gone := m.sw.order[at]
1342+ size := Rect{W: 120, H: 90}
1343+ m.sw.sizes = map[WindowID]Rect{gone: size}
1065 other := w.Columns[1].Windows[0]1344 other := w.Columns[1].Windows[0]
1066 if other == gone {1345 if other == gone {
1067 other = w.Columns[1].Windows[1]1346 other = w.Columns[1].Windows[1]
@@ -1070,6 +1349,9 @@ func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1070 if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {1349 if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
1071 t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)1350 t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
1072 }1351 }
1352+ if m.sw.sizes[other] != size {
1353+ t.Fatalf("sizes %v: the new card keeps no size of the closed window's", m.sw.sizes)
1354+ }
1073 m.commitSwitcher()1355 m.commitSwitcher()
1074 if id, _ := w.Focused(); id != other {1356 if id, _ := w.Focused(); id != other {
1075 t.Fatalf("focus %d, want %d", id, other)1357 t.Fatalf("focus %d, want %d", id, other)
@@ -1109,12 +1391,3 @@ func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
1109 t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)1391 t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
1110 }1392 }
1111 }1393 }
1112-
1113-func TestSwitchUnmodifiedBindThroughKeyEvent(t *testing.T) {
1114- c, ic, _ := switcherCore(t, 3)
1115- start := switcherFocus(c)
1116- sendKeys(t, c, ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: true})
1117- if c.cur().mon.sw.ws != nil || len(ic.timers) != 0 || switcherFocus(c) == start {
1118- t.Fatalf("open %v timers %v focus %d (was %d)", c.cur().mon.sw.ws != nil, ic.timers, switcherFocus(c), start)
1119- }
1120-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close