v0.1.0 d9218d6

monitor_test.go RawHistoryFolder

1package core
2
3import (
4	"reflect"
5	"slices"
6	"testing"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11func monitor() *Monitor {
12	m := NewMonitor()
13	m.SetOutput(100, 80)
14	m.SetMaxColumns(2)
15	return m
16}
17
18// windows lists each workspace's windows, in column order.
19func windows(m *Monitor) [][]WindowID {
20	var out [][]WindowID
21	for _, w := range m.Workspaces {
22		list := []WindowID{}
23		for _, c := range w.Columns {
24			list = append(list, c.Windows...)
25		}
26		out = append(out, list)
27	}
28	return out
29}
30
31func TestMonitorKeepsOneEmptyWorkspaceBelow(t *testing.T) {
32	m := monitor()
33	if len(m.Workspaces) != 1 || m.Active != 0 {
34		t.Fatal(windows(m))
35	}
36	m.AddWindow(1)
37	if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {}}) {
38		t.Fatal(windows(m))
39	}
40	// Cmd+9 past the end lands on the trailing empty workspace.
41	m.FocusNumber(9)
42	if m.Active != 1 {
43		t.Fatal(m.Active)
44	}
45	m.AddWindow(2)
46	if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) {
47		t.Fatal(windows(m))
48	}
49}
50
51func TestMonitorRemovesEmptyWorkspaceOnceLeft(t *testing.T) {
52	m := monitor()
53	m.AddWindow(1)
54	m.FocusNumber(2)
55	m.AddWindow(2)
56	m.FocusNumber(3)
57	m.AddWindow(3)
58	// [1:{1}] [2:{2}] [3:{3}] [4:{}]
59	m.FocusNumber(2)
60	m.RemoveWindow(2)
61	// Still on the now-empty workspace 2: it stays while active.
62	if len(m.Workspaces) != 4 || m.Active != 1 {
63		t.Fatal(windows(m), m.Active)
64	}
65	m.FocusNumber(1)
66	// Once left it is removed: workspace 3 becomes 2.
67	if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 0 {
68		t.Fatal(windows(m), m.Active)
69	}
70	m.FocusNumber(2)
71	if id, _ := m.Focused(); id != 3 {
72		t.Fatal(id)
73	}
74}
75
76func TestMonitorFirstWorkspaceStays(t *testing.T) {
77	m := monitor()
78	m.AddWindow(1)
79	m.RemoveWindow(1)
80	m.FocusNumber(2)
81	if len(m.Workspaces) != 1 || m.Active != 0 {
82		t.Fatal(windows(m), m.Active)
83	}
84	// Leaving an empty first workspace does not remove it.
85	m.AddWindow(1)
86	m.FocusNumber(2)
87	m.AddWindow(2)
88	m.FocusNumber(1)
89	m.RemoveWindow(1)
90	m.FocusNumber(2)
91	if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) {
92		t.Fatal(windows(m))
93	}
94}
95
96func TestMonitorRemoveElsewhereKeepsActive(t *testing.T) {
97	m := monitor()
98	m.AddWindow(1)
99	m.FocusNumber(2)
100	m.AddWindow(2)
101	m.FocusNumber(3)
102	m.AddWindow(3)
103	m.FocusNumber(3)
104	// A window on another workspace closes: the user stays on workspace 3's windows.
105	m.RemoveWindow(2)
106	if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 1 {
107		t.Fatal(windows(m), m.Active)
108	}
109	if id, _ := m.Focused(); id != 3 {
110		t.Fatal(id)
111	}
112}
113
114func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
115	m := monitor()
116	m.AddWindow(1)
117	m.FocusNumber(2)
118	m.AddWindow(2)
119	for _, p := range m.Layout() {
120		if hidden := p.ID == 1; p.Hidden != hidden {
121			t.Fatal(m.Layout())
122		}
123	}
124}
125
126func TestMonitorFullscreenRequestStaysPut(t *testing.T) {
127	m := monitor()
128	m.AddWindow(1)
129	m.FocusNumber(2)
130	m.AddWindow(2)
131	m.SetFullscreen(1, true)
132	if m.Active != 1 {
133		t.Fatal("client request switched workspace")
134	}
135	if m.Workspaces[0].fullscreen != 1 {
136		t.Fatal("fullscreen not applied")
137	}
138}
139
140func TestMonitorActions(t *testing.T) {
141	m := monitor()
142	m.AddWindow(1)
143	m.AddWindow(2)
144	// Down past the only window of the column goes to the workspace below.
145	m.Apply(ActionFocusWindowDown)
146	if m.Active != 1 {
147		t.Fatal(m.Active)
148	}
149	m.Apply(ActionFocusWindowUp)
150	if m.Active != 0 {
151		t.Fatal(m.Active)
152	}
153	// Up at the top workspace stays.
154	m.Apply(ActionFocusWorkspaceUp)
155	if m.Active != 0 {
156		t.Fatal(m.Active)
157	}
158	// Move the focused window (2) down: focus stays on workspace 1.
159	m.Apply(ActionMoveWindowToWorkspaceDown)
160	if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 {
161		t.Fatal(windows(m), m.Active)
162	}
163	m.Apply("move-window-to-workspace 3")
164	if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 {
165		t.Fatal(windows(m), m.Active)
166	}
167	m.Apply("focus-workspace 3")
168	if id, _ := m.Focused(); id != 1 {
169		t.Fatal(id)
170	}
171	// Workspace 1 is empty but first: it stays.
172	if len(m.Workspaces) != 4 {
173		t.Fatal(windows(m))
174	}
175	if e := m.Apply("spawn foot"); !e.Spawn {
176		t.Fatal(e)
177	}
178}
179
180func TestEqualSharesAndBorderless(t *testing.T) {
181	m := monitor()
182	m.SetMaxColumns(3)
183	m.AddWindow(1)
184	p := m.Layout()
185	if p[0].Rect.W != 100 || p[0].Neighbors != 0 {
186		t.Fatal(p)
187	}
188	m.AddWindow(2)
189	m.AddWindow(3)
190	p = m.Layout()
191	if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Neighbors != ports.SideRight || p[0].Inset != ports.SideRight {
192		t.Fatal(p)
193	}
194	// A fourth column scrolls: still 1/3 each.
195	m.AddWindow(4)
196	if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 {
197		t.Fatal(w.ViewX)
198	}
199	// A preset on one column is kept; auto columns keep their share.
200	m.SetPresets([]Width{{Num: 1, Den: 2}})
201	m.Apply(ActionCycleColumnWidth)
202	if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {
203		t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
204	}
205}
206
207// stack returns a monitor with columns [1] [2 3], focus on window 3.
208func stack() *Monitor {
209	m := monitor()
210	m.AddWindow(1)
211	m.AddWindow(2)
212	m.AddWindow(3)
213	w := m.Current()
214	w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1}}
215	w.Focus = 1
216	return m
217}
218
219func TestMoveColumnToWorkspace(t *testing.T) {
220	t.Run("whole column, focus stays", func(t *testing.T) {
221		m := stack()
222		m.Apply("move-column-to-workspace 2")
223		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) || m.Active != 0 {
224			t.Fatal(got, m.Active)
225		}
226		if w := m.Workspaces[1]; len(w.Columns) != 1 || w.Columns[0].Focus != 1 {
227			t.Fatal(w.Columns)
228		}
229		if id, _ := m.Focused(); id != 1 {
230			t.Fatal(id)
231		}
232	})
233	t.Run("window only", func(t *testing.T) {
234		m := stack()
235		m.Apply(ActionMoveWindowToWorkspaceDown)
236		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) {
237			t.Fatal(got)
238		}
239	})
240	t.Run("follow shows the target", func(t *testing.T) {
241		m := stack()
242		m.SetFollowMove(true)
243		m.Apply(ActionMoveColumnToWorkspaceDown)
244		if id, _ := m.Focused(); m.Active != 1 || id != 3 {
245			t.Fatal(m.Active, id)
246		}
247		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) {
248			t.Fatal(got)
249		}
250	})
251	t.Run("the slot window alone releases its slot", func(t *testing.T) {
252		m := stack()
253		c := &m.Current().Columns[1]
254		c.Slot, c.Focus = 2, 0
255		m.Apply(ActionMoveWindowToWorkspaceDown)
256		if c := m.Current().Columns[1]; c.Slot != 0 || c.Windows[0] != 3 {
257			t.Fatal(c)
258		}
259	})
260	t.Run("follow a floating window", func(t *testing.T) {
261		m := stack()
262		m.SetFollowMove(true)
263		m.AddFloating(9, 10, 10)
264		m.Current().FocusID(9)
265		m.Apply(ActionMoveWindowToWorkspaceDown)
266		if id, _ := m.Focused(); m.Active != 1 || id != 9 {
267			t.Fatal(m.Active, id)
268		}
269	})
270	t.Run("slot column loses its slot", func(t *testing.T) {
271		m := stack()
272		m.Current().Columns[1].Slot = 2
273		m.Apply(ActionMoveColumnToWorkspaceDown)
274		if c := m.Workspaces[1].Columns[0]; c.Slot != 0 {
275			t.Fatal(c)
276		}
277	})
278}
279
280func TestWorkspaceArg(t *testing.T) {
281	for _, tc := range []struct {
282		a  Action
283		n  int
284		op WorkspaceOp
285		ok bool
286	}{{"focus-workspace 3", 3, FocusWorkspace, true}, {"move-column-to-workspace 12", 12, MoveColumnToWorkspace, true}, {"move-window-to-workspace 2", 2, MoveWindowToWorkspace, true}, {"move-to-workspace 2", 0, 0, false}, {"focus-workspace 0", 0, 0, false}, {"focus-workspace x", 0, 0, false}, {"quit", 0, 0, false}} {
287		n, op, ok := WorkspaceArg(tc.a)
288		if n != tc.n || op != tc.op || ok != tc.ok {
289			t.Errorf("%s: %d %v %v", tc.a, n, op, ok)
290		}
291	}
292}
293
294func TestFullscreenRequestKeepsFocus(t *testing.T) {
295	m := monitor()
296	m.AddWindow(1)
297	m.AddWindow(2)
298	m.SetFullscreen(1, true)
299	if id, _ := m.Focused(); id != 2 {
300		t.Fatal(id)
301	}
302	if m.Current().fullscreen != 1 {
303		t.Fatal("not fullscreen")
304	}
305}
306
307func TestLonePresetColumnKeepsWidth(t *testing.T) {
308	m := monitor()
309	m.SetPresets([]Width{{Num: 1, Den: 2}})
310	m.AddWindow(1)
311	m.Apply(ActionCycleColumnWidth)
312	if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
313		t.Fatal(p)
314	}
315}
316
317func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
318	m.SetNamed(specs)
319	return m
320}
321
322func TestHiddenWorkspaceToggle(t *testing.T) {
323	m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true})
324	m.AddWindow(1)
325	if len(m.Workspaces) != 2 {
326		t.Fatal("hidden workspace is numbered", windows(m))
327	}
328	m.Apply("workspace dev")
329	if m.Current().Name != "dev" {
330		t.Fatal(m.Current().Name)
331	}
332	m.AddWindow(2)
333	// Up/down do not leave a hidden workspace.
334	m.Apply(ActionFocusWorkspaceDown)
335	m.Apply(ActionFocusWindowDown)
336	if m.Current().Name != "dev" {
337		t.Fatal("left the hidden workspace")
338	}
339	for _, p := range m.Layout() {
340		if hidden := p.ID == 1; p.Hidden != hidden {
341			t.Fatal(m.Layout())
342		}
343	}
344	// The same bind returns to the previous workspace.
345	m.Apply("workspace dev")
346	if id, _ := m.Focused(); id != 1 || m.Current().Name != "" {
347		t.Fatal(id, m.Current().Name)
348	}
349	// Cmd+N from the hidden workspace goes to that number.
350	m.Apply("workspace dev")
351	m.FocusNumber(1)
352	if id, _ := m.Focused(); id != 1 {
353		t.Fatal(id)
354	}
355	// Moving a window up/down from a hidden workspace does nothing.
356	m.Apply("workspace dev")
357	m.Apply(ActionMoveWindowToWorkspaceDown)
358	m.Apply(ActionMoveWindowToWorkspaceUp)
359	if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
360		t.Fatal(id, windows(m))
361	}
362}
363
364func TestNamedWorkspaceIsNumberedAndKept(t *testing.T) {
365	m := monitor()
366	m.AddWindow(1)
367	named(m, NamedWorkspace{Name: "web"})
368	// Inserted above the trailing empty workspace: [1:{1}] [2:web] [3:{}].
369	if len(m.Workspaces) != 3 || m.Workspaces[1].Name != "web" || m.Active != 0 {
370		t.Fatal(windows(m), m.Active)
371	}
372	// Empty named workspaces are not removed when left.
373	m.FocusNumber(2)
374	m.FocusNumber(1)
375	if m.Workspaces[1].Name != "web" {
376		t.Fatal(windows(m))
377	}
378	m.Apply("workspace web")
379	if m.Active != 1 {
380		t.Fatal(m.Active)
381	}
382	m.Apply("workspace web")
383	if m.Active != 0 {
384		t.Fatal("toggle did not return", m.Active)
385	}
386	m.Apply("workspace nope") // unknown: nothing
387	if m.Active != 0 {
388		t.Fatal(m.Active)
389	}
390}
391
392func TestNamedWorkspaceReload(t *testing.T) {
393	m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web"})
394	m.Apply("workspace dev")
395	m.AddWindow(7)
396	// Reload keeps windows and the workspace on screen; web becomes hidden.
397	named(m, NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web", Hidden: true})
398	if m.Current().Name != "dev" || len(m.Workspaces) != 1 {
399		t.Fatal(m.Current().Name, windows(m))
400	}
401	// Dropping dev from config hands its windows to the numbered workspace.
402	named(m)
403	if m.Current().Name != "" || !reflect.DeepEqual(windows(m), [][]WindowID{{7}, {}}) {
404		t.Fatal(m.Current().Name, windows(m))
405	}
406}
407
408func TestNamedWorkspaceSettings(t *testing.T) {
409	m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true, MaxColumns: 3, Overflow: OverflowFixed})
410	m.SetMaxColumns(1)
411	if m.Current().MaxColumns != 1 || m.Current().Overflow != "" {
412		t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
413	}
414	m.Apply("workspace dev")
415	if m.Current().MaxColumns != 3 || m.Current().Overflow != OverflowFixed {
416		t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
417	}
418	// New dynamic workspaces follow the defaults.
419	m.SetOverflow(OverflowScroll)
420	m.Apply("workspace dev")
421	m.FocusNumber(9)
422	if m.Current().MaxColumns != 1 || m.Current().Overflow != OverflowScroll {
423		t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
424	}
425}
426
427// checkInvariants fails when the monitor is in a state no action should reach.
428func checkInvariants(t *testing.T, m *Monitor, want []WindowID) {
429	t.Helper()
430	if m.Active < 0 || m.Active >= len(m.Workspaces) {
431		t.Fatalf("active %d of %d", m.Active, len(m.Workspaces))
432	}
433	if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" {
434		t.Fatal("no trailing empty unnamed workspace")
435	}
436	if m.shown != nil && indexOf(m.hidden, m.shown) < 0 {
437		t.Fatal("shown is not a hidden workspace")
438	}
439	if m.back != nil && !m.has(m.back) {
440		t.Fatal("back dangles")
441	}
442	seen := map[WindowID]int{}
443	for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
444		for _, c := range w.Columns {
445			for _, id := range c.Windows {
446				seen[id]++
447			}
448		}
449	}
450	for _, id := range want {
451		if seen[id] != 1 {
452			t.Fatalf("window %d present %d times", id, seen[id])
453		}
454	}
455	if len(seen) != len(want) {
456		t.Fatalf("windows %v, want %v", seen, want)
457	}
458}
459
460func TestSetNamedTransitions(t *testing.T) {
461	dev := NamedWorkspace{Name: "dev"}
462	devHidden := NamedWorkspace{Name: "dev", Hidden: true}
463	web := NamedWorkspace{Name: "web"}
464	for _, tc := range []struct {
465		name   string
466		before []NamedWorkspace
467		after  []NamedWorkspace
468		onDev  bool   // show dev before the reload
469		want   string // workspace name on screen after
470	}{
471		{"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"},
472		{"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"},
473		{"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""},
474		{"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""},
475		{"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""},
476		{"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""},
477	} {
478		t.Run(tc.name, func(t *testing.T) {
479			m := named(monitor(), tc.before...)
480			m.AddWindow(1)
481			if tc.onDev {
482				m.Apply("workspace dev")
483			}
484			m.AddWindow(2)
485			checkInvariants(t, m, []WindowID{1, 2})
486			m.SetNamed(tc.after)
487			checkInvariants(t, m, []WindowID{1, 2})
488			if got := m.Current().Name; got != tc.want {
489				t.Fatalf("on %q, want %q", got, tc.want)
490			}
491			if id, _ := m.Focused(); id != 2 {
492				t.Fatalf("focus moved to %d", id)
493			}
494		})
495	}
496}
497
498// Under fixed overflow, fullscreen moves the window to its own workspace
499// below; workspace up/down still work and leaving brings it back in place.
500func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
501	m := monitor()
502	m.SetOverflow(OverflowFixed)
503	for id := WindowID(1); id <= 3; id++ {
504		m.AddWindow(id)
505	}
506	m.Current().FocusID(2)
507	m.ToggleFullscreen()
508	if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2}, {}}) || m.Active != 1 {
509		t.Fatal(got, m.Active)
510	}
511	if p := m.Layout(); !slices.ContainsFunc(p, func(p Placement) bool { return p.ID == 2 && p.Fullscreen && !p.Hidden }) {
512		t.Fatal(p)
513	}
514	m.Apply(ActionFocusWorkspaceUp)
515	if m.Active != 0 {
516		t.Fatal("stuck on the fullscreen workspace")
517	}
518	m.Apply(ActionFocusWorkspaceDown)
519	m.ToggleFullscreen()
520	if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Active != 0 {
521		t.Fatal(got, m.Active)
522	}
523	if id, _ := m.Focused(); id != 2 {
524		t.Fatal("focus", id)
525	}
526
527	// A client request moves it too, without taking the user along
528	// unless the window is the focused one on screen.
529	m.Current().FocusID(1)
530	m.SetFullscreen(3, true)
531	if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) || m.Active != 0 {
532		t.Fatal(got, m.Active)
533	}
534	m.SetFullscreen(3, false)
535	if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
536		t.Fatal(got)
537	}
538
539	// Closing the fullscreen window drops its workspace and returns.
540	m.Current().FocusID(3)
541	m.ToggleFullscreen()
542	m.RemoveWindow(3)
543	if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 {
544		t.Fatal(got, m.Active)
545	}
546
547	// Scroll overflow keeps the niri behaviour: same workspace.
548	m.SetOverflow(OverflowScroll)
549	m.ToggleFullscreen()
550	if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) {
551		t.Fatal(got)
552	}
553}
554
555// The fullscreen workspace link survives only while it holds just its
556// window; exits restore the exact place and never move the user's focus.
557func TestMonitorFixedFullscreenEdges(t *testing.T) {
558	fixed := func() *Monitor {
559		m := monitor()
560		m.SetOverflow(OverflowFixed)
561		for id := WindowID(1); id <= 3; id++ {
562			m.AddWindow(id)
563		}
564		return m
565	}
566	t.Run("a window opened there floats, then tiles after it at home", func(t *testing.T) {
567		m := fixed()
568		m.Current().FocusID(2)
569		m.ToggleFullscreen()
570		m.AddWindow(4)
571		if fs := m.Current(); fs.floatIndex(4) != 0 || fs.fullscreen != 2 {
572			t.Fatal(fs.Floats, fs.fullscreen)
573		}
574		m.SetFullscreen(2, false)
575		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 4, 3}, {}}) || m.Active != 0 {
576			t.Fatal(got, m.Active)
577		}
578	})
579	t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) {
580		m := fixed()
581		w := m.Current()
582		w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}}
583		w.FocusID(3)
584		m.ToggleFullscreen()
585		m.AddWindow(7)
586		m.RemoveWindow(1)
587		m.ToggleFullscreen()
588		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) {
589			t.Fatal(got)
590		}
591	})
592	t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {
593		m := fixed()
594		m.Current().FocusID(2)
595		m.ToggleFullscreen()
596		m.AddFloating(4, 10, 10) // focused dialog
597		m.ToggleFullscreen()
598		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {
599			t.Fatal(got, m.Current().Floats)
600		}
601	})
602	t.Run("a dialog asking fullscreen keeps the link", func(t *testing.T) {
603		m := fixed()
604		m.Current().FocusID(2)
605		m.ToggleFullscreen()
606		m.AddFloating(4, 10, 10)
607		m.SetFullscreen(4, true)
608		m.SetFullscreen(4, false)
609		m.ToggleFullscreen()
610		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
611			t.Fatal(got)
612		}
613	})
614	t.Run("moved in by hand it floats", func(t *testing.T) {
615		m := fixed()
616		m.Current().FocusID(2)
617		m.ToggleFullscreen()
618		m.Apply(ActionFocusWorkspaceUp)
619		m.Current().FocusID(1)
620		m.Apply(ActionMoveWindowToWorkspaceDown)
621		if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
622			t.Fatal(fs.Floats)
623		}
624	})
625	t.Run("a moved column floats window by window", func(t *testing.T) {
626		m := fixed()
627		m.Current().FocusID(2)
628		m.ToggleFullscreen()
629		m.Apply(ActionFocusWorkspaceUp)
630		w := m.Current()
631		ids := w.windows()
632		w.Columns = []Column{{Windows: ids}}
633		w.Focus = 0
634		m.Apply(ActionMoveColumnToWorkspaceDown)
635		fs := m.Workspaces[1]
636		for _, id := range ids {
637			if fs.floatIndex(id) < 0 {
638				t.Fatal(id, fs.Floats)
639			}
640		}
641		if fs.origin == nil || len(ids) < 2 {
642			t.Fatal(fs.origin, ids)
643		}
644	})
645	t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
646		m := fixed()
647		m.Current().FocusID(2)
648		m.ToggleFullscreen()
649		m.AddFloating(4, 10, 10)
650		m.Apply(ActionFocusWorkspaceUp)
651		m.AddFloating(9, 10, 10)
652		m.RemoveWindow(2)
653		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Current().floatIndex(4) != 0 {
654			t.Fatal(got, m.Current().Floats)
655		}
656		if id, _ := m.Focused(); id != 9 {
657			t.Fatal("focus", id)
658		}
659	})
660	t.Run("stack of three keeps its column", func(t *testing.T) {
661		m := fixed()
662		w := m.Current()
663		w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3, 5}}}
664		w.FocusID(3)
665		m.ToggleFullscreen()
666		m.RemoveWindow(2)
667		m.ToggleFullscreen()
668		if len(w.Columns) != 2 || !slices.Equal(w.Columns[1].Windows, []WindowID{3, 5}) {
669			t.Fatal(w.Columns)
670		}
671	})
672	t.Run("client exit keeps the user's focus", func(t *testing.T) {
673		m := fixed()
674		m.Current().FocusID(1)
675		m.SetFullscreen(3, true)
676		m.SetFullscreen(3, false)
677		if id, _ := m.Focused(); id != 1 || m.Active != 0 {
678			t.Fatal("focus moved to", id, m.Active)
679		}
680		m.Current().FocusID(3)
681		m.SetFullscreen(1, true)
682		m.SetFullscreen(1, false)
683		if id, _ := m.Focused(); id != 3 {
684			t.Fatal("focus moved to", id)
685		}
686	})
687	t.Run("stacked slot window returns to its row", func(t *testing.T) {
688		m := fixed()
689		w := m.Current()
690		w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}}
691		w.FocusID(3)
692		m.ToggleFullscreen()
693		m.ToggleFullscreen()
694		if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 {
695			t.Fatal(w.Columns, w.Focus)
696		}
697	})
698	t.Run("moved out by hand unlinks it", func(t *testing.T) {
699		m := fixed()
700		m.Current().FocusID(2)
701		m.ToggleFullscreen()
702		m.Apply(ActionMoveWindowToWorkspaceUp)
703		m.AddWindow(4)
704		m.RemoveWindow(4)
705		if m.Active != 1 {
706			t.Fatal("view jumped to", m.Active)
707		}
708	})
709	t.Run("client exit on screen keeps the window focused", func(t *testing.T) {
710		m := fixed()
711		m.Current().FocusID(1)
712		m.ToggleFullscreen()
713		m.SetFullscreen(1, false)
714		if id, _ := m.Focused(); id != 1 || m.Active != 0 {
715			t.Fatal("focus", id, m.Active)
716		}
717	})
718	t.Run("stack gone: back as a column", func(t *testing.T) {
719		m := fixed()
720		w := m.Current()
721		w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{4}}}
722		w.FocusID(3)
723		m.ToggleFullscreen()
724		m.RemoveWindow(2)
725		m.ToggleFullscreen()
726		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {}}) || len(m.Current().Columns) != 3 {
727			t.Fatal(got, m.Current().Columns)
728		}
729	})
730	t.Run("floating window", func(t *testing.T) {
731		m := fixed()
732		m.AddFloating(4, 10, 10)
733		m.ToggleFullscreen()
734		if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 {
735			t.Fatal(got, m.Active)
736		}
737		m.ToggleFullscreen()
738		if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus {
739			t.Fatal(m.Active, w.Floats)
740		}
741	})
742	t.Run("origin gone unlinks it", func(t *testing.T) {
743		m := fixed()
744		m.Current().FocusID(2)
745		m.ToggleFullscreen()
746		m.take(m.Workspaces[0])
747		m.ToggleFullscreen()
748		if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 {
749			t.Fatal(id)
750		}
751	})
752}

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