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