refactor/kvconf 3462c76

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

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