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