1package config
2
3import (
4 "fmt"
5 "math"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "syscall"
11 "testing"
12 "time"
13
14 "github.com/bnema/neferwl/internal/ports"
15)
16
17func parseString(t *testing.T, s string) (ports.Config, []Warning) {
18 t.Helper()
19 c, w, err := Parse(strings.NewReader(s))
20 if err != nil {
21 t.Fatal(err)
22 }
23 return c, w
24}
25
26func TestDefaultsAndLoad(t *testing.T) {
27 t.Setenv("TERMINAL", "")
28 d := Defaults()
29 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 79 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
30 t.Fatalf("defaults: %+v", d)
31 }
32 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
33 t.Fatal(d.Binds)
34 }
35 if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
36 t.Fatal(d.Binds)
37 }
38 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
39 t.Fatal(d.Binds)
40 }
41 if _, _, err := Load(filepath.Join(t.TempDir(), "missing")); !os.IsNotExist(err) {
42 t.Fatalf("explicit missing: %v", err)
43 }
44 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
45 if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "neferwl", "config") {
46 t.Fatal(DefaultPath())
47 }
48 got, w, err := LoadDefault()
49 if err != nil || len(w) != 0 || !reflect.DeepEqual(got, d) {
50 t.Fatalf("missing: %v %v %+v", err, w, got)
51 }
52 example, w, err := Load("../../../examples/config")
53 if err != nil || len(w) != 0 || !reflect.DeepEqual(example, d) {
54 t.Fatalf("example: %v %v\n%+v\n%+v", err, w, example, d)
55 }
56}
57
58func TestRealtime(t *testing.T) {
59 if !Defaults().Performance.Realtime {
60 t.Fatal("realtime default disabled")
61 }
62 c, w := parseString(t, "performance.realtime = off\n")
63 if c.Performance.Realtime || len(w) != 0 {
64 t.Fatal(c.Performance, w)
65 }
66 c, w = parseString(t, "performance.realtime = invalid\n")
67 if !c.Performance.Realtime || len(w) != 1 {
68 t.Fatal(c.Performance, w)
69 }
70}
71
72func TestFollowMove(t *testing.T) {
73 c, w := parseString(t, "focus.follow-move = on\n")
74 if !c.Focus.FollowMove || len(w) != 0 {
75 t.Fatal(c.Focus, w)
76 }
77 c, w = parseString(t, "focus.follow-move = yes\n")
78 if c.Focus.FollowMove || len(w) != 1 {
79 t.Fatal(c.Focus, w)
80 }
81}
82
83func TestAnimations(t *testing.T) {
84 d := Defaults().Animations
85 if d.On || d.Slowdown != normalSlowdown {
86 t.Fatal("animations defaults", d)
87 }
88 c, w := parseString(t, "animations = on\nanimations.speed = 0.5\n")
89 if !c.Animations.On || c.Animations.Slowdown != 2*normalSlowdown || len(w) != 0 {
90 t.Fatal(c.Animations, w)
91 }
92 for v, slowdown := range map[string]float64{"slow": 1, "normal": 0.7, "fast": 0.5, "10": 0.07, "0.1": 7} {
93 c, w = parseString(t, "animations.speed = "+v+"\n")
94 if math.Abs(c.Animations.Slowdown-slowdown) > 1e-9 || len(w) != 0 {
95 t.Fatal(v, c.Animations, w)
96 }
97 }
98 c, w = parseString(t, "animations = off\n")
99 if c.Animations.On || len(w) != 0 {
100 t.Fatal(c.Animations, w)
101 }
102 for _, line := range []string{"animations = maybe", "animations.speed = 0", "animations.speed = 11", "animations.speed = turbo", "animations.slowdown = 2"} {
103 c, w = parseString(t, line+"\n")
104 if c.Animations != d || len(w) != 1 {
105 t.Fatal(line, c.Animations, w)
106 }
107 if strings.Contains(line, "speed") && !strings.Contains(w[0].Msg, "must be slow, normal, fast or a number between 0.1 and 10") {
108 t.Fatal(line, w)
109 }
110 }
111}
112
113func TestFocusIndicator(t *testing.T) {
114 d := Defaults().Focus
115 if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
116 t.Fatal("focus indicator defaults", d)
117 }
118 c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")
119 if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {
120 t.Fatal(c.Focus, w)
121 }
122 // Each rejected value warns and keeps its default.
123 for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {
124 c, w = parseString(t, line+"\n")
125 if c.Focus != d || len(w) != 1 {
126 t.Fatal(line, c.Focus, w)
127 }
128 }
129}
130
131func TestTouchpadTap(t *testing.T) {
132 if !Defaults().Touchpad.Tap {
133 t.Fatal("tap to click off by default")
134 }
135 c, w := parseString(t, "touchpad.tap = off\n")
136 if c.Touchpad.Tap || len(w) != 0 {
137 t.Fatal(c.Touchpad, w)
138 }
139 c, w = parseString(t, "touchpad.tap = sometimes\n")
140 if !c.Touchpad.Tap || len(w) != 1 {
141 t.Fatal(c.Touchpad, w)
142 }
143}
144
145func TestTouchpadSpeed(t *testing.T) {
146 d := Defaults().Touchpad
147 if d.AccelSpeed != 0 || d.AccelProfile != ports.AccelAdaptive || d.ScrollFactor != 1 {
148 t.Fatal(d)
149 }
150 c, w := parseString(t, "touchpad.scroll-factor = 0.5\n")
151 if c.Touchpad.ScrollFactor != 0.5 || len(w) != 0 {
152 t.Fatal(c.Touchpad, w)
153 }
154 for _, bad := range []string{
155 "touchpad.scroll-factor = 0",
156 "touchpad.scroll-factor = 11",
157 } {
158 c, w := parseString(t, bad+"\n")
159 if c.Touchpad != d || len(w) != 1 {
160 t.Errorf("%s: %+v %v", bad, c.Touchpad, w)
161 }
162 }
163}
164
165func TestPointerKeys(t *testing.T) {
166 d := Defaults()
167 if d.Mouse != (ports.PointerConfig{AccelProfile: ports.AccelAdaptive}) {
168 t.Fatal(d.Mouse)
169 }
170 if d.Touchpad.LeftHanded || d.Touchpad.NaturalScroll {
171 t.Fatal(d.Touchpad)
172 }
173 touchpad := func(c ports.Config) ports.PointerConfig { return c.Touchpad.PointerConfig }
174 mouse := func(c ports.Config) ports.PointerConfig { return c.Mouse }
175 for _, tc := range []struct {
176 prefix string
177 pointer, other func(ports.Config) ports.PointerConfig
178 }{
179 {"touchpad", touchpad, mouse},
180 {"mouse", mouse, touchpad},
181 } {
182 t.Run(tc.prefix, func(t *testing.T) {
183 prefix := tc.prefix
184 c, w := parseString(t, prefix+".natural-scroll = on\n"+prefix+".left-handed = on\n"+prefix+".accel-speed = -0.5\n"+prefix+".accel-profile = flat\n")
185 want := ports.PointerConfig{NaturalScroll: true, LeftHanded: true, AccelSpeed: -0.5, AccelProfile: ports.AccelFlat}
186 if tc.pointer(c) != want || len(w) != 0 {
187 t.Errorf("%+v %v", tc.pointer(c), w)
188 }
189 // The other device keeps its defaults.
190 if other := tc.other(c); other != (ports.PointerConfig{AccelProfile: ports.AccelAdaptive}) {
191 t.Errorf("leaked into the other device: %+v", other)
192 }
193 for _, bad := range []string{
194 ".natural-scroll = maybe",
195 ".left-handed = 1",
196 ".accel-speed = 2",
197 ".accel-speed = -1.5",
198 ".accel-speed = NaN",
199 ".accel-speed = fast",
200 ".accel-profile = fast",
201 ".accel-profile = ",
202 ".unknown = on",
203 } {
204 c, w := parseString(t, prefix+bad+"\n")
205 if c.Mouse != d.Mouse || c.Touchpad != d.Touchpad || len(w) != 1 {
206 t.Errorf("%s%s: %+v %v", prefix, bad, tc.pointer(c), w)
207 }
208 }
209 })
210 }
211 // Touchpad-only keys do not exist for mice.
212 for _, bad := range []string{"mouse.tap = on", "mouse.scroll-factor = 2"} {
213 if c, w := parseString(t, bad+"\n"); c.Mouse != d.Mouse || c.Touchpad != d.Touchpad || len(w) != 1 {
214 t.Errorf("%s: %+v %v", bad, c.Mouse, w)
215 }
216 }
217}
218
219func TestParse(t *testing.T) {
220 c, w := parseString(t, `
221# comment
222keyboard.layout = fr
223keyboard.repeat-rate = 40 # inline comment
224terminal = foot --server
225background = #000000
226border.inactive =
227layout.presets = 1/3, 1/2 ,1
228log.debug = core, input
229output.DP-2.scale = 4/3
230output.DP-2 = 5120x2160@165.058
231output.HDMI-A-1 = off
232output.DP-1 = preferred
233output.DP-1.scale = 1.5
234output.DP-1.primary = on
235bind.cmd+code:30 = quit
236render.direct-scanout = off
237render.tearing = off
238render.vrr = off
239startup = wl-paste --watch cliphist store
240startup = wl-paste --primary --watch cliphist store; waybar ;mako;
241`)
242 if len(w) != 0 {
243 t.Fatal(w)
244 }
245 if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}, {"waybar"}, {"mako"}}) {
246 t.Fatalf("startup: %q", c.Startup)
247 }
248 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {
249 t.Fatalf("%+v", c)
250 }
251 if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
252 t.Fatalf("%+v", c)
253 }
254 // Scale and mode lines combine in either order.
255 if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 || !c.Outputs[2].Primary || c.Outputs[0].Primary {
256 t.Fatalf("%+v", c.Outputs)
257 }
258 if c.Binds["Cmd+code:30"] != "quit" {
259 t.Fatal(c.Binds)
260 }
261}
262
263func TestWarningsKeepDefaults(t *testing.T) {
264 cases := []struct{ line, want string }{
265 {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
266 {"keyboard.cmd = meta", "keyboard.cmd"},
267 {"terminal =", "terminal"},
268 {"xwayland = a b", "xwayland"},
269 {"background = red", "background"},
270 {"layout.gaps = 201", "layout.gaps"},
271 {"layout.max-columns = 0", "layout.max-columns"},
272 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
273 {"layout.presets = 0px", "layout.presets"},
274 {"log.level = trace", "log.level"},
275 {"log.debug = nope", "log.debug"},
276 {"output.DP-2 = big", "output.DP-2"},
277 {"output.DP-2 = 1920x1080@0", "output.DP-2"},
278 {"output.DP-2.scale = 0.5", "between 1 and 4"},
279 {"output.DP-2.primary = yes", "output.DP-2.primary"},
280 {"output.DP-2.scale = 3/0", "between 1 and 4"},
281 {"bind.cmd+code:x = quit", "evdev key code"},
282 {"nope = 1", "nope"},
283 {"just text", "expected key = value"},
284 {"bind.cmd+ = quit", "missing key"},
285 {"bind.hyper+x = quit", "unknown modifier"},
286 {"bind.cmd+x = bogus", "unknown action"},
287 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
288 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
289 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
290 {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},
291 {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},
292 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
293 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
294 {"bind.cmd+x = spawn ", "unknown action"},
295 {"bind.cmd+x = spawn ; ;", "spawn needs a command"},
296 {"startup = ;", "must not be empty"},
297 }
298 for _, tc := range cases {
299 t.Run(tc.line, func(t *testing.T) {
300 c, w := parseString(t, tc.line)
301 if len(w) != 1 || w[0].Line != 1 || !strings.Contains(w[0].Msg, tc.want) {
302 t.Fatalf("warnings %v, want %q", w, tc.want)
303 }
304 if !reflect.DeepEqual(c, Defaults()) {
305 t.Fatalf("config changed: %+v", c)
306 }
307 })
308 }
309}
310
311func TestWorkspacePrevNextAction(t *testing.T) {
312 c, w := parseString(t, "bind.cmd+x = focus-workspace-next")
313 if len(w) != 0 || c.Binds["Cmd+x"] != "focus-workspace-next" {
314 t.Fatal(c.Binds["Cmd+x"], w)
315 }
316}
317
318func TestXwayland(t *testing.T) {
319 if Defaults().Xwayland != "xwayland-satellite" {
320 t.Fatal(Defaults().Xwayland)
321 }
322 if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
323 t.Fatal(c.Xwayland, w)
324 }
325 if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
326 t.Fatal(c.Xwayland, w)
327 }
328}
329
330func TestDuplicateKeyLastWins(t *testing.T) {
331 c, w := parseString(t, "background = #000000\nbackground = #111111\n")
332 if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
333 t.Fatal(c.Background, w)
334 }
335}
336
337func TestBinds(t *testing.T) {
338 c, w := parseString(t, `
339bind.CTRL+Cmd+E = spawn foo --bar
340bind.cmd+; = close-window
341bind.cmd+é = quit
342bind.cmd+semicolon = quit
343bind.cmd+equal = spawn-terminal
344bind.cmd+# = none
345bind.cmd++ = quit
346bind.alt+f5 = quit
347bind.cmd+return = none
348bind.cmd+w = focus-workspace 12
349`)
350 want := map[string]string{
351 "Cmd+Ctrl+e": "spawn foo --bar",
352 "Cmd+semicolon": "quit",
353 "Cmd+eacute": "quit",
354 "Cmd+equal": "spawn-terminal",
355 "Cmd+plus": "quit",
356 "Alt+F5": "quit",
357 }
358 for combo, action := range want {
359 if c.Binds[combo] != action {
360 t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action)
361 }
362 }
363 if _, ok := c.Binds["Cmd+Return"]; ok {
364 t.Error("none did not remove the default")
365 }
366 if _, ok := c.Binds["Cmd+numbersign"]; ok {
367 t.Error("none kept")
368 }
369 // cmd+; then cmd+semicolon is the same key: warn and keep the last.
370 if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") {
371 t.Fatal(w)
372 }
373}
374
375func TestBindReservedCharacter(t *testing.T) {
376 _, w := parseString(t, "bind.cmd+= = quit\n")
377 if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") {
378 t.Fatal(w)
379 }
380}
381
382func TestBindResolvedCollision(t *testing.T) {
383 // The later line wins, like any duplicate key.
384 c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
385 if c.Binds["Cmd+x"] != "close-window" {
386 t.Fatal(c.Binds)
387 }
388 if _, ok := c.Binds["Ctrl+x"]; ok {
389 t.Fatal(c.Binds)
390 }
391 if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
392 t.Fatal(c.Binds)
393 }
394 if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
395 t.Fatal(w)
396 }
397 // A user bind replaces a default with the same resolved keys, without a warning.
398 c, w = parseString(t, "bind.super+q = quit\n")
399 if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
400 t.Fatal(c.Binds, w)
401 }
402}
403
404func TestColumnSwitcherBinds(t *testing.T) {
405 d := Defaults()
406 if d.Binds["Cmd+Tab"] != "switch-column-next" || d.Binds["Cmd+Shift+Tab"] != "switch-column-prev" {
407 t.Fatal(d.Binds)
408 }
409 for _, action := range []string{"switch-column-next", "switch-column-prev"} {
410 c, w := parseString(t, "bind.cmd+x = "+action+"\n")
411 if c.Binds["Cmd+x"] != action || len(w) != 0 {
412 t.Fatalf("%s: binds %v warnings %v", action, c.Binds, w)
413 }
414 }
415}
416
417func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
418 c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
419 if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
420 t.Fatal(c.Background, w)
421 }
422}
423
424func TestBindCharacters(t *testing.T) {
425 c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
426 if c.Binds["Cmd+eacute"] != "quit" {
427 t.Fatal(c.Binds)
428 }
429 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
430 t.Fatal(w)
431 }
432}
433
434func TestParseMode(t *testing.T) {
435 w, h, hz, err := ParseMode("3440x1440")
436 if err != nil || w != 3440 || h != 1440 || hz != 0 {
437 t.Fatalf("%d %d %v %v", w, h, hz, err)
438 }
439 if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
440 t.Fatal(hz, err)
441 }
442}
443
444func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
445 c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n")
446 if len(w) != 0 {
447 t.Fatal(w)
448 }
449 if _, ok := c.Binds["Cmd+code:2"]; ok {
450 t.Fatal("default cmd+code:2 kept")
451 }
452 if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" {
453 t.Fatal(c.Binds)
454 }
455 if c.Binds["Cmd+code:4"] != "focus-workspace 3" {
456 t.Fatal("other defaults must stay")
457 }
458}
459
460func TestWorkspaces(t *testing.T) {
461 c, w := parseString(t, `layout.overflow = fixed
462workspace.dev.hidden = on
463workspace.dev.max-columns = 3
464workspace.web.overflow = scroll
465workspace.dev.monitor = DP-2
466workspace.bad name.hidden = on
467workspace.dev.color = red
468workspace.web.overflow = spiral
469bind.cmd+d = workspace dev
470bind.cmd+w = workspace
471bind.cmd+x = workspace typo
472workspace.lost.monitor = DP-1
473`)
474 want := []ports.WorkspaceConfig{
475 {Name: "dev", Monitor: "DP-2", MaxColumns: 3},
476 {Name: "web", Overflow: "scroll"},
477 {Name: "lost", Monitor: "DP-1"},
478 }
479 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
480 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
481 }
482 if c.Binds["Cmd+d"] != "workspace dev" {
483 t.Fatal(c.Binds["Cmd+d"])
484 }
485 lines := []int{}
486 for _, x := range w {
487 lines = append(lines, x.Line)
488 }
489 if !reflect.DeepEqual(lines, []int{2, 4, 6, 7, 8, 10, 11, 12}) {
490 t.Fatal(w)
491 }
492}
493
494func TestLayoutPerOutput(t *testing.T) {
495 c, w := parseString(t, `layout.max-columns = 2
496layout.HDMI-A-1.max-columns = 3
497layout.HDMI-A-1.overflow = fixed
498layout.LG Electronics 27GR95UM 123.overflow = scroll
499layout.DP-2.overflow = spiral
500layout.DP-2.color = red
501`)
502 want := []ports.OutputLayout{
503 {Output: "HDMI-A-1", MaxColumns: 3, Overflow: "fixed"},
504 {Output: "LG Electronics 27GR95UM 123", Overflow: "scroll"},
505 }
506 if c.Layout.MaxColumns != 2 || !reflect.DeepEqual(c.Layout.Outputs, want) || len(w) != 2 {
507 t.Fatalf("%+v %v", c.Layout.Outputs, w)
508 }
509}
510
511func TestFixedOutputWarnsSlotWidths(t *testing.T) {
512 _, w := parseString(t, `layout.overflow = scroll
513layout.DP-2.overflow = fixed
514workspace.dev.monitor = DP-2
515workspace.dev.column.1 = 50%, foot
516workspace.web.column.1 = 50%, foot
517`)
518 if len(w) != 3 || w[1].Line != 4 {
519 t.Fatal(w)
520 }
521}
522
523func TestWorkspaceSlots(t *testing.T) {
524 c, w := parseString(t, `layout.overflow = scroll
525workspace.dev.monitor = DP-2
526workspace.dev.column.2 = 33%, foot --title x
527workspace.dev.column.1 = 67%, code --new-window
528workspace.dev.column.2 = 1/3, kitty
529workspace.dev.column.0 = 50%, foot
530workspace.dev.column.3 = 50%
531workspace.dev.column.4 = huge, foot
532bind.cmd+d = workspace dev
533`)
534 want := []ports.SlotConfig{
535 {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},
536 {Index: 2, Width: "1/3", Argv: []string{"kitty"}},
537 }
538 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
539 t.Fatalf("%+v", c.Workspaces)
540 }
541 lines := []int{}
542 for _, x := range w {
543 lines = append(lines, x.Line)
544 }
545 if !reflect.DeepEqual(lines, []int{5, 6, 7, 8}) {
546 t.Fatal(w)
547 }
548}
549
550func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
551 _, w := parseString(t, `workspace.dev.overflow = fixed
552workspace.dev.column.1 = 67%, foot
553`)
554 if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {
555 t.Fatal(w)
556 }
557}
558
559func TestTerminalResolutionAndAutoOpen(t *testing.T) {
560 t.Setenv("TERMINAL", "ghostty --new-window")
561 c, w := parseString(t, "terminal.auto-open = all\n")
562 if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"ghostty", "--new-window"}) || c.Terminal.AutoOpen != "all" {
563 t.Fatal(c.Terminal, w)
564 }
565 c, w = parseString(t, "terminal = foot --server\nterminal.auto-open = off\n")
566 if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Terminal.AutoOpen != "off" {
567 t.Fatal(c.Terminal, w)
568 }
569 c, w = parseString(t, "terminal.auto-open = invalid\n")
570 if len(w) != 1 || c.Terminal.AutoOpen != "first" {
571 t.Fatal(c.Terminal, w)
572 }
573 t.Setenv("TERMINAL", " ")
574 if got := Defaults().Terminal.Command; !reflect.DeepEqual(got, []string{"foot"}) {
575 t.Fatal(got)
576 }
577}
578
579func TestStash(t *testing.T) {
580 if d := Defaults(); d.Stash.Width != 80 || d.Stash.Gap != 2 || d.Stash.Dim != 0.5 {
581 t.Fatalf("defaults %+v", d.Stash)
582 }
583 for _, tc := range []struct {
584 line string
585 width int
586 gap int
587 dim float64
588 warning bool
589 }{
590 {"stash.width = 10", 10, 2, 0.5, false}, {"stash.width = 70%", 70, 2, 0.5, false}, {"stash.width = 90", 90, 2, 0.5, false},
591 {"stash.width = 9", 80, 2, 0.5, true}, {"stash.width = 91", 80, 2, 0.5, true}, {"stash.width = 0", 80, 2, 0.5, true}, {"stash.width = 1/2", 80, 2, 0.5, true},
592 {"stash.gap = 0", 80, 0, 0.5, false}, {"stash.gap = 10", 80, 10, 0.5, false},
593 {"stash.gap = 11", 80, 2, 0.5, true}, {"stash.gap = -1", 80, 2, 0.5, true}, {"stash.gap = 2.5", 80, 2, 0.5, true},
594 {"stash.dim = 0.8", 80, 2, 0.8, false}, {"stash.dim = 1.5", 80, 2, 0.5, true}, {"stash.dim = NaN", 80, 2, 0.5, true},
595 } {
596 t.Run(tc.line, func(t *testing.T) {
597 c, warnings := parseString(t, tc.line)
598 if c.Stash.Width != tc.width || c.Stash.Gap != tc.gap || c.Stash.Dim != tc.dim || (len(warnings) != 0) != tc.warning {
599 t.Fatalf("stash %+v, warnings %v", c.Stash, warnings)
600 }
601 })
602 }
603}
604
605func TestStashCapture(t *testing.T) {
606 if c, _ := parseString(t, ""); !c.Stash.Capture {
607 t.Fatal("stash.capture is on by default")
608 }
609 if c, w := parseString(t, "stash.capture = off"); c.Stash.Capture || len(w) != 0 {
610 t.Fatalf("off: %v %v", c.Stash.Capture, w)
611 }
612 if c, w := parseString(t, "stash.capture = yes"); !c.Stash.Capture || len(w) == 0 {
613 t.Fatalf("invalid: %v %v", c.Stash.Capture, w)
614 }
615}
616
617func TestVRRFlipGap(t *testing.T) {
618 for _, tc := range []struct {
619 value string
620 want time.Duration
621 warning bool
622 }{
623 {"0", 0, false}, {"0ms", 0, false}, {"500us", 500 * time.Microsecond, false}, {"2ms", 2 * time.Millisecond, false}, {"10ms", 10 * time.Millisecond, false},
624 {"11ms", time.Millisecond, true}, {"-1ms", time.Millisecond, true}, {"1", time.Millisecond, true}, {"fast", time.Millisecond, true},
625 } {
626 t.Run(tc.value, func(t *testing.T) {
627 c, warnings := parseString(t, "render.vrr-flip-gap = "+tc.value)
628 if c.Render.VRRFlipGap != tc.want || (len(warnings) != 0) != tc.warning {
629 t.Fatalf("gap %v, warnings %v", c.Render.VRRFlipGap, warnings)
630 }
631 })
632 }
633}
634
635func TestCursorHideAfter(t *testing.T) {
636 if d := Defaults().Cursor.HideAfter; d != 5*time.Second {
637 t.Fatalf("default %v", d)
638 }
639 for _, tc := range []struct {
640 value string
641 want time.Duration
642 warning bool
643 }{
644 {"off", 0, false}, {"100ms", 100 * time.Millisecond, false}, {"2s", 2 * time.Second, false}, {"1h", time.Hour, false},
645 {"0", 5 * time.Second, true}, {"50ms", 5 * time.Second, true}, {"2h", 5 * time.Second, true}, {"on", 5 * time.Second, true},
646 } {
647 t.Run(tc.value, func(t *testing.T) {
648 c, warnings := parseString(t, "cursor.hide-after = "+tc.value)
649 if c.Cursor.HideAfter != tc.want || (len(warnings) != 0) != tc.warning {
650 t.Fatalf("hide-after %v, warnings %v", c.Cursor.HideAfter, warnings)
651 }
652 })
653 }
654}
655
656func TestFloatingDim(t *testing.T) {
657 for _, tc := range []struct {
658 value string
659 want float64
660 warning bool
661 }{
662 {"0", 0, false}, {"0.75", 0.75, false}, {"1", 1, false},
663 {"-0.1", 0.3, true}, {"1.1", 0.3, true}, {"NaN", 0.3, true}, {"invalid", 0.3, true},
664 } {
665 t.Run(tc.value, func(t *testing.T) {
666 c, warnings := parseString(t, "floating.dim = "+tc.value)
667 if c.Floating.Dim != tc.want || (len(warnings) != 0) != tc.warning {
668 t.Fatalf("dim %v, warnings %v", c.Floating.Dim, warnings)
669 }
670 })
671 }
672}
673
674func TestOutputRelations(t *testing.T) {
675 c, w := parseString(t, `
676output.DP-2.right-of = DP-1
677output.DP-2.offset = 180
678output.DP-3.left-of = DP-1
679output.DP-3.offset = -40
680output.DP-4.above = DP-1
681output.DP-5.below = DP-1
682output.DP-5.offset = 7
683`)
684 if len(w) != 0 {
685 t.Fatal(w)
686 }
687 want := map[string]ports.OutputAnchor{
688 "DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
689 "DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
690 "DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
691 "DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
692 }
693 if len(c.Outputs) != len(want) {
694 t.Fatalf("%+v", c.Outputs)
695 }
696 for _, o := range c.Outputs {
697 if o.Anchor != want[o.Name] || !o.ScaleOnly {
698 t.Fatalf("%s: %+v", o.Name, o)
699 }
700 }
701}
702
703func TestOutputTransform(t *testing.T) {
704 names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
705 var text strings.Builder
706 for i, n := range names {
707 fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
708 }
709 c, w := parseString(t, text.String())
710 if len(w) != 0 || len(c.Outputs) != len(names) {
711 t.Fatalf("%v %+v", w, c.Outputs)
712 }
713 for i, o := range c.Outputs {
714 if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
715 t.Fatalf("%s: %+v", names[i], o)
716 }
717 }
718
719 c, w = parseString(t, "output.DP-1.transform = 45\n")
720 if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
721 t.Fatalf("warnings %v", w)
722 }
723 if len(c.Outputs) != 0 {
724 t.Fatalf("%+v", c.Outputs)
725 }
726
727 // A later mode line keeps the transform.
728 c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
729 if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
730 t.Fatalf("%v %+v", w, c.Outputs)
731 }
732}
733
734func TestOutputRelationOverride(t *testing.T) {
735 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
736 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
737 t.Fatalf("warnings %v", w)
738 }
739 if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
740 t.Fatalf("%+v", got)
741 }
742}
743
744func TestOutputRelationWarnings(t *testing.T) {
745 const rangeMsg = "must be an integer between -65536 and 65536"
746 rel := "output.DP-2.right-of = DP-1\n"
747 cases := []struct {
748 name, text string
749 want []string // expected warning messages (substring), in order
750 lines []int // expected warning lines, in order
751 anchored []string // outputs that keep a relation
752 }{
753 {"self", "output.DP-2.right-of = DP-2", []string{"relative to itself"}, []int{1}, nil},
754 {"empty", "output.DP-2.above =", []string{"expected an output name"}, []int{1}, nil},
755 {"bad offset", rel + "output.DP-2.offset = abc", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
756 {"offset too large", rel + "output.DP-2.offset = 65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
757 {"offset too small", rel + "output.DP-2.offset = -65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
758 {"offset overflow", rel + "output.DP-2.offset = 99999999999999999999", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
759 {"offset alone", "output.DP-2.offset = 10", []string{"without a relation"}, []int{1}, nil},
760 {"two cycle", "output.A.right-of = B\noutput.B.below = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, nil},
761 {"three cycle", "output.A.right-of = B\noutput.B.right-of = C\noutput.C.above = A", []string{"relation cycle", "relation cycle", "relation cycle"}, []int{1, 2, 3}, nil},
762 {"tail into cycle", "output.A.right-of = B\noutput.B.right-of = A\noutput.C.left-of = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, []string{"C"}},
763 {"relation overridden twice", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C", []string{"overrides line 1", "overrides line 2"}, []int{2, 3}, []string{"DP-2"}},
764 {"relation overridden three times", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C\noutput.DP-2.below = D", []string{"overrides line 1", "overrides line 2", "overrides line 3"}, []int{2, 3, 4}, []string{"DP-2"}},
765 {"same relation on two outputs", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1", []string{"output.DP-3: same relation as output.DP-2; both get the same position"}, []int{2}, []string{"DP-2", "DP-3"}},
766 {"same relation and offset on three outputs", "output.A.below = R\noutput.A.offset = 5\noutput.B.below = R\noutput.B.offset = 5\noutput.C.below = R\noutput.C.offset = 5", []string{"output.B: same relation as output.A", "output.C: same relation as output.A"}, []int{3, 5}, []string{"A", "B", "C"}},
767 {"same reference, other offset", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1\noutput.DP-3.offset = 10", nil, nil, []string{"DP-2", "DP-3"}},
768 {"same reference, other relation", "output.DP-2.right-of = DP-1\noutput.DP-3.left-of = DP-1", nil, nil, []string{"DP-2", "DP-3"}},
769 {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
770 }
771 for _, tc := range cases {
772 t.Run(tc.name, func(t *testing.T) {
773 c, w := parseString(t, tc.text)
774 if len(w) != len(tc.want) {
775 t.Fatalf("warnings %v, want %q", w, tc.want)
776 }
777 for i, x := range w {
778 if !strings.Contains(x.Msg, tc.want[i]) || x.Line != tc.lines[i] {
779 t.Fatalf("warning %d = %+v, want %q at line %d (all: %v)", i, x, tc.want[i], tc.lines[i], w)
780 }
781 }
782 var got []string
783 for _, o := range c.Outputs {
784 if o.Anchor.Relation != ports.RelationNone {
785 got = append(got, o.Name)
786 }
787 }
788 if !reflect.DeepEqual(got, tc.anchored) {
789 t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
790 }
791 })
792 }
793}
794
795func TestDefaultMonitorBinds(t *testing.T) {
796 d := Defaults()
797 for combo, action := range map[string]string{
798 "Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up",
799 "Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down",
800 "Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right",
801 } {
802 if d.Binds[combo] != action {
803 t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action)
804 }
805 }
806 // The up/down move-workspace-to-monitor actions have no default bind but
807 // load from a config file.
808 c, w := parseString(t, "bind.cmd+ctrl+alt+up = move-workspace-to-monitor-up\nbind.cmd+ctrl+alt+down = move-workspace-to-monitor-down\nbind.cmd+alt+u = focus-monitor-up\n")
809 if len(w) != 0 || c.Binds["Alt+Cmd+Ctrl+Up"] != "move-workspace-to-monitor-up" || c.Binds["Alt+Cmd+Ctrl+Down"] != "move-workspace-to-monitor-down" {
810 t.Fatalf("%v %v", w, c.Binds)
811 }
812}
813
814func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) {
815 c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n")
816 if got := c.Outputs[0].Anchor.Offset; got != 40 {
817 t.Fatalf("offset %d, want 40", got)
818 }
819 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 65536\noutput.DP-2.offset = -65536\n")
820 if len(w) != 1 || c.Outputs[0].Anchor.Offset != -65536 {
821 t.Fatalf("%v %+v", w, c.Outputs)
822 }
823}
824
825func TestOutputModeLineKeepsAnchor(t *testing.T) {
826 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
827 if len(w) != 0 {
828 t.Fatal(w)
829 }
830 o := c.Outputs[0]
831 if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
832 t.Fatalf("%+v", o)
833 }
834}
835
836func TestFileSyntax(t *testing.T) {
837 for _, tc := range []struct {
838 name, in string
839 check func(t *testing.T, c ports.Config)
840 warnings []string
841 }{
842 {
843 name: "invalid UTF-8 costs a warning, not the file",
844 in: "# caf\xe9\nborder.width = 5\n",
845 check: func(t *testing.T, c ports.Config) {
846 if c.Border.Width != 5 {
847 t.Fatalf("border.width = %d", c.Border.Width)
848 }
849 },
850 warnings: []string{"line 1: not valid UTF-8; invalid bytes replaced"},
851 },
852 {
853 name: "output name with spaces",
854 in: "output.LG Electronics 27GR95UM.scale = 1.5\n",
855 check: func(t *testing.T, c ports.Config) {
856 if len(c.Outputs) != 1 || c.Outputs[0].Name != "LG Electronics 27GR95UM" || c.Outputs[0].Scale != 1.5 {
857 t.Fatalf("%+v", c.Outputs)
858 }
859 },
860 },
861 {
862 // A '#' inside a quoted word is kept, as a shell would.
863 name: "comment after a quoted word",
864 in: "startup = notify-send \"a # b\" # note\n",
865 check: func(t *testing.T, c ports.Config) {
866 if !reflect.DeepEqual(c.Startup, [][]string{{"notify-send", `"a`, "#", `b"`}}) {
867 t.Fatalf("startup: %q", c.Startup)
868 }
869 },
870 },
871 } {
872 t.Run(tc.name, func(t *testing.T) {
873 c, w := parseString(t, tc.in)
874 got := make([]string, 0, len(w))
875 for _, x := range w {
876 got = append(got, x.String())
877 }
878 if len(got) != len(tc.warnings) || (len(got) > 0 && !reflect.DeepEqual(got, tc.warnings)) {
879 t.Fatalf("warnings %q, want %q", got, tc.warnings)
880 }
881 tc.check(t, c)
882 })
883 }
884}
885
886// A FIFO in place of the config fails at once instead of blocking startup.
887func TestLoadRejectsFIFO(t *testing.T) {
888 path := filepath.Join(t.TempDir(), "config")
889 if err := syscall.Mkfifo(path, 0o600); err != nil {
890 t.Skip(err)
891 }
892 done := make(chan error, 1)
893 go func() { _, _, err := Load(path); done <- err }()
894 select {
895 case err := <-done:
896 if err == nil {
897 t.Fatal("FIFO loaded")
898 }
899 case <-time.After(5 * time.Second):
900 t.Fatal("Load blocked on a FIFO")
901 }
902}