feat/data-dnd e05cb15

config.go RawHistoryFolder

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

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