v0.2.0 13c6a90

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

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