v0.1.0 d9218d6

slots_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"slices"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/bnema/neferwl/internal/adapters/config"
11	"github.com/bnema/neferwl/internal/core"
12	"github.com/bnema/neferwl/internal/ports"
13)
14
15type slotRig struct {
16	client chan ports.ClientEvent
17	input  chan ports.InputEvent
18	reload chan ports.ConfigChanged
19	spawn  chan ports.SpawnRequest
20	scenes chan []ports.Scene
21	errs   chan error
22	cfg    ports.Config
23}
24
25// startSlots runs core with a hidden "dev" workspace of two slots, toggled
26// by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
27// fixed overflow.
28func startSlots(t *testing.T, numbered ...bool) *slotRig {
29	t.Helper()
30	cfg := config.Defaults()
31	cfg.Keyboard.CmdKey = "alt"
32	cfg.Border.Width = 0
33	cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: len(numbered) == 0, Slots: []ports.SlotConfig{
34		{Index: 1, Width: "70%", Argv: []string{"code"}},
35		{Index: 2, Width: "30%", Argv: []string{"foot"}},
36	}}}
37	cfg.Binds["Alt+d"] = "workspace dev"
38	if len(numbered) > 1 {
39		cfg.Layout.Overflow = "fixed"
40	}
41	r := &slotRig{
42		client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
43		reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
44		scenes: make(chan []ports.Scene, 1), errs: make(chan error, 4), cfg: cfg,
45	}
46	output := make(chan ports.OutputEvent, 1)
47	commands := make(chan ports.ClientCommand, 1024)
48	c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, ConfigErrors: r.errs})
49	if err != nil {
50		t.Fatal(err)
51	}
52	ctx, cancel := context.WithCancel(context.Background())
53	done := make(chan error, 1)
54	go func() { done <- c.Run(ctx) }()
55	t.Cleanup(func() { cancel(); <-done })
56	go func() { // commands are not under test
57		for range commands {
58		}
59	}()
60	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
61	scene(t, r.scenes)
62	return r
63}
64
65// token returns the SlotEnv value of a spawn request.
66func token(t *testing.T, req ports.SpawnRequest) string {
67	t.Helper()
68	for _, e := range req.Env {
69		if v, ok := strings.CutPrefix(e, ports.SlotEnv+"="); ok {
70			return v
71		}
72	}
73	t.Fatalf("no %s in %v", ports.SlotEnv, req.Env)
74	return ""
75}
76
77// noSpawn checks that nothing was spawned. Core sends spawns before it
78// publishes the scene of the same event, so callers wait for that scene first.
79func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
80	t.Helper()
81	select {
82	case v := <-ch:
83		t.Fatalf("unexpected spawn %v", v.Argv)
84	default:
85	}
86}
87
88// press sends Alt+key and waits for the scene it publishes.
89func (r *slotRig) press(t *testing.T, key string) ports.Scene {
90	t.Helper()
91	r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}
92	r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}
93	return scene(t, r.scenes)
94}
95
96// sync waits for core to handle everything sent so far.
97func (r *slotRig) sync(t *testing.T) ports.Scene {
98	t.Helper()
99	return r.press(t, "Left") // focus-column-left publishes a scene
100}
101
102// fill maps the slot windows 2 (code) and 3 (foot) and waits until core
103// has placed them: client and input events are separate channels.
104func (r *slotRig) fill(t *testing.T) (code, foot ports.SpawnRequest) {
105	t.Helper()
106	code, foot = receive(t, r.spawn), receive(t, r.spawn)
107	r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
108	r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
109	sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
110	return code, foot
111}
112
113// sceneMatch waits for a first-output scene matching ok.
114func sceneMatch(t *testing.T, ch <-chan []ports.Scene, ok func(ports.Scene) bool) ports.Scene {
115	t.Helper()
116	deadline := time.After(time.Second)
117	for {
118		select {
119		case s := <-ch:
120			if ok(s[0]) {
121				return s[0]
122			}
123		case <-deadline:
124			t.Fatal("no matching scene")
125		}
126	}
127}
128
129func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
130	m := map[ports.WindowID]ports.Rect{}
131	for _, w := range s.Windows {
132		if !w.Hidden {
133			m[w.ID] = w.Rect
134		}
135	}
136	return m
137}
138
139func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
140	r := startSlots(t)
141	code, foot := receive(t, r.spawn), receive(t, r.spawn)
142	if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) {
143		t.Fatal(code, foot)
144	}
145	// A normal window on workspace 1 keeps focus while slot windows map.
146	r.client <- ports.WindowMapped{ID: 1}
147	// foot maps first; code's window still goes left of it.
148	r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
149	r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
150	s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
151	for _, w := range s.Windows {
152		if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
153			t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
154		}
155	}
156	s = r.press(t, "d")
157	got := visible(s)
158	if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
159		t.Fatal(got)
160	}
161	noSpawn(t, r.spawn) // full slots are not respawned
162}
163
164func TestSlotRefilledOnlyWhenShown(t *testing.T) {
165	r := startSlots(t)
166	// Wait for both windows: a one-window scene from before the unmap
167	// would let the key below race it.
168	_, foot := r.fill(t)
169	r.client <- ports.WindowUnmapped{ID: 3} // foot exits
170	sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
171	noSpawn(t, r.spawn) // nothing relaunches on its own
172	r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
173	r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
174	again := receive(t, r.spawn)
175	if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
176		t.Fatal(again)
177	}
178	r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
179	s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
180	if got := visible(s); got[4].X != 70 {
181		t.Fatal(got)
182	}
183	// Leaving and showing dev again with full slots spawns nothing.
184	r.press(t, "d")
185	r.press(t, "d")
186	noSpawn(t, r.spawn)
187}
188
189func TestSlotExtraWindowsAreNormal(t *testing.T) {
190	r := startSlots(t)
191	code := receive(t, r.spawn)
192	receive(t, r.spawn)
193	r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
194	// A second window of the same process: the slot is taken, normal rules.
195	r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
196	r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
197	s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
198	if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
199		t.Fatalf("extra windows not on the workspace on screen: %v", got)
200	}
201}
202
203func TestSlotReload(t *testing.T) {
204	r := startSlots(t)
205	code, foot := receive(t, r.spawn), receive(t, r.spawn)
206	r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
207	sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
208	cfg := r.cfg
209	ws := cfg.Workspaces[0]
210	ws.Slots = []ports.SlotConfig{
211		{Index: 1, Width: "50%", Argv: []string{"code"}},  // width change only
212		{Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
213		{Index: 3, Width: "20%", Argv: []string{"btop"}},  // new slot
214	}
215	cfg.Workspaces = []ports.WorkspaceConfig{ws}
216	r.reload <- ports.ConfigChanged{Config: cfg}
217	var argv []string
218	for range 2 {
219		argv = append(argv, receive(t, r.spawn).Argv[0])
220	}
221	slices.Sort(argv)
222	if !slices.Equal(argv, []string{"btop", "kitty"}) {
223		t.Fatal(argv)
224	}
225	scene(t, r.scenes)
226	noSpawn(t, r.spawn)
227	// The old foot spawn no longer fills slot 2 (its command changed): it
228	// opens as a normal window on workspace 1, on screen.
229	r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
230	s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
231	if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
232		t.Fatal(got)
233	}
234	r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
235	r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
236	s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
237	if got := visible(s); got[2].W != 50 {
238		t.Fatal(got)
239	}
240	noSpawn(t, r.spawn) // kitty and btop are still starting
241	// Dropping the workspace's slots spawns nothing and keeps the window.
242	ws.Slots = nil
243	cfg.Workspaces = []ports.WorkspaceConfig{ws}
244	r.reload <- ports.ConfigChanged{Config: cfg}
245	scene(t, r.scenes)
246	noSpawn(t, r.spawn)
247}
248
249func TestInvalidSlotWidthRejected(t *testing.T) {
250	r := startSlots(t)
251	receive(t, r.spawn)
252	receive(t, r.spawn)
253	cfg := r.cfg
254	ws := cfg.Workspaces[0]
255	ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
256	cfg.Workspaces = []ports.WorkspaceConfig{ws}
257	r.reload <- ports.ConfigChanged{Config: cfg}
258	if err := receive(t, r.errs); err == nil {
259		t.Fatal("no error")
260	}
261	r.sync(t)
262	noSpawn(t, r.spawn)
263}
264
265// Reloading while the slot workspace is on screen spawns a new slot once.
266func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
267	r := startSlots(t)
268	r.fill(t)
269	r.press(t, "d")
270	cfg := r.cfg
271	ws := cfg.Workspaces[0]
272	ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
273	cfg.Workspaces = []ports.WorkspaceConfig{ws}
274	r.reload <- ports.ConfigChanged{Config: cfg}
275	if v := receive(t, r.spawn); v.Argv[0] != "btop" {
276		t.Fatal(v)
277	}
278	scene(t, r.scenes)
279	noSpawn(t, r.spawn)
280}
281
282// Binds on the workspace on screen do not refill its empty slots; only
283// showing it again does.
284func TestSlotNotRefilledByOtherBinds(t *testing.T) {
285	r := startSlots(t)
286	r.fill(t)
287	r.press(t, "d")
288	r.client <- ports.WindowUnmapped{ID: 3}
289	sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
290	r.sync(t)
291	noSpawn(t, r.spawn)
292	r.press(t, "d")
293	r.press(t, "d")
294	if v := receive(t, r.spawn); v.Argv[0] != "foot" {
295		t.Fatal(v)
296	}
297}
298
299// After a slot window closes, another window with the old token (a child
300// of the slot app) opens as a normal window, not in the empty slot.
301func TestSlotOldTokenIsNormal(t *testing.T) {
302	r := startSlots(t)
303	_, foot := r.fill(t)
304	r.client <- ports.WindowUnmapped{ID: 3}
305	r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
306	s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
307	if got := visible(s); got[9] == (ports.Rect{}) {
308		t.Fatalf("old-token window hidden in the slot: %v", got)
309	}
310}
311
312// A slot whose command never maps a window gets one show to do it; on the
313// next show it is respawned and its old token no longer fills it.
314func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
315	r := startSlots(t)
316	code, foot := receive(t, r.spawn), receive(t, r.spawn)
317	r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
318	sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
319	r.press(t, "d") // foot still starting: not respawned
320	noSpawn(t, r.spawn)
321	r.press(t, "d")
322	r.press(t, "d")
323	again := receive(t, r.spawn)
324	if again.Argv[0] != "foot" || token(t, again) == token(t, foot) {
325		t.Fatal(again)
326	}
327	r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
328	s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
329	if got := visible(s); got[7] == (ports.Rect{}) {
330		t.Fatalf("stale-token window not placed as a normal one: %v", got)
331	}
332	r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
333	s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
334	if got := visible(s); got[8].W != 30 {
335		t.Fatalf("respawned window not in its slot: %v", got)
336	}
337}
338
339// Moving a slot window to another workspace releases the slot; showing the
340// workspace again refills it.
341func TestSlotReleasedWhenWindowMoved(t *testing.T) {
342	r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
343	r.fill(t)
344	r.press(t, "d") // on dev, focus on code (slot 1)
345	r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
346	scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1
347	noSpawn(t, r.spawn)
348	r.press(t, "d") // back to workspace 1
349	r.press(t, "d") // dev again: slot 1 is empty
350	if v := receive(t, r.spawn); v.Argv[0] != "code" {
351		t.Fatal(v)
352	}
353}
354
355// Startup commands are spawned once, before the slots.
356func TestStartupCommands(t *testing.T) {
357	cfg := config.Defaults()
358	cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}}
359	spawn := make(chan ports.SpawnRequest, 4)
360	c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)})
361	if err != nil {
362		t.Fatal(err)
363	}
364	ctx, cancel := context.WithCancel(context.Background())
365	done := make(chan error, 1)
366	go func() { done <- c.Run(ctx) }()
367	defer func() { cancel(); <-done }()
368	for _, want := range cfg.Startup {
369		if got := receive(t, spawn); !slices.Equal(got.Argv, want) {
370			t.Fatalf("spawned %q, want %q", got.Argv, want)
371		}
372	}
373}
374
375// A slot window in its own fullscreen workspace (fixed overflow) keeps its
376// slot: coming back to dev spawns no duplicate.
377func TestSlotKeptWhileFullscreenAway(t *testing.T) {
378	r := startSlots(t, true, true)
379	r.fill(t)
380	r.press(t, "d") // on dev, focus on code (slot 1)
381	r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
382	r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift}
383	scene(t, r.scenes)
384	r.press(t, "d") // leave and show dev while code is away
385	r.press(t, "d")
386	noSpawn(t, r.spawn)
387	// code opens a second window while fullscreen: the slot stays.
388	r.client <- ports.WindowMapped{ID: 9}
389	scene(t, r.scenes)
390	r.press(t, "d")
391	r.press(t, "d")
392	noSpawn(t, r.spawn)
393}
394
395// Expelling a slot window from a stacked column keeps one column per slot:
396// the slot and its width follow the window, and nothing is respawned.
397func TestSlotFollowsExpelledWindow(t *testing.T) {
398	r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
399	r.fill(t)
400	r.press(t, "d") // dev: [code] [foot], focus on code
401	r.client <- ports.WindowMapped{ID: 9}
402	sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
403	r.press(t, "bracketleft")  // 9 joins code: [code 9] [foot]
404	r.press(t, "Up")           // focus code, the slot window
405	r.press(t, "bracketright") // code leaves: [9] [code] [foot]
406	r.press(t, "d")
407	s := r.press(t, "d") // shown again: no slot is empty
408	noSpawn(t, r.spawn)
409	if got := visible(s); got[2].W != 70 {
410		t.Fatalf("slots: %v", got)
411	}
412}

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