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