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}