config_test.go RawHistoryFolder

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

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 26

Tags 10

↑↓ moveenter openesc close