v0.2.0 13c6a90

config_test.go RawHistoryFolder

1package config
2
3import (
4	"os"
5	"path/filepath"
6	"reflect"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/bnema/neferwl/internal/ports"
12)
13
14func parseString(t *testing.T, s string) (ports.Config, []Warning) {
15	t.Helper()
16	c, w, err := Parse(strings.NewReader(s))
17	if err != nil {
18		t.Fatal(err)
19	}
20	return c, w
21}
22
23func TestDefaultsAndLoad(t *testing.T) {
24	t.Setenv("TERMINAL", "")
25	d := Defaults()
26	if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 73 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
27		t.Fatalf("defaults: %+v", d)
28	}
29	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 {
30		t.Fatal(d.Binds)
31	}
32	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-down" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-up" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
33		t.Fatal(d.Binds)
34	}
35	if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
36		t.Fatal(d.Binds)
37	}
38	if _, _, err := Load(filepath.Join(t.TempDir(), "missing")); !os.IsNotExist(err) {
39		t.Fatalf("explicit missing: %v", err)
40	}
41	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
42	if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "neferwl", "config") {
43		t.Fatal(DefaultPath())
44	}
45	got, w, err := LoadDefault()
46	if err != nil || len(w) != 0 || !reflect.DeepEqual(got, d) {
47		t.Fatalf("missing: %v %v %+v", err, w, got)
48	}
49	example, w, err := Load("../../../examples/config")
50	if err != nil || len(w) != 0 || !reflect.DeepEqual(example, d) {
51		t.Fatalf("example: %v %v\n%+v\n%+v", err, w, example, d)
52	}
53}
54
55func TestRealtime(t *testing.T) {
56	if !Defaults().Performance.Realtime {
57		t.Fatal("realtime default disabled")
58	}
59	c, w := parseString(t, "performance.realtime = off\n")
60	if c.Performance.Realtime || len(w) != 0 {
61		t.Fatal(c.Performance, w)
62	}
63	c, w = parseString(t, "performance.realtime = invalid\n")
64	if !c.Performance.Realtime || len(w) != 1 {
65		t.Fatal(c.Performance, w)
66	}
67}
68
69func TestFollowMove(t *testing.T) {
70	c, w := parseString(t, "focus.follow-move = on\n")
71	if !c.Focus.FollowMove || len(w) != 0 {
72		t.Fatal(c.Focus, w)
73	}
74	c, w = parseString(t, "focus.follow-move = yes\n")
75	if c.Focus.FollowMove || len(w) != 1 {
76		t.Fatal(c.Focus, w)
77	}
78}
79
80func TestFocusIndicator(t *testing.T) {
81	d := Defaults().Focus
82	if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
83		t.Fatal("focus indicator defaults", d)
84	}
85	c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")
86	if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {
87		t.Fatal(c.Focus, w)
88	}
89	// Each rejected value warns and keeps its default.
90	for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {
91		c, w = parseString(t, line+"\n")
92		if c.Focus != d || len(w) != 1 {
93			t.Fatal(line, c.Focus, w)
94		}
95	}
96}
97
98func TestTouchpadNaturalScroll(t *testing.T) {
99	if Defaults().Touchpad.NaturalScroll {
100		t.Fatal("natural scroll on by default")
101	}
102	c, w := parseString(t, "touchpad.natural-scroll = on\n")
103	if !c.Touchpad.NaturalScroll || len(w) != 0 {
104		t.Fatal(c.Touchpad, w)
105	}
106	c, w = parseString(t, "touchpad.natural-scroll = maybe\n")
107	if c.Touchpad.NaturalScroll || len(w) != 1 {
108		t.Fatal(c.Touchpad, w)
109	}
110}
111
112func TestTouchpadTap(t *testing.T) {
113	if !Defaults().Touchpad.Tap {
114		t.Fatal("tap to click off by default")
115	}
116	c, w := parseString(t, "touchpad.tap = off\n")
117	if c.Touchpad.Tap || len(w) != 0 {
118		t.Fatal(c.Touchpad, w)
119	}
120	c, w = parseString(t, "touchpad.tap = sometimes\n")
121	if !c.Touchpad.Tap || len(w) != 1 {
122		t.Fatal(c.Touchpad, w)
123	}
124}
125
126func TestTouchpadSpeed(t *testing.T) {
127	d := Defaults().Touchpad
128	if d.AccelSpeed != 0 || d.AccelProfile != ports.AccelAdaptive || d.ScrollFactor != 1 {
129		t.Fatal(d)
130	}
131	c, w := parseString(t, "touchpad.accel-speed = -0.5\ntouchpad.accel-profile = flat\ntouchpad.scroll-factor = 0.5\n")
132	if c.Touchpad.AccelSpeed != -0.5 || c.Touchpad.AccelProfile != ports.AccelFlat || c.Touchpad.ScrollFactor != 0.5 || len(w) != 0 {
133		t.Fatal(c.Touchpad, w)
134	}
135	for _, bad := range []string{
136		"touchpad.accel-speed = 2",
137		"touchpad.accel-speed = NaN",
138		"touchpad.accel-profile = fast",
139		"touchpad.scroll-factor = 0",
140		"touchpad.scroll-factor = 11",
141	} {
142		c, w := parseString(t, bad+"\n")
143		if c.Touchpad != d || len(w) != 1 {
144			t.Errorf("%s: %+v %v", bad, c.Touchpad, w)
145		}
146	}
147}
148
149func TestParse(t *testing.T) {
150	c, w := parseString(t, `
151# comment
152keyboard.layout = fr
153keyboard.repeat-rate = 40   # inline comment
154terminal = foot --server
155background = #000000
156border.inactive =
157layout.presets = 1/3, 1/2 ,1
158log.debug = core, input
159output.DP-2.scale = 4/3
160output.DP-2 = 5120x2160@165.058
161output.HDMI-A-1 = off
162output.DP-1 = preferred
163output.DP-1.scale = 1.5
164output.DP-1.primary = on
165bind.cmd+code:30 = quit
166render.direct-scanout = off
167render.tearing = off
168render.vrr = off
169startup = wl-paste --watch cliphist store
170startup = wl-paste --primary --watch cliphist store
171`)
172	if len(w) != 0 {
173		t.Fatal(w)
174	}
175	if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}}) {
176		t.Fatalf("startup: %q", c.Startup)
177	}
178	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 {
179		t.Fatalf("%+v", c)
180	}
181	if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
182		t.Fatalf("%+v", c)
183	}
184	// Scale and mode lines combine in either order.
185	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 {
186		t.Fatalf("%+v", c.Outputs)
187	}
188	if c.Binds["Cmd+code:30"] != "quit" {
189		t.Fatal(c.Binds)
190	}
191}
192
193func TestWarningsKeepDefaults(t *testing.T) {
194	cases := []struct{ line, want string }{
195		{"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
196		{"keyboard.cmd = meta", "keyboard.cmd"},
197		{"terminal =", "terminal"},
198		{"xwayland = a b", "xwayland"},
199		{"background = red", "background"},
200		{"layout.gaps = 201", "layout.gaps"},
201		{"layout.max-columns = 0", "layout.max-columns"},
202		{"bind.cmd+x = focus-workspace 0", "focus-workspace"},
203		{"layout.presets = 0px", "layout.presets"},
204		{"log.level = trace", "log.level"},
205		{"log.debug = nope", "log.debug"},
206		{"output.DP-2 = big", "output.DP-2"},
207		{"output.DP-2 = 1920x1080@0", "output.DP-2"},
208		{"output.DP-2.scale = 0.5", "between 1 and 4"},
209		{"output.DP-2.primary = yes", "output.DP-2.primary"},
210		{"output.DP-2.scale = 3/0", "between 1 and 4"},
211		{"bind.cmd+code:x = quit", "evdev key code"},
212		{"nope = 1", "nope"},
213		{"just text", "expected key = value"},
214		{"bind.cmd+ = quit", "missing key"},
215		{"bind.hyper+x = quit", "unknown modifier"},
216		{"bind.cmd+x = bogus", "unknown action"},
217		{"bind.cmd+x = set-column-width 10%", "set-column-width needs"},
218		{"bind.cmd+x = set-column-width +0%", "set-column-width needs"},
219		{"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
220		{"bind.cmd+x = set-window-height +5", "set-window-height needs"},
221		{"bind.cmd+x = spawn   ", "unknown action"},
222	}
223	for _, tc := range cases {
224		t.Run(tc.line, func(t *testing.T) {
225			c, w := parseString(t, tc.line)
226			if len(w) != 1 || w[0].Line != 1 || !strings.Contains(w[0].Msg, tc.want) {
227				t.Fatalf("warnings %v, want %q", w, tc.want)
228			}
229			if !reflect.DeepEqual(c, Defaults()) {
230				t.Fatalf("config changed: %+v", c)
231			}
232		})
233	}
234}
235
236func TestXwayland(t *testing.T) {
237	if Defaults().Xwayland != "xwayland-satellite" {
238		t.Fatal(Defaults().Xwayland)
239	}
240	if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
241		t.Fatal(c.Xwayland, w)
242	}
243	if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
244		t.Fatal(c.Xwayland, w)
245	}
246}
247
248func TestDuplicateKeyLastWins(t *testing.T) {
249	c, w := parseString(t, "background = #000000\nbackground = #111111\n")
250	if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
251		t.Fatal(c.Background, w)
252	}
253}
254
255func TestBinds(t *testing.T) {
256	c, w := parseString(t, `
257bind.CTRL+Cmd+E = spawn foo --bar
258bind.cmd+; = close-window
259bind.cmd+é = quit
260bind.cmd+semicolon = quit
261bind.cmd+equal = spawn-terminal
262bind.cmd+# = none
263bind.cmd++ = quit
264bind.alt+f5 = quit
265bind.cmd+return = none
266bind.cmd+w = focus-workspace 12
267`)
268	want := map[string]string{
269		"Cmd+Ctrl+e":    "spawn foo --bar",
270		"Cmd+semicolon": "quit",
271		"Cmd+eacute":    "quit",
272		"Cmd+equal":     "spawn-terminal",
273		"Cmd+plus":      "quit",
274		"Alt+F5":        "quit",
275	}
276	for combo, action := range want {
277		if c.Binds[combo] != action {
278			t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action)
279		}
280	}
281	if _, ok := c.Binds["Cmd+Return"]; ok {
282		t.Error("none did not remove the default")
283	}
284	if _, ok := c.Binds["Cmd+numbersign"]; ok {
285		t.Error("none kept")
286	}
287	// cmd+; then cmd+semicolon is the same key: warn and keep the last.
288	if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") {
289		t.Fatal(w)
290	}
291}
292
293func TestBindReservedCharacter(t *testing.T) {
294	_, w := parseString(t, "bind.cmd+= = quit\n")
295	if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") {
296		t.Fatal(w)
297	}
298}
299
300func TestBindResolvedCollision(t *testing.T) {
301	// The later line wins, like any duplicate key.
302	c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
303	if c.Binds["Cmd+x"] != "close-window" {
304		t.Fatal(c.Binds)
305	}
306	if _, ok := c.Binds["Ctrl+x"]; ok {
307		t.Fatal(c.Binds)
308	}
309	if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
310		t.Fatal(c.Binds)
311	}
312	if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
313		t.Fatal(w)
314	}
315	// A user bind replaces a default with the same resolved keys, without a warning.
316	c, w = parseString(t, "bind.super+q = quit\n")
317	if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
318		t.Fatal(c.Binds, w)
319	}
320}
321
322func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
323	c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
324	if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
325		t.Fatal(c.Background, w)
326	}
327}
328
329func TestBindCharacters(t *testing.T) {
330	c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
331	if c.Binds["Cmd+eacute"] != "quit" {
332		t.Fatal(c.Binds)
333	}
334	if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
335		t.Fatal(w)
336	}
337}
338
339func TestParseMode(t *testing.T) {
340	w, h, hz, err := ParseMode("3440x1440")
341	if err != nil || w != 3440 || h != 1440 || hz != 0 {
342		t.Fatalf("%d %d %v %v", w, h, hz, err)
343	}
344	if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
345		t.Fatal(hz, err)
346	}
347}
348
349func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
350	c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n")
351	if len(w) != 0 {
352		t.Fatal(w)
353	}
354	if _, ok := c.Binds["Cmd+code:2"]; ok {
355		t.Fatal("default cmd+code:2 kept")
356	}
357	if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" {
358		t.Fatal(c.Binds)
359	}
360	if c.Binds["Cmd+code:4"] != "focus-workspace 3" {
361		t.Fatal("other defaults must stay")
362	}
363}
364
365func TestWorkspaces(t *testing.T) {
366	c, w := parseString(t, `layout.overflow = fixed
367workspace.dev.hidden = on
368workspace.dev.max-columns = 3
369workspace.web.overflow = scroll
370workspace.dev.monitor = DP-2
371workspace.bad name.hidden = on
372workspace.dev.color = red
373workspace.web.overflow = spiral
374bind.cmd+d = workspace dev
375bind.cmd+w = workspace
376bind.cmd+x = workspace typo
377workspace.lost.monitor = DP-1
378`)
379	want := []ports.WorkspaceConfig{
380		{Name: "dev", Monitor: "DP-2", MaxColumns: 3},
381		{Name: "web", Overflow: "scroll"},
382		{Name: "lost", Monitor: "DP-1"},
383	}
384	if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
385		t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
386	}
387	if c.Binds["Cmd+d"] != "workspace dev" {
388		t.Fatal(c.Binds["Cmd+d"])
389	}
390	lines := []int{}
391	for _, x := range w {
392		lines = append(lines, x.Line)
393	}
394	if !reflect.DeepEqual(lines, []int{2, 4, 6, 7, 8, 10, 11, 12}) {
395		t.Fatal(w)
396	}
397}
398
399func TestLayoutPerOutput(t *testing.T) {
400	c, w := parseString(t, `layout.max-columns = 2
401layout.HDMI-A-1.max-columns = 3
402layout.HDMI-A-1.overflow = fixed
403layout.LG Electronics 27GR95UM 123.overflow = scroll
404layout.DP-2.overflow = spiral
405layout.DP-2.color = red
406`)
407	want := []ports.OutputLayout{
408		{Output: "HDMI-A-1", MaxColumns: 3, Overflow: "fixed"},
409		{Output: "LG Electronics 27GR95UM 123", Overflow: "scroll"},
410	}
411	if c.Layout.MaxColumns != 2 || !reflect.DeepEqual(c.Layout.Outputs, want) || len(w) != 2 {
412		t.Fatalf("%+v %v", c.Layout.Outputs, w)
413	}
414}
415
416func TestFixedOutputWarnsSlotWidths(t *testing.T) {
417	_, w := parseString(t, `layout.DP-2.overflow = fixed
418workspace.dev.monitor = DP-2
419workspace.dev.column.1 = 50%, foot
420workspace.web.column.1 = 50%, foot
421`)
422	if len(w) != 3 || w[1].Line != 3 {
423		t.Fatal(w)
424	}
425}
426
427func TestWorkspaceSlots(t *testing.T) {
428	c, w := parseString(t, `workspace.dev.monitor = DP-2
429workspace.dev.column.2 = 33%, foot --title x
430workspace.dev.column.1 = 67%, code --new-window
431workspace.dev.column.2 = 1/3, kitty
432workspace.dev.column.0 = 50%, foot
433workspace.dev.column.3 = 50%
434workspace.dev.column.4 = huge, foot
435bind.cmd+d = workspace dev
436`)
437	want := []ports.SlotConfig{
438		{Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},
439		{Index: 2, Width: "1/3", Argv: []string{"kitty"}},
440	}
441	if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
442		t.Fatalf("%+v", c.Workspaces)
443	}
444	lines := []int{}
445	for _, x := range w {
446		lines = append(lines, x.Line)
447	}
448	if !reflect.DeepEqual(lines, []int{4, 5, 6, 7}) {
449		t.Fatal(w)
450	}
451}
452
453func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
454	_, w := parseString(t, `workspace.dev.overflow = fixed
455workspace.dev.column.1 = 67%, foot
456`)
457	if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {
458		t.Fatal(w)
459	}
460}
461
462func TestTerminalResolutionAndAutoOpen(t *testing.T) {
463	t.Setenv("TERMINAL", "ghostty --new-window")
464	c, w := parseString(t, "terminal.auto-open = all\n")
465	if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"ghostty", "--new-window"}) || c.Terminal.AutoOpen != "all" {
466		t.Fatal(c.Terminal, w)
467	}
468	c, w = parseString(t, "terminal = foot --server\nterminal.auto-open = off\n")
469	if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Terminal.AutoOpen != "off" {
470		t.Fatal(c.Terminal, w)
471	}
472	c, w = parseString(t, "terminal.auto-open = invalid\n")
473	if len(w) != 1 || c.Terminal.AutoOpen != "first" {
474		t.Fatal(c.Terminal, w)
475	}
476	t.Setenv("TERMINAL", " ")
477	if got := Defaults().Terminal.Command; !reflect.DeepEqual(got, []string{"foot"}) {
478		t.Fatal(got)
479	}
480}
481
482func TestStash(t *testing.T) {
483	if d := Defaults(); d.Stash.Width != 80 || d.Stash.Gap != 2 || d.Stash.Dim != 0.5 {
484		t.Fatalf("defaults %+v", d.Stash)
485	}
486	for _, tc := range []struct {
487		line    string
488		width   int
489		gap     int
490		dim     float64
491		warning bool
492	}{
493		{"stash.width = 10", 10, 2, 0.5, false}, {"stash.width = 70%", 70, 2, 0.5, false}, {"stash.width = 90", 90, 2, 0.5, false},
494		{"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},
495		{"stash.gap = 0", 80, 0, 0.5, false}, {"stash.gap = 10", 80, 10, 0.5, false},
496		{"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},
497		{"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},
498	} {
499		t.Run(tc.line, func(t *testing.T) {
500			c, warnings := parseString(t, tc.line)
501			if c.Stash.Width != tc.width || c.Stash.Gap != tc.gap || c.Stash.Dim != tc.dim || (len(warnings) != 0) != tc.warning {
502				t.Fatalf("stash %+v, warnings %v", c.Stash, warnings)
503			}
504		})
505	}
506}
507
508func TestStashCapture(t *testing.T) {
509	if c, _ := parseString(t, ""); !c.Stash.Capture {
510		t.Fatal("stash.capture is on by default")
511	}
512	if c, w := parseString(t, "stash.capture = off"); c.Stash.Capture || len(w) != 0 {
513		t.Fatalf("off: %v %v", c.Stash.Capture, w)
514	}
515	if c, w := parseString(t, "stash.capture = yes"); !c.Stash.Capture || len(w) == 0 {
516		t.Fatalf("invalid: %v %v", c.Stash.Capture, w)
517	}
518}
519
520func TestVRRFlipGap(t *testing.T) {
521	for _, tc := range []struct {
522		value   string
523		want    time.Duration
524		warning bool
525	}{
526		{"0", 0, false}, {"0ms", 0, false}, {"500us", 500 * time.Microsecond, false}, {"2ms", 2 * time.Millisecond, false}, {"10ms", 10 * time.Millisecond, false},
527		{"11ms", time.Millisecond, true}, {"-1ms", time.Millisecond, true}, {"1", time.Millisecond, true}, {"fast", time.Millisecond, true},
528	} {
529		t.Run(tc.value, func(t *testing.T) {
530			c, warnings := parseString(t, "render.vrr-flip-gap = "+tc.value)
531			if c.Render.VRRFlipGap != tc.want || (len(warnings) != 0) != tc.warning {
532				t.Fatalf("gap %v, warnings %v", c.Render.VRRFlipGap, warnings)
533			}
534		})
535	}
536}
537
538func TestFloatingDim(t *testing.T) {
539	for _, tc := range []struct {
540		value   string
541		want    float64
542		warning bool
543	}{
544		{"0", 0, false}, {"0.75", 0.75, false}, {"1", 1, false},
545		{"-0.1", 0.3, true}, {"1.1", 0.3, true}, {"NaN", 0.3, true}, {"invalid", 0.3, true},
546	} {
547		t.Run(tc.value, func(t *testing.T) {
548			c, warnings := parseString(t, "floating.dim = "+tc.value)
549			if c.Floating.Dim != tc.want || (len(warnings) != 0) != tc.warning {
550				t.Fatalf("dim %v, warnings %v", c.Floating.Dim, warnings)
551			}
552		})
553	}
554}

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