v0.1.0 d9218d6

screens_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"reflect"
6	"slices"
7	"testing"
8
9	"github.com/bnema/neferwl/internal/adapters/config"
10	"github.com/bnema/neferwl/internal/core"
11	"github.com/bnema/neferwl/internal/ports"
12)
13
14type multiRig struct {
15	client   chan ports.ClientEvent
16	input    chan ports.InputEvent
17	output   chan ports.OutputEvent
18	reload   chan ports.ConfigChanged
19	commands chan ports.ClientCommand
20	scenes   chan []ports.Scene
21	spawn    chan ports.SpawnRequest
22	state    chan ports.State
23	cfg      ports.Config
24}
25
26var (
27	left  = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100}
28	right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200}
29)
30
31// startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
32// the given outputs in order.
33func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
34	t.Helper()
35	return startRig(t, false, edit, outs...)
36}
37
38// startRig is startMulti; with terminal set, core keeps a terminal on every
39// empty workspace on screen.
40func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
41	t.Helper()
42	cfg := config.Defaults()
43	cfg.Keyboard.CmdKey = "alt"
44	cfg.Border.Width = 0
45	if edit != nil {
46		edit(&cfg)
47	}
48	r := &multiRig{
49		client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
50		output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
51		commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
52	}
53	c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})
54	if err != nil {
55		t.Fatal(err)
56	}
57	ctx, cancel := context.WithCancel(context.Background())
58	done := make(chan error, 1)
59	go func() { done <- c.Run(ctx) }()
60	t.Cleanup(func() { cancel(); <-done })
61	for _, o := range outs {
62		r.plug(t, o)
63	}
64	return r
65}
66
67func (r *multiRig) plug(t *testing.T, o ports.OutputInfo) []ports.Scene {
68	t.Helper()
69	r.output <- ports.OutputAdded{Info: o}
70	return receive(t, r.scenes)
71}
72
73func (r *multiRig) key(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
74	t.Helper()
75	r.input <- ports.KeyEvent{Keysym: sym, Mods: mods, Pressed: true}
76	s := receive(t, r.scenes)
77	r.input <- ports.KeyEvent{Keysym: sym, Mods: mods}
78	return s
79}
80
81func (r *multiRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
82	t.Helper()
83	r.client <- ports.WindowMapped{ID: id}
84	return receive(t, r.scenes)
85}
86
87// shown returns the visible window IDs of each output, by connector.
88func shown(set []ports.Scene) map[string][]ports.WindowID {
89	out := map[string][]ports.WindowID{}
90	for _, s := range set {
91		out[s.Output] = []ports.WindowID{}
92		for _, w := range s.Windows {
93			if !w.Hidden {
94				out[s.Output] = append(out[s.Output], w.ID)
95			}
96		}
97	}
98	return out
99}
100
101func lastOutputs(t *testing.T, ch chan ports.ClientCommand) ports.SetOutputs {
102	t.Helper()
103	var last ports.SetOutputs
104	found := false
105	for len(ch) > 0 {
106		if v, ok := (<-ch).(ports.SetOutputs); ok {
107			last, found = v, true
108		}
109	}
110	if !found {
111		t.Fatal("no SetOutputs")
112	}
113	return last
114}
115
116func TestOutputsLeftToRight(t *testing.T) {
117	r := startMulti(t, func(c *ports.Config) {
118		c.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}, {Name: "DP-1"}}
119	}, left, right)
120	out := lastOutputs(t, r.commands)
121	// Config order wins: DP-2 (200x100 logical at scale 2), then DP-1.
122	if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-2" || out.Outputs[0].X != 0 || out.Outputs[0].Width != 200 ||
123		out.Outputs[1].Info.Name != "DP-1" || out.Outputs[1].X != 200 || out.Focused != "DP-1" {
124		t.Fatalf("%+v", out)
125	}
126}
127
128func TestWindowsOpenOnFocusedOutput(t *testing.T) {
129	r := startMulti(t, nil, left, right)
130	r.mapWindow(t, 1)
131	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
132	set := r.mapWindow(t, 2)
133	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
134		t.Fatal(got)
135	}
136	// Only the focused output draws a focused window.
137	for _, s := range set {
138		for _, w := range s.Windows {
139			if w.Focused != (s.Output == "DP-2") {
140				t.Fatalf("%s: %+v", s.Output, w)
141			}
142		}
143	}
144}
145
146func TestFocusColumnCrossesOutputs(t *testing.T) {
147	r := startMulti(t, nil, left, right)
148	r.mapWindow(t, 1)
149	// The only column is the edge: focus moves to the right output.
150	r.key(t, "Right", ports.ModAlt)
151	r.mapWindow(t, 2)
152	for len(r.commands) > 0 {
153		<-r.commands
154	}
155	r.key(t, "Left", ports.ModAlt)
156	var focus ports.WindowID
157	for len(r.commands) > 0 {
158		if v, ok := (<-r.commands).(ports.FocusWindow); ok {
159			focus = v.ID
160		}
161	}
162	if focus != 1 {
163		t.Fatal(focus)
164	}
165}
166
167func TestPointerCrossesOutputs(t *testing.T) {
168	r := startMulti(t, nil, left, right)
169	r.mapWindow(t, 1)
170	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
171	r.mapWindow(t, 2)
172	for len(r.commands) > 0 {
173		<-r.commands
174	}
175	// DP-2 starts at x 200: global (250, 50) is (50, 50) on window 2.
176	r.input <- ports.PointerMotion{X: 250, Y: 50}
177	if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 50, Y: 50}) {
178		t.Fatal(v)
179	}
180	// Back on DP-1, then below it (100 high): off every output, y stays on DP-1.
181	r.input <- ports.PointerMotion{X: 10, Y: 50}
182	r.input <- ports.PointerMotion{X: 10, Y: 150}
183	for {
184		if v, ok := command(t, r.commands).(ports.PointerMotionTo); ok && v.Y == 99 {
185			if v.ID != 1 || v.X != 10 {
186				t.Fatal(v)
187			}
188			break
189		}
190	}
191	// A click focuses the output under the pointer.
192	r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
193	set := receive(t, r.scenes)
194	for _, s := range set {
195		for _, w := range s.Windows {
196			if w.Focused != (w.ID == 1) {
197				t.Fatalf("%s: %+v", s.Output, w)
198			}
199		}
200	}
201}
202
203func TestMoveWorkspaceToMonitor(t *testing.T) {
204	r := startMulti(t, nil, left, right)
205	r.mapWindow(t, 1)
206	set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
207	if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
208		t.Fatal(got)
209	}
210	// It is DP-2's now: unplugging DP-1 does not move it.
211	r.output <- ports.OutputRemoved{Name: "DP-1"}
212	set = receive(t, r.scenes)
213	if len(set) != 1 || set[0].Output != "DP-2" || len(shown(set)["DP-2"]) != 1 {
214		t.Fatal(shown(set))
215	}
216}
217
218func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
219	r := startMulti(t, nil, left, right)
220	r.mapWindow(t, 1)
221	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
222	r.mapWindow(t, 2)
223	// Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
224	r.output <- ports.OutputRemoved{Name: "DP-2"}
225	set := receive(t, r.scenes)
226	if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
227		t.Fatal(shown(set))
228	}
229	// Cmd+2 shows the guest.
230	r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
231	set = receive(t, r.scenes)
232	r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
233	if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
234		t.Fatal(got)
235	}
236	// Replugged while on screen: the guest stays until the user leaves it.
237	set = r.plug(t, right)
238	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
239		t.Fatal(got)
240	}
241	r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
242	set = receive(t, r.scenes)
243	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
244		t.Fatal(got)
245	}
246}
247
248func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
249	r := startMulti(t, nil, left, right)
250	r.mapWindow(t, 1)
251	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
252	r.mapWindow(t, 2)
253	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
254	r.output <- ports.OutputRemoved{Name: "DP-2"}
255	receive(t, r.scenes)
256	// Not on screen: it goes home as soon as DP-2 is back.
257	set := r.plug(t, right)
258	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
259		t.Fatal(got)
260	}
261}
262
263func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
264	r := startMulti(t, func(c *ports.Config) {
265		c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2", Hidden: true}}
266		c.Binds["Alt+w"] = "workspace web"
267	}, left)
268	// DP-2 is not plugged: web lives on DP-1 as a guest.
269	r.key(t, "w", ports.ModAlt)
270	r.mapWindow(t, 1)
271	r.key(t, "w", ports.ModAlt)
272	// DP-2 arrives: web goes home (not on screen).
273	r.plug(t, right)
274	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
275	set := r.key(t, "w", ports.ModAlt)
276	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
277		t.Fatal(got)
278	}
279}
280
281func TestLayersStayOnTheirOutput(t *testing.T) {
282	r := startMulti(t, nil, left, right)
283	bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, ExclusiveZone: 10, Output: "DP-2"}
284	r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
285	set := receive(t, r.scenes)
286	for _, s := range set {
287		if (len(s.Layers) == 1) != (s.Output == "DP-2") {
288			t.Fatalf("%s: %+v", s.Output, s.Layers)
289		}
290	}
291	if set[1].Layers[0].Rect != (ports.Rect{W: 400, H: 10}) {
292		t.Fatal(set[1].Layers)
293	}
294}
295
296func TestScaleBindTargetsFocusedOutput(t *testing.T) {
297	r := startMulti(t, func(c *ports.Config) { c.Binds["Alt+equal"] = "scale-up" }, left, right)
298	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
299	set := r.key(t, "equal", ports.ModAlt)
300	if set[0].Scale != 1 || set[1].Scale == 1 {
301		t.Fatal(set[0].Scale, set[1].Scale)
302	}
303}
304
305func TestLastOutputUnpluggedKeepsWindows(t *testing.T) {
306	r := startMulti(t, nil, left)
307	r.mapWindow(t, 1)
308	r.output <- ports.OutputRemoved{Name: "DP-1"}
309	if set := receive(t, r.scenes); len(set) != 1 || set[0].Output != "" {
310		t.Fatal(shown(set))
311	}
312	if out := lastOutputs(t, r.commands); len(out.Outputs) != 0 {
313		t.Fatalf("%+v", out)
314	}
315	// Another monitor takes the windows over.
316	set := r.plug(t, right)
317	if got := shown(set); len(got) != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
318		t.Fatal(got)
319	}
320}
321
322func TestModeChangeKeepsScreen(t *testing.T) {
323	r := startMulti(t, nil, left, right)
324	r.mapWindow(t, 1)
325	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
326	r.mapWindow(t, 2)
327	// A new mode on DP-2 is only a new size: nothing moves, focus stays.
328	bigger := right
329	bigger.Width, bigger.Height = 800, 400
330	set := r.plug(t, bigger)
331	if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 || len(got["DP-1"]) != 1 {
332		t.Fatal(got)
333	}
334	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" || out.Outputs[1].Width != 800 {
335		t.Fatalf("%+v", out)
336	}
337}
338
339func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) {
340	r := startMulti(t, func(c *ports.Config) {
341		c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Primary: true}}
342	}, left, right)
343	out := lastOutputs(t, r.commands)
344	// Placed right of DP-1, but focused.
345	if out.Focused != "DP-2" || out.Outputs[1].Info.Name != "DP-2" || !out.Outputs[1].Primary || out.Outputs[0].Primary {
346		t.Fatalf("%+v", out)
347	}
348	set := r.mapWindow(t, 1)
349	if got := shown(set); len(got["DP-2"]) != 1 {
350		t.Fatal(got)
351	}
352}
353
354func TestMoveColumnCrossesOutputs(t *testing.T) {
355	r := startMulti(t, nil, left, right)
356	r.mapWindow(t, 1)
357	r.mapWindow(t, 2)
358	// Window 2 is the right column of DP-1: moving right first hits the
359	// edge, then the column goes to DP-2 and focus follows it.
360	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
361	if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
362		t.Fatal(got)
363	}
364	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
365		t.Fatal(out.Focused)
366	}
367	// Back left (no terminal kept here, so a last column may move): it
368	// lands on the right side of DP-1.
369	set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
370	if got := shown(set)["DP-1"]; len(got) != 2 {
371		t.Fatal(got)
372	}
373	for _, s := range set {
374		for _, w := range s.Windows {
375			if w.ID == 2 && (s.Output != "DP-1" || !w.Focused || w.Rect.X == 0) {
376				t.Fatalf("%s %+v", s.Output, w)
377			}
378		}
379	}
380}
381
382func TestPointerFocusesOutput(t *testing.T) {
383	r := startMulti(t, nil, left, right)
384	r.input <- ports.PointerMotion{X: 250, Y: 50}
385	receive(t, r.scenes)
386	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
387		t.Fatal(out.Focused)
388	}
389	// New windows open where the pointer is.
390	if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
391		t.Fatal(got)
392	}
393}
394
395func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
396	r := startRig(t, true, nil, left, right)
397	// One terminal per output, each tagged for its workspace.
398	var reqs []ports.SpawnRequest
399	for len(reqs) < 2 {
400		reqs = append(reqs, receive(t, r.spawn))
401	}
402	if len(r.spawn) != 0 || reqs[0].Argv[0] != "foot" || reqs[0].Env[0] == reqs[1].Env[0] {
403		t.Fatal(reqs, len(r.spawn))
404	}
405	token := func(req ports.SpawnRequest) string { return req.Env[0][len(ports.SlotEnv)+1:] }
406	// The focused screen is DP-2 now; DP-1's terminal still lands on DP-1.
407	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
408	r.client <- ports.WindowMapped{ID: 1, Slot: token(reqs[0])}
409	r.client <- ports.WindowMapped{ID: 2, Slot: token(reqs[1])}
410	receive(t, r.scenes)
411	set := receive(t, r.scenes)
412	if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {
413		t.Fatal(got)
414	}
415	// A terminal that exits right after mapping is not respawned at once
416	// (no loop for a broken terminal command).
417	r.client <- ports.WindowUnmapped{ID: 2}
418	receive(t, r.scenes)
419	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
420	if len(r.spawn) != 0 {
421		t.Fatal("respawned at once")
422	}
423}
424
425func TestLastColumnStaysOnItsOutput(t *testing.T) {
426	r := startRig(t, true, nil, left, right)
427	r.mapWindow(t, 1)
428	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
429	if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
430		t.Fatal(got)
431	}
432}
433
434func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
435	r := startMulti(t, nil, left, right)
436	r.mapWindow(t, 1)
437	r.input <- ports.PointerMotion{X: 10, Y: 10}
438	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
439	// Jitter on DP-1 keeps DP-2 focused: the next window opens there.
440	r.input <- ports.PointerMotion{X: 11, Y: 10}
441	if got := shown(r.mapWindow(t, 2)); len(got["DP-2"]) != 1 {
442		t.Fatal(got)
443	}
444	// Entering DP-1 again focuses it.
445	r.input <- ports.PointerMotion{X: 250, Y: 10}
446	r.input <- ports.PointerMotion{X: 10, Y: 10}
447	receive(t, r.scenes)
448	if got := shown(r.mapWindow(t, 3)); len(got["DP-1"]) != 2 {
449		t.Fatal(got)
450	}
451}
452
453func TestStateSnapshot(t *testing.T) {
454	r := startMulti(t, nil, left, right)
455	r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
456	receive(t, r.scenes)
457	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
458	<-r.state // the channel holds the latest snapshot only
459	r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
460	receive(t, r.scenes)
461	// Snapshots from before window 2 may still be queued: wait for it.
462	st := receive(t, r.state)
463	for len(st.Windows) < 2 {
464		st = receive(t, r.state)
465	}
466	want := ports.State{
467		Output:  "DP-2",
468		Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 1, Count: 1}},
469		Windows: []ports.WindowState{
470			{ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, Visible: true},
471			{ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, Visible: true},
472		},
473	}
474	want.Window = &want.Windows[1]
475	if !reflect.DeepEqual(st, want) {
476		t.Fatalf("got  %+v\nwant %+v", st, want)
477	}
478	// Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
479	r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
480	receive(t, r.scenes)
481	st = receive(t, r.state)
482	if st.Outputs[1] != (ports.OutputState{Name: "DP-2", Active: 2, Count: 2}) || st.Windows[1].Visible || st.Window != nil {
483		t.Fatalf("%+v", st)
484	}
485}
486
487func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
488	r := startMulti(t, func(c *ports.Config) {
489		c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}}
490		c.Binds["Alt+n"] = "workspace notes"
491	}, left)
492	r.client <- ports.WindowMapped{ID: 1, PID: 100}
493	receive(t, r.scenes)
494	<-r.state
495	// An app ID set after map reaches the state.
496	r.client <- ports.WindowAppID{ID: 1, AppID: "foot"}
497	receive(t, r.scenes)
498	// Snapshots from before the app ID may still be queued.
499	for st := receive(t, r.state); st.Windows[0].AppID != "foot"; st = receive(t, r.state) {
500	}
501	// A hidden workspace on screen: no active number, its name, window 1 off screen.
502	r.key(t, "n", ports.ModAlt)
503	st := receive(t, r.state)
504	if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
505		t.Fatalf("%+v", st)
506	}
507}
508
509func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
510	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
511	r.mapWindow(t, 1)
512	r.mapWindow(t, 2)
513	// Window 2 goes to DP-2, alone, and goes fullscreen in place.
514	r.key(t, "Right", ports.ModAlt|ports.ModShift)
515	r.key(t, "f", ports.ModAlt|ports.ModShift)
516	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
517	// Window 1 joins from DP-1: the fullscreen ends so it is visible and focused.
518	set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
519	for _, s := range set {
520		for _, w := range s.Windows {
521			if w.ID == 1 && (s.Output != "DP-2" || w.Hidden || !w.Focused) {
522				t.Fatalf("%s %+v", s.Output, w)
523			}
524		}
525	}
526}
527
528// windowsOf lists the tiled windows of an output in scene order, with the
529// focused one.
530func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
531	for _, s := range set {
532		if s.Output != out {
533			continue
534		}
535		for _, w := range s.Windows {
536			if !w.Hidden {
537				ids = append(ids, w.ID)
538			}
539			if w.Focused {
540				focused = w.ID
541			}
542		}
543	}
544	return ids, focused
545}
546
547func TestExpelCrossesToFullNeighbor(t *testing.T) {
548	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
549	// DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
550	// [1] [2 3], focus on 3 at the bottom of the edge column.
551	r.mapWindow(t, 1)
552	r.mapWindow(t, 2)
553	r.mapWindow(t, 3)
554	r.key(t, "bracketleft", ports.ModAlt)
555	// DP-2: [4] [5], full too.
556	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
557	r.mapWindow(t, 4)
558	r.mapWindow(t, 5)
559	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
560	// Expel right at the edge: 3 stacks under 4, the first column of DP-2.
561	set := r.key(t, "bracketright", ports.ModAlt)
562	if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
563		t.Fatal(got)
564	}
565	got, focused := windowsOf(set, "DP-2")
566	if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
567		t.Fatal(got, focused)
568	}
569	if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
570		t.Fatal(out.Focused)
571	}
572	// 3 is not in the edge column of DP-2: it stacks into [5].
573	set = r.key(t, "bracketright", ports.ModAlt)
574	if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
575		t.Fatal(got, focused)
576	}
577	// Now at the edge with no right monitor: nothing moves.
578	set = r.key(t, "bracketright", ports.ModAlt)
579	if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
580		t.Fatal(got, focused)
581	}
582}
583
584func TestExpelOntoFixedFullscreenFloats(t *testing.T) {
585	r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
586	// DP-1: [1] [2 3], focus on 3.
587	r.mapWindow(t, 1)
588	r.mapWindow(t, 2)
589	r.mapWindow(t, 3)
590	r.key(t, "bracketleft", ports.ModAlt)
591	// DP-2: [4] [5]; 5 goes fullscreen on its own workspace.
592	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
593	r.mapWindow(t, 4)
594	r.mapWindow(t, 5)
595	r.key(t, "f", ports.ModAlt|ports.ModShift)
596	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
597	// 3 floats above the fullscreen window, visible and focused.
598	set := r.key(t, "bracketright", ports.ModAlt)
599	got, focused := windowsOf(set, "DP-2")
600	if !slices.Contains(got, 3) || focused != 3 {
601		t.Fatal(got, focused)
602	}
603}

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 26

Tags 10

↑↓ moveenter openesc close