v0.1.0 d9218d6

config.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close