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

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 23

Tags 10

↑↓ moveenter openesc close