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

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