config.go RawHistoryFolder

1// Package config reads the flat `key = value` config file (ADR 012).
2package config
3
4import (
5	"bufio"
6	"fmt"
7	"io"
8	"math"
9	"os"
10	"path/filepath"
11	"regexp"
12	"slices"
13	"sort"
14	"strconv"
15	"strings"
16	"time"
17	"unicode"
18
19	"github.com/bnema/neferwl/internal/ports"
20)
21
22// maxVRRFlipGap bounds render.vrr-flip-gap below one refresh of a slow
23// VRR panel (48 Hz: 20.8 ms).
24const maxVRRFlipGap = 10 * time.Millisecond
25
26// maxCursorHideAfter bounds cursor.hide-after.
27const maxCursorHideAfter = time.Hour
28
29// defaultBinds use the config syntax and go through the same parser as user binds.
30var defaultBinds = []struct{ combo, action string }{
31	{"cmd+return", "spawn-terminal"},
32	{"cmd+left", "focus-column-left"},
33	{"cmd+right", "focus-column-right"},
34	{"cmd+up", "focus-window-up"},
35	{"cmd+down", "focus-window-down"},
36	{"cmd+shift+left", "move-column-left"},
37	{"cmd+shift+right", "move-column-right"},
38	{"cmd+bracketleft", "consume-or-expel-window-left"},
39	{"cmd+bracketright", "consume-or-expel-window-right"},
40	{"cmd+r", "cycle-column-width"},
41	{"cmd+f", "maximize-column"},
42	{"cmd+shift+f", "toggle-fullscreen"},
43	{"cmd+s", "toggle-stash-visible"},
44	{"cmd+shift+s", "toggle-window-stash"},
45	{"cmd+o", "toggle-overview"},
46	{"cmd+q", "close-window"},
47	{"ctrl+alt+backspace", "quit"},
48	{"ctrl+cmd+space", "spawn fuzzel"},
49	{"cmd+pageup", "focus-workspace-up"},
50	{"cmd+pagedown", "focus-workspace-down"},
51	{"cmd+shift+pageup", "move-column-to-workspace-up"},
52	{"cmd+shift+pagedown", "move-column-to-workspace-down"},
53	{"cmd+ctrl+left", "focus-monitor-left"},
54	{"cmd+ctrl+right", "focus-monitor-right"},
55	{"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
56	{"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
57	{"cmd+shift+up", "move-window-up"},
58	{"cmd+shift+down", "move-window-down"},
59	{"cmd+alt+left", "set-column-width -10%"},
60	{"cmd+alt+right", "set-column-width +10%"},
61	{"cmd+alt+up", "set-window-height -10%"},
62	{"cmd+alt+down", "set-window-height +10%"},
63	{"cmd+ctrl+shift+up", "move-workspace-up"},
64	{"cmd+ctrl+shift+down", "move-workspace-down"},
65	{"cmd+shift+space", "toggle-floating"},
66}
67var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "toggle-overview": true, "close-window": true, "quit": true, "move-window-up": true, "move-window-down": true, "move-workspace-up": true, "move-workspace-down": true, "toggle-floating": true}
68var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
69var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
70
71// resizeStep is the +N% or -N% step of set-column-width and
72// set-window-height, N from 1 to 100.
73var resizeStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)
74
75// namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
76var namedKeys = map[string]string{
77	"return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape",
78	"backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End",
79	"pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print",
80	"left": "Left", "right": "Right", "up": "Up", "down": "Down",
81}
82
83// Warning is a non-fatal problem at one line; the key keeps its default.
84type Warning struct {
85	Line int
86	Msg  string
87}
88
89func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) }
90
91func DefaultPath() string {
92	base := os.Getenv("XDG_CONFIG_HOME")
93	if base == "" {
94		home, err := os.UserHomeDir()
95		if err == nil {
96			base = filepath.Join(home, ".config")
97		}
98	}
99	return filepath.Join(base, "neferwl", "config")
100}
101
102func Defaults() ports.Config {
103	var c ports.Config
104	c.Keyboard.RepeatRate = 25
105	c.Keyboard.RepeatDelay = 600
106	c.Keyboard.CmdKey = "super"
107	c.Terminal.Command = strings.Fields(os.Getenv("TERMINAL"))
108	if len(c.Terminal.Command) == 0 {
109		c.Terminal.Command = []string{"foot"}
110	}
111	c.Terminal.AutoOpen = "first"
112	c.Xwayland = "xwayland-satellite"
113	c.Background.Color = "#111111"
114	c.Floating.Dim = 0.3
115	c.Stash.Width = 80
116	c.Stash.Gap = 2
117	c.Stash.Dim = 0.5
118	c.Stash.Capture = true
119	c.Border.Width = 2
120	c.Border.Active = "#808080"
121	c.Border.Inactive = "#111111"
122	c.Layout.MaxColumns = 2
123	c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
124	c.Layout.Overflow = "scroll"
125	c.Touchpad.Tap = true
126	c.Touchpad.AccelProfile = ports.AccelAdaptive
127	c.Touchpad.ScrollFactor = 1
128	c.Cursor.HideAfter = 5 * time.Second
129	c.Focus.Animation = ports.FocusAnimationPulse
130	c.Focus.Effect = ports.FocusEffectScreen
131	c.Focus.Strength = 0.04
132	c.Binds = map[string]string{}
133	for _, b := range defaultBinds {
134		combos := []string{b.combo}
135		// Every arrow bind has a vim twin: h j k l.
136		for arrow, vim := range map[string]string{"left": "h", "down": "j", "up": "k", "right": "l"} {
137			if base, ok := strings.CutSuffix(b.combo, "+"+arrow); ok {
138				combos = append(combos, base+"+"+vim)
139			}
140		}
141		for _, s := range combos {
142			combo, err := parseCombo(s)
143			if err != nil {
144				panic(err)
145			}
146			c.Binds[combo] = b.action
147		}
148	}
149	// Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they
150	// work the same on AZERTY, QWERTZ or Dvorak.
151	for combo, action := range map[string]string{"cmd+code:13": "scale-up", "cmd+code:12": "scale-down"} {
152		canon, _ := parseCombo(combo)
153		c.Binds[canon] = action
154	}
155	for n := 1; n <= 9; n++ {
156		focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1))
157		move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1))
158		c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n)
159		c.Binds[move] = fmt.Sprintf("move-column-to-workspace %d", n)
160	}
161	c.Render.DirectScanout = true
162	c.Render.Tearing = true
163	c.Render.VRR = true
164	c.Render.VRRFlipGap = time.Millisecond
165	c.Performance.Realtime = true
166	c.Log.Level = "info"
167	c.Log.Debug = []string{}
168	return c
169}
170
171// LoadDefault loads DefaultPath; a missing file means defaults.
172func LoadDefault() (ports.Config, []Warning, error) {
173	c, w, err := Load(DefaultPath())
174	if os.IsNotExist(err) {
175		return Defaults(), nil, nil
176	}
177	return c, w, err
178}
179
180// Load reads a config file. The error is only for I/O; bad lines become warnings.
181func Load(path string) (ports.Config, []Warning, error) {
182	f, err := os.Open(path)
183	if err != nil {
184		return Defaults(), nil, err
185	}
186	defer f.Close()
187	return Parse(f)
188}
189
190// Parse reads `key = value` lines on top of the defaults.
191func Parse(r io.Reader) (ports.Config, []Warning, error) {
192	c, _, w, err := parse(r)
193	return c, w, err
194}
195
196// parse also returns the effective key/value pairs, used to log what changed on reload.
197func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
198	c := Defaults()
199	var warnings []Warning
200	raw := map[string]string{}
201	seen := map[string]int{}
202	outputs := map[string]int{}
203	workspaces := map[string]int{}
204	scanner := bufio.NewScanner(r)
205	for n := 1; scanner.Scan(); n++ {
206		line := strings.TrimSpace(scanner.Text())
207		if line == "" || strings.HasPrefix(line, "#") {
208			continue
209		}
210		warn := func(format string, a ...any) {
211			warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
212		}
213		key, value, ok := strings.Cut(line, "=")
214		key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
215		if !ok || key == "" {
216			warn("expected key = value")
217			continue
218		}
219		if combo, ok := strings.CutPrefix(key, "bind."); ok {
220			if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
221				warn("%s: write %sequal instead of %s=", key, combo, combo)
222				continue
223			}
224			canon, err := parseCombo(combo)
225			if err != nil {
226				warn("%s: %v", key, err)
227				continue
228			}
229			if err := checkAction(value); err != nil {
230				warn("%s: %v", key, err)
231				continue
232			}
233			key = "bind." + canon
234			if prev, dup := seen[key]; dup {
235				warn("%s: overrides line %d", key, prev)
236			}
237			seen[key] = n
238			raw[key] = value
239			c.Binds[canon] = value
240			continue
241		}
242		override := func() {
243			if prev, dup := seen[key]; dup {
244				warn("%s: overrides line %d", key, prev)
245			}
246			seen[key] = n
247			raw[key] = value
248		}
249		if name, ok := strings.CutPrefix(key, "output."); ok {
250			entry := func() *ports.OutputConfig {
251				if i, ok := outputs[name]; ok {
252					return &c.Outputs[i]
253				}
254				outputs[name] = len(c.Outputs)
255				c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness})
256				return &c.Outputs[len(c.Outputs)-1]
257			}
258			if base, ok := strings.CutSuffix(name, ".primary"); ok {
259				name = base
260				b, err := onOff(value)
261				if err != nil {
262					warn("%s: %v", key, err)
263					continue
264				}
265				override()
266				entry().Primary = b
267				continue
268			}
269			if base, ok := strings.CutSuffix(name, ".hdr"); ok {
270				name = base
271				b, err := onOff(value)
272				if err != nil {
273					warn("%s: %v", key, err)
274					continue
275				}
276				override()
277				entry().HDR = b
278				continue
279			}
280			if base, ok := strings.CutSuffix(name, ".sdr-brightness"); ok {
281				name = base
282				nits, err := strconv.Atoi(value)
283				if err != nil || nits < 80 || nits > 1000 {
284					warn("%s: must be between 80 and 1000", key)
285					continue
286				}
287				override()
288				entry().SDRBrightness = nits
289				continue
290			}
291			if base, ok := strings.CutSuffix(name, ".scale"); ok {
292				name = base
293				s, err := parseScale(value)
294				if err != nil {
295					warn("%s: %v", key, err)
296					continue
297				}
298				override()
299				entry().Scale = s
300				continue
301			}
302			o := ports.OutputConfig{Name: name}
303			if value == "off" {
304				o.Off = true
305			} else if _, _, _, err := ParseMode(value); err != nil {
306				warn("%s: %v", key, err)
307				continue
308			} else if value != "preferred" {
309				o.Mode = value
310			}
311			override()
312			e := entry()
313			o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness
314			*e = o
315			continue
316		}
317		if rest, ok := strings.CutPrefix(key, "workspace."); ok {
318			name, field, _ := strings.Cut(rest, ".")
319			if !workspaceName.MatchString(name) {
320				warn("%s: workspace name must be letters, digits, - or _", key)
321				continue
322			}
323			var ws ports.WorkspaceConfig
324			if i, ok := workspaces[name]; ok {
325				ws = c.Workspaces[i]
326			}
327			if err := setWorkspace(&ws, field, value); err != nil {
328				warn("%s: %v", key, err)
329				continue
330			}
331			override()
332			if i, ok := workspaces[name]; ok {
333				c.Workspaces[i] = ws
334			} else {
335				ws.Name = name
336				workspaces[name] = len(c.Workspaces)
337				c.Workspaces = append(c.Workspaces, ws)
338			}
339			continue
340		}
341		if err := set(&c, key, value); err != nil {
342			warn("%s: %v", key, err)
343			continue
344		}
345		if key == "startup" {
346			// Repeatable: each line adds a command.
347			raw[fmt.Sprintf("startup.%d", len(c.Startup))] = value
348			continue
349		}
350		override()
351	}
352	if err := scanner.Err(); err != nil {
353		return c, raw, warnings, err
354	}
355	warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
356	sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
357	// A user bind on a digit (cmd+1, even "none") replaces the default bound
358	// to the same physical key (cmd+code:2), so older configs keep working.
359	for combo := range c.Binds {
360		if seen["bind."+combo] == 0 {
361			continue
362		}
363		parts := strings.Split(combo, "+")
364		key := parts[len(parts)-1]
365		if len(key) != 1 || key[0] < '0' || key[0] > '9' {
366			continue
367		}
368		code := int(key[0]-'0') + 1
369		if key == "0" {
370			code = 11
371		}
372		parts[len(parts)-1] = "code:" + strconv.Itoa(code)
373		if physical := strings.Join(parts, "+"); seen["bind."+physical] == 0 {
374			delete(c.Binds, physical)
375		}
376	}
377	// "none" only removes a default bind.
378	for combo, a := range c.Binds {
379		if a == "none" {
380			delete(c.Binds, combo)
381		}
382	}
383	// Cmd resolves to a real modifier; drop binds that then collide. As for any key,
384	// the later line wins; defaults (no line) lose to every user line.
385	combos := make([]string, 0, len(c.Binds))
386	for combo := range c.Binds {
387		combos = append(combos, combo)
388	}
389	order := func(combo string) int {
390		if n := seen["bind."+combo]; n > 0 {
391			return -n
392		}
393		return math.MaxInt
394	}
395	sort.Slice(combos, func(i, j int) bool {
396		a, b := order(combos[i]), order(combos[j])
397		return a < b || a == b && combos[i] < combos[j]
398	})
399	resolved := map[string]string{}
400	for _, combo := range combos {
401		r, err := expandCmd(combo, c.Keyboard.CmdKey)
402		if prev, dup := resolved[r]; err == nil && dup {
403			err = fmt.Errorf("overridden by bind.%s (same keys with keyboard.cmd = %s)", prev, c.Keyboard.CmdKey)
404		}
405		if err != nil {
406			if n := seen["bind."+combo]; n > 0 {
407				warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)})
408			}
409			delete(c.Binds, combo)
410			delete(raw, "bind."+combo)
411			continue
412		}
413		resolved[r] = combo
414	}
415	return c, raw, warnings, nil
416}
417
418// expandCmd replaces Cmd with the configured modifier and re-sorts.
419func expandCmd(combo, cmd string) (string, error) {
420	parts := strings.Split(combo, "+")
421	key := parts[len(parts)-1]
422	// cmd is super, alt or ctrl; its modifier name is the capitalized form.
423	cmdName := strings.ToUpper(cmd[:1]) + cmd[1:]
424	seen := map[string]bool{}
425	for _, m := range parts[:len(parts)-1] {
426		if m == "Cmd" {
427			m = cmdName
428		}
429		if seen[m] {
430			return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd)
431		}
432		seen[m] = true
433	}
434	mods := make([]string, 0, len(seen))
435	for m := range seen {
436		mods = append(mods, m)
437	}
438	sort.Strings(mods)
439	return strings.Join(append(mods, key), "+"), nil
440}
441
442// set applies one scalar key. Unknown keys and invalid values are errors.
443func set(c *ports.Config, key, v string) error {
444	switch key {
445	case "keyboard.layout":
446		c.Keyboard.Layout = v
447	case "keyboard.variant":
448		c.Keyboard.Variant = v
449	case "keyboard.options":
450		c.Keyboard.Options = v
451	case "keyboard.repeat-rate":
452		return positive(&c.Keyboard.RepeatRate, v, 1000)
453	case "keyboard.repeat-delay":
454		return positive(&c.Keyboard.RepeatDelay, v, 10000)
455	case "keyboard.cmd":
456		if v != "super" && v != "alt" && v != "ctrl" {
457			return fmt.Errorf("must be super, alt or ctrl")
458		}
459		c.Keyboard.CmdKey = v
460	case "startup":
461		argv := strings.Fields(v)
462		if len(argv) == 0 {
463			return fmt.Errorf("must not be empty")
464		}
465		c.Startup = append(c.Startup, argv)
466	case "terminal":
467		argv := strings.Fields(v)
468		if len(argv) == 0 {
469			return fmt.Errorf("must not be empty")
470		}
471		c.Terminal.Command = argv
472	case "terminal.auto-open":
473		if v != "first" && v != "all" && v != "off" {
474			return fmt.Errorf("must be first, all or off")
475		}
476		c.Terminal.AutoOpen = v
477	case "xwayland":
478		switch {
479		case v == "off":
480			c.Xwayland = ""
481		case v == "" || strings.ContainsAny(v, " \t"):
482			return fmt.Errorf("must be off or the xwayland-satellite program")
483		default:
484			c.Xwayland = v
485		}
486	case "background":
487		if !color.MatchString(v) {
488			return fmt.Errorf("must be #rrggbb")
489		}
490		c.Background.Color = v
491	case "floating.dim":
492		dim, err := strconv.ParseFloat(v, 64)
493		if err != nil || !(dim >= 0 && dim <= 1) {
494			return fmt.Errorf("must be between 0 and 1")
495		}
496		c.Floating.Dim = dim
497	case "stash.width":
498		n, err := strconv.Atoi(strings.TrimSuffix(v, "%"))
499		if err != nil || n < 10 || n > 90 {
500			return fmt.Errorf("must be between 10 and 90")
501		}
502		c.Stash.Width = n
503	case "stash.gap":
504		n, err := strconv.Atoi(v)
505		if err != nil || n < 0 || n > 10 {
506			return fmt.Errorf("must be between 0 and 10")
507		}
508		c.Stash.Gap = n
509	case "stash.dim":
510		dim, err := strconv.ParseFloat(v, 64)
511		if err != nil || !(dim >= 0 && dim <= 1) {
512			return fmt.Errorf("must be between 0 and 1")
513		}
514		c.Stash.Dim = dim
515	case "stash.capture":
516		b, err := onOff(v)
517		if err != nil {
518			return err
519		}
520		c.Stash.Capture = b
521	case "border.width":
522		n, err := strconv.Atoi(v)
523		if err != nil || n < 0 || n > 32 {
524			return fmt.Errorf("must be between 0 and 32")
525		}
526		c.Border.Width = n
527	case "border.active":
528		if !color.MatchString(v) {
529			return fmt.Errorf("must be #rrggbb")
530		}
531		c.Border.Active = v
532	case "border.inactive":
533		if v != "" && !color.MatchString(v) {
534			return fmt.Errorf("must be #rrggbb or empty (none)")
535		}
536		c.Border.Inactive = v
537	case "layout.gaps":
538		n, err := strconv.Atoi(v)
539		if err != nil || n < 0 || n > 200 {
540			return fmt.Errorf("must be between 0 and 200")
541		}
542		c.Layout.Gaps = n
543	case "layout.default-width":
544		return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
545	case "layout.max-columns":
546		return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
547	case "layout.overflow":
548		return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)
549	case "layout.presets":
550		list := splitList(v)
551		if len(list) == 0 {
552			return fmt.Errorf("must not be empty")
553		}
554		for _, s := range list {
555			if !width(s) {
556				return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
557			}
558		}
559		c.Layout.Presets = list
560	case "touchpad.natural-scroll":
561		b, err := onOff(v)
562		if err != nil {
563			return err
564		}
565		c.Touchpad.NaturalScroll = b
566	case "touchpad.tap":
567		b, err := onOff(v)
568		if err != nil {
569			return err
570		}
571		c.Touchpad.Tap = b
572	case "touchpad.accel-speed":
573		s, err := strconv.ParseFloat(v, 64)
574		if err != nil || !(s >= -1 && s <= 1) {
575			return fmt.Errorf("must be between -1 and 1")
576		}
577		c.Touchpad.AccelSpeed = s
578	case "touchpad.accel-profile":
579		if v != ports.AccelAdaptive && v != ports.AccelFlat {
580			return fmt.Errorf("must be adaptive or flat")
581		}
582		c.Touchpad.AccelProfile = v
583	case "touchpad.scroll-factor":
584		f, err := strconv.ParseFloat(v, 64)
585		if err != nil || !(f > 0 && f <= 10) {
586			return fmt.Errorf("must be above 0 and at most 10")
587		}
588		c.Touchpad.ScrollFactor = f
589	case "cursor.hide-after":
590		if v == "off" {
591			c.Cursor.HideAfter = 0
592			return nil
593		}
594		d, err := time.ParseDuration(v)
595		if err != nil || d < 100*time.Millisecond || d > maxCursorHideAfter {
596			return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter)
597		}
598		c.Cursor.HideAfter = d
599	case "focus.follow-move":
600		b, err := onOff(v)
601		if err != nil {
602			return err
603		}
604		c.Focus.FollowMove = b
605	case "focus.animation":
606		if v != ports.FocusAnimationPulse && v != ports.FocusAnimationOff {
607			return fmt.Errorf("must be pulse or off")
608		}
609		c.Focus.Animation = v
610	case "focus.effect":
611		if v != ports.FocusEffectScreen {
612			return fmt.Errorf("must be screen")
613		}
614		c.Focus.Effect = v
615	case "focus.strength":
616		// Capped low: the focus indicator must never flash.
617		f, err := strconv.ParseFloat(v, 64)
618		if err != nil || !(f >= 0.01 && f <= 0.2) {
619			return fmt.Errorf("must be between 0.01 and 0.2")
620		}
621		c.Focus.Strength = f
622	case "render.direct-scanout":
623		b, err := onOff(v)
624		if err != nil {
625			return err
626		}
627		c.Render.DirectScanout = b
628	case "render.tearing":
629		b, err := onOff(v)
630		if err != nil {
631			return err
632		}
633		c.Render.Tearing = b
634	case "render.vrr":
635		b, err := onOff(v)
636		if err != nil {
637			return err
638		}
639		c.Render.VRR = b
640	case "render.vrr-flip-gap":
641		d, err := time.ParseDuration(v)
642		if v == "0" {
643			d, err = 0, nil
644		}
645		if err != nil || d < 0 || d > maxVRRFlipGap {
646			return fmt.Errorf("must be a duration between 0 and %s, e.g. 1ms", maxVRRFlipGap)
647		}
648		c.Render.VRRFlipGap = d
649	case "performance.realtime":
650		b, err := onOff(v)
651		if err != nil {
652			return err
653		}
654		c.Performance.Realtime = b
655	case "log.level":
656		if v != "debug" && v != "info" && v != "warn" && v != "error" {
657			return fmt.Errorf("must be debug, info, warn or error")
658		}
659		c.Log.Level = v
660	case "log.debug":
661		list := splitList(v)
662		for _, s := range list {
663			if !components[s] && s != "all" {
664				return fmt.Errorf("unknown component %q", s)
665			}
666		}
667		c.Log.Debug = list
668	default:
669		if rest, ok := strings.CutPrefix(key, "layout."); ok {
670			if i := strings.LastIndex(rest, "."); i > 0 {
671				return setOutputLayout(c, rest[:i], rest[i+1:], v)
672			}
673		}
674		return fmt.Errorf("unknown key")
675	}
676	return nil
677}
678
679// setOutputLayout applies one layout.<output>.<field> key.
680func setOutputLayout(c *ports.Config, output, field, v string) error {
681	i := slices.IndexFunc(c.Layout.Outputs, func(o ports.OutputLayout) bool { return o.Output == output })
682	o := ports.OutputLayout{Output: output}
683	if i >= 0 {
684		o = c.Layout.Outputs[i]
685	}
686	if err := setLayoutRule(&o.LayoutRules, field, v); err != nil {
687		return err
688	}
689	if i < 0 {
690		c.Layout.Outputs = append(c.Layout.Outputs, o)
691	} else {
692		c.Layout.Outputs[i] = o
693	}
694	return nil
695}
696
697// setLayoutRule applies max-columns or overflow, for layout.*,
698// layout.<output>.* and workspace.<name>.*.
699func setLayoutRule(r *ports.LayoutRules, field, v string) error {
700	switch field {
701	case "max-columns":
702		return positive(&r.MaxColumns, v, 16)
703	case "overflow":
704		if v != "scroll" && v != "fixed" {
705			return fmt.Errorf("must be scroll or fixed")
706		}
707		r.Overflow = v
708		return nil
709	}
710	return fmt.Errorf("unknown key (max-columns, overflow)")
711}
712
713// checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
714// workspace (it does nothing) and a named workspace no bind reaches.
715func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
716	declared := map[string]bool{}
717	for _, w := range c.Workspaces {
718		declared[w.Name] = true
719	}
720	bound := map[string]bool{}
721	var warnings []Warning
722	for combo, a := range c.Binds {
723		name, ok := strings.CutPrefix(a, "workspace ")
724		if !ok {
725			continue
726		}
727		name = strings.TrimSpace(name)
728		bound[name] = true
729		if !declared[name] {
730			warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)})
731		}
732	}
733	for _, w := range c.Workspaces {
734		// The config only knows the monitor as written: a connector home
735		// does not match a key rule, and the reverse.
736		rules := c.Layout.LayoutRules
737		for _, o := range c.Layout.Outputs {
738			if w.Monitor != "" && o.Output == w.Monitor {
739				rules = o.Over(rules)
740			}
741		}
742		if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 {
743			line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
744			warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
745		}
746		if !bound[w.Name] {
747			line := 0
748			for key, n := range seen {
749				if strings.HasPrefix(key, "workspace."+w.Name+".") && (line == 0 || n < line) {
750					line = n
751				}
752			}
753			warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
754		}
755	}
756	sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
757	return warnings
758}
759
760var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
761
762// setWorkspace applies one workspace.<name>.<field> key.
763func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
764	switch field {
765	case "monitor":
766		if v == "" {
767			return fmt.Errorf("needs a connector (DP-2) or a monitor key")
768		}
769		w.Monitor = v
770	case "size":
771		if v == "inherit" {
772			w.Size = [2]int{}
773			return nil
774		}
775		width, height, _, err := ParseMode(v)
776		if err != nil || width == 0 || strings.Contains(v, "@") {
777			return fmt.Errorf("must be inherit or WxH in logical pixels")
778		}
779		w.Size = [2]int{width, height}
780	case "max-columns", "overflow":
781		return setLayoutRule(&w.LayoutRules, field, v)
782	default:
783		n, ok := strings.CutPrefix(field, "column.")
784		if !ok {
785			return fmt.Errorf("unknown key (monitor, size, max-columns, overflow, column.N)")
786		}
787		index, err := strconv.Atoi(n)
788		if err != nil || index < 1 || index > 16 {
789			return fmt.Errorf("column number must be between 1 and 16")
790		}
791		size, cmd, _ := strings.Cut(v, ",")
792		size = strings.TrimSpace(size)
793		argv := strings.Fields(cmd)
794		if !width(size) || len(argv) == 0 {
795			return fmt.Errorf("must be <width>, <command> like 67%%, foot")
796		}
797		slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
798		i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
799		if i < len(w.Slots) && w.Slots[i].Index == index {
800			w.Slots[i] = slot
801		} else {
802			w.Slots = slices.Insert(w.Slots, i, slot)
803		}
804	}
805	return nil
806}
807
808func positive(dst *int, v string, max int) error {
809	n, err := strconv.Atoi(v)
810	if err != nil || n <= 0 || n > max {
811		return fmt.Errorf("must be between 1 and %d", max)
812	}
813	*dst = n
814	return nil
815}
816
817func onOff(v string) (bool, error) {
818	switch v {
819	case "on":
820		return true, nil
821	case "off":
822		return false, nil
823	}
824	return false, fmt.Errorf("must be on or off")
825}
826
827func splitList(v string) []string {
828	list := []string{}
829	for _, s := range strings.Split(v, ",") {
830		if s = strings.TrimSpace(s); s != "" {
831			list = append(list, s)
832		}
833	}
834	return list
835}
836
837// stripComment drops a trailing ` # comment` from a value. A # that starts the
838// value is kept, so colors like #1e1e2e need no quotes.
839func stripComment(value string) string {
840	for i := 1; i < len(value); i++ {
841		if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
842			return strings.TrimSpace(value[:i])
843		}
844	}
845	return value
846}
847
848func checkAction(v string) error {
849	if rest, ok := strings.CutPrefix(v, "spawn "); ok {
850		if len(strings.Fields(rest)) == 0 {
851			return fmt.Errorf("spawn needs a command")
852		}
853		return nil
854	}
855	if rest, ok := strings.CutPrefix(v, "workspace "); ok {
856		if !workspaceName.MatchString(strings.TrimSpace(rest)) {
857			return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
858		}
859		return nil
860	}
861	for _, prefix := range []string{"focus-workspace ", "move-column-to-workspace ", "move-window-to-workspace "} {
862		if rest, ok := strings.CutPrefix(v, prefix); ok {
863			if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
864				return fmt.Errorf("%snumber must be between 1 and 99", prefix)
865			}
866			return nil
867		}
868	}
869	for _, prefix := range []string{"set-column-width ", "set-window-height "} {
870		if rest, ok := strings.CutPrefix(v, prefix); ok {
871			if !resizeStep.MatchString(strings.TrimSpace(rest)) {
872				return fmt.Errorf("%sneeds +N%% or -N%% (1-100)", prefix)
873			}
874			return nil
875		}
876	}
877	if !actions[v] {
878		return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
879	}
880	return nil
881}
882
883// parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
884// modifiers sorted, the key as an xkb keysym name.
885func parseCombo(s string) (string, error) {
886	parts := strings.Split(s, "+")
887	key := parts[len(parts)-1]
888	if len(parts) > 1 && key == "" {
889		// "cmd++" means the + key.
890		if len(parts) > 2 && parts[len(parts)-2] == "" {
891			parts, key = parts[:len(parts)-1], "+"
892		} else {
893			return "", fmt.Errorf("missing key")
894		}
895	}
896	if key == "" {
897		return "", fmt.Errorf("missing key")
898	}
899	seen := map[string]bool{}
900	for _, m := range parts[:len(parts)-1] {
901		var name string
902		switch strings.ToLower(m) {
903		case "cmd":
904			name = "Cmd"
905		case "shift":
906			name = "Shift"
907		case "ctrl":
908			name = "Ctrl"
909		case "alt":
910			name = "Alt"
911		case "super":
912			name = "Super"
913		default:
914			return "", fmt.Errorf("unknown modifier %q", m)
915		}
916		if seen[name] {
917			return "", fmt.Errorf("duplicate modifier %q", m)
918		}
919		seen[name] = true
920	}
921	mods := make([]string, 0, len(seen))
922	for m := range seen {
923		mods = append(mods, m)
924	}
925	sort.Strings(mods)
926	name, err := keysym(key)
927	if err != nil {
928		return "", err
929	}
930	return strings.Join(append(mods, name), "+"), nil
931}
932
933// keysym maps a written key to its xkb keysym name.
934func keysym(k string) (string, error) {
935	// code:N is the physical key (evdev code), the same on every layout.
936	if rest, ok := strings.CutPrefix(strings.ToLower(k), "code:"); ok {
937		if n, err := strconv.Atoi(rest); err == nil && n > 0 && n < 768 {
938			return "code:" + rest, nil
939		}
940		return "", fmt.Errorf("code must be an evdev key code like code:2")
941	}
942	if r := []rune(k); len(r) == 1 {
943		// Binds match the unshifted keysym, so É means é.
944		if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
945			return name, nil
946		}
947		if r[0] > unicode.MaxASCII {
948			return "", fmt.Errorf("write %q by its xkb keysym name", k)
949		}
950		return strings.ToLower(k), nil
951	}
952	if name, ok := namedKeys[strings.ToLower(k)]; ok {
953		return name, nil
954	}
955	if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
956		if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
957			return "F" + k[1:], nil
958		}
959	}
960	return k, nil
961}
962
963// parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.
964func parseScale(v string) (float64, error) {
965	var s float64
966	var err error
967	if num, den, ok := strings.Cut(v, "/"); ok {
968		var n, d float64
969		n, err = strconv.ParseFloat(strings.TrimSpace(num), 64)
970		if err == nil {
971			d, err = strconv.ParseFloat(strings.TrimSpace(den), 64)
972		}
973		if err == nil && d != 0 {
974			s = n / d
975		}
976	} else {
977		s, err = strconv.ParseFloat(v, 64)
978	}
979	if err != nil || !(s >= 1 && s <= 4) {
980		return 0, fmt.Errorf("must be between 1 and 4, like 1.5 or 4/3")
981	}
982	return s, nil
983}
984
985// ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
986func ParseMode(s string) (w, h int, hz float64, err error) {
987	if s == "preferred" {
988		return 0, 0, 0, nil
989	}
990	size, rate, hasRate := strings.Cut(s, "@")
991	ws, hs, ok := strings.Cut(size, "x")
992	w, e1 := strconv.Atoi(ws)
993	h, e2 := strconv.Atoi(hs)
994	if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
995		return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s)
996	}
997	if hasRate {
998		if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
999			return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
1000		}
1001	}
1002	return w, h, hz, nil
1003}
1004
1005func width(s string) bool {
1006	if s == "1" {
1007		return true
1008	}
1009	if n, ok := strings.CutSuffix(s, "%"); ok {
1010		v, e := strconv.Atoi(n)
1011		return e == nil && v > 0 && v <= 100
1012	}
1013	if strings.HasSuffix(s, "px") {
1014		n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
1015		return e == nil && n > 0
1016	}
1017	p := strings.Split(s, "/")
1018	if len(p) != 2 {
1019		return false
1020	}
1021	a, e1 := strconv.Atoi(p[0])
1022	b, e2 := strconv.Atoi(p[1])
1023	return e1 == nil && e2 == nil && a > 0 && a <= b
1024}

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