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