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 -1" || d.Binds["Alt+Cmd+l"] != "set-column-width +1" || 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 TestFocusFlash(t *testing.T) {
114 d := Defaults().Focus
115 if !d.Flash || d.FlashWidth != 2 || d.FlashOpacity != 0.6 || d.FlashDuration != 200*time.Millisecond || d.FlashColor != "" {
116 t.Fatal("focus flash defaults", d)
117 }
118 c, w := parseString(t, "focus.flash = off\nfocus.flash-width = 8\nfocus.flash-opacity = 1\nfocus.flash-duration = 1s\nfocus.flash-color = #112233\n")
119 want := d
120 want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = false, 8, 1, time.Second, "#112233"
121 if c.Focus != want || len(w) != 0 {
122 t.Fatal(c.Focus, w)
123 }
124 c, w = parseString(t, "focus.flash = on\nfocus.flash-width = 1\nfocus.flash-opacity = 0.1\nfocus.flash-duration = 50ms\nfocus.flash-color =\n")
125 want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = true, 1, 0.1, 50*time.Millisecond, ""
126 if c.Focus != want || len(w) != 0 {
127 t.Fatal(c.Focus, w)
128 }
129 // Each rejected value warns and keeps its default; removed keys are unknown.
130 for _, line := range []string{
131 "focus.flash = maybe",
132 "focus.flash-width = 0", "focus.flash-width = 9", "focus.flash-width = x", "focus.flash-width = 1.5",
133 "focus.flash-opacity = 0.05", "focus.flash-opacity = 1.1", "focus.flash-opacity = x",
134 "focus.flash-duration = 10ms", "focus.flash-duration = 2s", "focus.flash-duration = x", "focus.flash-duration = 200",
135 "focus.flash-color = red", "focus.flash-color = #12345",
136 "focus.animation = pulse", "focus.effect = screen", "focus.strength = 0.04",
137 } {
138 c, w = parseString(t, line+"\n")
139 if c.Focus != d || len(w) != 1 {
140 t.Fatal(line, c.Focus, w)
141 }
142 if strings.HasPrefix(line, "focus.animation") || strings.HasPrefix(line, "focus.effect") || strings.HasPrefix(line, "focus.strength") {
143 if !strings.Contains(w[0].Msg, "unknown key") {
144 t.Fatal(line, w)
145 }
146 }
147 }
148}
149
150func TestTouchpadTap(t *testing.T) {
151 if !Defaults().Touchpad.Tap {
152 t.Fatal("tap to click off by default")
153 }
154 c, w := parseString(t, "touchpad.tap = off\n")
155 if c.Touchpad.Tap || len(w) != 0 {
156 t.Fatal(c.Touchpad, w)
157 }
158 c, w = parseString(t, "touchpad.tap = sometimes\n")
159 if !c.Touchpad.Tap || len(w) != 1 {
160 t.Fatal(c.Touchpad, w)
161 }
162}
163
164func TestTouchpadSpeed(t *testing.T) {
165 d := Defaults().Touchpad
166 if d.AccelSpeed != 0 || d.AccelProfile != ports.AccelAdaptive || d.ScrollFactor != 1 {
167 t.Fatal(d)
168 }
169 c, w := parseString(t, "touchpad.scroll-factor = 0.5\n")
170 if c.Touchpad.ScrollFactor != 0.5 || len(w) != 0 {
171 t.Fatal(c.Touchpad, w)
172 }
173 for _, bad := range []string{
174 "touchpad.scroll-factor = 0",
175 "touchpad.scroll-factor = 11",
176 } {
177 c, w := parseString(t, bad+"\n")
178 if c.Touchpad != d || len(w) != 1 {
179 t.Errorf("%s: %+v %v", bad, c.Touchpad, w)
180 }
181 }
182}
183
184func TestPointerKeys(t *testing.T) {
185 d := Defaults()
186 if d.Mouse != (ports.PointerConfig{AccelProfile: ports.AccelAdaptive}) {
187 t.Fatal(d.Mouse)
188 }
189 if d.Touchpad.LeftHanded || d.Touchpad.NaturalScroll {
190 t.Fatal(d.Touchpad)
191 }
192 touchpad := func(c ports.Config) ports.PointerConfig { return c.Touchpad.PointerConfig }
193 mouse := func(c ports.Config) ports.PointerConfig { return c.Mouse }
194 for _, tc := range []struct {
195 prefix string
196 pointer, other func(ports.Config) ports.PointerConfig
197 }{
198 {"touchpad", touchpad, mouse},
199 {"mouse", mouse, touchpad},
200 } {
201 t.Run(tc.prefix, func(t *testing.T) {
202 prefix := tc.prefix
203 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")
204 want := ports.PointerConfig{NaturalScroll: true, LeftHanded: true, AccelSpeed: -0.5, AccelProfile: ports.AccelFlat}
205 if tc.pointer(c) != want || len(w) != 0 {
206 t.Errorf("%+v %v", tc.pointer(c), w)
207 }
208 // The other device keeps its defaults.
209 if other := tc.other(c); other != (ports.PointerConfig{AccelProfile: ports.AccelAdaptive}) {
210 t.Errorf("leaked into the other device: %+v", other)
211 }
212 for _, bad := range []string{
213 ".natural-scroll = maybe",
214 ".left-handed = 1",
215 ".accel-speed = 2",
216 ".accel-speed = -1.5",
217 ".accel-speed = NaN",
218 ".accel-speed = fast",
219 ".accel-profile = fast",
220 ".accel-profile = ",
221 ".unknown = on",
222 } {
223 c, w := parseString(t, prefix+bad+"\n")
224 if c.Mouse != d.Mouse || c.Touchpad != d.Touchpad || len(w) != 1 {
225 t.Errorf("%s%s: %+v %v", prefix, bad, tc.pointer(c), w)
226 }
227 }
228 })
229 }
230 // Touchpad-only keys do not exist for mice.
231 for _, bad := range []string{"mouse.tap = on", "mouse.scroll-factor = 2"} {
232 if c, w := parseString(t, bad+"\n"); c.Mouse != d.Mouse || c.Touchpad != d.Touchpad || len(w) != 1 {
233 t.Errorf("%s: %+v %v", bad, c.Mouse, w)
234 }
235 }
236}
237
238func TestParse(t *testing.T) {
239 c, w := parseString(t, `
240# comment
241keyboard.layout = fr
242keyboard.repeat-rate = 40 # inline comment
243terminal = foot --server
244background = #000000
245border.inactive =
246log.debug = core, input
247output.DP-2.scale = 4/3
248output.DP-2 = 5120x2160@165.058
249output.HDMI-A-1 = off
250output.DP-1 = preferred
251output.DP-1.scale = 1.5
252output.DP-1.primary = on
253bind.cmd+code:30 = quit
254render.direct-scanout = off
255render.tearing = off
256render.vrr = off
257startup = wl-paste --watch cliphist store
258startup = wl-paste --primary --watch cliphist store; waybar ;mako;
259`)
260 if len(w) != 0 {
261 t.Fatal(w)
262 }
263 if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}, {"waybar"}, {"mako"}}) {
264 t.Fatalf("startup: %q", c.Startup)
265 }
266 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 {
267 t.Fatalf("%+v", c)
268 }
269 if !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
270 t.Fatalf("%+v", c)
271 }
272 // Scale and mode lines combine in either order.
273 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 {
274 t.Fatalf("%+v", c.Outputs)
275 }
276 if c.Binds["Cmd+code:30"] != "quit" {
277 t.Fatal(c.Binds)
278 }
279}
280
281func TestWarningsKeepDefaults(t *testing.T) {
282 cases := []struct{ line, want string }{
283 {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
284 {"keyboard.cmd = meta", "keyboard.cmd"},
285 {"terminal =", "terminal"},
286 {"xwayland = a b", "xwayland"},
287 {"background = red", "background"},
288 {"layout.gaps = 201", "layout.gaps"},
289 {"layout.max-columns = 0", "layout.max-columns"},
290 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
291 {"layout.presets = 1/2, 1", "layout.presets: unknown key"},
292 {"log.level = trace", "log.level"},
293 {"log.debug = nope", "log.debug"},
294 {"output.DP-2 = big", "output.DP-2"},
295 {"output.DP-2 = 1920x1080@0", "output.DP-2"},
296 {"output.DP-2.scale = 0.5", "between 1 and 4"},
297 {"output.DP-2.primary = yes", "output.DP-2.primary"},
298 {"output.DP-2.scale = 3/0", "between 1 and 4"},
299 {"bind.cmd+code:x = quit", "evdev key code"},
300 {"nope = 1", "nope"},
301 {"just text", "expected key = value"},
302 {"bind.cmd+ = quit", "missing key"},
303 {"bind.hyper+x = quit", "unknown modifier"},
304 {"bind.cmd+x = bogus", "unknown action"},
305 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
306 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
307 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
308 {"bind.cmd+x = set-column-width 1", "set-column-width needs"},
309 {"bind.cmd+x = set-column-width +0", "set-column-width needs"},
310 {"bind.cmd+x = set-column-width +17", "set-column-width needs"},
311 {"bind.cmd+x = set-column-width +10%", "set-column-width needs"},
312 {"bind.cmd+x = set-column-width +-1", "set-column-width needs"},
313 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
314 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
315 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
316 {"bind.cmd+x = spawn ", "unknown action"},
317 {"bind.cmd+x = spawn ; ;", "spawn needs a command"},
318 {"bind.cmd+x = stash ", "unknown action"},
319 {"bind.cmd+x = stash ; ;", "stash needs a command"},
320 {"startup = ;", "must not be empty"},
321 }
322 for _, tc := range cases {
323 t.Run(tc.line, func(t *testing.T) {
324 c, w := parseString(t, tc.line)
325 if len(w) != 1 || w[0].Line != 1 || !strings.Contains(w[0].Msg, tc.want) {
326 t.Fatalf("warnings %v, want %q", w, tc.want)
327 }
328 if !reflect.DeepEqual(c, Defaults()) {
329 t.Fatalf("config changed: %+v", c)
330 }
331 })
332 }
333}
334
335func TestWorkspacePrevNextAction(t *testing.T) {
336 c, w := parseString(t, "bind.cmd+x = focus-workspace-next")
337 if len(w) != 0 || c.Binds["Cmd+x"] != "focus-workspace-next" {
338 t.Fatal(c.Binds["Cmd+x"], w)
339 }
340}
341
342func TestXwayland(t *testing.T) {
343 if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}) {
344 t.Fatal(Defaults().Xwayland)
345 }
346 for _, tt := range []struct {
347 value string
348 want ports.XwaylandConfig
349 }{
350 {"off", ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: true}},
351 {"satellite", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}},
352 {"builtin", ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}},
353 {"/opt/xwls", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "/opt/xwls", XSettings: true}},
354 } {
355 t.Run(tt.value, func(t *testing.T) {
356 c, w := parseString(t, "xwayland = "+tt.value)
357 if c.Xwayland != tt.want || len(w) != 0 {
358 t.Fatal(c.Xwayland, w)
359 }
360 })
361 }
362}
363
364func TestXwaylandXSettings(t *testing.T) {
365 for _, tt := range []struct {
366 src string
367 want bool
368 }{
369 {"", true},
370 {"xwayland.xsettings = on", true},
371 {"xwayland.xsettings = off", false},
372 {"xwayland.xsettings = off\nxwayland = builtin", false},
373 {"xwayland = builtin\nxwayland.xsettings = off", false},
374 } {
375 c, w := parseString(t, tt.src)
376 if c.Xwayland.XSettings != tt.want || len(w) != 0 {
377 t.Fatal(tt.src, c.Xwayland, w)
378 }
379 }
380 c, w := parseString(t, "xwayland.xsettings = maybe")
381 if !c.Xwayland.XSettings || len(w) != 1 || !strings.Contains(w[0].Msg, "must be on or off") {
382 t.Fatal(c.Xwayland, w)
383 }
384}
385
386func TestDuplicateKeyLastWins(t *testing.T) {
387 c, w := parseString(t, "background = #000000\nbackground = #111111\n")
388 if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
389 t.Fatal(c.Background, w)
390 }
391}
392
393func TestBinds(t *testing.T) {
394 c, w := parseString(t, `
395bind.CTRL+Cmd+E = spawn foo --bar
396bind.cmd+; = close-window
397bind.cmd+é = quit
398bind.cmd+semicolon = quit
399bind.cmd+equal = spawn-terminal
400bind.cmd+# = none
401bind.cmd++ = quit
402bind.alt+f5 = quit
403bind.cmd+return = none
404bind.cmd+w = focus-workspace 12
405`)
406 want := map[string]string{
407 "Cmd+Ctrl+e": "spawn foo --bar",
408 "Cmd+semicolon": "quit",
409 "Cmd+eacute": "quit",
410 "Cmd+equal": "spawn-terminal",
411 "Cmd+plus": "quit",
412 "Alt+F5": "quit",
413 }
414 for combo, action := range want {
415 if c.Binds[combo] != action {
416 t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action)
417 }
418 }
419 if _, ok := c.Binds["Cmd+Return"]; ok {
420 t.Error("none did not remove the default")
421 }
422 if _, ok := c.Binds["Cmd+numbersign"]; ok {
423 t.Error("none kept")
424 }
425 // cmd+; then cmd+semicolon is the same key: warn and keep the last.
426 if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") {
427 t.Fatal(w)
428 }
429}
430
431func TestBindReservedCharacter(t *testing.T) {
432 _, w := parseString(t, "bind.cmd+= = quit\n")
433 if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") {
434 t.Fatal(w)
435 }
436}
437
438func TestBindResolvedCollision(t *testing.T) {
439 // The later line wins, like any duplicate key.
440 c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
441 if c.Binds["Cmd+x"] != "close-window" {
442 t.Fatal(c.Binds)
443 }
444 if _, ok := c.Binds["Ctrl+x"]; ok {
445 t.Fatal(c.Binds)
446 }
447 if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
448 t.Fatal(c.Binds)
449 }
450 if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
451 t.Fatal(w)
452 }
453 // A user bind replaces a default with the same resolved keys, without a warning.
454 c, w = parseString(t, "bind.super+q = quit\n")
455 if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
456 t.Fatal(c.Binds, w)
457 }
458}
459
460func TestColumnSwitcherBinds(t *testing.T) {
461 d := Defaults()
462 if d.Binds["Cmd+Tab"] != "switch-column-next" || d.Binds["Cmd+Shift+Tab"] != "switch-column-prev" {
463 t.Fatal(d.Binds)
464 }
465 for _, action := range []string{"switch-column-next", "switch-column-prev"} {
466 c, w := parseString(t, "bind.cmd+x = "+action+"\n")
467 if c.Binds["Cmd+x"] != action || len(w) != 0 {
468 t.Fatalf("%s: binds %v warnings %v", action, c.Binds, w)
469 }
470 }
471}
472
473func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
474 c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
475 if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
476 t.Fatal(c.Background, w)
477 }
478}
479
480func TestBindCharacters(t *testing.T) {
481 c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
482 if c.Binds["Cmd+eacute"] != "quit" {
483 t.Fatal(c.Binds)
484 }
485 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
486 t.Fatal(w)
487 }
488}
489
490func TestParseMode(t *testing.T) {
491 w, h, hz, err := ParseMode("3440x1440")
492 if err != nil || w != 3440 || h != 1440 || hz != 0 {
493 t.Fatalf("%d %d %v %v", w, h, hz, err)
494 }
495 if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
496 t.Fatal(hz, err)
497 }
498}
499
500func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
501 c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n")
502 if len(w) != 0 {
503 t.Fatal(w)
504 }
505 if _, ok := c.Binds["Cmd+code:2"]; ok {
506 t.Fatal("default cmd+code:2 kept")
507 }
508 if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" {
509 t.Fatal(c.Binds)
510 }
511 if c.Binds["Cmd+code:4"] != "focus-workspace 3" {
512 t.Fatal("other defaults must stay")
513 }
514}
515
516func TestWorkspaces(t *testing.T) {
517 c, w := parseString(t, `layout.overflow = fixed
518workspace.dev.hidden = on
519workspace.dev.max-columns = 3
520workspace.web.overflow = scroll
521workspace.dev.monitor = DP-2
522workspace.bad name.hidden = on
523workspace.dev.color = red
524workspace.web.overflow = spiral
525bind.cmd+d = workspace dev
526bind.cmd+w = workspace
527bind.cmd+x = workspace typo
528workspace.lost.monitor = DP-1
529`)
530 want := []ports.WorkspaceConfig{
531 {Name: "dev", Monitor: "DP-2", MaxColumns: 3},
532 {Name: "web", Overflow: "scroll"},
533 {Name: "lost", Monitor: "DP-1"},
534 }
535 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
536 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
537 }
538 if c.Binds["Cmd+d"] != "workspace dev" {
539 t.Fatal(c.Binds["Cmd+d"])
540 }
541 lines := []int{}
542 for _, x := range w {
543 lines = append(lines, x.Line)
544 }
545 if !reflect.DeepEqual(lines, []int{2, 4, 6, 7, 8, 10, 11, 12}) {
546 t.Fatal(w)
547 }
548}
549
550func TestLayoutPerOutput(t *testing.T) {
551 c, w := parseString(t, `layout.max-columns = 2
552layout.HDMI-A-1.max-columns = 3
553layout.HDMI-A-1.overflow = fixed
554layout.LG Electronics 27GR95UM 123.overflow = scroll
555layout.DP-2.overflow = spiral
556layout.DP-2.color = red
557`)
558 want := []ports.OutputLayout{
559 {Output: "HDMI-A-1", MaxColumns: 3, Overflow: "fixed"},
560 {Output: "LG Electronics 27GR95UM 123", Overflow: "scroll"},
561 }
562 if c.Layout.MaxColumns != 2 || !reflect.DeepEqual(c.Layout.Outputs, want) || len(w) != 2 {
563 t.Fatalf("%+v %v", c.Layout.Outputs, w)
564 }
565}
566
567func TestFixedOutputWarnsSlotWidths(t *testing.T) {
568 _, w := parseString(t, `layout.overflow = scroll
569layout.DP-2.overflow = fixed
570workspace.dev.monitor = DP-2
571workspace.dev.column.1 = 50%, foot
572workspace.web.column.1 = 50%, foot
573`)
574 if len(w) != 3 || w[1].Line != 4 {
575 t.Fatal(w)
576 }
577}
578
579func TestWorkspaceSlots(t *testing.T) {
580 c, w := parseString(t, `layout.overflow = scroll
581workspace.dev.monitor = DP-2
582workspace.dev.column.2 = 1, foot --title x
583workspace.dev.column.1 = 3, code --new-window
584workspace.dev.column.2 = 16, kitty
585workspace.dev.column.0 = 2, foot
586workspace.dev.column.3 = 2
587workspace.dev.column.4 = huge, foot
588workspace.dev.column.5 = 67%, foot
589workspace.dev.column.6 = 1/3, foot
590workspace.dev.column.7 = 800px, foot
591workspace.dev.column.8 = 0, foot
592workspace.dev.column.9 = 17, foot
593bind.cmd+d = workspace dev
594`)
595 want := []ports.SlotConfig{
596 {Index: 1, Cells: 3, Argv: []string{"code", "--new-window"}},
597 {Index: 2, Cells: 16, Argv: []string{"kitty"}},
598 }
599 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
600 t.Fatalf("%+v", c.Workspaces)
601 }
602 lines := []int{}
603 for _, x := range w {
604 lines = append(lines, x.Line)
605 }
606 if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 9, 10, 11, 12, 13}) {
607 t.Fatal(w)
608 }
609}
610
611// Slot widths are cells in every overflow: nothing is ignored.
612func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) {
613 c, w := parseString(t, `workspace.dev.overflow = fixed
614workspace.dev.column.1 = 2, foot
615bind.cmd+d = workspace dev
616`)
617 if len(w) != 0 || len(c.Workspaces) != 1 || len(c.Workspaces[0].Slots) != 1 || c.Workspaces[0].Slots[0].Cells != 2 {
618 t.Fatal(w, c.Workspaces)
619 }
620}
621
622func TestTerminalResolutionAndAutoOpen(t *testing.T) {
623 t.Setenv("TERMINAL", "ghostty --new-window")
624 c, w := parseString(t, "terminal.auto-open = all\n")
625 if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"ghostty", "--new-window"}) || c.Terminal.AutoOpen != "all" {
626 t.Fatal(c.Terminal, w)
627 }
628 c, w = parseString(t, "terminal = foot --server\nterminal.auto-open = off\n")
629 if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Terminal.AutoOpen != "off" {
630 t.Fatal(c.Terminal, w)
631 }
632 c, w = parseString(t, "terminal.auto-open = invalid\n")
633 if len(w) != 1 || c.Terminal.AutoOpen != "first" {
634 t.Fatal(c.Terminal, w)
635 }
636 t.Setenv("TERMINAL", " ")
637 if got := Defaults().Terminal.Command; !reflect.DeepEqual(got, []string{"foot"}) {
638 t.Fatal(got)
639 }
640}
641
642func TestStash(t *testing.T) {
643 if d := Defaults(); d.Stash.Width != 80 || d.Stash.Gap != 2 || d.Stash.Dim != 0.5 {
644 t.Fatalf("defaults %+v", d.Stash)
645 }
646 for _, tc := range []struct {
647 line string
648 width int
649 gap int
650 dim float64
651 warning bool
652 }{
653 {"stash.width = 10", 10, 2, 0.5, false}, {"stash.width = 70%", 70, 2, 0.5, false}, {"stash.width = 90", 90, 2, 0.5, false},
654 {"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},
655 {"stash.gap = 0", 80, 0, 0.5, false}, {"stash.gap = 10", 80, 10, 0.5, false},
656 {"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},
657 {"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},
658 } {
659 t.Run(tc.line, func(t *testing.T) {
660 c, warnings := parseString(t, tc.line)
661 if c.Stash.Width != tc.width || c.Stash.Gap != tc.gap || c.Stash.Dim != tc.dim || (len(warnings) != 0) != tc.warning {
662 t.Fatalf("stash %+v, warnings %v", c.Stash, warnings)
663 }
664 })
665 }
666}
667
668func TestStashCapture(t *testing.T) {
669 if c, _ := parseString(t, ""); !c.Stash.Capture {
670 t.Fatal("stash.capture is on by default")
671 }
672 if c, w := parseString(t, "stash.capture = off"); c.Stash.Capture || len(w) != 0 {
673 t.Fatalf("off: %v %v", c.Stash.Capture, w)
674 }
675 if c, w := parseString(t, "stash.capture = yes"); !c.Stash.Capture || len(w) == 0 {
676 t.Fatalf("invalid: %v %v", c.Stash.Capture, w)
677 }
678}
679
680func TestStashSpawnTerminal(t *testing.T) {
681 if c, _ := parseString(t, ""); !c.Stash.SpawnTerminal {
682 t.Fatal("stash.spawn-terminal is on by default")
683 }
684 if c, w := parseString(t, "stash.spawn-terminal = off"); c.Stash.SpawnTerminal || len(w) != 0 {
685 t.Fatalf("off: %v %v", c.Stash.SpawnTerminal, w)
686 }
687 if c, w := parseString(t, "stash.spawn-terminal = on"); !c.Stash.SpawnTerminal || len(w) != 0 {
688 t.Fatalf("on: %v %v", c.Stash.SpawnTerminal, w)
689 }
690 if c, w := parseString(t, "stash.spawn-terminal = yes"); !c.Stash.SpawnTerminal || len(w) == 0 {
691 t.Fatalf("invalid: %v %v", c.Stash.SpawnTerminal, w)
692 }
693}
694
695func TestStashBind(t *testing.T) {
696 c, w := parseString(t, "bind.cmd+shift+f = stash firefox --private-window")
697 if len(w) != 0 || c.Binds["Cmd+Shift+f"] != "stash firefox --private-window" {
698 t.Fatalf("stash bind: %v %v", c.Binds, w)
699 }
700}
701
702func TestVRRFlipGap(t *testing.T) {
703 for _, tc := range []struct {
704 value string
705 want time.Duration
706 warning bool
707 }{
708 {"0", 0, false}, {"0ms", 0, false}, {"500us", 500 * time.Microsecond, false}, {"2ms", 2 * time.Millisecond, false}, {"10ms", 10 * time.Millisecond, false},
709 {"11ms", time.Millisecond, true}, {"-1ms", time.Millisecond, true}, {"1", time.Millisecond, true}, {"fast", time.Millisecond, true},
710 } {
711 t.Run(tc.value, func(t *testing.T) {
712 c, warnings := parseString(t, "render.vrr-flip-gap = "+tc.value)
713 if c.Render.VRRFlipGap != tc.want || (len(warnings) != 0) != tc.warning {
714 t.Fatalf("gap %v, warnings %v", c.Render.VRRFlipGap, warnings)
715 }
716 })
717 }
718}
719
720func TestCursorHideAfter(t *testing.T) {
721 if d := Defaults().Cursor.HideAfter; d != 5*time.Second {
722 t.Fatalf("default %v", d)
723 }
724 for _, tc := range []struct {
725 value string
726 want time.Duration
727 warning bool
728 }{
729 {"off", 0, false}, {"100ms", 100 * time.Millisecond, false}, {"2s", 2 * time.Second, false}, {"1h", time.Hour, false},
730 {"0", 5 * time.Second, true}, {"50ms", 5 * time.Second, true}, {"2h", 5 * time.Second, true}, {"on", 5 * time.Second, true},
731 } {
732 t.Run(tc.value, func(t *testing.T) {
733 c, warnings := parseString(t, "cursor.hide-after = "+tc.value)
734 if c.Cursor.HideAfter != tc.want || (len(warnings) != 0) != tc.warning {
735 t.Fatalf("hide-after %v, warnings %v", c.Cursor.HideAfter, warnings)
736 }
737 })
738 }
739}
740
741func TestFloatingDim(t *testing.T) {
742 for _, tc := range []struct {
743 value string
744 want float64
745 warning bool
746 }{
747 {"0", 0, false}, {"0.75", 0.75, false}, {"1", 1, false},
748 {"-0.1", 0.3, true}, {"1.1", 0.3, true}, {"NaN", 0.3, true}, {"invalid", 0.3, true},
749 } {
750 t.Run(tc.value, func(t *testing.T) {
751 c, warnings := parseString(t, "floating.dim = "+tc.value)
752 if c.Floating.Dim != tc.want || (len(warnings) != 0) != tc.warning {
753 t.Fatalf("dim %v, warnings %v", c.Floating.Dim, warnings)
754 }
755 })
756 }
757}
758
759func TestOutputRelations(t *testing.T) {
760 c, w := parseString(t, `
761output.DP-2.right-of = DP-1
762output.DP-2.offset = 180
763output.DP-3.left-of = DP-1
764output.DP-3.offset = -40
765output.DP-4.above = DP-1
766output.DP-5.below = DP-1
767output.DP-5.offset = 7
768`)
769 if len(w) != 0 {
770 t.Fatal(w)
771 }
772 want := map[string]ports.OutputAnchor{
773 "DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
774 "DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
775 "DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
776 "DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
777 }
778 if len(c.Outputs) != len(want) {
779 t.Fatalf("%+v", c.Outputs)
780 }
781 for _, o := range c.Outputs {
782 if o.Anchor != want[o.Name] || !o.ScaleOnly {
783 t.Fatalf("%s: %+v", o.Name, o)
784 }
785 }
786}
787
788func TestOutputTransform(t *testing.T) {
789 names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
790 var text strings.Builder
791 for i, n := range names {
792 fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
793 }
794 c, w := parseString(t, text.String())
795 if len(w) != 0 || len(c.Outputs) != len(names) {
796 t.Fatalf("%v %+v", w, c.Outputs)
797 }
798 for i, o := range c.Outputs {
799 if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
800 t.Fatalf("%s: %+v", names[i], o)
801 }
802 }
803
804 c, w = parseString(t, "output.DP-1.transform = 45\n")
805 if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
806 t.Fatalf("warnings %v", w)
807 }
808 if len(c.Outputs) != 0 {
809 t.Fatalf("%+v", c.Outputs)
810 }
811
812 // A later mode line keeps the transform.
813 c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
814 if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
815 t.Fatalf("%v %+v", w, c.Outputs)
816 }
817}
818
819func TestOutputRelationOverride(t *testing.T) {
820 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
821 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
822 t.Fatalf("warnings %v", w)
823 }
824 if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
825 t.Fatalf("%+v", got)
826 }
827}
828
829func TestOutputRelationWarnings(t *testing.T) {
830 const rangeMsg = "must be an integer between -65536 and 65536"
831 rel := "output.DP-2.right-of = DP-1\n"
832 cases := []struct {
833 name, text string
834 want []string // expected warning messages (substring), in order
835 lines []int // expected warning lines, in order
836 anchored []string // outputs that keep a relation
837 }{
838 {"self", "output.DP-2.right-of = DP-2", []string{"relative to itself"}, []int{1}, nil},
839 {"empty", "output.DP-2.above =", []string{"expected an output name"}, []int{1}, nil},
840 {"bad offset", rel + "output.DP-2.offset = abc", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
841 {"offset too large", rel + "output.DP-2.offset = 65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
842 {"offset too small", rel + "output.DP-2.offset = -65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
843 {"offset overflow", rel + "output.DP-2.offset = 99999999999999999999", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
844 {"offset alone", "output.DP-2.offset = 10", []string{"without a relation"}, []int{1}, nil},
845 {"two cycle", "output.A.right-of = B\noutput.B.below = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, nil},
846 {"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},
847 {"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"}},
848 {"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"}},
849 {"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"}},
850 {"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"}},
851 {"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"}},
852 {"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"}},
853 {"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"}},
854 {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
855 }
856 for _, tc := range cases {
857 t.Run(tc.name, func(t *testing.T) {
858 c, w := parseString(t, tc.text)
859 if len(w) != len(tc.want) {
860 t.Fatalf("warnings %v, want %q", w, tc.want)
861 }
862 for i, x := range w {
863 if !strings.Contains(x.Msg, tc.want[i]) || x.Line != tc.lines[i] {
864 t.Fatalf("warning %d = %+v, want %q at line %d (all: %v)", i, x, tc.want[i], tc.lines[i], w)
865 }
866 }
867 var got []string
868 for _, o := range c.Outputs {
869 if o.Anchor.Relation != ports.RelationNone {
870 got = append(got, o.Name)
871 }
872 }
873 if !reflect.DeepEqual(got, tc.anchored) {
874 t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
875 }
876 })
877 }
878}
879
880func TestDefaultMonitorBinds(t *testing.T) {
881 d := Defaults()
882 for combo, action := range map[string]string{
883 "Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up",
884 "Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down",
885 "Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right",
886 } {
887 if d.Binds[combo] != action {
888 t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action)
889 }
890 }
891 // The up/down move-workspace-to-monitor actions have no default bind but
892 // load from a config file.
893 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")
894 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" {
895 t.Fatalf("%v %v", w, c.Binds)
896 }
897}
898
899func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) {
900 c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n")
901 if got := c.Outputs[0].Anchor.Offset; got != 40 {
902 t.Fatalf("offset %d, want 40", got)
903 }
904 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 65536\noutput.DP-2.offset = -65536\n")
905 if len(w) != 1 || c.Outputs[0].Anchor.Offset != -65536 {
906 t.Fatalf("%v %+v", w, c.Outputs)
907 }
908}
909
910func TestOutputModeLineKeepsAnchor(t *testing.T) {
911 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
912 if len(w) != 0 {
913 t.Fatal(w)
914 }
915 o := c.Outputs[0]
916 if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
917 t.Fatalf("%+v", o)
918 }
919}
920
921func TestFileSyntax(t *testing.T) {
922 for _, tc := range []struct {
923 name, in string
924 check func(t *testing.T, c ports.Config)
925 warnings []string
926 }{
927 {
928 name: "invalid UTF-8 costs a warning, not the file",
929 in: "# caf\xe9\nborder.width = 5\n",
930 check: func(t *testing.T, c ports.Config) {
931 if c.Border.Width != 5 {
932 t.Fatalf("border.width = %d", c.Border.Width)
933 }
934 },
935 warnings: []string{"line 1: not valid UTF-8; invalid bytes replaced"},
936 },
937 {
938 name: "output name with spaces",
939 in: "output.LG Electronics 27GR95UM.scale = 1.5\n",
940 check: func(t *testing.T, c ports.Config) {
941 if len(c.Outputs) != 1 || c.Outputs[0].Name != "LG Electronics 27GR95UM" || c.Outputs[0].Scale != 1.5 {
942 t.Fatalf("%+v", c.Outputs)
943 }
944 },
945 },
946 {
947 // A '#' inside a quoted word is kept, as a shell would.
948 name: "comment after a quoted word",
949 in: "startup = notify-send \"a # b\" # note\n",
950 check: func(t *testing.T, c ports.Config) {
951 if !reflect.DeepEqual(c.Startup, [][]string{{"notify-send", `"a`, "#", `b"`}}) {
952 t.Fatalf("startup: %q", c.Startup)
953 }
954 },
955 },
956 } {
957 t.Run(tc.name, func(t *testing.T) {
958 c, w := parseString(t, tc.in)
959 got := make([]string, 0, len(w))
960 for _, x := range w {
961 got = append(got, x.String())
962 }
963 if len(got) != len(tc.warnings) || (len(got) > 0 && !reflect.DeepEqual(got, tc.warnings)) {
964 t.Fatalf("warnings %q, want %q", got, tc.warnings)
965 }
966 tc.check(t, c)
967 })
968 }
969}
970
971// A FIFO in place of the config fails at once instead of blocking startup.
972func TestLoadRejectsFIFO(t *testing.T) {
973 path := filepath.Join(t.TempDir(), "config")
974 if err := syscall.Mkfifo(path, 0o600); err != nil {
975 t.Skip(err)
976 }
977 done := make(chan error, 1)
978 go func() { _, _, err := Load(path); done <- err }()
979 select {
980 case err := <-done:
981 if err == nil {
982 t.Fatal("FIFO loaded")
983 }
984 case <-time.After(5 * time.Second):
985 t.Fatal("Load blocked on a FIFO")
986 }
987}
988
989func TestHintTargets(t *testing.T) {
990 if Defaults().Hint.LinksOnly {
991 t.Fatal("hint targets default must be clickable")
992 }
993 c, w := parseString(t, "hint.targets = links\n")
994 if !c.Hint.LinksOnly || len(w) != 0 {
995 t.Fatal(c.Hint, w)
996 }
997 c, w = parseString(t, "hint.targets = clickable\n")
998 if c.Hint.LinksOnly || len(w) != 0 {
999 t.Fatal(c.Hint, w)
1000 }
1001 // An invalid value warns and keeps the default.
1002 c, w = parseString(t, "hint.targets = buttons\n")
1003 if c.Hint.LinksOnly || len(w) != 1 {
1004 t.Fatal(c.Hint, w)
1005 }
1006 c, w = parseString(t, "bind.cmd+g = hint\n")
1007 if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
1008 t.Fatal(c.Binds["Cmd+g"], w)
1009 }
1010}
1011
1012func TestHintStyleConfig(t *testing.T) {
1013 c, warnings := parseString(t, "hint.size = 17.5\nhint.background = #89b4fa\nhint.foreground = #111111\n")
1014 if len(warnings) != 0 || c.Hint.Size != 17.5 || c.Hint.Background != "#89b4fa" || c.Hint.Foreground != "#111111" {
1015 t.Fatalf("style: %+v, warnings: %v", c.Hint, warnings)
1016 }
1017 c, warnings = parseString(t, "hint.size = auto\nhint.background =\nhint.foreground =\n")
1018 if len(warnings) != 0 || c.Hint.Size != 0 || c.Hint.Background != "" || c.Hint.Foreground != "" {
1019 t.Fatalf("automatic: %+v %v", c.Hint, warnings)
1020 }
1021 for _, line := range []string{"hint.size = 0", "hint.size = -1", "hint.size = NaN", "hint.size = Inf", "hint.size = huge", "hint.size = 5.9", "hint.size = 72.1", "hint.size = 1e9", "hint.background = red", "hint.foreground = #fff"} {
1022 _, warnings := parseString(t, line+"\n")
1023 if len(warnings) == 0 {
1024 t.Errorf("expected validate-config warning for %s", line)
1025 }
1026 }
1027}
1028
1029func TestHintCapture(t *testing.T) {
1030 if Defaults().Hint.Capture {
1031 t.Fatal("hint.capture defaults to off")
1032 }
1033 c, w := parseString(t, "hint.capture = on\n")
1034 if !c.Hint.Capture || len(w) != 0 {
1035 t.Fatal(c.Hint, w)
1036 }
1037 c, w = parseString(t, "hint.capture = off\n")
1038 if c.Hint.Capture || len(w) != 0 {
1039 t.Fatal(c.Hint, w)
1040 }
1041 c, w = parseString(t, "hint.capture = maybe\n")
1042 if c.Hint.Capture || len(w) != 1 {
1043 t.Fatal(c.Hint, w)
1044 }
1045}
1046
1047func TestHintSizeBounds(t *testing.T) {
1048 for _, v := range []string{"6", "72", "13"} {
1049 c, w := parseString(t, "hint.size = "+v+"\n")
1050 if len(w) != 0 || c.Hint.Size == 0 {
1051 t.Errorf("hint.size = %s: %+v %v", v, c.Hint, w)
1052 }
1053 }
1054}
1055
1056func TestHintTextClick(t *testing.T) {
1057 if got := Defaults().Hint.TextClick; got != ports.ModCtrl|ports.ModShift {
1058 t.Fatalf("default %d", got)
1059 }
1060 for in, want := range map[string]ports.Mods{
1061 "ctrl+shift": ports.ModCtrl | ports.ModShift,
1062 "Alt": ports.ModAlt,
1063 "super": ports.ModSuper,
1064 "cmd+shift": ports.ModSuper | ports.ModShift,
1065 "none": 0,
1066 "shift+ctrl+alt": ports.ModShift | ports.ModCtrl | ports.ModAlt,
1067 } {
1068 c, w := parseString(t, "hint.text-click = "+in+"\n")
1069 if c.Hint.TextClick != want || len(w) != 0 {
1070 t.Errorf("%q: %d %v", in, c.Hint.TextClick, w)
1071 }
1072 }
1073 for _, bad := range []string{"hyper", "ctrl+", "ctrl+ctrl", "ctrl+none", ""} {
1074 c, w := parseString(t, "hint.text-click = "+bad+"\n")
1075 if len(w) != 1 || c.Hint.TextClick != ports.ModCtrl|ports.ModShift {
1076 t.Errorf("%q: %d %v", bad, c.Hint.TextClick, w)
1077 }
1078 }
1079}
1080
1081func TestHintEnable(t *testing.T) {
1082 if Defaults().Hint.Enable {
1083 t.Fatal("hint mode must be off by default")
1084 }
1085 c, w := parseString(t, "hint.enable = on\n")
1086 if !c.Hint.Enable || len(w) != 0 {
1087 t.Fatal(c.Hint, w)
1088 }
1089 c, w = parseString(t, "hint.enable = off\n")
1090 if c.Hint.Enable || len(w) != 0 {
1091 t.Fatal(c.Hint, w)
1092 }
1093 c, w = parseString(t, "hint.enable = maybe\n")
1094 if c.Hint.Enable || len(w) != 1 {
1095 t.Fatal(c.Hint, w)
1096 }
1097}
1098
1099func TestPositionHint(t *testing.T) {
1100 if !Defaults().Focus.PositionHint {
1101 t.Fatal("position hint default off")
1102 }
1103 for _, value := range []string{"on", "off", "invalid"} {
1104 c, warnings := parseString(t, "focus.position-hint = "+value+"\n")
1105 if value == "invalid" {
1106 if len(warnings) == 0 {
1107 t.Fatal("accepted invalid value")
1108 }
1109 continue
1110 }
1111 if len(warnings) != 0 || c.Focus.PositionHint != (value == "on") {
1112 t.Fatalf("%s: %+v %v", value, c.Focus, warnings)
1113 }
1114 }
1115}
1116
1117func TestBorderRadius(t *testing.T) {
1118 if Defaults().Border.Radius != 0 {
1119 t.Fatal("nonzero default")
1120 }
1121 for _, v := range []string{"0", "10", "64"} {
1122 c, w := parseString(t, "border.radius = "+v+"\n")
1123 if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v {
1124 t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w)
1125 }
1126 }
1127 for _, v := range []string{"-1", "bad", "65"} {
1128 _, w := parseString(t, "border.radius = "+v+"\n")
1129 if len(w) == 0 {
1130 t.Fatalf("accepted %s", v)
1131 }
1132 }
1133}