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

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