config.go RawHistoryFolder

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

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