feat/data-dnd e05cb15

switcher_test.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close