1// Package config reads the flat `key = value` config file (ADR 012).
2package config
3
4import (
5 "bufio"
6 "fmt"
7 "io"
8 "math"
9 "os"
10 "path/filepath"
11 "regexp"
12 "slices"
13 "sort"
14 "strconv"
15 "strings"
16 "unicode"
17
18 "github.com/bnema/neferwl/internal/ports"
19)
20
21// defaultBinds use the config syntax and go through the same parser as user binds.
22var defaultBinds = []struct{ combo, action string }{
23 {"cmd+return", "spawn-terminal"},
24 {"cmd+left", "focus-column-left"},
25 {"cmd+right", "focus-column-right"},
26 {"cmd+up", "focus-window-up"},
27 {"cmd+down", "focus-window-down"},
28 {"cmd+shift+left", "move-column-left"},
29 {"cmd+shift+right", "move-column-right"},
30 {"cmd+bracketleft", "consume-or-expel-window-left"},
31 {"cmd+bracketright", "consume-or-expel-window-right"},
32 {"cmd+r", "cycle-column-width"},
33 {"cmd+shift+f", "toggle-fullscreen"},
34 {"cmd+q", "close-window"},
35 {"ctrl+alt+backspace", "quit"},
36 {"ctrl+cmd+space", "spawn fuzzel"},
37 {"cmd+pageup", "focus-workspace-up"},
38 {"cmd+pagedown", "focus-workspace-down"},
39 {"cmd+shift+pageup", "move-column-to-workspace-up"},
40 {"cmd+shift+pagedown", "move-column-to-workspace-down"},
41 {"cmd+ctrl+left", "focus-monitor-left"},
42 {"cmd+ctrl+right", "focus-monitor-right"},
43 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
44 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
45}
46var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
47var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
48var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
49
50// namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
51var namedKeys = map[string]string{
52 "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape",
53 "backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End",
54 "pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print",
55 "left": "Left", "right": "Right", "up": "Up", "down": "Down",
56}
57
58// Warning is a non-fatal problem at one line; the key keeps its default.
59type Warning struct {
60 Line int
61 Msg string
62}
63
64func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) }
65
66func DefaultPath() string {
67 base := os.Getenv("XDG_CONFIG_HOME")
68 if base == "" {
69 home, err := os.UserHomeDir()
70 if err == nil {
71 base = filepath.Join(home, ".config")
72 }
73 }
74 return filepath.Join(base, "neferwl", "config")
75}
76
77func Defaults() ports.Config {
78 var c ports.Config
79 c.Keyboard.RepeatRate = 25
80 c.Keyboard.RepeatDelay = 600
81 c.Keyboard.CmdKey = "super"
82 c.Terminal.Command = []string{"foot"}
83 c.Xwayland = "xwayland-satellite"
84 c.Background.Color = "#111111"
85 c.Border.Width = 2
86 c.Border.Active = "#808080"
87 c.Border.Inactive = "#111111"
88 c.Layout.MaxColumns = 2
89 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
90 c.Layout.Overflow = "scroll"
91 c.Binds = map[string]string{}
92 for _, b := range defaultBinds {
93 combos := []string{b.combo}
94 // Every arrow bind has a vim twin: h j k l.
95 for arrow, vim := range map[string]string{"left": "h", "down": "j", "up": "k", "right": "l"} {
96 if base, ok := strings.CutSuffix(b.combo, "+"+arrow); ok {
97 combos = append(combos, base+"+"+vim)
98 }
99 }
100 for _, s := range combos {
101 combo, err := parseCombo(s)
102 if err != nil {
103 panic(err)
104 }
105 c.Binds[combo] = b.action
106 }
107 }
108 // Digits and zoom use physical keys (evdev codes 2-10, 12, 13), so they
109 // work the same on AZERTY, QWERTZ or Dvorak.
110 for combo, action := range map[string]string{"cmd+code:13": "scale-up", "cmd+code:12": "scale-down"} {
111 canon, _ := parseCombo(combo)
112 c.Binds[canon] = action
113 }
114 for n := 1; n <= 9; n++ {
115 focus, _ := parseCombo(fmt.Sprintf("cmd+code:%d", n+1))
116 move, _ := parseCombo(fmt.Sprintf("cmd+shift+code:%d", n+1))
117 c.Binds[focus] = fmt.Sprintf("focus-workspace %d", n)
118 c.Binds[move] = fmt.Sprintf("move-column-to-workspace %d", n)
119 }
120 c.Render.DirectScanout = true
121 c.Render.Tearing = true
122 c.Render.VRR = true
123 c.Log.Level = "info"
124 c.Log.Debug = []string{}
125 return c
126}
127
128// LoadDefault loads DefaultPath; a missing file means defaults.
129func LoadDefault() (ports.Config, []Warning, error) {
130 c, w, err := Load(DefaultPath())
131 if os.IsNotExist(err) {
132 return Defaults(), nil, nil
133 }
134 return c, w, err
135}
136
137// Load reads a config file. The error is only for I/O; bad lines become warnings.
138func Load(path string) (ports.Config, []Warning, error) {
139 f, err := os.Open(path)
140 if err != nil {
141 return Defaults(), nil, err
142 }
143 defer f.Close()
144 return Parse(f)
145}
146
147// Parse reads `key = value` lines on top of the defaults.
148func Parse(r io.Reader) (ports.Config, []Warning, error) {
149 c, _, w, err := parse(r)
150 return c, w, err
151}
152
153// parse also returns the effective key/value pairs, used to log what changed on reload.
154func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
155 c := Defaults()
156 var warnings []Warning
157 raw := map[string]string{}
158 seen := map[string]int{}
159 outputs := map[string]int{}
160 workspaces := map[string]int{}
161 scanner := bufio.NewScanner(r)
162 for n := 1; scanner.Scan(); n++ {
163 line := strings.TrimSpace(scanner.Text())
164 if line == "" || strings.HasPrefix(line, "#") {
165 continue
166 }
167 warn := func(format string, a ...any) {
168 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
169 }
170 key, value, ok := strings.Cut(line, "=")
171 key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
172 if !ok || key == "" {
173 warn("expected key = value")
174 continue
175 }
176 if combo, ok := strings.CutPrefix(key, "bind."); ok {
177 if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
178 warn("%s: write %sequal instead of %s=", key, combo, combo)
179 continue
180 }
181 canon, err := parseCombo(combo)
182 if err != nil {
183 warn("%s: %v", key, err)
184 continue
185 }
186 if err := checkAction(value); err != nil {
187 warn("%s: %v", key, err)
188 continue
189 }
190 key = "bind." + canon
191 if prev, dup := seen[key]; dup {
192 warn("%s: overrides line %d", key, prev)
193 }
194 seen[key] = n
195 raw[key] = value
196 c.Binds[canon] = value
197 continue
198 }
199 override := func() {
200 if prev, dup := seen[key]; dup {
201 warn("%s: overrides line %d", key, prev)
202 }
203 seen[key] = n
204 raw[key] = value
205 }
206 if name, ok := strings.CutPrefix(key, "output."); ok {
207 entry := func() *ports.OutputConfig {
208 if i, ok := outputs[name]; ok {
209 return &c.Outputs[i]
210 }
211 outputs[name] = len(c.Outputs)
212 c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true})
213 return &c.Outputs[len(c.Outputs)-1]
214 }
215 if base, ok := strings.CutSuffix(name, ".primary"); ok {
216 name = base
217 b, err := onOff(value)
218 if err != nil {
219 warn("%s: %v", key, err)
220 continue
221 }
222 override()
223 entry().Primary = b
224 continue
225 }
226 if base, ok := strings.CutSuffix(name, ".scale"); ok {
227 name = base
228 s, err := parseScale(value)
229 if err != nil {
230 warn("%s: %v", key, err)
231 continue
232 }
233 override()
234 entry().Scale = s
235 continue
236 }
237 o := ports.OutputConfig{Name: name}
238 if value == "off" {
239 o.Off = true
240 } else if _, _, _, err := ParseMode(value); err != nil {
241 warn("%s: %v", key, err)
242 continue
243 } else if value != "preferred" {
244 o.Mode = value
245 }
246 override()
247 e := entry()
248 o.Scale, o.Primary = e.Scale, e.Primary
249 *e = o
250 continue
251 }
252 if rest, ok := strings.CutPrefix(key, "workspace."); ok {
253 name, field, _ := strings.Cut(rest, ".")
254 if !workspaceName.MatchString(name) {
255 warn("%s: workspace name must be letters, digits, - or _", key)
256 continue
257 }
258 var ws ports.WorkspaceConfig
259 if i, ok := workspaces[name]; ok {
260 ws = c.Workspaces[i]
261 }
262 if err := setWorkspace(&ws, field, value); err != nil {
263 warn("%s: %v", key, err)
264 continue
265 }
266 override()
267 if i, ok := workspaces[name]; ok {
268 c.Workspaces[i] = ws
269 } else {
270 ws.Name = name
271 workspaces[name] = len(c.Workspaces)
272 c.Workspaces = append(c.Workspaces, ws)
273 }
274 continue
275 }
276 if err := set(&c, key, value); err != nil {
277 warn("%s: %v", key, err)
278 continue
279 }
280 if key == "startup" {
281 // Repeatable: each line adds a command.
282 raw[fmt.Sprintf("startup.%d", len(c.Startup))] = value
283 continue
284 }
285 override()
286 }
287 if err := scanner.Err(); err != nil {
288 return c, raw, warnings, err
289 }
290 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
291 // A user bind on a digit (cmd+1, even "none") replaces the default bound
292 // to the same physical key (cmd+code:2), so older configs keep working.
293 for combo := range c.Binds {
294 if seen["bind."+combo] == 0 {
295 continue
296 }
297 parts := strings.Split(combo, "+")
298 key := parts[len(parts)-1]
299 if len(key) != 1 || key[0] < '0' || key[0] > '9' {
300 continue
301 }
302 code := int(key[0]-'0') + 1
303 if key == "0" {
304 code = 11
305 }
306 parts[len(parts)-1] = "code:" + strconv.Itoa(code)
307 if physical := strings.Join(parts, "+"); seen["bind."+physical] == 0 {
308 delete(c.Binds, physical)
309 }
310 }
311 // "none" only removes a default bind.
312 for combo, a := range c.Binds {
313 if a == "none" {
314 delete(c.Binds, combo)
315 }
316 }
317 // Cmd resolves to a real modifier; drop binds that then collide. As for any key,
318 // the later line wins; defaults (no line) lose to every user line.
319 combos := make([]string, 0, len(c.Binds))
320 for combo := range c.Binds {
321 combos = append(combos, combo)
322 }
323 order := func(combo string) int {
324 if n := seen["bind."+combo]; n > 0 {
325 return -n
326 }
327 return math.MaxInt
328 }
329 sort.Slice(combos, func(i, j int) bool {
330 a, b := order(combos[i]), order(combos[j])
331 return a < b || a == b && combos[i] < combos[j]
332 })
333 resolved := map[string]string{}
334 for _, combo := range combos {
335 r, err := expandCmd(combo, c.Keyboard.CmdKey)
336 if prev, dup := resolved[r]; err == nil && dup {
337 err = fmt.Errorf("overridden by bind.%s (same keys with keyboard.cmd = %s)", prev, c.Keyboard.CmdKey)
338 }
339 if err != nil {
340 if n := seen["bind."+combo]; n > 0 {
341 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)})
342 }
343 delete(c.Binds, combo)
344 delete(raw, "bind."+combo)
345 continue
346 }
347 resolved[r] = combo
348 }
349 return c, raw, warnings, nil
350}
351
352// expandCmd replaces Cmd with the configured modifier and re-sorts.
353func expandCmd(combo, cmd string) (string, error) {
354 parts := strings.Split(combo, "+")
355 key := parts[len(parts)-1]
356 // cmd is super, alt or ctrl; its modifier name is the capitalized form.
357 cmdName := strings.ToUpper(cmd[:1]) + cmd[1:]
358 seen := map[string]bool{}
359 for _, m := range parts[:len(parts)-1] {
360 if m == "Cmd" {
361 m = cmdName
362 }
363 if seen[m] {
364 return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd)
365 }
366 seen[m] = true
367 }
368 mods := make([]string, 0, len(seen))
369 for m := range seen {
370 mods = append(mods, m)
371 }
372 sort.Strings(mods)
373 return strings.Join(append(mods, key), "+"), nil
374}
375
376// set applies one scalar key. Unknown keys and invalid values are errors.
377func set(c *ports.Config, key, v string) error {
378 switch key {
379 case "keyboard.layout":
380 c.Keyboard.Layout = v
381 case "keyboard.variant":
382 c.Keyboard.Variant = v
383 case "keyboard.options":
384 c.Keyboard.Options = v
385 case "keyboard.repeat-rate":
386 return positive(&c.Keyboard.RepeatRate, v, 1000)
387 case "keyboard.repeat-delay":
388 return positive(&c.Keyboard.RepeatDelay, v, 10000)
389 case "keyboard.cmd":
390 if v != "super" && v != "alt" && v != "ctrl" {
391 return fmt.Errorf("must be super, alt or ctrl")
392 }
393 c.Keyboard.CmdKey = v
394 case "startup":
395 argv := strings.Fields(v)
396 if len(argv) == 0 {
397 return fmt.Errorf("must not be empty")
398 }
399 c.Startup = append(c.Startup, argv)
400 case "terminal":
401 argv := strings.Fields(v)
402 if len(argv) == 0 {
403 return fmt.Errorf("must not be empty")
404 }
405 c.Terminal.Command = argv
406 case "xwayland":
407 switch {
408 case v == "off":
409 c.Xwayland = ""
410 case v == "" || strings.ContainsAny(v, " \t"):
411 return fmt.Errorf("must be off or the xwayland-satellite program")
412 default:
413 c.Xwayland = v
414 }
415 case "background":
416 if !color.MatchString(v) {
417 return fmt.Errorf("must be #rrggbb")
418 }
419 c.Background.Color = v
420 case "border.width":
421 n, err := strconv.Atoi(v)
422 if err != nil || n < 0 || n > 32 {
423 return fmt.Errorf("must be between 0 and 32")
424 }
425 c.Border.Width = n
426 case "border.active":
427 if !color.MatchString(v) {
428 return fmt.Errorf("must be #rrggbb")
429 }
430 c.Border.Active = v
431 case "border.inactive":
432 if v != "" && !color.MatchString(v) {
433 return fmt.Errorf("must be #rrggbb or empty (none)")
434 }
435 c.Border.Inactive = v
436 case "layout.gaps":
437 n, err := strconv.Atoi(v)
438 if err != nil || n < 0 || n > 200 {
439 return fmt.Errorf("must be between 0 and 200")
440 }
441 c.Layout.Gaps = n
442 case "layout.default-width":
443 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
444 case "layout.max-columns":
445 return positive(&c.Layout.MaxColumns, v, 16)
446 case "layout.overflow":
447 if v != "scroll" && v != "fixed" {
448 return fmt.Errorf("must be scroll or fixed")
449 }
450 c.Layout.Overflow = v
451 case "layout.presets":
452 list := splitList(v)
453 if len(list) == 0 {
454 return fmt.Errorf("must not be empty")
455 }
456 for _, s := range list {
457 if !width(s) {
458 return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
459 }
460 }
461 c.Layout.Presets = list
462 case "focus.follow-move":
463 b, err := onOff(v)
464 if err != nil {
465 return err
466 }
467 c.Focus.FollowMove = b
468 case "render.direct-scanout":
469 b, err := onOff(v)
470 if err != nil {
471 return err
472 }
473 c.Render.DirectScanout = b
474 case "render.tearing":
475 b, err := onOff(v)
476 if err != nil {
477 return err
478 }
479 c.Render.Tearing = b
480 case "render.vrr":
481 b, err := onOff(v)
482 if err != nil {
483 return err
484 }
485 c.Render.VRR = b
486 case "log.level":
487 if v != "debug" && v != "info" && v != "warn" && v != "error" {
488 return fmt.Errorf("must be debug, info, warn or error")
489 }
490 c.Log.Level = v
491 case "log.debug":
492 list := splitList(v)
493 for _, s := range list {
494 if !components[s] && s != "all" {
495 return fmt.Errorf("unknown component %q", s)
496 }
497 }
498 c.Log.Debug = list
499 default:
500 return fmt.Errorf("unknown key")
501 }
502 return nil
503}
504
505// checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
506// workspace (it does nothing) and a hidden workspace no bind reaches (its
507// windows could only come back by editing the config).
508func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
509 declared := map[string]bool{}
510 for _, w := range c.Workspaces {
511 declared[w.Name] = true
512 }
513 bound := map[string]bool{}
514 var warnings []Warning
515 for combo, a := range c.Binds {
516 name, ok := strings.CutPrefix(a, "workspace ")
517 if !ok {
518 continue
519 }
520 name = strings.TrimSpace(name)
521 bound[name] = true
522 if !declared[name] {
523 warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)})
524 }
525 }
526 for _, w := range c.Workspaces {
527 overflow := w.Overflow
528 if overflow == "" {
529 overflow = c.Layout.Overflow
530 }
531 if overflow == "fixed" && len(w.Slots) > 0 {
532 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
533 warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
534 }
535 if w.Hidden && !bound[w.Name] {
536 warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
537 }
538 }
539 sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
540 return warnings
541}
542
543var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
544
545// setWorkspace applies one workspace.<name>.<field> key.
546func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
547 switch field {
548 case "hidden":
549 b, err := onOff(v)
550 w.Hidden = b
551 return err
552 case "monitor":
553 if v == "" {
554 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
555 }
556 w.Monitor = v
557 case "max-columns":
558 return positive(&w.MaxColumns, v, 16)
559 case "overflow":
560 if v != "scroll" && v != "fixed" {
561 return fmt.Errorf("must be scroll or fixed")
562 }
563 w.Overflow = v
564 default:
565 n, ok := strings.CutPrefix(field, "column.")
566 if !ok {
567 return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)")
568 }
569 index, err := strconv.Atoi(n)
570 if err != nil || index < 1 || index > 16 {
571 return fmt.Errorf("column number must be between 1 and 16")
572 }
573 size, cmd, _ := strings.Cut(v, ",")
574 size = strings.TrimSpace(size)
575 argv := strings.Fields(cmd)
576 if !width(size) || len(argv) == 0 {
577 return fmt.Errorf("must be <width>, <command> like 67%%, foot")
578 }
579 slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
580 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
581 if i < len(w.Slots) && w.Slots[i].Index == index {
582 w.Slots[i] = slot
583 } else {
584 w.Slots = slices.Insert(w.Slots, i, slot)
585 }
586 }
587 return nil
588}
589
590func positive(dst *int, v string, max int) error {
591 n, err := strconv.Atoi(v)
592 if err != nil || n <= 0 || n > max {
593 return fmt.Errorf("must be between 1 and %d", max)
594 }
595 *dst = n
596 return nil
597}
598
599func onOff(v string) (bool, error) {
600 switch v {
601 case "on":
602 return true, nil
603 case "off":
604 return false, nil
605 }
606 return false, fmt.Errorf("must be on or off")
607}
608
609func splitList(v string) []string {
610 list := []string{}
611 for _, s := range strings.Split(v, ",") {
612 if s = strings.TrimSpace(s); s != "" {
613 list = append(list, s)
614 }
615 }
616 return list
617}
618
619// stripComment drops a trailing ` # comment` from a value. A # that starts the
620// value is kept, so colors like #1e1e2e need no quotes.
621func stripComment(value string) string {
622 for i := 1; i < len(value); i++ {
623 if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
624 return strings.TrimSpace(value[:i])
625 }
626 }
627 return value
628}
629
630func checkAction(v string) error {
631 if rest, ok := strings.CutPrefix(v, "spawn "); ok {
632 if len(strings.Fields(rest)) == 0 {
633 return fmt.Errorf("spawn needs a command")
634 }
635 return nil
636 }
637 if rest, ok := strings.CutPrefix(v, "workspace "); ok {
638 if !workspaceName.MatchString(strings.TrimSpace(rest)) {
639 return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
640 }
641 return nil
642 }
643 for _, prefix := range []string{"focus-workspace ", "move-column-to-workspace ", "move-window-to-workspace "} {
644 if rest, ok := strings.CutPrefix(v, prefix); ok {
645 if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 {
646 return fmt.Errorf("%snumber must be between 1 and 99", prefix)
647 }
648 return nil
649 }
650 }
651 if !actions[v] {
652 return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
653 }
654 return nil
655}
656
657// parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
658// modifiers sorted, the key as an xkb keysym name.
659func parseCombo(s string) (string, error) {
660 parts := strings.Split(s, "+")
661 key := parts[len(parts)-1]
662 if len(parts) > 1 && key == "" {
663 // "cmd++" means the + key.
664 if len(parts) > 2 && parts[len(parts)-2] == "" {
665 parts, key = parts[:len(parts)-1], "+"
666 } else {
667 return "", fmt.Errorf("missing key")
668 }
669 }
670 if key == "" {
671 return "", fmt.Errorf("missing key")
672 }
673 seen := map[string]bool{}
674 for _, m := range parts[:len(parts)-1] {
675 var name string
676 switch strings.ToLower(m) {
677 case "cmd":
678 name = "Cmd"
679 case "shift":
680 name = "Shift"
681 case "ctrl":
682 name = "Ctrl"
683 case "alt":
684 name = "Alt"
685 case "super":
686 name = "Super"
687 default:
688 return "", fmt.Errorf("unknown modifier %q", m)
689 }
690 if seen[name] {
691 return "", fmt.Errorf("duplicate modifier %q", m)
692 }
693 seen[name] = true
694 }
695 mods := make([]string, 0, len(seen))
696 for m := range seen {
697 mods = append(mods, m)
698 }
699 sort.Strings(mods)
700 name, err := keysym(key)
701 if err != nil {
702 return "", err
703 }
704 return strings.Join(append(mods, name), "+"), nil
705}
706
707// keysym maps a written key to its xkb keysym name.
708func keysym(k string) (string, error) {
709 // code:N is the physical key (evdev code), the same on every layout.
710 if rest, ok := strings.CutPrefix(strings.ToLower(k), "code:"); ok {
711 if n, err := strconv.Atoi(rest); err == nil && n > 0 && n < 768 {
712 return "code:" + rest, nil
713 }
714 return "", fmt.Errorf("code must be an evdev key code like code:2")
715 }
716 if r := []rune(k); len(r) == 1 {
717 // Binds match the unshifted keysym, so É means é.
718 if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
719 return name, nil
720 }
721 if r[0] > unicode.MaxASCII {
722 return "", fmt.Errorf("write %q by its xkb keysym name", k)
723 }
724 return strings.ToLower(k), nil
725 }
726 if name, ok := namedKeys[strings.ToLower(k)]; ok {
727 return name, nil
728 }
729 if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
730 if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
731 return "F" + k[1:], nil
732 }
733 }
734 return k, nil
735}
736
737// parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.
738func parseScale(v string) (float64, error) {
739 var s float64
740 var err error
741 if num, den, ok := strings.Cut(v, "/"); ok {
742 var n, d float64
743 n, err = strconv.ParseFloat(strings.TrimSpace(num), 64)
744 if err == nil {
745 d, err = strconv.ParseFloat(strings.TrimSpace(den), 64)
746 }
747 if err == nil && d != 0 {
748 s = n / d
749 }
750 } else {
751 s, err = strconv.ParseFloat(v, 64)
752 }
753 if err != nil || !(s >= 1 && s <= 4) {
754 return 0, fmt.Errorf("must be between 1 and 4, like 1.5 or 4/3")
755 }
756 return s, nil
757}
758
759// ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
760func ParseMode(s string) (w, h int, hz float64, err error) {
761 if s == "preferred" {
762 return 0, 0, 0, nil
763 }
764 size, rate, hasRate := strings.Cut(s, "@")
765 ws, hs, ok := strings.Cut(size, "x")
766 w, e1 := strconv.Atoi(ws)
767 h, e2 := strconv.Atoi(hs)
768 if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
769 return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s)
770 }
771 if hasRate {
772 if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
773 return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
774 }
775 }
776 return w, h, hz, nil
777}
778
779func width(s string) bool {
780 if s == "1" {
781 return true
782 }
783 if n, ok := strings.CutSuffix(s, "%"); ok {
784 v, e := strconv.Atoi(n)
785 return e == nil && v > 0 && v <= 100
786 }
787 if strings.HasSuffix(s, "px") {
788 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
789 return e == nil && n > 0
790 }
791 p := strings.Split(s, "/")
792 if len(p) != 2 {
793 return false
794 }
795 a, e1 := strconv.Atoi(p[0])
796 b, e2 := strconv.Atoi(p[1])
797 return e1 == nil && e2 == nil && a > 0 && a <= b
798}