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