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