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 = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}
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 xs := c.Xwayland.XSettings // independent of the backend
728 switch {
729 case v == "off":
730 c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: xs}
731 case v == "satellite":
732 c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: xs}
733 case v == "builtin":
734 c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: xs}
735 case v == "" || strings.ContainsAny(v, " \t"):
736 return fmt.Errorf("must be off, satellite, builtin or a satellite program")
737 default:
738 c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v, XSettings: xs}
739 }
740 case "xwayland.xsettings":
741 switch v {
742 case "on":
743 c.Xwayland.XSettings = true
744 case "off":
745 c.Xwayland.XSettings = false
746 default:
747 return fmt.Errorf("must be on or off")
748 }
749 case "background":
750 if !color.MatchString(v) {
751 return fmt.Errorf("must be #rrggbb")
752 }
753 c.Background.Color = v
754 case "floating.dim":
755 dim, err := strconv.ParseFloat(v, 64)
756 if err != nil || !(dim >= 0 && dim <= 1) {
757 return fmt.Errorf("must be between 0 and 1")
758 }
759 c.Floating.Dim = dim
760 case "stash.width":
761 n, err := strconv.Atoi(strings.TrimSuffix(v, "%"))
762 if err != nil || n < 10 || n > 90 {
763 return fmt.Errorf("must be between 10 and 90")
764 }
765 c.Stash.Width = n
766 case "stash.gap":
767 n, err := strconv.Atoi(v)
768 if err != nil || n < 0 || n > 10 {
769 return fmt.Errorf("must be between 0 and 10")
770 }
771 c.Stash.Gap = n
772 case "stash.dim":
773 dim, err := strconv.ParseFloat(v, 64)
774 if err != nil || !(dim >= 0 && dim <= 1) {
775 return fmt.Errorf("must be between 0 and 1")
776 }
777 c.Stash.Dim = dim
778 case "stash.capture":
779 b, err := onOff(v)
780 if err != nil {
781 return err
782 }
783 c.Stash.Capture = b
784 case "stash.spawn-terminal":
785 b, err := onOff(v)
786 if err != nil {
787 return err
788 }
789 c.Stash.SpawnTerminal = b
790 case "border.radius":
791 n, err := strconv.Atoi(v)
792 if err != nil || n < 0 || n > 64 {
793 return fmt.Errorf("must be between 0 and 64")
794 }
795 c.Border.Radius = n
796 case "border.width":
797 n, err := strconv.Atoi(v)
798 if err != nil || n < 0 || n > 32 {
799 return fmt.Errorf("must be between 0 and 32")
800 }
801 c.Border.Width = n
802 case "border.active":
803 if !color.MatchString(v) {
804 return fmt.Errorf("must be #rrggbb")
805 }
806 c.Border.Active = v
807 case "border.inactive":
808 if v != "" && !color.MatchString(v) {
809 return fmt.Errorf("must be #rrggbb or empty (none)")
810 }
811 c.Border.Inactive = v
812 case "layout.gaps":
813 n, err := strconv.Atoi(v)
814 if err != nil || n < 0 || n > 200 {
815 return fmt.Errorf("must be between 0 and 200")
816 }
817 c.Layout.Gaps = n
818 case "layout.default-width":
819 return fmt.Errorf("replaced by layout.max-columns (column widths are cells of 1/max-columns)")
820 case "layout.max-columns":
821 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
822 case "layout.overflow":
823 return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)
824 case "touchpad.tap":
825 b, err := onOff(v)
826 if err != nil {
827 return err
828 }
829 c.Touchpad.Tap = b
830 case "touchpad.scroll-factor":
831 f, err := strconv.ParseFloat(v, 64)
832 if err != nil || !(f > 0 && f <= 10) {
833 return fmt.Errorf("must be above 0 and at most 10")
834 }
835 c.Touchpad.ScrollFactor = f
836 case "cursor.hide-after":
837 if v == "off" {
838 c.Cursor.HideAfter = 0
839 return nil
840 }
841 d, err := time.ParseDuration(v)
842 if err != nil || d < 100*time.Millisecond || d > maxCursorHideAfter {
843 return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter)
844 }
845 c.Cursor.HideAfter = d
846 case "animations":
847 b, err := onOff(v)
848 if err != nil {
849 return err
850 }
851 c.Animations.On = b
852 case "animations.speed":
853 f, ok := animationSpeeds[v]
854 if !ok {
855 var err error
856 f, err = strconv.ParseFloat(v, 64)
857 if err != nil || !(f >= 0.1 && f <= 10) {
858 return fmt.Errorf("must be slow, normal, fast or a number between 0.1 and 10")
859 }
860 }
861 // Core stretches springs by a duration factor: the inverse.
862 c.Animations.Slowdown = normalSlowdown / f
863 case "focus.follow-move":
864 b, err := onOff(v)
865 if err != nil {
866 return err
867 }
868 c.Focus.FollowMove = b
869 case "focus.position-hint":
870 b, err := onOff(v)
871 if err != nil {
872 return err
873 }
874 c.Focus.PositionHint = b
875 case "focus.flash":
876 b, err := onOff(v)
877 if err != nil {
878 return err
879 }
880 c.Focus.Flash = b
881 case "hint.enable":
882 b, err := onOff(v)
883 if err != nil {
884 return err
885 }
886 c.Hint.Enable = b
887 case "hint.capture":
888 b, err := onOff(v)
889 if err != nil {
890 return err
891 }
892 c.Hint.Capture = b
893 case "hint.size":
894 if v == "auto" {
895 c.Hint.Size = 0
896 break
897 }
898 n, err := strconv.ParseFloat(v, 64)
899 if err != nil || math.IsNaN(n) || n < 6 || n > 72 {
900 return fmt.Errorf("must be auto or a number from 6 to 72 (logical pixels)")
901 }
902 c.Hint.Size = n
903 case "hint.background", "hint.foreground":
904 if v != "" && !color.MatchString(v) {
905 return fmt.Errorf("must be #rrggbb or empty (derived)")
906 }
907 if key == "hint.background" {
908 c.Hint.Background = v
909 } else {
910 c.Hint.Foreground = v
911 }
912 case "hint.targets":
913 switch v {
914 case "clickable":
915 c.Hint.LinksOnly = false
916 case "links":
917 c.Hint.LinksOnly = true
918 default:
919 return fmt.Errorf("must be clickable or links")
920 }
921 case "hint.text-click":
922 m, err := parseTextClick(v)
923 if err != nil {
924 return err
925 }
926 c.Hint.TextClick = m
927 case "focus.flash-width":
928 n, err := strconv.Atoi(v)
929 if err != nil || n < 1 || n > 8 {
930 return fmt.Errorf("must be an integer between 1 and 8")
931 }
932 c.Focus.FlashWidth = n
933 case "focus.flash-opacity":
934 f, err := strconv.ParseFloat(v, 64)
935 if err != nil || !(f >= 0.1 && f <= 1) {
936 return fmt.Errorf("must be between 0.1 and 1")
937 }
938 c.Focus.FlashOpacity = f
939 case "focus.flash-duration":
940 d, err := time.ParseDuration(v)
941 if err != nil || d < 50*time.Millisecond || d > time.Second {
942 return fmt.Errorf("must be a duration between 50ms and 1s, e.g. 200ms")
943 }
944 c.Focus.FlashDuration = d
945 case "focus.flash-color":
946 if v != "" && !color.MatchString(v) {
947 return fmt.Errorf("must be #rrggbb or empty (border.active)")
948 }
949 c.Focus.FlashColor = v
950 case "render.direct-scanout":
951 b, err := onOff(v)
952 if err != nil {
953 return err
954 }
955 c.Render.DirectScanout = b
956 case "render.tearing":
957 b, err := onOff(v)
958 if err != nil {
959 return err
960 }
961 c.Render.Tearing = b
962 case "render.vrr":
963 b, err := onOff(v)
964 if err != nil {
965 return err
966 }
967 c.Render.VRR = b
968 case "render.vrr-flip-gap":
969 d, err := time.ParseDuration(v)
970 if v == "0" {
971 d, err = 0, nil
972 }
973 if err != nil || d < 0 || d > maxVRRFlipGap {
974 return fmt.Errorf("must be a duration between 0 and %s, e.g. 1ms", maxVRRFlipGap)
975 }
976 c.Render.VRRFlipGap = d
977 case "performance.realtime":
978 b, err := onOff(v)
979 if err != nil {
980 return err
981 }
982 c.Performance.Realtime = b
983 case "log.level":
984 if v != "debug" && v != "info" && v != "warn" && v != "error" {
985 return fmt.Errorf("must be debug, info, warn or error")
986 }
987 c.Log.Level = v
988 case "log.debug":
989 list := splitList(v)
990 for _, s := range list {
991 if !components[s] && s != "all" {
992 return fmt.Errorf("unknown component %q", s)
993 }
994 }
995 c.Log.Debug = list
996 default:
997 if rest, ok := strings.CutPrefix(key, "touchpad."); ok {
998 return parsePointerKey(rest, v, &c.Touchpad.PointerConfig)
999 }
1000 if rest, ok := strings.CutPrefix(key, "mouse."); ok {
1001 return parsePointerKey(rest, v, &c.Mouse)
1002 }
1003 if rest, ok := strings.CutPrefix(key, "layout."); ok {
1004 if i := strings.LastIndex(rest, "."); i > 0 {
1005 return setOutputLayout(c, rest[:i], rest[i+1:], v)
1006 }
1007 }
1008 return fmt.Errorf("unknown key")
1009 }
1010 return nil
1011}
1012
1013// parsePointerKey applies one pointer key shared by touchpads and mice, with
1014// the touchpad. or mouse. prefix already removed. Touchpad-only keys are
1015// handled before; anything else is unknown. On an error p is left unchanged.
1016func parsePointerKey(key, v string, p *ports.PointerConfig) error {
1017 switch key {
1018 case "natural-scroll":
1019 b, err := onOff(v)
1020 if err != nil {
1021 return err
1022 }
1023 p.NaturalScroll = b
1024 case "left-handed":
1025 b, err := onOff(v)
1026 if err != nil {
1027 return err
1028 }
1029 p.LeftHanded = b
1030 case "accel-speed":
1031 s, err := strconv.ParseFloat(v, 64)
1032 if err != nil || !(s >= -1 && s <= 1) {
1033 return fmt.Errorf("must be between -1 and 1")
1034 }
1035 p.AccelSpeed = s
1036 case "accel-profile":
1037 if v != ports.AccelAdaptive && v != ports.AccelFlat {
1038 return fmt.Errorf("must be adaptive or flat")
1039 }
1040 p.AccelProfile = v
1041 default:
1042 return fmt.Errorf("unknown key")
1043 }
1044 return nil
1045}
1046
1047// setOutputLayout applies one layout.<output>.<field> key.
1048func setOutputLayout(c *ports.Config, output, field, v string) error {
1049 i := slices.IndexFunc(c.Layout.Outputs, func(o ports.OutputLayout) bool { return o.Output == output })
1050 o := ports.OutputLayout{Output: output}
1051 if i >= 0 {
1052 o = c.Layout.Outputs[i]
1053 }
1054 if err := setLayoutRule(&o.LayoutRules, field, v); err != nil {
1055 return err
1056 }
1057 if i < 0 {
1058 c.Layout.Outputs = append(c.Layout.Outputs, o)
1059 } else {
1060 c.Layout.Outputs[i] = o
1061 }
1062 return nil
1063}
1064
1065// setLayoutRule applies max-columns or overflow, for layout.*,
1066// layout.<output>.* and workspace.<name>.*.
1067func setLayoutRule(r *ports.LayoutRules, field, v string) error {
1068 switch field {
1069 case "max-columns":
1070 return positive(&r.MaxColumns, v, 16)
1071 case "overflow":
1072 if v != "scroll" && v != "fixed" && v != "cascade" {
1073 return fmt.Errorf("must be scroll, fixed or cascade")
1074 }
1075 r.Overflow = v
1076 return nil
1077 }
1078 return fmt.Errorf("unknown key (max-columns, overflow)")
1079}
1080
1081// checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
1082// workspace (it does nothing) and a named workspace no bind reaches.
1083func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
1084 declared := map[string]bool{}
1085 for _, w := range c.Workspaces {
1086 declared[w.Name] = true
1087 }
1088 bound := map[string]bool{}
1089 var warnings []Warning
1090 for combo, a := range c.Binds {
1091 name, ok := strings.CutPrefix(a, "workspace ")
1092 if !ok {
1093 continue
1094 }
1095 name = strings.TrimSpace(name)
1096 bound[name] = true
1097 if !declared[name] {
1098 warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)})
1099 }
1100 }
1101 for _, w := range c.Workspaces {
1102 // The config only knows the monitor as written: a connector home
1103 // does not match a key rule, and the reverse.
1104 rules := c.Layout.LayoutRules
1105 for _, o := range c.Layout.Outputs {
1106 if w.Monitor != "" && o.Output == w.Monitor {
1107 rules = o.Over(rules)
1108 }
1109 }
1110 if !bound[w.Name] {
1111 line := 0
1112 for key, n := range seen {
1113 if strings.HasPrefix(key, "workspace."+w.Name+".") && (line == 0 || n < line) {
1114 line = n
1115 }
1116 }
1117 warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
1118 }
1119 }
1120 sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
1121 return warnings
1122}
1123
1124var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
1125
1126// ruleEntry is a rule.<name>.* group while the file is read.
1127type ruleEntry struct {
1128 rule ports.WindowRule
1129 line int // of its first key
1130 // dropped is set by an invalid value with no valid one before it: the
1131 // whole rule is ignored.
1132 dropped bool
1133}
1134
1135var errUnknownRuleField = errors.New("unknown key (app-id, floating, workspace, monitor, width)")
1136
1137// setRule applies one rule.<name>.<field> key.
1138func setRule(r *ports.WindowRule, field, v string) error {
1139 switch field {
1140 case "app-id":
1141 if v == "" {
1142 return fmt.Errorf("needs a regular expression")
1143 }
1144 // Compiled alone first, so a value cannot close the anchoring
1145 // group ("a)|(b").
1146 if _, err := regexp.Compile(v); err != nil {
1147 return fmt.Errorf("invalid regular expression: %v", err)
1148 }
1149 // The whole app ID must match: "steam" does not match "steam_app_1".
1150 // A value can still break the wrapping ("\Qsteam" quotes the ")").
1151 re, err := regexp.Compile("^(?:" + v + ")$")
1152 if err != nil {
1153 return fmt.Errorf("invalid regular expression: %v", err)
1154 }
1155 r.AppID = re
1156 case "floating":
1157 b, err := onOff(v)
1158 if err != nil {
1159 return err
1160 }
1161 r.Floating = &b
1162 case "workspace":
1163 if n, err := strconv.Atoi(v); err == nil {
1164 if n < 1 || n > maxRuleWorkspace {
1165 return fmt.Errorf("workspace number must be between 1 and %d", maxRuleWorkspace)
1166 }
1167 } else if !workspaceName.MatchString(v) {
1168 return fmt.Errorf("must be a workspace number or the name of a declared workspace")
1169 }
1170 r.Workspace = v
1171 case "monitor":
1172 if v == "" {
1173 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
1174 }
1175 r.Monitor = v
1176 case "width":
1177 n, ok := cells(v)
1178 if !ok {
1179 return errCells
1180 }
1181 r.Cells = n
1182 default:
1183 return errUnknownRuleField
1184 }
1185 return nil
1186}
1187
1188// maxRuleWorkspace bounds rule.<name>.workspace; core clamps it to the
1189// workspaces that exist.
1190const maxRuleWorkspace = 1000
1191
1192// finishRules keeps the valid rules in file order, warning about those
1193// without an app-id (dropped) and about named workspaces nobody declared
1194// (kept: core falls back to the default place).
1195func finishRules(entries []*ruleEntry, workspaces []ports.WorkspaceConfig, seen map[string]int) ([]ports.WindowRule, []Warning) {
1196 var rules []ports.WindowRule
1197 var warnings []Warning
1198 for _, e := range entries {
1199 if e.dropped {
1200 continue
1201 }
1202 if e.rule.AppID == nil {
1203 warnings = append(warnings, Warning{Line: e.line, Msg: fmt.Sprintf("rule.%s: no app-id, rule ignored", e.rule.Name)})
1204 continue
1205 }
1206 if w := e.rule.Workspace; w != "" {
1207 if _, err := strconv.Atoi(w); err != nil && !slices.ContainsFunc(workspaces, func(c ports.WorkspaceConfig) bool { return c.Name == w }) {
1208 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)})
1209 }
1210 }
1211 rules = append(rules, e.rule)
1212 }
1213 return rules, warnings
1214}
1215
1216// setWorkspace applies one workspace.<name>.<field> key.
1217func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
1218 switch field {
1219 case "monitor":
1220 if v == "" {
1221 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
1222 }
1223 w.Monitor = v
1224 case "size":
1225 if v == "inherit" {
1226 w.Size = [2]int{}
1227 return nil
1228 }
1229 width, height, _, err := ParseMode(v)
1230 if err != nil || width == 0 || strings.Contains(v, "@") {
1231 return fmt.Errorf("must be inherit or WxH in logical pixels")
1232 }
1233 w.Size = [2]int{width, height}
1234 case "max-columns", "overflow":
1235 return setLayoutRule(&w.LayoutRules, field, v)
1236 default:
1237 n, ok := strings.CutPrefix(field, "column.")
1238 if !ok {
1239 return fmt.Errorf("unknown key (monitor, size, max-columns, overflow, column.N)")
1240 }
1241 index, err := strconv.Atoi(n)
1242 if err != nil || index < 1 || index > 16 {
1243 return fmt.Errorf("column number must be between 1 and 16")
1244 }
1245 size, cmd, _ := strings.Cut(v, ",")
1246 size = strings.TrimSpace(size)
1247 argv := strings.Fields(cmd)
1248 count, ok := cells(size)
1249 if !ok || len(argv) == 0 {
1250 return fmt.Errorf("must be <cells>, <command> like 2, foot (cells from 1 to 16)")
1251 }
1252 slot := ports.SlotConfig{Index: index, Cells: count, Argv: argv}
1253 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
1254 if i < len(w.Slots) && w.Slots[i].Index == index {
1255 w.Slots[i] = slot
1256 } else {
1257 w.Slots = slices.Insert(w.Slots, i, slot)
1258 }
1259 }
1260 return nil
1261}
1262
1263func positive(dst *int, v string, max int) error {
1264 n, err := strconv.Atoi(v)
1265 if err != nil || n <= 0 || n > max {
1266 return fmt.Errorf("must be between 1 and %d", max)
1267 }
1268 *dst = n
1269 return nil
1270}
1271
1272// normalSlowdown is the spring duration factor of animations.speed =
1273// normal (1): a view move lands in about 180 ms.
1274const normalSlowdown = 0.7
1275
1276// animationSpeeds are the named animations.speed values, relative to
1277// normal: a view move lands in about 260 ms at slow and 130 ms at fast.
1278var animationSpeeds = map[string]float64{"slow": 0.7, "normal": 1, "fast": 1.4}
1279
1280func onOff(v string) (bool, error) {
1281 switch v {
1282 case "on":
1283 return true, nil
1284 case "off":
1285 return false, nil
1286 }
1287 return false, fmt.Errorf("must be on or off")
1288}
1289
1290func splitList(v string) []string {
1291 list := []string{}
1292 for _, s := range strings.Split(v, ",") {
1293 if s = strings.TrimSpace(s); s != "" {
1294 list = append(list, s)
1295 }
1296 }
1297 return list
1298}
1299
1300func checkAction(v string) error {
1301 if rest, ok := strings.CutPrefix(v, "spawn "); ok {
1302 if len(ports.SplitCommands(rest)) == 0 {
1303 return fmt.Errorf("spawn needs a command")
1304 }
1305 return nil
1306 }
1307 if rest, ok := strings.CutPrefix(v, "stash "); ok {
1308 if len(ports.SplitCommands(rest)) == 0 {
1309 return fmt.Errorf("stash needs a command")
1310 }
1311 return nil
1312 }
1313 if rest, ok := strings.CutPrefix(v, "workspace "); ok {
1314 if !workspaceName.MatchString(strings.TrimSpace(rest)) {
1315 return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
1316 }
1317 return nil
1318 }
1319 for _, prefix := range []string{"focus-workspace ", "move-column-to-workspace ", "move-window-to-workspace "} {
1320 if rest, ok := strings.CutPrefix(v, prefix); ok {
1321 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
1322 return fmt.Errorf("%snumber must be between 1 and 99", prefix)
1323 }
1324 return nil
1325 }
1326 }
1327 if rest, ok := strings.CutPrefix(v, "set-column-width "); ok {
1328 if !columnStep.MatchString(strings.TrimSpace(rest)) {
1329 return fmt.Errorf("set-column-width needs +N or -N cells (1-16)")
1330 }
1331 return nil
1332 }
1333 if rest, ok := strings.CutPrefix(v, "set-window-height "); ok {
1334 if !heightStep.MatchString(strings.TrimSpace(rest)) {
1335 return fmt.Errorf("set-window-height needs +N%% or -N%% (1-100)")
1336 }
1337 return nil
1338 }
1339 for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {
1340 if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {
1341 return fmt.Errorf("action %q was renamed to %q", v, base+repl)
1342 }
1343 }
1344 if !actions[v] {
1345 return fmt.Errorf(`unknown action %q (or "spawn <command>", "stash <command>", "workspace <name>")`, v)
1346 }
1347 return nil
1348}
1349
1350// parseTextClick parses hint.text-click: `+`-separated modifier names as in
1351// binds, or none. cmd is an alias of super here (it is not resolved through
1352// keyboard.cmd, so the result does not depend on key order).
1353func parseTextClick(v string) (ports.Mods, error) {
1354 if strings.ToLower(v) == "none" {
1355 return 0, nil
1356 }
1357 var mods ports.Mods
1358 for _, p := range strings.Split(v, "+") {
1359 var m ports.Mods
1360 switch strings.ToLower(strings.TrimSpace(p)) {
1361 case "shift":
1362 m = ports.ModShift
1363 case "ctrl":
1364 m = ports.ModCtrl
1365 case "alt":
1366 m = ports.ModAlt
1367 case "super", "cmd":
1368 m = ports.ModSuper
1369 default:
1370 return 0, fmt.Errorf("unknown modifier %q (use shift, ctrl, alt, super or cmd joined by +, or none)", p)
1371 }
1372 if mods&m != 0 {
1373 return 0, fmt.Errorf("duplicate modifier %q", p)
1374 }
1375 mods |= m
1376 }
1377 return mods, nil
1378}
1379
1380// parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
1381// modifiers sorted, the key as an xkb keysym name.
1382func parseCombo(s string) (string, error) {
1383 parts := strings.Split(s, "+")
1384 key := parts[len(parts)-1]
1385 if len(parts) > 1 && key == "" {
1386 // "cmd++" means the + key.
1387 if len(parts) > 2 && parts[len(parts)-2] == "" {
1388 parts, key = parts[:len(parts)-1], "+"
1389 } else {
1390 return "", fmt.Errorf("missing key")
1391 }
1392 }
1393 if key == "" {
1394 return "", fmt.Errorf("missing key")
1395 }
1396 seen := map[string]bool{}
1397 for _, m := range parts[:len(parts)-1] {
1398 var name string
1399 switch strings.ToLower(m) {
1400 case "cmd":
1401 name = "Cmd"
1402 case "shift":
1403 name = "Shift"
1404 case "ctrl":
1405 name = "Ctrl"
1406 case "alt":
1407 name = "Alt"
1408 case "super":
1409 name = "Super"
1410 default:
1411 return "", fmt.Errorf("unknown modifier %q", m)
1412 }
1413 if seen[name] {
1414 return "", fmt.Errorf("duplicate modifier %q", m)
1415 }
1416 seen[name] = true
1417 }
1418 mods := make([]string, 0, len(seen))
1419 for m := range seen {
1420 mods = append(mods, m)
1421 }
1422 sort.Strings(mods)
1423 name, err := keysym(key)
1424 if err != nil {
1425 return "", err
1426 }
1427 return strings.Join(append(mods, name), "+"), nil
1428}
1429
1430// keysym maps a written key to its xkb keysym name.
1431func keysym(k string) (string, error) {
1432 // code:N is the physical key (evdev code), the same on every layout.
1433 if rest, ok := strings.CutPrefix(strings.ToLower(k), "code:"); ok {
1434 if n, err := strconv.Atoi(rest); err == nil && n > 0 && n < 768 {
1435 return "code:" + rest, nil
1436 }
1437 return "", fmt.Errorf("code must be an evdev key code like code:2")
1438 }
1439 if r := []rune(k); len(r) == 1 {
1440 // Binds match the unshifted keysym, so É means é.
1441 if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
1442 return name, nil
1443 }
1444 if r[0] > unicode.MaxASCII {
1445 return "", fmt.Errorf("write %q by its xkb keysym name", k)
1446 }
1447 return strings.ToLower(k), nil
1448 }
1449 if name, ok := namedKeys[strings.ToLower(k)]; ok {
1450 return name, nil
1451 }
1452 if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
1453 if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
1454 return "F" + k[1:], nil
1455 }
1456 }
1457 return k, nil
1458}
1459
1460// parseTransform accepts the wl_output.transform names.
1461func parseTransform(v string) (ports.BufferTransform, error) {
1462 switch v {
1463 case "normal":
1464 return 0, nil
1465 case "90":
1466 return 1, nil
1467 case "180":
1468 return 2, nil
1469 case "270":
1470 return 3, nil
1471 case "flipped":
1472 return 4, nil
1473 case "flipped-90":
1474 return 5, nil
1475 case "flipped-180":
1476 return 6, nil
1477 case "flipped-270":
1478 return 7, nil
1479 }
1480 return 0, fmt.Errorf("invalid transform %q (normal, 90, 180, 270, flipped, flipped-90, flipped-180, flipped-270)", v)
1481}
1482
1483// parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.
1484func parseScale(v string) (float64, error) {
1485 var s float64
1486 var err error
1487 if num, den, ok := strings.Cut(v, "/"); ok {
1488 var n, d float64
1489 n, err = strconv.ParseFloat(strings.TrimSpace(num), 64)
1490 if err == nil {
1491 d, err = strconv.ParseFloat(strings.TrimSpace(den), 64)
1492 }
1493 if err == nil && d != 0 {
1494 s = n / d
1495 }
1496 } else {
1497 s, err = strconv.ParseFloat(v, 64)
1498 }
1499 if err != nil || !(s >= 1 && s <= 4) {
1500 return 0, fmt.Errorf("must be between 1 and 4, like 1.5 or 4/3")
1501 }
1502 return s, nil
1503}
1504
1505// ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
1506func ParseMode(s string) (w, h int, hz float64, err error) {
1507 if s == "preferred" {
1508 return 0, 0, 0, nil
1509 }
1510 size, rate, hasRate := strings.Cut(s, "@")
1511 ws, hs, ok := strings.Cut(size, "x")
1512 w, e1 := strconv.Atoi(ws)
1513 h, e2 := strconv.Atoi(hs)
1514 if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
1515 return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s)
1516 }
1517 if hasRate {
1518 if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
1519 return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
1520 }
1521 }
1522 return w, h, hz, nil
1523}
1524
1525// errCells is the error of a width that is not a cell count.
1526var errCells = fmt.Errorf("must be a cell count from 1 to 16")
1527
1528// cells parses a column width in cells: an integer from 1 to 16.
1529func cells(s string) (int, bool) {
1530 if s == "" || strings.Trim(s, "0123456789") != "" {
1531 return 0, false
1532 }
1533 n, err := strconv.Atoi(s)
1534 return n, err == nil && n >= 1 && n <= 16
1535}