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