feat/data-dnd e05cb15

rules_test.go RawHistoryFolder

1package core_test
2
3import (
4	"regexp"
5	"testing"
6
7	"github.com/bnema/neferwl/internal/ports"
8)
9
10func winRule(name, appID string, edit func(*ports.WindowRule)) ports.WindowRule {
11	r := ports.WindowRule{Name: name, AppID: regexp.MustCompile("^(?:" + appID + ")$")}
12	if edit != nil {
13		edit(&r)
14	}
15	return r
16}
17
18func ruleOn() *bool  { v := true; return &v }
19func ruleOff() *bool { v := false; return &v }
20
21func declareDev(c *ports.Config) {
22	c.Workspaces = []ports.WorkspaceConfig{{Name: "dev"}}
23	c.Binds["Alt+d"] = "workspace dev"
24}
25
26// mapApp maps a window with an app ID and returns the state once it is in.
27func (r *multiRig) mapApp(t *testing.T, id ports.WindowID, appID string) ports.State {
28	t.Helper()
29	r.client <- ports.WindowMapped{ID: id, AppID: appID}
30	return r.stateWith(t, id)
31}
32
33func (r *multiRig) stateWith(t *testing.T, id ports.WindowID) ports.State {
34	t.Helper()
35	return stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, id) != nil })
36}
37
38func stWindow(st ports.State, id ports.WindowID) *ports.WindowState {
39	for i := range st.Windows {
40		if st.Windows[i].ID == id {
41			return &st.Windows[i]
42		}
43	}
44	return nil
45}
46
47func stFocus(st ports.State) ports.WindowID {
48	if st.Window == nil {
49		return 0
50	}
51	return st.Window.ID
52}
53
54func stOutput(st ports.State, name string) ports.OutputState {
55	for _, o := range st.Outputs {
56		if o.Name == name {
57			return o
58		}
59	}
60	return ports.OutputState{}
61}
62
63func TestRuleFloatingOnAndOff(t *testing.T) {
64	r := startMulti(t, func(c *ports.Config) {
65		c.Rules = []ports.WindowRule{
66			winRule("float", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
67			winRule("tile", "splash", func(r *ports.WindowRule) { r.Floating = ruleOff() }),
68		}
69	}, left)
70	r.mapApp(t, 1, "term")
71	st := r.mapApp(t, 2, "calc")
72	if w := stWindow(st, 2); !w.Floating || stFocus(st) != 2 {
73		t.Fatalf("calc not floating on the current workspace: %+v focus %d", w, stFocus(st))
74	}
75	if w := stWindow(st, 1); w.Floating {
76		t.Fatal("unrelated window floats")
77	}
78	// A fixed-size window (Floating from the client) tiles under floating = off.
79	r.client <- ports.WindowMapped{ID: 3, AppID: "splash", Floating: true, Width: 50, Height: 40}
80	st = r.stateWith(t, 3)
81	if w := stWindow(st, 3); w.Floating || w.Column == 0 {
82		t.Fatalf("splash not tiled: %+v", w)
83	}
84	// Without a rule the same window floats as before.
85	r.client <- ports.WindowMapped{ID: 4, AppID: "other", Floating: true, Width: 50, Height: 40}
86	if w := stWindow(r.stateWith(t, 4), 4); !w.Floating {
87		t.Fatalf("unmatched fixed-size window tiled: %+v", w)
88	}
89}
90
91func TestRuleNamedWorkspaceKeepsFocusAndView(t *testing.T) {
92	r := startMulti(t, func(c *ports.Config) {
93		declareDev(c)
94		c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
95	}, left)
96	r.mapApp(t, 1, "term")
97	st := r.mapApp(t, 2, "code")
98	w := stWindow(st, 2)
99	if w.WorkspaceName != "dev" || w.Workspace != 0 || w.Visible {
100		t.Fatalf("window not quietly on dev: %+v", w)
101	}
102	if stFocus(st) != 1 || stOutput(st, "DP-1").Workspace != "" || stOutput(st, "DP-1").Active != 1 {
103		t.Fatalf("rule moved focus or view: %+v", st)
104	}
105	// The workspace shows its window when the user goes there.
106	r.key(t, "d", ports.ModAlt)
107	st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
108	if stFocus(st) != 2 {
109		t.Fatalf("dev window not focused on arrival: %d", stFocus(st))
110	}
111}
112
113func TestRuleNamedWorkspaceFloat(t *testing.T) {
114	r := startMulti(t, func(c *ports.Config) {
115		declareDev(c)
116		c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOn() })}
117	}, left)
118	r.mapApp(t, 1, "term")
119	st := r.mapApp(t, 2, "code")
120	if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating || stFocus(st) != 1 {
121		t.Fatalf("%+v focus %d", w, stFocus(st))
122	}
123	r.key(t, "d", ports.ModAlt)
124	st = stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
125	if stFocus(st) != 2 {
126		t.Fatalf("quiet float not focused on arrival: %d", stFocus(st))
127	}
128}
129
130func TestRuleUndeclaredWorkspaceFallsBack(t *testing.T) {
131	r := startMulti(t, func(c *ports.Config) {
132		c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "nope" })}
133	}, left)
134	r.mapApp(t, 1, "term")
135	st := r.mapApp(t, 2, "code")
136	if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 {
137		t.Fatalf("%+v", w)
138	}
139}
140
141func TestRuleNumberedWorkspace(t *testing.T) {
142	r := startMulti(t, func(c *ports.Config) {
143		c.Rules = []ports.WindowRule{
144			winRule("two", "two", func(r *ports.WindowRule) { r.Workspace = "2" }),
145			winRule("far", "far", func(r *ports.WindowRule) { r.Workspace = "9" }),
146		}
147	}, left)
148	r.mapApp(t, 1, "term")
149	st := r.mapApp(t, 2, "two")
150	if w := stWindow(st, 2); w.Workspace != 2 || stFocus(st) != 1 || stOutput(st, "DP-1").Active != 1 {
151		t.Fatalf("%+v active %d focus %d", w, stOutput(st, "DP-1").Active, stFocus(st))
152	}
153	// 9 is clamped to the last workspace, the spare after workspace 2: no
154	// pile of empty workspaces in between.
155	st = r.mapApp(t, 3, "far")
156	if w := stWindow(st, 3); w.Workspace != 3 {
157		t.Fatalf("clamped to %d", w.Workspace)
158	}
159	if o := stOutput(st, "DP-1"); o.Count != 3 || o.Active != 1 || stFocus(st) != 1 {
160		t.Fatalf("%+v focus %d", o, stFocus(st))
161	}
162}
163
164func TestRuleNumberOnCurrentWorkspaceActsAsNormalMap(t *testing.T) {
165	r := startMulti(t, func(c *ports.Config) {
166		c.Rules = []ports.WindowRule{winRule("one", "term", func(r *ports.WindowRule) { r.Workspace = "1" })}
167	}, left)
168	r.mapApp(t, 1, "other")
169	st := r.mapApp(t, 2, "term")
170	if w := stWindow(st, 2); w.Workspace != 1 || stFocus(st) != 2 {
171		t.Fatalf("%+v focus %d", w, stFocus(st))
172	}
173}
174
175func TestRuleMonitor(t *testing.T) {
176	for _, mon := range []string{"DP-2", "Acme B 2"} {
177		t.Run(mon, func(t *testing.T) {
178			r := startMulti(t, func(c *ports.Config) {
179				c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = mon })}
180			}, left, right)
181			r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
182			before := r.mapApp(t, 1, "term")
183			if before.Output != "DP-1" {
184				t.Fatalf("focused %s", before.Output)
185			}
186			st := r.mapApp(t, 2, "chat")
187			w := stWindow(st, 2)
188			if w.Output != "DP-2" || st.Output != "DP-1" || stFocus(st) != 1 {
189				t.Fatalf("%+v focused output %s window %d", w, st.Output, stFocus(st))
190			}
191			if !w.Visible {
192				t.Fatal("the window is not shown on the current workspace of DP-2")
193			}
194		})
195	}
196}
197
198func TestRuleMonitorWithNumberedWorkspace(t *testing.T) {
199	r := startMulti(t, func(c *ports.Config) {
200		c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Workspace = "2" })}
201	}, left, right)
202	r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
203	r.mapApp(t, 3, "term") // DP-2 gets a window, so a second workspace
204	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
205	r.mapApp(t, 1, "term")
206	st := r.mapApp(t, 2, "chat")
207	if w := stWindow(st, 2); w.Output != "DP-2" || w.Workspace != 2 || st.Output != "DP-1" || stFocus(st) != 1 || stOutput(st, "DP-2").Active != 1 {
208		t.Fatalf("%+v", w)
209	}
210}
211
212func TestRuleDisconnectedMonitorFallsBack(t *testing.T) {
213	r := startMulti(t, func(c *ports.Config) {
214		c.Rules = []ports.WindowRule{winRule("m", "chat", func(r *ports.WindowRule) { r.Monitor = "HDMI-9" })}
215	}, left, right)
216	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
217	r.mapApp(t, 1, "term")
218	st := r.mapApp(t, 2, "chat")
219	if w := stWindow(st, 2); w.Output != "DP-1" || stFocus(st) != 2 {
220		t.Fatalf("%+v focus %d", w, stFocus(st))
221	}
222}
223
224func TestRulesMerge(t *testing.T) {
225	r := startMulti(t, func(c *ports.Config) {
226		c.Layout.MaxColumns = 4
227		c.Rules = []ports.WindowRule{
228			winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Cells = 1 }),
229			winRule("chat", "chat", func(r *ports.WindowRule) { r.Cells = 2 }),
230		}
231	}, left, right)
232	r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
233	r.client <- ports.WindowMapped{ID: 1, AppID: "chat"}
234	r.client <- ports.WindowMapped{ID: 2, AppID: "term"}
235	set := sceneMatch2(t, r, func(s ports.Scene) bool { return s.Output == "DP-2" && len(s.Windows) == 2 })
236	got := map[ports.WindowID]int{}
237	for _, w := range set.Windows {
238		got[w.ID] = w.Rect.W
239	}
240	// DP-2 is 400 wide: the later rule wins the width, the generic monitor still applies.
241	if got[1] != 200 || got[2] != 100 {
242		t.Fatalf("widths %v", got)
243	}
244}
245
246// sceneMatch2 waits for a scene of the multi rig that ok accepts.
247func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Scene {
248	t.Helper()
249	for range 50 {
250		for _, s := range receive(t, r.scenes) {
251			if ok(s) {
252				return s
253			}
254		}
255	}
256	t.Fatal("no matching scene")
257	return ports.Scene{}
258}
259
260func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
261	r := startMulti(t, func(c *ports.Config) {
262		c.Layout.MaxColumns = 4
263		c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 2 })}
264	}, left)
265	r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}
266	s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })
267	if s.Windows[0].Rect.W != 100 {
268		t.Fatalf("width %d", s.Windows[0].Rect.W)
269	}
270}
271
272func TestSlotWinsOverRule(t *testing.T) {
273	r := startMulti(t, func(c *ports.Config) {
274		c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Cells: 2, Argv: []string{"code"}}}}}
275		c.Binds["Alt+d"] = "workspace dev"
276		c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}
277	}, left)
278	req := receive(t, r.spawn)
279	var slot string
280	for _, e := range req.Env {
281		if len(e) > len(ports.SlotEnv)+1 && e[:len(ports.SlotEnv)+1] == ports.SlotEnv+"=" {
282			slot = e[len(ports.SlotEnv)+1:]
283		}
284	}
285	r.client <- ports.WindowMapped{ID: 1, AppID: "code", Slot: slot}
286	st := r.stateWith(t, 1)
287	if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
288		t.Fatalf("rule overrode the slot: %+v", w)
289	}
290}
291
292func TestDialogStaysOverParentDespiteRules(t *testing.T) {
293	r := startMulti(t, func(c *ports.Config) {
294		declareDev(c)
295		c.Rules = []ports.WindowRule{winRule("x", ".*", func(r *ports.WindowRule) { r.Workspace = "dev"; r.Floating = ruleOff() })}
296	}, left)
297	r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
298	st := r.stateWith(t, 1)
299	if w := stWindow(st, 1); w.WorkspaceName != "dev" {
300		t.Fatalf("parent: %+v", w)
301	}
302	r.key(t, "d", ports.ModAlt)
303	stateAfter(t, r.state, func(st ports.State) bool { return stOutput(st, "DP-1").Workspace == "dev" })
304	r.client <- ports.WindowMapped{ID: 2, AppID: "term", Floating: true, Width: 30, Height: 30, Parent: 1}
305	st = r.stateWith(t, 2)
306	if w := stWindow(st, 2); w.WorkspaceName != "dev" || !w.Floating {
307		t.Fatalf("dialog moved or tiled: %+v", w)
308	}
309	// Same app ID without a parent: the rule applies (tiled on dev).
310	r.client <- ports.WindowMapped{ID: 3, AppID: "term", Floating: true, Width: 30, Height: 30}
311	if w := stWindow(r.stateWith(t, 3), 3); w.Floating {
312		t.Fatalf("parentless fixed-size window ignores floating = off: %+v", w)
313	}
314}
315
316func TestAppIDChangeAfterMapMovesNothing(t *testing.T) {
317	r := startMulti(t, func(c *ports.Config) {
318		declareDev(c)
319		c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
320	}, left)
321	r.mapApp(t, 1, "")
322	r.client <- ports.WindowAppID{ID: 1, AppID: "code"}
323	st := r.mapApp(t, 2, "term") // after the change, to know it was handled
324	if w := stWindow(st, 1); w.Workspace != 1 || w.AppID != "code" {
325		t.Fatalf("window re-placed: %+v", w)
326	}
327}
328
329func TestRemapKeepsPlace(t *testing.T) {
330	r := startMulti(t, func(c *ports.Config) {
331		declareDev(c)
332		c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
333	}, left)
334	r.mapApp(t, 1, "term")
335	r.client <- ports.WindowMapped{ID: 1, AppID: "code"}
336	if w := stWindow(r.mapApp(t, 2, "term"), 1); w.Workspace != 1 {
337		t.Fatalf("re-map moved the window: %+v", w)
338	}
339}
340
341// A toplevel that maps again after an unmap (a tray app restored) opens as
342// any other window: on the workspace the user looks at, not its rule's.
343func TestRuleSkipsRemapAfterUnmap(t *testing.T) {
344	r := startMulti(t, func(c *ports.Config) {
345		declareDev(c)
346		c.Rules = []ports.WindowRule{winRule("dev", "discord", func(r *ports.WindowRule) { r.Workspace = "dev" })}
347	}, left)
348	if w := stWindow(r.mapApp(t, 1, "discord"), 1); w.WorkspaceName != "dev" {
349		t.Fatalf("first map not placed by the rule: %+v", w)
350	}
351	r.client <- ports.WindowUnmapped{ID: 1}
352	stateAfter(t, r.state, func(st ports.State) bool { return stWindow(st, 1) == nil })
353	r.client <- ports.WindowMapped{ID: 1, AppID: "discord", Remap: true}
354	st := r.stateWith(t, 1)
355	if w := stWindow(st, 1); w.WorkspaceName != "" || w.Workspace != 1 || stFocus(st) != 1 {
356		t.Fatalf("remap placed by the rule: %+v focus %d", w, stFocus(st))
357	}
358}
359
360func TestRuleReloadAffectsFutureMapsOnly(t *testing.T) {
361	r := startMulti(t, func(c *ports.Config) {
362		declareDev(c)
363		c.Rules = []ports.WindowRule{winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" })}
364	}, left)
365	r.mapApp(t, 1, "code")
366	cfg := r.cfg
367	cfg.Rules = []ports.WindowRule{winRule("fl", "code", func(r *ports.WindowRule) { r.Floating = ruleOn() })}
368	r.reload <- ports.ConfigChanged{Config: cfg}
369	// Handled in order with the map below.
370	st := r.mapApp(t, 2, "code")
371	if w := stWindow(st, 1); w.WorkspaceName != "dev" || w.Floating {
372		t.Fatalf("reload moved a mapped window: %+v", w)
373	}
374	if w := stWindow(st, 2); w.WorkspaceName != "" || !w.Floating {
375		t.Fatalf("new rules not applied: %+v", w)
376	}
377}
378
379func TestRuleQuietPlacementWithAnimations(t *testing.T) {
380	r := startMulti(t, func(c *ports.Config) {
381		declareDev(c)
382		c.Animations.On = true
383		c.Rules = []ports.WindowRule{
384			winRule("dev", "code", func(r *ports.WindowRule) { r.Workspace = "dev" }),
385			winRule("fl", "calc", func(r *ports.WindowRule) { r.Floating = ruleOn() }),
386		}
387	}, left)
388	r.mapApp(t, 1, "term")
389	st := r.mapApp(t, 2, "code")
390	if w := stWindow(st, 2); w.WorkspaceName != "dev" || w.Visible || stFocus(st) != 1 {
391		t.Fatalf("%+v focus %d", w, stFocus(st))
392	}
393	st = r.mapApp(t, 3, "calc")
394	if w := stWindow(st, 3); !w.Floating || stFocus(st) != 3 {
395		t.Fatalf("%+v focus %d", w, stFocus(st))
396	}
397}
398
399// A fixed row shares its width by cells: the auto column takes the cell
400// the wide one leaves.
401func TestRuleWidthWithFixedOverflow(t *testing.T) {
402	r := startMulti(t, func(c *ports.Config) {
403		c.Layout.MaxColumns = 4
404		c.Layout.Overflow = "fixed"
405		c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 3 })}
406	}, left)
407	r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
408	r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
409	s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
410	if s.Windows[0].Rect.W != 50 || s.Windows[1].Rect.W != 150 {
411		t.Fatalf("%+v", s.Windows)
412	}
413}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close