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