main f2d166b

config_test.go RawHistoryFolder

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 != "xwayland-satellite" {
344		t.Fatal(Defaults().Xwayland)
345	}
346	if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
347		t.Fatal(c.Xwayland, w)
348	}
349	if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
350		t.Fatal(c.Xwayland, w)
351	}
352}
353
354func TestDuplicateKeyLastWins(t *testing.T) {
355	c, w := parseString(t, "background = #000000\nbackground = #111111\n")
356	if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
357		t.Fatal(c.Background, w)
358	}
359}
360
361func TestBinds(t *testing.T) {
362	c, w := parseString(t, `
363bind.CTRL+Cmd+E = spawn foo --bar
364bind.cmd+; = close-window
365bind.cmd+é = quit
366bind.cmd+semicolon = quit
367bind.cmd+equal = spawn-terminal
368bind.cmd+# = none
369bind.cmd++ = quit
370bind.alt+f5 = quit
371bind.cmd+return = none
372bind.cmd+w = focus-workspace 12
373`)
374	want := map[string]string{
375		"Cmd+Ctrl+e":    "spawn foo --bar",
376		"Cmd+semicolon": "quit",
377		"Cmd+eacute":    "quit",
378		"Cmd+equal":     "spawn-terminal",
379		"Cmd+plus":      "quit",
380		"Alt+F5":        "quit",
381	}
382	for combo, action := range want {
383		if c.Binds[combo] != action {
384			t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action)
385		}
386	}
387	if _, ok := c.Binds["Cmd+Return"]; ok {
388		t.Error("none did not remove the default")
389	}
390	if _, ok := c.Binds["Cmd+numbersign"]; ok {
391		t.Error("none kept")
392	}
393	// cmd+; then cmd+semicolon is the same key: warn and keep the last.
394	if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") {
395		t.Fatal(w)
396	}
397}
398
399func TestBindReservedCharacter(t *testing.T) {
400	_, w := parseString(t, "bind.cmd+= = quit\n")
401	if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") {
402		t.Fatal(w)
403	}
404}
405
406func TestBindResolvedCollision(t *testing.T) {
407	// The later line wins, like any duplicate key.
408	c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
409	if c.Binds["Cmd+x"] != "close-window" {
410		t.Fatal(c.Binds)
411	}
412	if _, ok := c.Binds["Ctrl+x"]; ok {
413		t.Fatal(c.Binds)
414	}
415	if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
416		t.Fatal(c.Binds)
417	}
418	if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
419		t.Fatal(w)
420	}
421	// A user bind replaces a default with the same resolved keys, without a warning.
422	c, w = parseString(t, "bind.super+q = quit\n")
423	if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
424		t.Fatal(c.Binds, w)
425	}
426}
427
428func TestColumnSwitcherBinds(t *testing.T) {
429	d := Defaults()
430	if d.Binds["Cmd+Tab"] != "switch-column-next" || d.Binds["Cmd+Shift+Tab"] != "switch-column-prev" {
431		t.Fatal(d.Binds)
432	}
433	for _, action := range []string{"switch-column-next", "switch-column-prev"} {
434		c, w := parseString(t, "bind.cmd+x = "+action+"\n")
435		if c.Binds["Cmd+x"] != action || len(w) != 0 {
436			t.Fatalf("%s: binds %v warnings %v", action, c.Binds, w)
437		}
438	}
439}
440
441func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
442	c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
443	if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
444		t.Fatal(c.Background, w)
445	}
446}
447
448func TestBindCharacters(t *testing.T) {
449	c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
450	if c.Binds["Cmd+eacute"] != "quit" {
451		t.Fatal(c.Binds)
452	}
453	if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
454		t.Fatal(w)
455	}
456}
457
458func TestParseMode(t *testing.T) {
459	w, h, hz, err := ParseMode("3440x1440")
460	if err != nil || w != 3440 || h != 1440 || hz != 0 {
461		t.Fatalf("%d %d %v %v", w, h, hz, err)
462	}
463	if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
464		t.Fatal(hz, err)
465	}
466}
467
468func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
469	c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n")
470	if len(w) != 0 {
471		t.Fatal(w)
472	}
473	if _, ok := c.Binds["Cmd+code:2"]; ok {
474		t.Fatal("default cmd+code:2 kept")
475	}
476	if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" {
477		t.Fatal(c.Binds)
478	}
479	if c.Binds["Cmd+code:4"] != "focus-workspace 3" {
480		t.Fatal("other defaults must stay")
481	}
482}
483
484func TestWorkspaces(t *testing.T) {
485	c, w := parseString(t, `layout.overflow = fixed
486workspace.dev.hidden = on
487workspace.dev.max-columns = 3
488workspace.web.overflow = scroll
489workspace.dev.monitor = DP-2
490workspace.bad name.hidden = on
491workspace.dev.color = red
492workspace.web.overflow = spiral
493bind.cmd+d = workspace dev
494bind.cmd+w = workspace
495bind.cmd+x = workspace typo
496workspace.lost.monitor = DP-1
497`)
498	want := []ports.WorkspaceConfig{
499		{Name: "dev", Monitor: "DP-2", MaxColumns: 3},
500		{Name: "web", Overflow: "scroll"},
501		{Name: "lost", Monitor: "DP-1"},
502	}
503	if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
504		t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
505	}
506	if c.Binds["Cmd+d"] != "workspace dev" {
507		t.Fatal(c.Binds["Cmd+d"])
508	}
509	lines := []int{}
510	for _, x := range w {
511		lines = append(lines, x.Line)
512	}
513	if !reflect.DeepEqual(lines, []int{2, 4, 6, 7, 8, 10, 11, 12}) {
514		t.Fatal(w)
515	}
516}
517
518func TestLayoutPerOutput(t *testing.T) {
519	c, w := parseString(t, `layout.max-columns = 2
520layout.HDMI-A-1.max-columns = 3
521layout.HDMI-A-1.overflow = fixed
522layout.LG Electronics 27GR95UM 123.overflow = scroll
523layout.DP-2.overflow = spiral
524layout.DP-2.color = red
525`)
526	want := []ports.OutputLayout{
527		{Output: "HDMI-A-1", MaxColumns: 3, Overflow: "fixed"},
528		{Output: "LG Electronics 27GR95UM 123", Overflow: "scroll"},
529	}
530	if c.Layout.MaxColumns != 2 || !reflect.DeepEqual(c.Layout.Outputs, want) || len(w) != 2 {
531		t.Fatalf("%+v %v", c.Layout.Outputs, w)
532	}
533}
534
535func TestFixedOutputWarnsSlotWidths(t *testing.T) {
536	_, w := parseString(t, `layout.overflow = scroll
537layout.DP-2.overflow = fixed
538workspace.dev.monitor = DP-2
539workspace.dev.column.1 = 50%, foot
540workspace.web.column.1 = 50%, foot
541`)
542	if len(w) != 3 || w[1].Line != 4 {
543		t.Fatal(w)
544	}
545}
546
547func TestWorkspaceSlots(t *testing.T) {
548	c, w := parseString(t, `layout.overflow = scroll
549workspace.dev.monitor = DP-2
550workspace.dev.column.2 = 1, foot --title x
551workspace.dev.column.1 = 3, code --new-window
552workspace.dev.column.2 = 16, kitty
553workspace.dev.column.0 = 2, foot
554workspace.dev.column.3 = 2
555workspace.dev.column.4 = huge, foot
556workspace.dev.column.5 = 67%, foot
557workspace.dev.column.6 = 1/3, foot
558workspace.dev.column.7 = 800px, foot
559workspace.dev.column.8 = 0, foot
560workspace.dev.column.9 = 17, foot
561bind.cmd+d = workspace dev
562`)
563	want := []ports.SlotConfig{
564		{Index: 1, Cells: 3, Argv: []string{"code", "--new-window"}},
565		{Index: 2, Cells: 16, Argv: []string{"kitty"}},
566	}
567	if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
568		t.Fatalf("%+v", c.Workspaces)
569	}
570	lines := []int{}
571	for _, x := range w {
572		lines = append(lines, x.Line)
573	}
574	if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 9, 10, 11, 12, 13}) {
575		t.Fatal(w)
576	}
577}
578
579// Slot widths are cells in every overflow: nothing is ignored.
580func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) {
581	c, w := parseString(t, `workspace.dev.overflow = fixed
582workspace.dev.column.1 = 2, foot
583bind.cmd+d = workspace dev
584`)
585	if len(w) != 0 || len(c.Workspaces) != 1 || len(c.Workspaces[0].Slots) != 1 || c.Workspaces[0].Slots[0].Cells != 2 {
586		t.Fatal(w, c.Workspaces)
587	}
588}
589
590func TestTerminalResolutionAndAutoOpen(t *testing.T) {
591	t.Setenv("TERMINAL", "ghostty --new-window")
592	c, w := parseString(t, "terminal.auto-open = all\n")
593	if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"ghostty", "--new-window"}) || c.Terminal.AutoOpen != "all" {
594		t.Fatal(c.Terminal, w)
595	}
596	c, w = parseString(t, "terminal = foot --server\nterminal.auto-open = off\n")
597	if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Terminal.AutoOpen != "off" {
598		t.Fatal(c.Terminal, w)
599	}
600	c, w = parseString(t, "terminal.auto-open = invalid\n")
601	if len(w) != 1 || c.Terminal.AutoOpen != "first" {
602		t.Fatal(c.Terminal, w)
603	}
604	t.Setenv("TERMINAL", " ")
605	if got := Defaults().Terminal.Command; !reflect.DeepEqual(got, []string{"foot"}) {
606		t.Fatal(got)
607	}
608}
609
610func TestStash(t *testing.T) {
611	if d := Defaults(); d.Stash.Width != 80 || d.Stash.Gap != 2 || d.Stash.Dim != 0.5 {
612		t.Fatalf("defaults %+v", d.Stash)
613	}
614	for _, tc := range []struct {
615		line    string
616		width   int
617		gap     int
618		dim     float64
619		warning bool
620	}{
621		{"stash.width = 10", 10, 2, 0.5, false}, {"stash.width = 70%", 70, 2, 0.5, false}, {"stash.width = 90", 90, 2, 0.5, false},
622		{"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},
623		{"stash.gap = 0", 80, 0, 0.5, false}, {"stash.gap = 10", 80, 10, 0.5, false},
624		{"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},
625		{"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},
626	} {
627		t.Run(tc.line, func(t *testing.T) {
628			c, warnings := parseString(t, tc.line)
629			if c.Stash.Width != tc.width || c.Stash.Gap != tc.gap || c.Stash.Dim != tc.dim || (len(warnings) != 0) != tc.warning {
630				t.Fatalf("stash %+v, warnings %v", c.Stash, warnings)
631			}
632		})
633	}
634}
635
636func TestStashCapture(t *testing.T) {
637	if c, _ := parseString(t, ""); !c.Stash.Capture {
638		t.Fatal("stash.capture is on by default")
639	}
640	if c, w := parseString(t, "stash.capture = off"); c.Stash.Capture || len(w) != 0 {
641		t.Fatalf("off: %v %v", c.Stash.Capture, w)
642	}
643	if c, w := parseString(t, "stash.capture = yes"); !c.Stash.Capture || len(w) == 0 {
644		t.Fatalf("invalid: %v %v", c.Stash.Capture, w)
645	}
646}
647
648func TestStashSpawnTerminal(t *testing.T) {
649	if c, _ := parseString(t, ""); !c.Stash.SpawnTerminal {
650		t.Fatal("stash.spawn-terminal is on by default")
651	}
652	if c, w := parseString(t, "stash.spawn-terminal = off"); c.Stash.SpawnTerminal || len(w) != 0 {
653		t.Fatalf("off: %v %v", c.Stash.SpawnTerminal, w)
654	}
655	if c, w := parseString(t, "stash.spawn-terminal = on"); !c.Stash.SpawnTerminal || len(w) != 0 {
656		t.Fatalf("on: %v %v", c.Stash.SpawnTerminal, w)
657	}
658	if c, w := parseString(t, "stash.spawn-terminal = yes"); !c.Stash.SpawnTerminal || len(w) == 0 {
659		t.Fatalf("invalid: %v %v", c.Stash.SpawnTerminal, w)
660	}
661}
662
663func TestStashBind(t *testing.T) {
664	c, w := parseString(t, "bind.cmd+shift+f = stash firefox --private-window")
665	if len(w) != 0 || c.Binds["Cmd+Shift+f"] != "stash firefox --private-window" {
666		t.Fatalf("stash bind: %v %v", c.Binds, w)
667	}
668}
669
670func TestVRRFlipGap(t *testing.T) {
671	for _, tc := range []struct {
672		value   string
673		want    time.Duration
674		warning bool
675	}{
676		{"0", 0, false}, {"0ms", 0, false}, {"500us", 500 * time.Microsecond, false}, {"2ms", 2 * time.Millisecond, false}, {"10ms", 10 * time.Millisecond, false},
677		{"11ms", time.Millisecond, true}, {"-1ms", time.Millisecond, true}, {"1", time.Millisecond, true}, {"fast", time.Millisecond, true},
678	} {
679		t.Run(tc.value, func(t *testing.T) {
680			c, warnings := parseString(t, "render.vrr-flip-gap = "+tc.value)
681			if c.Render.VRRFlipGap != tc.want || (len(warnings) != 0) != tc.warning {
682				t.Fatalf("gap %v, warnings %v", c.Render.VRRFlipGap, warnings)
683			}
684		})
685	}
686}
687
688func TestCursorHideAfter(t *testing.T) {
689	if d := Defaults().Cursor.HideAfter; d != 5*time.Second {
690		t.Fatalf("default %v", d)
691	}
692	for _, tc := range []struct {
693		value   string
694		want    time.Duration
695		warning bool
696	}{
697		{"off", 0, false}, {"100ms", 100 * time.Millisecond, false}, {"2s", 2 * time.Second, false}, {"1h", time.Hour, false},
698		{"0", 5 * time.Second, true}, {"50ms", 5 * time.Second, true}, {"2h", 5 * time.Second, true}, {"on", 5 * time.Second, true},
699	} {
700		t.Run(tc.value, func(t *testing.T) {
701			c, warnings := parseString(t, "cursor.hide-after = "+tc.value)
702			if c.Cursor.HideAfter != tc.want || (len(warnings) != 0) != tc.warning {
703				t.Fatalf("hide-after %v, warnings %v", c.Cursor.HideAfter, warnings)
704			}
705		})
706	}
707}
708
709func TestFloatingDim(t *testing.T) {
710	for _, tc := range []struct {
711		value   string
712		want    float64
713		warning bool
714	}{
715		{"0", 0, false}, {"0.75", 0.75, false}, {"1", 1, false},
716		{"-0.1", 0.3, true}, {"1.1", 0.3, true}, {"NaN", 0.3, true}, {"invalid", 0.3, true},
717	} {
718		t.Run(tc.value, func(t *testing.T) {
719			c, warnings := parseString(t, "floating.dim = "+tc.value)
720			if c.Floating.Dim != tc.want || (len(warnings) != 0) != tc.warning {
721				t.Fatalf("dim %v, warnings %v", c.Floating.Dim, warnings)
722			}
723		})
724	}
725}
726
727func TestOutputRelations(t *testing.T) {
728	c, w := parseString(t, `
729output.DP-2.right-of = DP-1
730output.DP-2.offset = 180
731output.DP-3.left-of = DP-1
732output.DP-3.offset = -40
733output.DP-4.above = DP-1
734output.DP-5.below = DP-1
735output.DP-5.offset = 7
736`)
737	if len(w) != 0 {
738		t.Fatal(w)
739	}
740	want := map[string]ports.OutputAnchor{
741		"DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
742		"DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
743		"DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
744		"DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
745	}
746	if len(c.Outputs) != len(want) {
747		t.Fatalf("%+v", c.Outputs)
748	}
749	for _, o := range c.Outputs {
750		if o.Anchor != want[o.Name] || !o.ScaleOnly {
751			t.Fatalf("%s: %+v", o.Name, o)
752		}
753	}
754}
755
756func TestOutputTransform(t *testing.T) {
757	names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
758	var text strings.Builder
759	for i, n := range names {
760		fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
761	}
762	c, w := parseString(t, text.String())
763	if len(w) != 0 || len(c.Outputs) != len(names) {
764		t.Fatalf("%v %+v", w, c.Outputs)
765	}
766	for i, o := range c.Outputs {
767		if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
768			t.Fatalf("%s: %+v", names[i], o)
769		}
770	}
771
772	c, w = parseString(t, "output.DP-1.transform = 45\n")
773	if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
774		t.Fatalf("warnings %v", w)
775	}
776	if len(c.Outputs) != 0 {
777		t.Fatalf("%+v", c.Outputs)
778	}
779
780	// A later mode line keeps the transform.
781	c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
782	if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
783		t.Fatalf("%v %+v", w, c.Outputs)
784	}
785}
786
787func TestOutputRelationOverride(t *testing.T) {
788	c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
789	if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
790		t.Fatalf("warnings %v", w)
791	}
792	if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
793		t.Fatalf("%+v", got)
794	}
795}
796
797func TestOutputRelationWarnings(t *testing.T) {
798	const rangeMsg = "must be an integer between -65536 and 65536"
799	rel := "output.DP-2.right-of = DP-1\n"
800	cases := []struct {
801		name, text string
802		want       []string // expected warning messages (substring), in order
803		lines      []int    // expected warning lines, in order
804		anchored   []string // outputs that keep a relation
805	}{
806		{"self", "output.DP-2.right-of = DP-2", []string{"relative to itself"}, []int{1}, nil},
807		{"empty", "output.DP-2.above =", []string{"expected an output name"}, []int{1}, nil},
808		{"bad offset", rel + "output.DP-2.offset = abc", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
809		{"offset too large", rel + "output.DP-2.offset = 65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
810		{"offset too small", rel + "output.DP-2.offset = -65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
811		{"offset overflow", rel + "output.DP-2.offset = 99999999999999999999", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
812		{"offset alone", "output.DP-2.offset = 10", []string{"without a relation"}, []int{1}, nil},
813		{"two cycle", "output.A.right-of = B\noutput.B.below = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, nil},
814		{"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},
815		{"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"}},
816		{"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"}},
817		{"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"}},
818		{"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"}},
819		{"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"}},
820		{"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"}},
821		{"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"}},
822		{"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
823	}
824	for _, tc := range cases {
825		t.Run(tc.name, func(t *testing.T) {
826			c, w := parseString(t, tc.text)
827			if len(w) != len(tc.want) {
828				t.Fatalf("warnings %v, want %q", w, tc.want)
829			}
830			for i, x := range w {
831				if !strings.Contains(x.Msg, tc.want[i]) || x.Line != tc.lines[i] {
832					t.Fatalf("warning %d = %+v, want %q at line %d (all: %v)", i, x, tc.want[i], tc.lines[i], w)
833				}
834			}
835			var got []string
836			for _, o := range c.Outputs {
837				if o.Anchor.Relation != ports.RelationNone {
838					got = append(got, o.Name)
839				}
840			}
841			if !reflect.DeepEqual(got, tc.anchored) {
842				t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
843			}
844		})
845	}
846}
847
848func TestDefaultMonitorBinds(t *testing.T) {
849	d := Defaults()
850	for combo, action := range map[string]string{
851		"Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up",
852		"Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down",
853		"Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right",
854	} {
855		if d.Binds[combo] != action {
856			t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action)
857		}
858	}
859	// The up/down move-workspace-to-monitor actions have no default bind but
860	// load from a config file.
861	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")
862	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" {
863		t.Fatalf("%v %v", w, c.Binds)
864	}
865}
866
867func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) {
868	c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n")
869	if got := c.Outputs[0].Anchor.Offset; got != 40 {
870		t.Fatalf("offset %d, want 40", got)
871	}
872	c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 65536\noutput.DP-2.offset = -65536\n")
873	if len(w) != 1 || c.Outputs[0].Anchor.Offset != -65536 {
874		t.Fatalf("%v %+v", w, c.Outputs)
875	}
876}
877
878func TestOutputModeLineKeepsAnchor(t *testing.T) {
879	c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
880	if len(w) != 0 {
881		t.Fatal(w)
882	}
883	o := c.Outputs[0]
884	if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
885		t.Fatalf("%+v", o)
886	}
887}
888
889func TestFileSyntax(t *testing.T) {
890	for _, tc := range []struct {
891		name, in string
892		check    func(t *testing.T, c ports.Config)
893		warnings []string
894	}{
895		{
896			name: "invalid UTF-8 costs a warning, not the file",
897			in:   "# caf\xe9\nborder.width = 5\n",
898			check: func(t *testing.T, c ports.Config) {
899				if c.Border.Width != 5 {
900					t.Fatalf("border.width = %d", c.Border.Width)
901				}
902			},
903			warnings: []string{"line 1: not valid UTF-8; invalid bytes replaced"},
904		},
905		{
906			name: "output name with spaces",
907			in:   "output.LG Electronics 27GR95UM.scale = 1.5\n",
908			check: func(t *testing.T, c ports.Config) {
909				if len(c.Outputs) != 1 || c.Outputs[0].Name != "LG Electronics 27GR95UM" || c.Outputs[0].Scale != 1.5 {
910					t.Fatalf("%+v", c.Outputs)
911				}
912			},
913		},
914		{
915			// A '#' inside a quoted word is kept, as a shell would.
916			name: "comment after a quoted word",
917			in:   "startup = notify-send \"a # b\" # note\n",
918			check: func(t *testing.T, c ports.Config) {
919				if !reflect.DeepEqual(c.Startup, [][]string{{"notify-send", `"a`, "#", `b"`}}) {
920					t.Fatalf("startup: %q", c.Startup)
921				}
922			},
923		},
924	} {
925		t.Run(tc.name, func(t *testing.T) {
926			c, w := parseString(t, tc.in)
927			got := make([]string, 0, len(w))
928			for _, x := range w {
929				got = append(got, x.String())
930			}
931			if len(got) != len(tc.warnings) || (len(got) > 0 && !reflect.DeepEqual(got, tc.warnings)) {
932				t.Fatalf("warnings %q, want %q", got, tc.warnings)
933			}
934			tc.check(t, c)
935		})
936	}
937}
938
939// A FIFO in place of the config fails at once instead of blocking startup.
940func TestLoadRejectsFIFO(t *testing.T) {
941	path := filepath.Join(t.TempDir(), "config")
942	if err := syscall.Mkfifo(path, 0o600); err != nil {
943		t.Skip(err)
944	}
945	done := make(chan error, 1)
946	go func() { _, _, err := Load(path); done <- err }()
947	select {
948	case err := <-done:
949		if err == nil {
950			t.Fatal("FIFO loaded")
951		}
952	case <-time.After(5 * time.Second):
953		t.Fatal("Load blocked on a FIFO")
954	}
955}
956
957func TestHintTargets(t *testing.T) {
958	if Defaults().Hint.LinksOnly {
959		t.Fatal("hint targets default must be clickable")
960	}
961	c, w := parseString(t, "hint.targets = links\n")
962	if !c.Hint.LinksOnly || len(w) != 0 {
963		t.Fatal(c.Hint, w)
964	}
965	c, w = parseString(t, "hint.targets = clickable\n")
966	if c.Hint.LinksOnly || len(w) != 0 {
967		t.Fatal(c.Hint, w)
968	}
969	// An invalid value warns and keeps the default.
970	c, w = parseString(t, "hint.targets = buttons\n")
971	if c.Hint.LinksOnly || len(w) != 1 {
972		t.Fatal(c.Hint, w)
973	}
974	c, w = parseString(t, "bind.cmd+g = hint\n")
975	if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
976		t.Fatal(c.Binds["Cmd+g"], w)
977	}
978}
979
980func TestHintStyleConfig(t *testing.T) {
981	c, warnings := parseString(t, "hint.size = 17.5\nhint.background = #89b4fa\nhint.foreground = #111111\n")
982	if len(warnings) != 0 || c.Hint.Size != 17.5 || c.Hint.Background != "#89b4fa" || c.Hint.Foreground != "#111111" {
983		t.Fatalf("style: %+v, warnings: %v", c.Hint, warnings)
984	}
985	c, warnings = parseString(t, "hint.size = auto\nhint.background =\nhint.foreground =\n")
986	if len(warnings) != 0 || c.Hint.Size != 0 || c.Hint.Background != "" || c.Hint.Foreground != "" {
987		t.Fatalf("automatic: %+v %v", c.Hint, warnings)
988	}
989	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"} {
990		_, warnings := parseString(t, line+"\n")
991		if len(warnings) == 0 {
992			t.Errorf("expected validate-config warning for %s", line)
993		}
994	}
995}
996
997func TestHintCapture(t *testing.T) {
998	if Defaults().Hint.Capture {
999		t.Fatal("hint.capture defaults to off")
1000	}
1001	c, w := parseString(t, "hint.capture = on\n")
1002	if !c.Hint.Capture || len(w) != 0 {
1003		t.Fatal(c.Hint, w)
1004	}
1005	c, w = parseString(t, "hint.capture = off\n")
1006	if c.Hint.Capture || len(w) != 0 {
1007		t.Fatal(c.Hint, w)
1008	}
1009	c, w = parseString(t, "hint.capture = maybe\n")
1010	if c.Hint.Capture || len(w) != 1 {
1011		t.Fatal(c.Hint, w)
1012	}
1013}
1014
1015func TestHintSizeBounds(t *testing.T) {
1016	for _, v := range []string{"6", "72", "13"} {
1017		c, w := parseString(t, "hint.size = "+v+"\n")
1018		if len(w) != 0 || c.Hint.Size == 0 {
1019			t.Errorf("hint.size = %s: %+v %v", v, c.Hint, w)
1020		}
1021	}
1022}
1023
1024func TestHintTextClick(t *testing.T) {
1025	if got := Defaults().Hint.TextClick; got != ports.ModCtrl|ports.ModShift {
1026		t.Fatalf("default %d", got)
1027	}
1028	for in, want := range map[string]ports.Mods{
1029		"ctrl+shift":     ports.ModCtrl | ports.ModShift,
1030		"Alt":            ports.ModAlt,
1031		"super":          ports.ModSuper,
1032		"cmd+shift":      ports.ModSuper | ports.ModShift,
1033		"none":           0,
1034		"shift+ctrl+alt": ports.ModShift | ports.ModCtrl | ports.ModAlt,
1035	} {
1036		c, w := parseString(t, "hint.text-click = "+in+"\n")
1037		if c.Hint.TextClick != want || len(w) != 0 {
1038			t.Errorf("%q: %d %v", in, c.Hint.TextClick, w)
1039		}
1040	}
1041	for _, bad := range []string{"hyper", "ctrl+", "ctrl+ctrl", "ctrl+none", ""} {
1042		c, w := parseString(t, "hint.text-click = "+bad+"\n")
1043		if len(w) != 1 || c.Hint.TextClick != ports.ModCtrl|ports.ModShift {
1044			t.Errorf("%q: %d %v", bad, c.Hint.TextClick, w)
1045		}
1046	}
1047}
1048
1049func TestHintEnable(t *testing.T) {
1050	if Defaults().Hint.Enable {
1051		t.Fatal("hint mode must be off by default")
1052	}
1053	c, w := parseString(t, "hint.enable = on\n")
1054	if !c.Hint.Enable || len(w) != 0 {
1055		t.Fatal(c.Hint, w)
1056	}
1057	c, w = parseString(t, "hint.enable = off\n")
1058	if c.Hint.Enable || len(w) != 0 {
1059		t.Fatal(c.Hint, w)
1060	}
1061	c, w = parseString(t, "hint.enable = maybe\n")
1062	if c.Hint.Enable || len(w) != 1 {
1063		t.Fatal(c.Hint, w)
1064	}
1065}
1066
1067func TestPositionHint(t *testing.T) {
1068	if !Defaults().Focus.PositionHint {
1069		t.Fatal("position hint default off")
1070	}
1071	for _, value := range []string{"on", "off", "invalid"} {
1072		c, warnings := parseString(t, "focus.position-hint = "+value+"\n")
1073		if value == "invalid" {
1074			if len(warnings) == 0 {
1075				t.Fatal("accepted invalid value")
1076			}
1077			continue
1078		}
1079		if len(warnings) != 0 || c.Focus.PositionHint != (value == "on") {
1080			t.Fatalf("%s: %+v %v", value, c.Focus, warnings)
1081		}
1082	}
1083}
1084
1085func TestBorderRadius(t *testing.T) {
1086	if Defaults().Border.Radius != 0 {
1087		t.Fatal("nonzero default")
1088	}
1089	for _, v := range []string{"0", "10", "64"} {
1090		c, w := parseString(t, "border.radius = "+v+"\n")
1091		if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v {
1092			t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w)
1093		}
1094	}
1095	for _, v := range []string{"-1", "bad", "65"} {
1096		_, w := parseString(t, "border.radius = "+v+"\n")
1097		if len(w) == 0 {
1098			t.Fatalf("accepted %s", v)
1099		}
1100	}
1101}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close