config.go RawHistoryFolder

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

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 26

Tags 10

↑↓ moveenter openesc close