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