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

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