detached 830fa72

feat(config): flat key=value config with live keyboard reload

Replaces TOML with the vev-style format (ADR 012): bind.<keys> = action, keys as xkb keysyms of the active layout, invalid lines warn and keep defaults, reloads log changed keys. Keyboard layout and repeat changes rebuild the keymap and send it to clients live.
commit
830fa72a4c691dbd5a66b9a28b8b48f781788fc8
author
brice · · 2026-09-24 13:38 UTC
parent
2172e12
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +1031 -408
M README.md +9 -7
@@ -10,16 +10,18 @@ Run: `nefertty --backend=headless --timeout 5s`
1010
1111 ## Configuration
1212
13-Configuration lives at `$XDG_CONFIG_HOME/nefertty/config.toml` (or `~/.config/nefertty/config.toml`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor.
13+Configuration lives at `$XDG_CONFIG_HOME/nefertty/config` (or `~/.config/nefertty/config`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor.
1414
15-```toml
16-[terminal]
17-command = ["foot"]
18-[layout]
19-gaps = 0
15+One `key = value` per line; `#` starts a comment:
16+
17+```text
18+keyboard.layout = fr
19+terminal = foot
20+bind.cmd+return = spawn-terminal
21+bind.ctrl+cmd+space = spawn fuzzel
2022 ```
2123
22-See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place.
24+See `examples/config` for every key and its default. A missing file means defaults. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. Every key applies live when the file changes, including the keyboard layout; each reload logs the keys that changed.
2325
2426 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.
2527
@@ -10,16 +10,18 @@ Run: `nefertty --backend=headless --timeout 5s`
10 10
11 ## Configuration11 ## Configuration
12 12
13-Configuration lives at `$XDG_CONFIG_HOME/nefertty/config.toml` (or `~/.config/nefertty/config.toml`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor.13+Configuration lives at `$XDG_CONFIG_HOME/nefertty/config` (or `~/.config/nefertty/config`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor.
14 14
15-```toml15+One `key = value` per line; `#` starts a comment:
16-[terminal]16+
17-command = ["foot"]17+```text
18-[layout]18+keyboard.layout = fr
19-gaps = 019+terminal = foot
20+bind.cmd+return = spawn-terminal
21+bind.ctrl+cmd+space = spawn fuzzel
20 ```22 ```
21 23
22-See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place.24+See `examples/config` for every key and its default. A missing file means defaults. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. Every key applies live when the file changes, including the keyboard layout; each reload logs the keys that changed.
23 25
24 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.26 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.
25 27
M cmd/nefertty/main.go +13 -6
@@ -50,17 +50,20 @@ func run() error {
5050 return err
5151 }
5252 path := config.DefaultPath()
53- var err error
5453 if len(os.Args) == 3 {
5554 path = os.Args[2]
56- _, err = config.Load(path)
57- } else {
58- _, err = config.LoadDefault()
5955 }
56+ _, warnings, err := config.Load(path)
6057 if err != nil {
6158 fmt.Fprintln(os.Stderr, err)
6259 return err
6360 }
61+ for _, w := range warnings {
62+ fmt.Fprintf(os.Stderr, "%s:%s\n", path, w)
63+ }
64+ if len(warnings) > 0 {
65+ return fmt.Errorf("%d warning(s)", len(warnings))
66+ }
6467 fmt.Println("ok: " + path)
6568 return nil
6669 }
@@ -132,11 +135,12 @@ func run() error {
132135 path := *configFlag
133136 var cfgErr error
134137 var cfg = config.Defaults()
138+ var warnings []config.Warning
135139 if path == "" {
136140 path = config.DefaultPath()
137- cfg, cfgErr = config.LoadDefault()
141+ cfg, warnings, cfgErr = config.LoadDefault()
138142 } else {
139- cfg, cfgErr = config.Load(path)
143+ cfg, warnings, cfgErr = config.Load(path)
140144 }
141145 if cfgErr != nil {
142146 fmt.Fprintln(os.Stderr, cfgErr)
@@ -180,6 +184,9 @@ func run() error {
180184 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
181185 configLog := logging.For(ctx, "config")
182186 configLog.Info().Str("path", path).Msg("loaded config")
187+ for _, w := range warnings {
188+ configLog.Warn().Int("line", w.Line).Msg(w.Msg)
189+ }
183190 err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script})
184191 // SIGINT and SIGTERM cancel the context and are clean exits.
185192 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
@@ -50,17 +50,20 @@ func run() error {
50 return err50 return err
51 }51 }
52 path := config.DefaultPath()52 path := config.DefaultPath()
53- var err error
54 if len(os.Args) == 3 {53 if len(os.Args) == 3 {
55 path = os.Args[2]54 path = os.Args[2]
56- _, err = config.Load(path)
57- } else {
58- _, err = config.LoadDefault()
59 }55 }
56+ _, warnings, err := config.Load(path)
60 if err != nil {57 if err != nil {
61 fmt.Fprintln(os.Stderr, err)58 fmt.Fprintln(os.Stderr, err)
62 return err59 return err
63 }60 }
61+ for _, w := range warnings {
62+ fmt.Fprintf(os.Stderr, "%s:%s\n", path, w)
63+ }
64+ if len(warnings) > 0 {
65+ return fmt.Errorf("%d warning(s)", len(warnings))
66+ }
64 fmt.Println("ok: " + path)67 fmt.Println("ok: " + path)
65 return nil68 return nil
66 }69 }
@@ -132,11 +135,12 @@ func run() error {
132 path := *configFlag135 path := *configFlag
133 var cfgErr error136 var cfgErr error
134 var cfg = config.Defaults()137 var cfg = config.Defaults()
138+ var warnings []config.Warning
135 if path == "" {139 if path == "" {
136 path = config.DefaultPath()140 path = config.DefaultPath()
137- cfg, cfgErr = config.LoadDefault()141+ cfg, warnings, cfgErr = config.LoadDefault()
138 } else {142 } else {
139- cfg, cfgErr = config.Load(path)143+ cfg, warnings, cfgErr = config.Load(path)
140 }144 }
141 if cfgErr != nil {145 if cfgErr != nil {
142 fmt.Fprintln(os.Stderr, cfgErr)146 fmt.Fprintln(os.Stderr, cfgErr)
@@ -180,6 +184,9 @@ func run() error {
180 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")184 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
181 configLog := logging.For(ctx, "config")185 configLog := logging.For(ctx, "config")
182 configLog.Info().Str("path", path).Msg("loaded config")186 configLog.Info().Str("path", path).Msg("loaded config")
187+ for _, w := range warnings {
188+ configLog.Warn().Int("line", w.Line).Msg(w.Msg)
189+ }
183 err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script})190 err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script})
184 // SIGINT and SIGTERM cancel the context and are clean exits.191 // SIGINT and SIGTERM cancel the context and are clean exits.
185 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {192 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
M cmd/nefertty/main_test.go +2 -2
@@ -24,8 +24,8 @@ func TestExitCodes(t *testing.T) {
2424 {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
2525 {[]string{"nefertty", "--timeout=-1s"}, 2},
2626 {[]string{"nefertty", "extra"}, 2},
27- {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1},
28- {[]string{"nefertty", "validate-config", "/nonexistent/nefertty.toml"}, 1},
27+ {[]string{"nefertty", "--config=/nonexistent/nefertty.conf"}, 1},
28+ {[]string{"nefertty", "validate-config", "/nonexistent/nefertty.conf"}, 1},
2929 } {
3030 old := os.Args
3131 os.Args = tc.args
@@ -24,8 +24,8 @@ func TestExitCodes(t *testing.T) {
24 {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2},24 {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
25 {[]string{"nefertty", "--timeout=-1s"}, 2},25 {[]string{"nefertty", "--timeout=-1s"}, 2},
26 {[]string{"nefertty", "extra"}, 2},26 {[]string{"nefertty", "extra"}, 2},
27- {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1},27+ {[]string{"nefertty", "--config=/nonexistent/nefertty.conf"}, 1},
28- {[]string{"nefertty", "validate-config", "/nonexistent/nefertty.toml"}, 1},28+ {[]string{"nefertty", "validate-config", "/nonexistent/nefertty.conf"}, 1},
29 } {29 } {
30 old := os.Args30 old := os.Args
31 os.Args = tc.args31 os.Args = tc.args
A examples/config +54 -0
@@ -0,0 +1,54 @@
1+# nefertty config: one `key = value` per line, # starts a comment.
2+# Every value below is the default; a missing file means defaults.
3+# Changes apply live. An invalid line logs a warning and keeps the default.
4+
5+# Keyboard (xkb names, e.g. layout = fr, options = caps:escape).
6+keyboard.layout =
7+keyboard.variant =
8+keyboard.options =
9+keyboard.repeat-rate = 25
10+keyboard.repeat-delay = 600
11+# Which modifier `cmd` means in binds: super, alt or ctrl.
12+keyboard.cmd = super
13+
14+# Command for spawn-terminal and the terminal opened at startup.
15+terminal = foot
16+
17+background = #1e1e2e
18+
19+# Drawn inside the window edge; width 0 disables it, empty inactive draws nothing.
20+border.width = 2
21+border.active = #b4befe
22+border.inactive =
23+
24+layout.gaps = 0
25+layout.default-width = 1/2
26+layout.presets = 1/3, 1/2, 2/3, 1
27+
28+# Displays (drm backend): WxH, WxH@Hz, preferred or off.
29+# The first listed display that is connected and not off is used.
30+# output.DP-2 = 5120x2160@165.058
31+# output.HDMI-A-1 = off
32+
33+render.direct-scanout = on
34+
35+# log.level: debug, info, warn or error. log.debug: components or all.
36+log.level = info
37+log.debug =
38+
39+# Binds: bind.<keys> = <action>. Modifiers: cmd, shift, ctrl, alt, super.
40+# Keys are what your layout prints (cmd+é on AZERTY). Write = # and space
41+# by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default.
42+# `spawn <command>` runs a program; arguments split on spaces, no shell.
43+bind.cmd+return = spawn-terminal
44+bind.cmd+left = focus-column-left
45+bind.cmd+right = focus-column-right
46+bind.cmd+up = focus-window-up
47+bind.cmd+down = focus-window-down
48+bind.cmd+shift+left = move-column-left
49+bind.cmd+shift+right = move-column-right
50+bind.cmd+r = cycle-column-width
51+bind.cmd+shift+f = toggle-fullscreen
52+bind.cmd+q = close-window
53+bind.ctrl+alt+backspace = quit
54+bind.ctrl+cmd+space = spawn fuzzel
@@ -0,0 +1,54 @@
1+# nefertty config: one `key = value` per line, # starts a comment.
2+# Every value below is the default; a missing file means defaults.
3+# Changes apply live. An invalid line logs a warning and keeps the default.
4+
5+# Keyboard (xkb names, e.g. layout = fr, options = caps:escape).
6+keyboard.layout =
7+keyboard.variant =
8+keyboard.options =
9+keyboard.repeat-rate = 25
10+keyboard.repeat-delay = 600
11+# Which modifier `cmd` means in binds: super, alt or ctrl.
12+keyboard.cmd = super
13+
14+# Command for spawn-terminal and the terminal opened at startup.
15+terminal = foot
16+
17+background = #1e1e2e
18+
19+# Drawn inside the window edge; width 0 disables it, empty inactive draws nothing.
20+border.width = 2
21+border.active = #b4befe
22+border.inactive =
23+
24+layout.gaps = 0
25+layout.default-width = 1/2
26+layout.presets = 1/3, 1/2, 2/3, 1
27+
28+# Displays (drm backend): WxH, WxH@Hz, preferred or off.
29+# The first listed display that is connected and not off is used.
30+# output.DP-2 = 5120x2160@165.058
31+# output.HDMI-A-1 = off
32+
33+render.direct-scanout = on
34+
35+# log.level: debug, info, warn or error. log.debug: components or all.
36+log.level = info
37+log.debug =
38+
39+# Binds: bind.<keys> = <action>. Modifiers: cmd, shift, ctrl, alt, super.
40+# Keys are what your layout prints (cmd+é on AZERTY). Write = # and space
41+# by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default.
42+# `spawn <command>` runs a program; arguments split on spaces, no shell.
43+bind.cmd+return = spawn-terminal
44+bind.cmd+left = focus-column-left
45+bind.cmd+right = focus-column-right
46+bind.cmd+up = focus-window-up
47+bind.cmd+down = focus-window-down
48+bind.cmd+shift+left = move-column-left
49+bind.cmd+shift+right = move-column-right
50+bind.cmd+r = cycle-column-width
51+bind.cmd+shift+f = toggle-fullscreen
52+bind.cmd+q = close-window
53+bind.ctrl+alt+backspace = quit
54+bind.ctrl+cmd+space = spawn fuzzel
D examples/config.toml +0 -58
@@ -1,58 +0,0 @@
1-[keyboard]
2-layout = ""
3-variant = ""
4-options = ""
5-repeat_rate = 25
6-repeat_delay = 600
7-cmd_key = "super"
8-
9-[terminal]
10-command = ["foot"]
11-
12-[background]
13-color = "#1e1e2e"
14-
15-[border]
16-# Drawn inside the window edge; width = 0 disables it. inactive = "" draws nothing.
17-width = 2
18-active = "#b4befe"
19-inactive = ""
20-
21-[layout]
22-gaps = 0
23-default_column_width = "1/2"
24-presets = ["1/3", "1/2", "2/3", "1"]
25-
26-# Physical displays (drm backend). The first connected, enabled entry is used;
27-# without one, the first connected display that is not off. List names with
28-# `make tty` logs ("connector" lines) or wlr-randr.
29-# mode: "WxH" picks the highest refresh, "WxH@Hz" the closest; omit for preferred.
30-# [[output]]
31-# name = "DP-2"
32-# mode = "5120x2160@165.058"
33-#
34-# [[output]]
35-# name = "HDMI-A-1"
36-# off = true
37-
38-[render]
39-direct_scanout = true
40-
41-[log]
42-level = "info"
43-debug = []
44-
45-[binds]
46-"Cmd+Return" = "spawn-terminal"
47-"Cmd+Left" = "focus-column-left"
48-"Cmd+Right" = "focus-column-right"
49-"Cmd+Up" = "focus-window-up"
50-"Cmd+Down" = "focus-window-down"
51-"Cmd+Shift+Left" = "move-column-left"
52-"Cmd+Shift+Right" = "move-column-right"
53-"Cmd+R" = "cycle-column-width"
54-"Cmd+Shift+F" = "toggle-fullscreen"
55-"Cmd+Q" = "close-window"
56-"Ctrl+Alt+BackSpace" = "quit"
57-# "spawn <command>" runs a program; arguments split on spaces, no shell.
58-"Ctrl+Cmd+space" = "spawn fuzzel"
@@ -1,58 +0,0 @@
1-[keyboard]
2-layout = ""
3-variant = ""
4-options = ""
5-repeat_rate = 25
6-repeat_delay = 600
7-cmd_key = "super"
8-
9-[terminal]
10-command = ["foot"]
11-
12-[background]
13-color = "#1e1e2e"
14-
15-[border]
16-# Drawn inside the window edge; width = 0 disables it. inactive = "" draws nothing.
17-width = 2
18-active = "#b4befe"
19-inactive = ""
20-
21-[layout]
22-gaps = 0
23-default_column_width = "1/2"
24-presets = ["1/3", "1/2", "2/3", "1"]
25-
26-# Physical displays (drm backend). The first connected, enabled entry is used;
27-# without one, the first connected display that is not off. List names with
28-# `make tty` logs ("connector" lines) or wlr-randr.
29-# mode: "WxH" picks the highest refresh, "WxH@Hz" the closest; omit for preferred.
30-# [[output]]
31-# name = "DP-2"
32-# mode = "5120x2160@165.058"
33-#
34-# [[output]]
35-# name = "HDMI-A-1"
36-# off = true
37-
38-[render]
39-direct_scanout = true
40-
41-[log]
42-level = "info"
43-debug = []
44-
45-[binds]
46-"Cmd+Return" = "spawn-terminal"
47-"Cmd+Left" = "focus-column-left"
48-"Cmd+Right" = "focus-column-right"
49-"Cmd+Up" = "focus-window-up"
50-"Cmd+Down" = "focus-window-down"
51-"Cmd+Shift+Left" = "move-column-left"
52-"Cmd+Shift+Right" = "move-column-right"
53-"Cmd+R" = "cycle-column-width"
54-"Cmd+Shift+F" = "toggle-fullscreen"
55-"Cmd+Q" = "close-window"
56-"Ctrl+Alt+BackSpace" = "quit"
57-# "spawn <command>" runs a program; arguments split on spaces, no shell.
58-"Ctrl+Cmd+space" = "spawn fuzzel"
M go.mod +1 -2
@@ -3,7 +3,7 @@ module github.com/bnema/nefertty
33 go 1.27
44
55 require (
6- github.com/BurntSushi/toml v1.5.0
6+ github.com/bnema/purego v0.11.0-bnema.4
77 github.com/bnema/purego-libwayland v0.0.0
88 github.com/bnema/purego-vulkan v0.0.0
99 github.com/bnema/wlturbo v0.2.0
@@ -14,7 +14,6 @@ require (
1414 )
1515
1616 require (
17- github.com/bnema/purego v0.11.0-bnema.4 // indirect
1817 github.com/mattn/go-colorable v0.1.15 // indirect
1918 github.com/mattn/go-isatty v0.0.22 // indirect
2019 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
@@ -3,7 +3,7 @@ module github.com/bnema/nefertty
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/BurntSushi/toml v1.5.06+ github.com/bnema/purego v0.11.0-bnema.4
7 github.com/bnema/purego-libwayland v0.0.07 github.com/bnema/purego-libwayland v0.0.0
8 github.com/bnema/purego-vulkan v0.0.08 github.com/bnema/purego-vulkan v0.0.0
9 github.com/bnema/wlturbo v0.2.09 github.com/bnema/wlturbo v0.2.0
@@ -14,7 +14,6 @@ require (
14 )14 )
15 15
16 require (16 require (
17- github.com/bnema/purego v0.11.0-bnema.4 // indirect
18 github.com/mattn/go-colorable v0.1.15 // indirect17 github.com/mattn/go-colorable v0.1.15 // indirect
19 github.com/mattn/go-isatty v0.0.22 // indirect18 github.com/mattn/go-isatty v0.0.22 // indirect
20 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect19 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
M go.sum +0 -2 generated file: collapsed
M internal/adapters/config/config.go +405 -185
@@ -1,8 +1,11 @@
1+// Package config reads the flat `key = value` config file (ADR 012).
12 package config
23
34 import (
4- "errors"
5+ "bufio"
56 "fmt"
7+ "io"
8+ "math"
69 "os"
710 "path/filepath"
811 "regexp"
@@ -10,17 +13,44 @@ import (
1013 "strconv"
1114 "strings"
1215
13- "github.com/BurntSushi/toml"
1416 "github.com/bnema/nefertty/internal/ports"
1517 )
1618
17-var defaultBinds = map[string]string{
18- "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel",
19+// defaultBinds use the config syntax and go through the same parser as user binds.
20+var defaultBinds = []struct{ combo, action string }{
21+ {"cmd+return", "spawn-terminal"},
22+ {"cmd+left", "focus-column-left"},
23+ {"cmd+right", "focus-column-right"},
24+ {"cmd+up", "focus-window-up"},
25+ {"cmd+down", "focus-window-down"},
26+ {"cmd+shift+left", "move-column-left"},
27+ {"cmd+shift+right", "move-column-right"},
28+ {"cmd+r", "cycle-column-width"},
29+ {"cmd+shift+f", "toggle-fullscreen"},
30+ {"cmd+q", "close-window"},
31+ {"ctrl+alt+backspace", "quit"},
32+ {"ctrl+cmd+space", "spawn fuzzel"},
1933 }
2034 var actions = map[string]bool{"none": 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}
2135 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
2236 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
2337
38+// namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
39+var namedKeys = map[string]string{
40+ "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape",
41+ "backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End",
42+ "pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print",
43+ "left": "Left", "right": "Right", "up": "Up", "down": "Down",
44+}
45+
46+// Warning is a non-fatal problem at one line; the key keeps its default.
47+type Warning struct {
48+ Line int
49+ Msg string
50+}
51+
52+func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) }
53+
2454 func DefaultPath() string {
2555 base := os.Getenv("XDG_CONFIG_HOME")
2656 if base == "" {
@@ -29,8 +59,9 @@ func DefaultPath() string {
2959 base = filepath.Join(home, ".config")
3060 }
3161 }
32- return filepath.Join(base, "nefertty", "config.toml")
62+ return filepath.Join(base, "nefertty", "config")
3363 }
64+
3465 func Defaults() ports.Config {
3566 var c ports.Config
3667 c.Keyboard.RepeatRate = 25
@@ -40,111 +71,178 @@ func Defaults() ports.Config {
4071 c.Background.Color = "#1e1e2e"
4172 c.Border.Width = 2
4273 c.Border.Active = "#b4befe"
43- c.Border.Inactive = ""
44- c.Layout.Gaps = 0
4574 c.Layout.DefaultColumnWidth = "1/2"
4675 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
47- c.Binds = make(map[string]string, len(defaultBinds))
48- for k, v := range defaultBinds {
49- c.Binds[k] = v
76+ c.Binds = map[string]string{}
77+ for _, b := range defaultBinds {
78+ combo, err := parseCombo(b.combo)
79+ if err != nil {
80+ panic(err)
81+ }
82+ c.Binds[combo] = b.action
5083 }
5184 c.Render.DirectScanout = true
5285 c.Log.Level = "info"
5386 c.Log.Debug = []string{}
5487 return c
5588 }
56-func LoadDefault() (ports.Config, error) {
57- c, err := Load(DefaultPath())
89+
90+// LoadDefault loads DefaultPath; a missing file means defaults.
91+func LoadDefault() (ports.Config, []Warning, error) {
92+ c, w, err := Load(DefaultPath())
5893 if os.IsNotExist(err) {
59- return Defaults(), nil
94+ return Defaults(), nil, nil
6095 }
61- return c, err
96+ return c, w, err
6297 }
63-func Load(path string) (ports.Config, error) {
64- c := Defaults()
65- data, err := os.ReadFile(path)
98+
99+// Load reads a config file. The error is only for I/O; bad lines become warnings.
100+func Load(path string) (ports.Config, []Warning, error) {
101+ f, err := os.Open(path)
66102 if err != nil {
67- return c, err
68- }
69- // Decode binds separately so user entries can remove or override defaults.
70- var raw struct {
71- Binds map[string]string `toml:"binds"`
72- }
73- if _, err = toml.Decode(string(data), &raw); err != nil {
74- return c, err
103+ return Defaults(), nil, err
75104 }
76- c.Binds = make(map[string]string)
77- md, err := toml.Decode(string(data), &c)
78- if err != nil {
79- return c, err
80- }
81- var errs []error
82- for _, key := range md.Undecoded() {
83- errs = append(errs, fmt.Errorf("%s: unknown key", key.String()))
84- }
85- // Validate user bindings before merging to catch normalized collisions.
86- errs = append(errs, Validate(c))
87- if err = errors.Join(errs...); err != nil {
88- return c, err
89- }
90- c.Binds = Defaults().Binds
91- for k, v := range raw.Binds {
92- normalized, _ := normalize(k)
93- for old := range c.Binds {
94- n, _ := normalize(old)
95- if n == normalized {
96- delete(c.Binds, old)
105+ defer f.Close()
106+ return Parse(f)
107+}
108+
109+// Parse reads `key = value` lines on top of the defaults.
110+func Parse(r io.Reader) (ports.Config, []Warning, error) {
111+ c, _, w, err := parse(r)
112+ return c, w, err
113+}
114+
115+// parse also returns the effective key/value pairs, used to log what changed on reload.
116+func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
117+ c := Defaults()
118+ var warnings []Warning
119+ raw := map[string]string{}
120+ seen := map[string]int{}
121+ outputs := map[string]int{}
122+ scanner := bufio.NewScanner(r)
123+ for n := 1; scanner.Scan(); n++ {
124+ line := strings.TrimSpace(scanner.Text())
125+ if line == "" || strings.HasPrefix(line, "#") {
126+ continue
127+ }
128+ warn := func(format string, a ...any) {
129+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
130+ }
131+ key, value, ok := strings.Cut(line, "=")
132+ key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
133+ if !ok || key == "" {
134+ warn("expected key = value")
135+ continue
136+ }
137+ if combo, ok := strings.CutPrefix(key, "bind."); ok {
138+ if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
139+ warn("%s: write %sequal instead of %s=", key, combo, combo)
140+ continue
141+ }
142+ canon, err := parseCombo(combo)
143+ if err != nil {
144+ warn("%s: %v", key, err)
145+ continue
146+ }
147+ if err := checkAction(value); err != nil {
148+ warn("%s: %v", key, err)
149+ continue
150+ }
151+ key = "bind." + canon
152+ if prev, dup := seen[key]; dup {
153+ warn("%s: overrides line %d", key, prev)
154+ }
155+ seen[key] = n
156+ raw[key] = value
157+ c.Binds[canon] = value
158+ continue
159+ }
160+ if prev, dup := seen[key]; dup {
161+ warn("%s: overrides line %d", key, prev)
162+ }
163+ seen[key] = n
164+ if name, ok := strings.CutPrefix(key, "output."); ok {
165+ o := ports.OutputConfig{Name: name}
166+ if value == "off" {
167+ o.Off = true
168+ } else if _, _, _, err := ParseMode(value); err != nil {
169+ warn("%s: %v", key, err)
170+ continue
171+ } else if value != "preferred" {
172+ o.Mode = value
97173 }
174+ raw[key] = value
175+ if i, ok := outputs[name]; ok {
176+ c.Outputs[i] = o
177+ } else {
178+ outputs[name] = len(c.Outputs)
179+ c.Outputs = append(c.Outputs, o)
180+ }
181+ continue
98182 }
99- if v != "none" {
100- c.Binds[k] = v
183+ if err := set(&c, key, value); err != nil {
184+ warn("%s: %v", key, err)
185+ continue
101186 }
187+ raw[key] = value
102188 }
103- return c, nil
104-}
105-
106-// ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted.
107-func ParseMode(s string) (w, h int, hz float64, err error) {
108- size, rate, hasRate := strings.Cut(s, "@")
109- ws, hs, ok := strings.Cut(size, "x")
110- w, e1 := strconv.Atoi(ws)
111- h, e2 := strconv.Atoi(hs)
112- if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
113- return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s)
189+ if err := scanner.Err(); err != nil {
190+ return c, raw, warnings, err
114191 }
115- if hasRate {
116- if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
117- return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
192+ // "none" only removes a default bind.
193+ for combo, a := range c.Binds {
194+ if a == "none" {
195+ delete(c.Binds, combo)
118196 }
119197 }
120- return w, h, hz, nil
121-}
122-
123-func width(s string) bool {
124- if s == "1" {
125- return true
126- }
127- if strings.HasSuffix(s, "px") {
128- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
129- return e == nil && n > 0
198+ // Cmd resolves to a real modifier; drop binds that then collide. User lines win over
199+ // defaults (line 0), then the earliest line wins.
200+ combos := make([]string, 0, len(c.Binds))
201+ for combo := range c.Binds {
202+ combos = append(combos, combo)
130203 }
131- p := strings.Split(s, "/")
132- if len(p) != 2 {
133- return false
204+ order := func(combo string) int {
205+ if n := seen["bind."+combo]; n > 0 {
206+ return n
207+ }
208+ return math.MaxInt
209+ }
210+ sort.Slice(combos, func(i, j int) bool {
211+ a, b := order(combos[i]), order(combos[j])
212+ return a < b || a == b && combos[i] < combos[j]
213+ })
214+ resolved := map[string]string{}
215+ for _, combo := range combos {
216+ r, err := expandCmd(combo, c.Keyboard.CmdKey)
217+ if prev, dup := resolved[r]; err == nil && dup {
218+ err = fmt.Errorf("same keys as %s with keyboard.cmd = %s", prev, c.Keyboard.CmdKey)
219+ }
220+ if err != nil {
221+ if n := seen["bind."+combo]; n > 0 {
222+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)})
223+ }
224+ delete(c.Binds, combo)
225+ delete(raw, "bind."+combo)
226+ continue
227+ }
228+ resolved[r] = combo
134229 }
135- a, e1 := strconv.Atoi(p[0])
136- b, e2 := strconv.Atoi(p[1])
137- return e1 == nil && e2 == nil && a > 0 && a <= b
230+ return c, raw, warnings, nil
138231 }
139-func normalize(s string) (string, error) {
140- p := strings.Split(s, "+")
141- if len(p) < 2 || p[len(p)-1] == "" {
142- return "", fmt.Errorf("invalid key combo")
143- }
232+
233+// expandCmd replaces Cmd with the configured modifier and re-sorts.
234+func expandCmd(combo, cmd string) (string, error) {
235+ parts := strings.Split(combo, "+")
236+ key := parts[len(parts)-1]
237+ // cmd is super, alt or ctrl; its modifier name is the capitalized form.
238+ cmdName := strings.ToUpper(cmd[:1]) + cmd[1:]
144239 seen := map[string]bool{}
145- for _, m := range p[:len(p)-1] {
146- if m != "Cmd" && m != "Shift" && m != "Ctrl" && m != "Alt" || seen[m] {
147- return "", fmt.Errorf("invalid key combo")
240+ for _, m := range parts[:len(parts)-1] {
241+ if m == "Cmd" {
242+ m = cmdName
243+ }
244+ if seen[m] {
245+ return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd)
148246 }
149247 seen[m] = true
150248 }
@@ -153,130 +251,252 @@ func normalize(s string) (string, error) {
153251 mods = append(mods, m)
154252 }
155253 sort.Strings(mods)
156- return strings.Join(append(mods, p[len(p)-1]), "+"), nil
254+ return strings.Join(append(mods, key), "+"), nil
157255 }
158256
159-// resolvedCombo canonicalizes modifiers after resolving Cmd, so aliases collide.
160-func resolvedCombo(s, cmd string) (string, error) {
161- parts := strings.Split(s, "+")
162- mods := map[string]bool{}
163- for _, m := range parts[:len(parts)-1] {
164- if m == "Cmd" {
165- switch cmd {
166- case "super":
167- m = "Super"
168- case "alt":
169- m = "Alt"
170- case "ctrl":
171- m = "Ctrl"
172- default:
173- return "", fmt.Errorf("invalid cmd key")
257+// set applies one scalar key. Unknown keys and invalid values are errors.
258+func set(c *ports.Config, key, v string) error {
259+ switch key {
260+ case "keyboard.layout":
261+ c.Keyboard.Layout = v
262+ case "keyboard.variant":
263+ c.Keyboard.Variant = v
264+ case "keyboard.options":
265+ c.Keyboard.Options = v
266+ case "keyboard.repeat-rate":
267+ return positive(&c.Keyboard.RepeatRate, v, 1000)
268+ case "keyboard.repeat-delay":
269+ return positive(&c.Keyboard.RepeatDelay, v, 10000)
270+ case "keyboard.cmd":
271+ if v != "super" && v != "alt" && v != "ctrl" {
272+ return fmt.Errorf("must be super, alt or ctrl")
273+ }
274+ c.Keyboard.CmdKey = v
275+ case "terminal":
276+ argv := strings.Fields(v)
277+ if len(argv) == 0 {
278+ return fmt.Errorf("must not be empty")
279+ }
280+ c.Terminal.Command = argv
281+ case "background":
282+ if !color.MatchString(v) {
283+ return fmt.Errorf("must be #rrggbb")
284+ }
285+ c.Background.Color = v
286+ case "border.width":
287+ n, err := strconv.Atoi(v)
288+ if err != nil || n < 0 || n > 32 {
289+ return fmt.Errorf("must be between 0 and 32")
290+ }
291+ c.Border.Width = n
292+ case "border.active":
293+ if !color.MatchString(v) {
294+ return fmt.Errorf("must be #rrggbb")
295+ }
296+ c.Border.Active = v
297+ case "border.inactive":
298+ if v != "" && !color.MatchString(v) {
299+ return fmt.Errorf("must be #rrggbb or empty (none)")
300+ }
301+ c.Border.Inactive = v
302+ case "layout.gaps":
303+ n, err := strconv.Atoi(v)
304+ if err != nil || n < 0 || n > 200 {
305+ return fmt.Errorf("must be between 0 and 200")
306+ }
307+ c.Layout.Gaps = n
308+ case "layout.default-width":
309+ if !width(v) {
310+ return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px")
311+ }
312+ c.Layout.DefaultColumnWidth = v
313+ case "layout.presets":
314+ list := splitList(v)
315+ if len(list) == 0 {
316+ return fmt.Errorf("must not be empty")
317+ }
318+ for _, s := range list {
319+ if !width(s) {
320+ return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s)
174321 }
175322 }
176- if mods[m] {
177- return "", fmt.Errorf("duplicate resolved modifier")
323+ c.Layout.Presets = list
324+ case "render.direct-scanout":
325+ b, err := onOff(v)
326+ if err != nil {
327+ return err
328+ }
329+ c.Render.DirectScanout = b
330+ case "log.level":
331+ if v != "debug" && v != "info" && v != "warn" && v != "error" {
332+ return fmt.Errorf("must be debug, info, warn or error")
333+ }
334+ c.Log.Level = v
335+ case "log.debug":
336+ list := splitList(v)
337+ for _, s := range list {
338+ if !components[s] && s != "all" {
339+ return fmt.Errorf("unknown component %q", s)
340+ }
178341 }
179- mods[m] = true
342+ c.Log.Debug = list
343+ default:
344+ return fmt.Errorf("unknown key")
180345 }
181- ordered := make([]string, 0, len(mods))
182- for m := range mods {
183- ordered = append(ordered, m)
346+ return nil
347+}
348+
349+func positive(dst *int, v string, max int) error {
350+ n, err := strconv.Atoi(v)
351+ if err != nil || n <= 0 || n > max {
352+ return fmt.Errorf("must be between 1 and %d", max)
184353 }
185- sort.Strings(ordered)
186- return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil
354+ *dst = n
355+ return nil
187356 }
188-func Validate(c ports.Config) error {
189- var errs []error
190- add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) }
191- if c.Keyboard.RepeatRate <= 0 {
192- add("keyboard.repeat_rate", "must be positive")
193- }
194- if c.Keyboard.RepeatDelay <= 0 {
195- add("keyboard.repeat_delay", "must be positive")
196- }
197- if c.Keyboard.CmdKey != "super" && c.Keyboard.CmdKey != "alt" && c.Keyboard.CmdKey != "ctrl" {
198- add("keyboard.cmd_key", "must be super, alt or ctrl")
199- }
200- if len(c.Terminal.Command) == 0 {
201- add("terminal.command", "must not be empty")
202- } else {
203- for _, s := range c.Terminal.Command {
204- if s == "" {
205- add("terminal.command", "must not contain empty entries")
206- break
207- }
357+
358+func onOff(v string) (bool, error) {
359+ switch v {
360+ case "on":
361+ return true, nil
362+ case "off":
363+ return false, nil
364+ }
365+ return false, fmt.Errorf("must be on or off")
366+}
367+
368+func splitList(v string) []string {
369+ list := []string{}
370+ for _, s := range strings.Split(v, ",") {
371+ if s = strings.TrimSpace(s); s != "" {
372+ list = append(list, s)
208373 }
209374 }
210- if !color.MatchString(c.Background.Color) {
211- add("background.color", "must be #rrggbb")
375+ return list
376+}
377+
378+// stripComment drops a trailing ` # comment` from a value. A # that starts the
379+// value is kept, so colors like #1e1e2e need no quotes.
380+func stripComment(value string) string {
381+ for i := 1; i < len(value); i++ {
382+ if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
383+ return strings.TrimSpace(value[:i])
384+ }
212385 }
213- if c.Border.Width < 0 || c.Border.Width > 32 {
214- add("border.width", "must be between 0 and 32")
386+ return value
387+}
388+
389+func checkAction(v string) error {
390+ if rest, ok := strings.CutPrefix(v, "spawn "); ok {
391+ if len(strings.Fields(rest)) == 0 {
392+ return fmt.Errorf("spawn needs a command")
393+ }
394+ return nil
215395 }
216- if !color.MatchString(c.Border.Active) {
217- add("border.active", "must be #rrggbb")
396+ if !actions[v] {
397+ return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
398+ }
399+ return nil
400+}
401+
402+// parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
403+// modifiers sorted, the key as an xkb keysym name.
404+func parseCombo(s string) (string, error) {
405+ parts := strings.Split(s, "+")
406+ key := parts[len(parts)-1]
407+ if len(parts) > 1 && key == "" {
408+ // "cmd++" means the + key.
409+ if len(parts) > 2 && parts[len(parts)-2] == "" {
410+ parts, key = parts[:len(parts)-1], "+"
411+ } else {
412+ return "", fmt.Errorf("missing key")
413+ }
218414 }
219- if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {
220- add("border.inactive", `must be #rrggbb or "" (none)`)
415+ if key == "" {
416+ return "", fmt.Errorf("missing key")
221417 }
222- for i, o := range c.Outputs {
223- path := fmt.Sprintf("output[%d]", i)
224- if o.Name == "" {
225- add(path+".name", "must not be empty")
418+ seen := map[string]bool{}
419+ for _, m := range parts[:len(parts)-1] {
420+ var name string
421+ switch strings.ToLower(m) {
422+ case "cmd":
423+ name = "Cmd"
424+ case "shift":
425+ name = "Shift"
426+ case "ctrl":
427+ name = "Ctrl"
428+ case "alt":
429+ name = "Alt"
430+ case "super":
431+ name = "Super"
432+ default:
433+ return "", fmt.Errorf("unknown modifier %q", m)
226434 }
227- if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {
228- add(path+".mode", err.Error())
435+ if seen[name] {
436+ return "", fmt.Errorf("duplicate modifier %q", m)
229437 }
438+ seen[name] = true
230439 }
231- if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
232- add("layout.gaps", "must be between 0 and 200")
440+ mods := make([]string, 0, len(seen))
441+ for m := range seen {
442+ mods = append(mods, m)
233443 }
234- if !width(c.Layout.DefaultColumnWidth) {
235- add("layout.default_column_width", "must be a positive fraction or pixel width")
444+ sort.Strings(mods)
445+ return strings.Join(append(mods, keysym(key)), "+"), nil
446+}
447+
448+// keysym maps a written key to its xkb keysym name.
449+func keysym(k string) string {
450+ if r := []rune(k); len(r) == 1 {
451+ if name, ok := runeKeysyms[r[0]]; ok {
452+ return name
453+ }
454+ return strings.ToLower(k)
236455 }
237- if len(c.Layout.Presets) == 0 {
238- add("layout.presets", "must not be empty")
456+ if name, ok := namedKeys[strings.ToLower(k)]; ok {
457+ return name
239458 }
240- for i, s := range c.Layout.Presets {
241- if !width(s) {
242- add(fmt.Sprintf("layout.presets[%d]", i), "must be a positive fraction or pixel width")
459+ if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
460+ if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
461+ return "F" + k[1:]
243462 }
244463 }
245- if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" {
246- add("log.level", "invalid level")
464+ return k
465+}
466+
467+// ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
468+func ParseMode(s string) (w, h int, hz float64, err error) {
469+ if s == "preferred" {
470+ return 0, 0, 0, nil
471+ }
472+ size, rate, hasRate := strings.Cut(s, "@")
473+ ws, hs, ok := strings.Cut(size, "x")
474+ w, e1 := strconv.Atoi(ws)
475+ h, e2 := strconv.Atoi(hs)
476+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
477+ return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s)
247478 }
248- for i, s := range c.Log.Debug {
249- if !components[s] {
250- add(fmt.Sprintf("log.debug[%d]", i), "invalid component")
479+ if hasRate {
480+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
481+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
251482 }
252483 }
253- keys := make([]string, 0, len(c.Binds))
254- for k := range c.Binds {
255- keys = append(keys, k)
484+ return w, h, hz, nil
485+}
486+
487+func width(s string) bool {
488+ if s == "1" {
489+ return true
256490 }
257- sort.Strings(keys)
258- combos := map[string]string{}
259- for _, k := range keys {
260- v := c.Binds[k]
261- if _, e := normalize(k); e != nil {
262- add("binds."+k, e.Error())
263- } else {
264- n, e := resolvedCombo(k, c.Keyboard.CmdKey)
265- if e != nil {
266- add("binds."+k, e.Error())
267- } else if old, ok := combos[n]; ok {
268- add("binds."+k, "duplicates "+old)
269- } else {
270- combos[n] = k
271- }
272- }
273- if rest, ok := strings.CutPrefix(v, "spawn "); ok {
274- if len(strings.Fields(rest)) == 0 {
275- add("binds."+k, "spawn needs a command")
276- }
277- } else if !actions[v] {
278- add("binds."+k, `invalid action (or "spawn <command>")`)
279- }
491+ if strings.HasSuffix(s, "px") {
492+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
493+ return e == nil && n > 0
494+ }
495+ p := strings.Split(s, "/")
496+ if len(p) != 2 {
497+ return false
280498 }
281- return errors.Join(errs...)
499+ a, e1 := strconv.Atoi(p[0])
500+ b, e2 := strconv.Atoi(p[1])
501+ return e1 == nil && e2 == nil && a > 0 && a <= b
282502 }
@@ -1,8 +1,11 @@
1+// Package config reads the flat `key = value` config file (ADR 012).
1 package config2 package config
2 3
3 import (4 import (
4- "errors"5+ "bufio"
5 "fmt"6 "fmt"
7+ "io"
8+ "math"
6 "os"9 "os"
7 "path/filepath"10 "path/filepath"
8 "regexp"11 "regexp"
@@ -10,17 +13,44 @@ import (
10 "strconv"13 "strconv"
11 "strings"14 "strings"
12 15
13- "github.com/BurntSushi/toml"
14 "github.com/bnema/nefertty/internal/ports"16 "github.com/bnema/nefertty/internal/ports"
15 )17 )
16 18
17-var defaultBinds = map[string]string{19+// defaultBinds use the config syntax and go through the same parser as user binds.
18- "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel",20+var defaultBinds = []struct{ combo, action string }{
21+ {"cmd+return", "spawn-terminal"},
22+ {"cmd+left", "focus-column-left"},
23+ {"cmd+right", "focus-column-right"},
24+ {"cmd+up", "focus-window-up"},
25+ {"cmd+down", "focus-window-down"},
26+ {"cmd+shift+left", "move-column-left"},
27+ {"cmd+shift+right", "move-column-right"},
28+ {"cmd+r", "cycle-column-width"},
29+ {"cmd+shift+f", "toggle-fullscreen"},
30+ {"cmd+q", "close-window"},
31+ {"ctrl+alt+backspace", "quit"},
32+ {"ctrl+cmd+space", "spawn fuzzel"},
19 }33 }
20 var actions = map[string]bool{"none": 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}34 var actions = map[string]bool{"none": 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}
21 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}35 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
22 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)36 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
23 37
38+// namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
39+var namedKeys = map[string]string{
40+ "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape",
41+ "backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End",
42+ "pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print",
43+ "left": "Left", "right": "Right", "up": "Up", "down": "Down",
44+}
45+
46+// Warning is a non-fatal problem at one line; the key keeps its default.
47+type Warning struct {
48+ Line int
49+ Msg string
50+}
51+
52+func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) }
53+
24 func DefaultPath() string {54 func DefaultPath() string {
25 base := os.Getenv("XDG_CONFIG_HOME")55 base := os.Getenv("XDG_CONFIG_HOME")
26 if base == "" {56 if base == "" {
@@ -29,8 +59,9 @@ func DefaultPath() string {
29 base = filepath.Join(home, ".config")59 base = filepath.Join(home, ".config")
30 }60 }
31 }61 }
32- return filepath.Join(base, "nefertty", "config.toml")62+ return filepath.Join(base, "nefertty", "config")
33 }63 }
64+
34 func Defaults() ports.Config {65 func Defaults() ports.Config {
35 var c ports.Config66 var c ports.Config
36 c.Keyboard.RepeatRate = 2567 c.Keyboard.RepeatRate = 25
@@ -40,111 +71,178 @@ func Defaults() ports.Config {
40 c.Background.Color = "#1e1e2e"71 c.Background.Color = "#1e1e2e"
41 c.Border.Width = 272 c.Border.Width = 2
42 c.Border.Active = "#b4befe"73 c.Border.Active = "#b4befe"
43- c.Border.Inactive = ""
44- c.Layout.Gaps = 0
45 c.Layout.DefaultColumnWidth = "1/2"74 c.Layout.DefaultColumnWidth = "1/2"
46 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}75 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
47- c.Binds = make(map[string]string, len(defaultBinds))76+ c.Binds = map[string]string{}
48- for k, v := range defaultBinds {77+ for _, b := range defaultBinds {
49- c.Binds[k] = v78+ combo, err := parseCombo(b.combo)
79+ if err != nil {
80+ panic(err)
81+ }
82+ c.Binds[combo] = b.action
50 }83 }
51 c.Render.DirectScanout = true84 c.Render.DirectScanout = true
52 c.Log.Level = "info"85 c.Log.Level = "info"
53 c.Log.Debug = []string{}86 c.Log.Debug = []string{}
54 return c87 return c
55 }88 }
56-func LoadDefault() (ports.Config, error) {89+
57- c, err := Load(DefaultPath())90+// LoadDefault loads DefaultPath; a missing file means defaults.
91+func LoadDefault() (ports.Config, []Warning, error) {
92+ c, w, err := Load(DefaultPath())
58 if os.IsNotExist(err) {93 if os.IsNotExist(err) {
59- return Defaults(), nil94+ return Defaults(), nil, nil
60 }95 }
61- return c, err96+ return c, w, err
62 }97 }
63-func Load(path string) (ports.Config, error) {98+
64- c := Defaults()99+// Load reads a config file. The error is only for I/O; bad lines become warnings.
65- data, err := os.ReadFile(path)100+func Load(path string) (ports.Config, []Warning, error) {
101+ f, err := os.Open(path)
66 if err != nil {102 if err != nil {
67- return c, err103+ return Defaults(), nil, err
68- }
69- // Decode binds separately so user entries can remove or override defaults.
70- var raw struct {
71- Binds map[string]string `toml:"binds"`
72- }
73- if _, err = toml.Decode(string(data), &raw); err != nil {
74- return c, err
75 }104 }
76- c.Binds = make(map[string]string)105+ defer f.Close()
77- md, err := toml.Decode(string(data), &c)106+ return Parse(f)
78- if err != nil {107+}
79- return c, err108+
80- }109+// Parse reads `key = value` lines on top of the defaults.
81- var errs []error110+func Parse(r io.Reader) (ports.Config, []Warning, error) {
82- for _, key := range md.Undecoded() {111+ c, _, w, err := parse(r)
83- errs = append(errs, fmt.Errorf("%s: unknown key", key.String()))112+ return c, w, err
84- }113+}
85- // Validate user bindings before merging to catch normalized collisions.114+
86- errs = append(errs, Validate(c))115+// parse also returns the effective key/value pairs, used to log what changed on reload.
87- if err = errors.Join(errs...); err != nil {116+func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
88- return c, err117+ c := Defaults()
89- }118+ var warnings []Warning
90- c.Binds = Defaults().Binds119+ raw := map[string]string{}
91- for k, v := range raw.Binds {120+ seen := map[string]int{}
92- normalized, _ := normalize(k)121+ outputs := map[string]int{}
93- for old := range c.Binds {122+ scanner := bufio.NewScanner(r)
94- n, _ := normalize(old)123+ for n := 1; scanner.Scan(); n++ {
95- if n == normalized {124+ line := strings.TrimSpace(scanner.Text())
96- delete(c.Binds, old)125+ if line == "" || strings.HasPrefix(line, "#") {
126+ continue
127+ }
128+ warn := func(format string, a ...any) {
129+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
130+ }
131+ key, value, ok := strings.Cut(line, "=")
132+ key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
133+ if !ok || key == "" {
134+ warn("expected key = value")
135+ continue
136+ }
137+ if combo, ok := strings.CutPrefix(key, "bind."); ok {
138+ if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
139+ warn("%s: write %sequal instead of %s=", key, combo, combo)
140+ continue
141+ }
142+ canon, err := parseCombo(combo)
143+ if err != nil {
144+ warn("%s: %v", key, err)
145+ continue
146+ }
147+ if err := checkAction(value); err != nil {
148+ warn("%s: %v", key, err)
149+ continue
150+ }
151+ key = "bind." + canon
152+ if prev, dup := seen[key]; dup {
153+ warn("%s: overrides line %d", key, prev)
154+ }
155+ seen[key] = n
156+ raw[key] = value
157+ c.Binds[canon] = value
158+ continue
159+ }
160+ if prev, dup := seen[key]; dup {
161+ warn("%s: overrides line %d", key, prev)
162+ }
163+ seen[key] = n
164+ if name, ok := strings.CutPrefix(key, "output."); ok {
165+ o := ports.OutputConfig{Name: name}
166+ if value == "off" {
167+ o.Off = true
168+ } else if _, _, _, err := ParseMode(value); err != nil {
169+ warn("%s: %v", key, err)
170+ continue
171+ } else if value != "preferred" {
172+ o.Mode = value
97 }173 }
174+ raw[key] = value
175+ if i, ok := outputs[name]; ok {
176+ c.Outputs[i] = o
177+ } else {
178+ outputs[name] = len(c.Outputs)
179+ c.Outputs = append(c.Outputs, o)
180+ }
181+ continue
98 }182 }
99- if v != "none" {183+ if err := set(&c, key, value); err != nil {
100- c.Binds[k] = v184+ warn("%s: %v", key, err)
185+ continue
101 }186 }
187+ raw[key] = value
102 }188 }
103- return c, nil189+ if err := scanner.Err(); err != nil {
104-}190+ return c, raw, warnings, err
105-
106-// ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted.
107-func ParseMode(s string) (w, h int, hz float64, err error) {
108- size, rate, hasRate := strings.Cut(s, "@")
109- ws, hs, ok := strings.Cut(size, "x")
110- w, e1 := strconv.Atoi(ws)
111- h, e2 := strconv.Atoi(hs)
112- if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
113- return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s)
114 }191 }
115- if hasRate {192+ // "none" only removes a default bind.
116- if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {193+ for combo, a := range c.Binds {
117- return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)194+ if a == "none" {
195+ delete(c.Binds, combo)
118 }196 }
119 }197 }
120- return w, h, hz, nil198+ // Cmd resolves to a real modifier; drop binds that then collide. User lines win over
121-}199+ // defaults (line 0), then the earliest line wins.
122-200+ combos := make([]string, 0, len(c.Binds))
123-func width(s string) bool {201+ for combo := range c.Binds {
124- if s == "1" {202+ combos = append(combos, combo)
125- return true
126- }
127- if strings.HasSuffix(s, "px") {
128- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
129- return e == nil && n > 0
130 }203 }
131- p := strings.Split(s, "/")204+ order := func(combo string) int {
132- if len(p) != 2 {205+ if n := seen["bind."+combo]; n > 0 {
133- return false206+ return n
207+ }
208+ return math.MaxInt
209+ }
210+ sort.Slice(combos, func(i, j int) bool {
211+ a, b := order(combos[i]), order(combos[j])
212+ return a < b || a == b && combos[i] < combos[j]
213+ })
214+ resolved := map[string]string{}
215+ for _, combo := range combos {
216+ r, err := expandCmd(combo, c.Keyboard.CmdKey)
217+ if prev, dup := resolved[r]; err == nil && dup {
218+ err = fmt.Errorf("same keys as %s with keyboard.cmd = %s", prev, c.Keyboard.CmdKey)
219+ }
220+ if err != nil {
221+ if n := seen["bind."+combo]; n > 0 {
222+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)})
223+ }
224+ delete(c.Binds, combo)
225+ delete(raw, "bind."+combo)
226+ continue
227+ }
228+ resolved[r] = combo
134 }229 }
135- a, e1 := strconv.Atoi(p[0])230+ return c, raw, warnings, nil
136- b, e2 := strconv.Atoi(p[1])
137- return e1 == nil && e2 == nil && a > 0 && a <= b
138 }231 }
139-func normalize(s string) (string, error) {232+
140- p := strings.Split(s, "+")233+// expandCmd replaces Cmd with the configured modifier and re-sorts.
141- if len(p) < 2 || p[len(p)-1] == "" {234+func expandCmd(combo, cmd string) (string, error) {
142- return "", fmt.Errorf("invalid key combo")235+ parts := strings.Split(combo, "+")
143- }236+ key := parts[len(parts)-1]
237+ // cmd is super, alt or ctrl; its modifier name is the capitalized form.
238+ cmdName := strings.ToUpper(cmd[:1]) + cmd[1:]
144 seen := map[string]bool{}239 seen := map[string]bool{}
145- for _, m := range p[:len(p)-1] {240+ for _, m := range parts[:len(parts)-1] {
146- if m != "Cmd" && m != "Shift" && m != "Ctrl" && m != "Alt" || seen[m] {241+ if m == "Cmd" {
147- return "", fmt.Errorf("invalid key combo")242+ m = cmdName
243+ }
244+ if seen[m] {
245+ return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd)
148 }246 }
149 seen[m] = true247 seen[m] = true
150 }248 }
@@ -153,130 +251,252 @@ func normalize(s string) (string, error) {
153 mods = append(mods, m)251 mods = append(mods, m)
154 }252 }
155 sort.Strings(mods)253 sort.Strings(mods)
156- return strings.Join(append(mods, p[len(p)-1]), "+"), nil254+ return strings.Join(append(mods, key), "+"), nil
157 }255 }
158 256
159-// resolvedCombo canonicalizes modifiers after resolving Cmd, so aliases collide.257+// set applies one scalar key. Unknown keys and invalid values are errors.
160-func resolvedCombo(s, cmd string) (string, error) {258+func set(c *ports.Config, key, v string) error {
161- parts := strings.Split(s, "+")259+ switch key {
162- mods := map[string]bool{}260+ case "keyboard.layout":
163- for _, m := range parts[:len(parts)-1] {261+ c.Keyboard.Layout = v
164- if m == "Cmd" {262+ case "keyboard.variant":
165- switch cmd {263+ c.Keyboard.Variant = v
166- case "super":264+ case "keyboard.options":
167- m = "Super"265+ c.Keyboard.Options = v
168- case "alt":266+ case "keyboard.repeat-rate":
169- m = "Alt"267+ return positive(&c.Keyboard.RepeatRate, v, 1000)
170- case "ctrl":268+ case "keyboard.repeat-delay":
171- m = "Ctrl"269+ return positive(&c.Keyboard.RepeatDelay, v, 10000)
172- default:270+ case "keyboard.cmd":
173- return "", fmt.Errorf("invalid cmd key")271+ if v != "super" && v != "alt" && v != "ctrl" {
272+ return fmt.Errorf("must be super, alt or ctrl")
273+ }
274+ c.Keyboard.CmdKey = v
275+ case "terminal":
276+ argv := strings.Fields(v)
277+ if len(argv) == 0 {
278+ return fmt.Errorf("must not be empty")
279+ }
280+ c.Terminal.Command = argv
281+ case "background":
282+ if !color.MatchString(v) {
283+ return fmt.Errorf("must be #rrggbb")
284+ }
285+ c.Background.Color = v
286+ case "border.width":
287+ n, err := strconv.Atoi(v)
288+ if err != nil || n < 0 || n > 32 {
289+ return fmt.Errorf("must be between 0 and 32")
290+ }
291+ c.Border.Width = n
292+ case "border.active":
293+ if !color.MatchString(v) {
294+ return fmt.Errorf("must be #rrggbb")
295+ }
296+ c.Border.Active = v
297+ case "border.inactive":
298+ if v != "" && !color.MatchString(v) {
299+ return fmt.Errorf("must be #rrggbb or empty (none)")
300+ }
301+ c.Border.Inactive = v
302+ case "layout.gaps":
303+ n, err := strconv.Atoi(v)
304+ if err != nil || n < 0 || n > 200 {
305+ return fmt.Errorf("must be between 0 and 200")
306+ }
307+ c.Layout.Gaps = n
308+ case "layout.default-width":
309+ if !width(v) {
310+ return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px")
311+ }
312+ c.Layout.DefaultColumnWidth = v
313+ case "layout.presets":
314+ list := splitList(v)
315+ if len(list) == 0 {
316+ return fmt.Errorf("must not be empty")
317+ }
318+ for _, s := range list {
319+ if !width(s) {
320+ return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s)
174 }321 }
175 }322 }
176- if mods[m] {323+ c.Layout.Presets = list
177- return "", fmt.Errorf("duplicate resolved modifier")324+ case "render.direct-scanout":
325+ b, err := onOff(v)
326+ if err != nil {
327+ return err
328+ }
329+ c.Render.DirectScanout = b
330+ case "log.level":
331+ if v != "debug" && v != "info" && v != "warn" && v != "error" {
332+ return fmt.Errorf("must be debug, info, warn or error")
333+ }
334+ c.Log.Level = v
335+ case "log.debug":
336+ list := splitList(v)
337+ for _, s := range list {
338+ if !components[s] && s != "all" {
339+ return fmt.Errorf("unknown component %q", s)
340+ }
178 }341 }
179- mods[m] = true342+ c.Log.Debug = list
343+ default:
344+ return fmt.Errorf("unknown key")
180 }345 }
181- ordered := make([]string, 0, len(mods))346+ return nil
182- for m := range mods {347+}
183- ordered = append(ordered, m)348+
349+func positive(dst *int, v string, max int) error {
350+ n, err := strconv.Atoi(v)
351+ if err != nil || n <= 0 || n > max {
352+ return fmt.Errorf("must be between 1 and %d", max)
184 }353 }
185- sort.Strings(ordered)354+ *dst = n
186- return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil355+ return nil
187 }356 }
188-func Validate(c ports.Config) error {357+
189- var errs []error358+func onOff(v string) (bool, error) {
190- add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) }359+ switch v {
191- if c.Keyboard.RepeatRate <= 0 {360+ case "on":
192- add("keyboard.repeat_rate", "must be positive")361+ return true, nil
193- }362+ case "off":
194- if c.Keyboard.RepeatDelay <= 0 {363+ return false, nil
195- add("keyboard.repeat_delay", "must be positive")364+ }
196- }365+ return false, fmt.Errorf("must be on or off")
197- if c.Keyboard.CmdKey != "super" && c.Keyboard.CmdKey != "alt" && c.Keyboard.CmdKey != "ctrl" {366+}
198- add("keyboard.cmd_key", "must be super, alt or ctrl")367+
199- }368+func splitList(v string) []string {
200- if len(c.Terminal.Command) == 0 {369+ list := []string{}
201- add("terminal.command", "must not be empty")370+ for _, s := range strings.Split(v, ",") {
202- } else {371+ if s = strings.TrimSpace(s); s != "" {
203- for _, s := range c.Terminal.Command {372+ list = append(list, s)
204- if s == "" {
205- add("terminal.command", "must not contain empty entries")
206- break
207- }
208 }373 }
209 }374 }
210- if !color.MatchString(c.Background.Color) {375+ return list
211- add("background.color", "must be #rrggbb")376+}
377+
378+// stripComment drops a trailing ` # comment` from a value. A # that starts the
379+// value is kept, so colors like #1e1e2e need no quotes.
380+func stripComment(value string) string {
381+ for i := 1; i < len(value); i++ {
382+ if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
383+ return strings.TrimSpace(value[:i])
384+ }
212 }385 }
213- if c.Border.Width < 0 || c.Border.Width > 32 {386+ return value
214- add("border.width", "must be between 0 and 32")387+}
388+
389+func checkAction(v string) error {
390+ if rest, ok := strings.CutPrefix(v, "spawn "); ok {
391+ if len(strings.Fields(rest)) == 0 {
392+ return fmt.Errorf("spawn needs a command")
393+ }
394+ return nil
215 }395 }
216- if !color.MatchString(c.Border.Active) {396+ if !actions[v] {
217- add("border.active", "must be #rrggbb")397+ return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
398+ }
399+ return nil
400+}
401+
402+// parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
403+// modifiers sorted, the key as an xkb keysym name.
404+func parseCombo(s string) (string, error) {
405+ parts := strings.Split(s, "+")
406+ key := parts[len(parts)-1]
407+ if len(parts) > 1 && key == "" {
408+ // "cmd++" means the + key.
409+ if len(parts) > 2 && parts[len(parts)-2] == "" {
410+ parts, key = parts[:len(parts)-1], "+"
411+ } else {
412+ return "", fmt.Errorf("missing key")
413+ }
218 }414 }
219- if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {415+ if key == "" {
220- add("border.inactive", `must be #rrggbb or "" (none)`)416+ return "", fmt.Errorf("missing key")
221 }417 }
222- for i, o := range c.Outputs {418+ seen := map[string]bool{}
223- path := fmt.Sprintf("output[%d]", i)419+ for _, m := range parts[:len(parts)-1] {
224- if o.Name == "" {420+ var name string
225- add(path+".name", "must not be empty")421+ switch strings.ToLower(m) {
422+ case "cmd":
423+ name = "Cmd"
424+ case "shift":
425+ name = "Shift"
426+ case "ctrl":
427+ name = "Ctrl"
428+ case "alt":
429+ name = "Alt"
430+ case "super":
431+ name = "Super"
432+ default:
433+ return "", fmt.Errorf("unknown modifier %q", m)
226 }434 }
227- if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {435+ if seen[name] {
228- add(path+".mode", err.Error())436+ return "", fmt.Errorf("duplicate modifier %q", m)
229 }437 }
438+ seen[name] = true
230 }439 }
231- if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {440+ mods := make([]string, 0, len(seen))
232- add("layout.gaps", "must be between 0 and 200")441+ for m := range seen {
442+ mods = append(mods, m)
233 }443 }
234- if !width(c.Layout.DefaultColumnWidth) {444+ sort.Strings(mods)
235- add("layout.default_column_width", "must be a positive fraction or pixel width")445+ return strings.Join(append(mods, keysym(key)), "+"), nil
446+}
447+
448+// keysym maps a written key to its xkb keysym name.
449+func keysym(k string) string {
450+ if r := []rune(k); len(r) == 1 {
451+ if name, ok := runeKeysyms[r[0]]; ok {
452+ return name
453+ }
454+ return strings.ToLower(k)
236 }455 }
237- if len(c.Layout.Presets) == 0 {456+ if name, ok := namedKeys[strings.ToLower(k)]; ok {
238- add("layout.presets", "must not be empty")457+ return name
239 }458 }
240- for i, s := range c.Layout.Presets {459+ if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
241- if !width(s) {460+ if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
242- add(fmt.Sprintf("layout.presets[%d]", i), "must be a positive fraction or pixel width")461+ return "F" + k[1:]
243 }462 }
244 }463 }
245- if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" {464+ return k
246- add("log.level", "invalid level")465+}
466+
467+// ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
468+func ParseMode(s string) (w, h int, hz float64, err error) {
469+ if s == "preferred" {
470+ return 0, 0, 0, nil
471+ }
472+ size, rate, hasRate := strings.Cut(s, "@")
473+ ws, hs, ok := strings.Cut(size, "x")
474+ w, e1 := strconv.Atoi(ws)
475+ h, e2 := strconv.Atoi(hs)
476+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
477+ return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s)
247 }478 }
248- for i, s := range c.Log.Debug {479+ if hasRate {
249- if !components[s] {480+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
250- add(fmt.Sprintf("log.debug[%d]", i), "invalid component")481+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
251 }482 }
252 }483 }
253- keys := make([]string, 0, len(c.Binds))484+ return w, h, hz, nil
254- for k := range c.Binds {485+}
255- keys = append(keys, k)486+
487+func width(s string) bool {
488+ if s == "1" {
489+ return true
256 }490 }
257- sort.Strings(keys)491+ if strings.HasSuffix(s, "px") {
258- combos := map[string]string{}492+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
259- for _, k := range keys {493+ return e == nil && n > 0
260- v := c.Binds[k]494+ }
261- if _, e := normalize(k); e != nil {495+ p := strings.Split(s, "/")
262- add("binds."+k, e.Error())496+ if len(p) != 2 {
263- } else {497+ return false
264- n, e := resolvedCombo(k, c.Keyboard.CmdKey)
265- if e != nil {
266- add("binds."+k, e.Error())
267- } else if old, ok := combos[n]; ok {
268- add("binds."+k, "duplicates "+old)
269- } else {
270- combos[n] = k
271- }
272- }
273- if rest, ok := strings.CutPrefix(v, "spawn "); ok {
274- if len(strings.Fields(rest)) == 0 {
275- add("binds."+k, "spawn needs a command")
276- }
277- } else if !actions[v] {
278- add("binds."+k, `invalid action (or "spawn <command>")`)
279- }
280 }498 }
281- return errors.Join(errs...)499+ a, e1 := strconv.Atoi(p[0])
500+ b, e2 := strconv.Atoi(p[1])
501+ return e1 == nil && e2 == nil && a > 0 && a <= b
282 }502 }
M internal/adapters/config/config_test.go +138 -83
@@ -6,124 +6,179 @@ import (
66 "reflect"
77 "strings"
88 "testing"
9+
10+ "github.com/bnema/nefertty/internal/ports"
911 )
1012
11-func TestDefaultsAndLoad(t *testing.T) {
12- d := Defaults()
13- if err := Validate(d); err != nil {
13+func parseString(t *testing.T, s string) (ports.Config, []Warning) {
14+ t.Helper()
15+ c, w, err := Parse(strings.NewReader(s))
16+ if err != nil {
1417 t.Fatal(err)
1518 }
16- if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 12 {
19+ return c, w
20+}
21+
22+func TestDefaultsAndLoad(t *testing.T) {
23+ d := Defaults()
24+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 12 {
1725 t.Fatalf("defaults: %+v", d)
1826 }
19- path := filepath.Join(t.TempDir(), "missing")
20- _, err := Load(path)
21- if !os.IsNotExist(err) {
22- t.Fatalf("explicit missing: %v", err)
27+ if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
28+ t.Fatal(d.Binds)
2329 }
24- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
25- got, err := LoadDefault()
26- if err != nil || !reflect.DeepEqual(got, d) {
27- t.Fatalf("missing: %v %+v", err, got)
30+ if _, _, err := Load(filepath.Join(t.TempDir(), "missing")); !os.IsNotExist(err) {
31+ t.Fatalf("explicit missing: %v", err)
2832 }
2933 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
30- if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config.toml") {
34+ if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config") {
3135 t.Fatal(DefaultPath())
3236 }
33- example, err := Load("../../../examples/config.toml")
34- if err != nil || !reflect.DeepEqual(example, d) {
35- t.Fatalf("example: %v %+v", err, example)
37+ got, w, err := LoadDefault()
38+ if err != nil || len(w) != 0 || !reflect.DeepEqual(got, d) {
39+ t.Fatalf("missing: %v %v %+v", err, w, got)
40+ }
41+ example, w, err := Load("../../../examples/config")
42+ if err != nil || len(w) != 0 || !reflect.DeepEqual(example, d) {
43+ t.Fatalf("example: %v %v\n%+v\n%+v", err, w, example, d)
44+ }
45+}
46+
47+func TestParse(t *testing.T) {
48+ c, w := parseString(t, `
49+# comment
50+keyboard.layout = fr
51+keyboard.repeat-rate = 40 # inline comment
52+terminal = foot --server
53+background = #000000
54+border.inactive =
55+layout.presets = 1/3, 1/2 ,1
56+log.debug = core, input
57+output.DP-2 = 5120x2160@165.058
58+output.HDMI-A-1 = off
59+output.DP-1 = preferred
60+render.direct-scanout = off
61+`)
62+ if len(w) != 0 {
63+ t.Fatal(w)
64+ }
65+ if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout {
66+ t.Fatalf("%+v", c)
67+ }
68+ if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
69+ t.Fatalf("%+v", c)
70+ }
71+ if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off {
72+ t.Fatalf("%+v", c.Outputs)
3673 }
3774 }
38-func TestValidation(t *testing.T) {
39- cases := []struct{ name, content, want string }{
40- {"rate", "[keyboard]\nrepeat_rate=0", "keyboard.repeat_rate"},
41- {"delay", "[keyboard]\nrepeat_delay=0", "keyboard.repeat_delay"},
42- {"cmd", "[keyboard]\ncmd_key='meta'", "keyboard.cmd_key"},
43- {"terminal", "[terminal]\ncommand=[]", "terminal.command"},
44- {"color", "[background]\ncolor='red'", "background.color"},
45- {"gaps", "[layout]\ngaps=201", "layout.gaps"},
46- {"width", "[layout]\ndefault_column_width='2/1'", "layout.default_column_width"},
47- {"preset", "[layout]\npresets=['0px']", "layout.presets[0]"},
48- {"level", "[log]\nlevel='trace'", "log.level"},
49- {"debug", "[log]\ndebug=['nope']", "log.debug[0]"},
50- {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"},
51- {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"},
52- {"resolved duplicate", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+X'='quit'\n'Ctrl+X'='quit'", "duplicates"},
53- {"duplicate modifier", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+Ctrl+X'='quit'", "duplicate resolved modifier"},
54- {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"},
55- {"unknown", "[layout]\nnope=1", "layout.nope"},
75+
76+func TestWarningsKeepDefaults(t *testing.T) {
77+ cases := []struct{ line, want string }{
78+ {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
79+ {"keyboard.cmd = meta", "keyboard.cmd"},
80+ {"terminal =", "terminal"},
81+ {"background = red", "background"},
82+ {"layout.gaps = 201", "layout.gaps"},
83+ {"layout.default-width = 2/1", "layout.default-width"},
84+ {"layout.presets = 0px", "layout.presets"},
85+ {"log.level = trace", "log.level"},
86+ {"log.debug = nope", "log.debug"},
87+ {"output.DP-2 = big", "output.DP-2"},
88+ {"output.DP-2 = 1920x1080@0", "output.DP-2"},
89+ {"nope = 1", "nope"},
90+ {"just text", "expected key = value"},
91+ {"bind.cmd+ = quit", "missing key"},
92+ {"bind.hyper+x = quit", "unknown modifier"},
93+ {"bind.cmd+x = bogus", "unknown action"},
94+ {"bind.cmd+x = spawn ", "unknown action"},
5695 }
5796 for _, tc := range cases {
58- t.Run(tc.name, func(t *testing.T) {
59- path := filepath.Join(t.TempDir(), "config.toml")
60- if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil {
61- t.Fatal(err)
97+ t.Run(tc.line, func(t *testing.T) {
98+ c, w := parseString(t, tc.line)
99+ if len(w) != 1 || w[0].Line != 1 || !strings.Contains(w[0].Msg, tc.want) {
100+ t.Fatalf("warnings %v, want %q", w, tc.want)
62101 }
63- _, err := Load(path)
64- if err == nil || !strings.Contains(err.Error(), tc.want) {
65- t.Fatalf("got %v, want %s", err, tc.want)
102+ if !reflect.DeepEqual(c, Defaults()) {
103+ t.Fatalf("config changed: %+v", c)
66104 }
67105 })
68106 }
69107 }
70-func TestBindMerge(t *testing.T) {
71- path := filepath.Join(t.TempDir(), "config.toml")
72- if err := os.WriteFile(path, []byte("[binds]\n'Cmd+Return'='none'\n'Shift+Cmd+Left'='quit'\n'Alt+X'='spawn-terminal'\n"), 0600); err != nil {
73- t.Fatal(err)
108+
109+func TestDuplicateKeyLastWins(t *testing.T) {
110+ c, w := parseString(t, "background = #000000\nbackground = #111111\n")
111+ if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
112+ t.Fatal(c.Background, w)
74113 }
75- c, err := Load(path)
76- if err != nil {
77- t.Fatal(err)
114+}
115+
116+func TestBinds(t *testing.T) {
117+ c, w := parseString(t, `
118+bind.CTRL+Cmd+E = spawn foo --bar
119+bind.cmd+; = close-window
120+bind.cmd+é = quit
121+bind.cmd+semicolon = quit
122+bind.cmd+equal = spawn-terminal
123+bind.cmd+# = none
124+bind.cmd++ = quit
125+bind.alt+f5 = quit
126+bind.cmd+return = none
127+`)
128+ want := map[string]string{
129+ "Cmd+Ctrl+e": "spawn foo --bar",
130+ "Cmd+semicolon": "quit",
131+ "Cmd+eacute": "quit",
132+ "Cmd+equal": "spawn-terminal",
133+ "Cmd+plus": "quit",
134+ "Alt+F5": "quit",
135+ }
136+ for combo, action := range want {
137+ if c.Binds[combo] != action {
138+ t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action)
139+ }
78140 }
79141 if _, ok := c.Binds["Cmd+Return"]; ok {
80- t.Fatal(c.Binds)
142+ t.Error("none did not remove the default")
81143 }
82- if _, ok := c.Binds["Cmd+Shift+Left"]; ok {
83- t.Fatal(c.Binds)
144+ if _, ok := c.Binds["Cmd+numbersign"]; ok {
145+ t.Error("none kept")
84146 }
85- if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" {
86- t.Fatal(c.Binds)
147+ // cmd+; then cmd+semicolon is the same key: warn and keep the last.
148+ if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") {
149+ t.Fatal(w)
87150 }
88151 }
89152
90-func TestOutputs(t *testing.T) {
91- path := filepath.Join(t.TempDir(), "c.toml")
92- good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n"
93- if err := os.WriteFile(path, []byte(good), 0o600); err != nil {
94- t.Fatal(err)
153+func TestBindReservedCharacter(t *testing.T) {
154+ _, w := parseString(t, "bind.cmd+= = quit\n")
155+ if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") {
156+ t.Fatal(w)
95157 }
96- c, err := Load(path)
97- if err != nil {
98- t.Fatal(err)
158+}
159+
160+func TestBindResolvedCollision(t *testing.T) {
161+ c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
162+ if c.Binds["Ctrl+x"] != "quit" {
163+ t.Fatal(c.Binds)
99164 }
100- if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {
101- t.Fatalf("%+v", c.Outputs)
165+ if _, ok := c.Binds["Cmd+x"]; ok {
166+ t.Fatal(c.Binds)
102167 }
103- for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} {
104- if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
105- t.Fatal(err)
106- }
107- if _, err := Load(path); err == nil {
108- t.Errorf("accepted %q", bad)
109- }
168+ if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
169+ t.Fatal(c.Binds)
110170 }
111- w, h, hz, err := ParseMode("3440x1440")
112- if err != nil || w != 3440 || h != 1440 || hz != 0 {
113- t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
171+ if len(w) != 2 || w[0].Line != 3 || w[1].Line != 4 {
172+ t.Fatal(w)
114173 }
115174 }
116175
117-func TestSpawnBind(t *testing.T) {
118- c := Defaults()
119- c.Binds = map[string]string{"Cmd+D": "spawn fuzzel --prompt >"}
120- if err := Validate(c); err != nil {
121- t.Fatal(err)
176+func TestParseMode(t *testing.T) {
177+ w, h, hz, err := ParseMode("3440x1440")
178+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
179+ t.Fatalf("%d %d %v %v", w, h, hz, err)
122180 }
123- for _, bad := range []string{"spawn", "spawn ", "spawnfuzzel"} {
124- c.Binds = map[string]string{"Cmd+D": bad}
125- if Validate(c) == nil {
126- t.Errorf("accepted %q", bad)
127- }
181+ if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
182+ t.Fatal(hz, err)
128183 }
129184 }
@@ -6,124 +6,179 @@ import (
6 "reflect"6 "reflect"
7 "strings"7 "strings"
8 "testing"8 "testing"
9+
10+ "github.com/bnema/nefertty/internal/ports"
9 )11 )
10 12
11-func TestDefaultsAndLoad(t *testing.T) {13+func parseString(t *testing.T, s string) (ports.Config, []Warning) {
12- d := Defaults()14+ t.Helper()
13- if err := Validate(d); err != nil {15+ c, w, err := Parse(strings.NewReader(s))
16+ if err != nil {
14 t.Fatal(err)17 t.Fatal(err)
15 }18 }
16- if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 12 {19+ return c, w
20+}
21+
22+func TestDefaultsAndLoad(t *testing.T) {
23+ d := Defaults()
24+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 12 {
17 t.Fatalf("defaults: %+v", d)25 t.Fatalf("defaults: %+v", d)
18 }26 }
19- path := filepath.Join(t.TempDir(), "missing")27+ if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
20- _, err := Load(path)28+ t.Fatal(d.Binds)
21- if !os.IsNotExist(err) {
22- t.Fatalf("explicit missing: %v", err)
23 }29 }
24- t.Setenv("XDG_CONFIG_HOME", t.TempDir())30+ if _, _, err := Load(filepath.Join(t.TempDir(), "missing")); !os.IsNotExist(err) {
25- got, err := LoadDefault()31+ t.Fatalf("explicit missing: %v", err)
26- if err != nil || !reflect.DeepEqual(got, d) {
27- t.Fatalf("missing: %v %+v", err, got)
28 }32 }
29 t.Setenv("XDG_CONFIG_HOME", t.TempDir())33 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
30- if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config.toml") {34+ if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config") {
31 t.Fatal(DefaultPath())35 t.Fatal(DefaultPath())
32 }36 }
33- example, err := Load("../../../examples/config.toml")37+ got, w, err := LoadDefault()
34- if err != nil || !reflect.DeepEqual(example, d) {38+ if err != nil || len(w) != 0 || !reflect.DeepEqual(got, d) {
35- t.Fatalf("example: %v %+v", err, example)39+ t.Fatalf("missing: %v %v %+v", err, w, got)
40+ }
41+ example, w, err := Load("../../../examples/config")
42+ if err != nil || len(w) != 0 || !reflect.DeepEqual(example, d) {
43+ t.Fatalf("example: %v %v\n%+v\n%+v", err, w, example, d)
44+ }
45+}
46+
47+func TestParse(t *testing.T) {
48+ c, w := parseString(t, `
49+# comment
50+keyboard.layout = fr
51+keyboard.repeat-rate = 40 # inline comment
52+terminal = foot --server
53+background = #000000
54+border.inactive =
55+layout.presets = 1/3, 1/2 ,1
56+log.debug = core, input
57+output.DP-2 = 5120x2160@165.058
58+output.HDMI-A-1 = off
59+output.DP-1 = preferred
60+render.direct-scanout = off
61+`)
62+ if len(w) != 0 {
63+ t.Fatal(w)
64+ }
65+ if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout {
66+ t.Fatalf("%+v", c)
67+ }
68+ if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
69+ t.Fatalf("%+v", c)
70+ }
71+ if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off {
72+ t.Fatalf("%+v", c.Outputs)
36 }73 }
37 }74 }
38-func TestValidation(t *testing.T) {75+
39- cases := []struct{ name, content, want string }{76+func TestWarningsKeepDefaults(t *testing.T) {
40- {"rate", "[keyboard]\nrepeat_rate=0", "keyboard.repeat_rate"},77+ cases := []struct{ line, want string }{
41- {"delay", "[keyboard]\nrepeat_delay=0", "keyboard.repeat_delay"},78+ {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
42- {"cmd", "[keyboard]\ncmd_key='meta'", "keyboard.cmd_key"},79+ {"keyboard.cmd = meta", "keyboard.cmd"},
43- {"terminal", "[terminal]\ncommand=[]", "terminal.command"},80+ {"terminal =", "terminal"},
44- {"color", "[background]\ncolor='red'", "background.color"},81+ {"background = red", "background"},
45- {"gaps", "[layout]\ngaps=201", "layout.gaps"},82+ {"layout.gaps = 201", "layout.gaps"},
46- {"width", "[layout]\ndefault_column_width='2/1'", "layout.default_column_width"},83+ {"layout.default-width = 2/1", "layout.default-width"},
47- {"preset", "[layout]\npresets=['0px']", "layout.presets[0]"},84+ {"layout.presets = 0px", "layout.presets"},
48- {"level", "[log]\nlevel='trace'", "log.level"},85+ {"log.level = trace", "log.level"},
49- {"debug", "[log]\ndebug=['nope']", "log.debug[0]"},86+ {"log.debug = nope", "log.debug"},
50- {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"},87+ {"output.DP-2 = big", "output.DP-2"},
51- {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"},88+ {"output.DP-2 = 1920x1080@0", "output.DP-2"},
52- {"resolved duplicate", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+X'='quit'\n'Ctrl+X'='quit'", "duplicates"},89+ {"nope = 1", "nope"},
53- {"duplicate modifier", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+Ctrl+X'='quit'", "duplicate resolved modifier"},90+ {"just text", "expected key = value"},
54- {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"},91+ {"bind.cmd+ = quit", "missing key"},
55- {"unknown", "[layout]\nnope=1", "layout.nope"},92+ {"bind.hyper+x = quit", "unknown modifier"},
93+ {"bind.cmd+x = bogus", "unknown action"},
94+ {"bind.cmd+x = spawn ", "unknown action"},
56 }95 }
57 for _, tc := range cases {96 for _, tc := range cases {
58- t.Run(tc.name, func(t *testing.T) {97+ t.Run(tc.line, func(t *testing.T) {
59- path := filepath.Join(t.TempDir(), "config.toml")98+ c, w := parseString(t, tc.line)
60- if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil {99+ if len(w) != 1 || w[0].Line != 1 || !strings.Contains(w[0].Msg, tc.want) {
61- t.Fatal(err)100+ t.Fatalf("warnings %v, want %q", w, tc.want)
62 }101 }
63- _, err := Load(path)102+ if !reflect.DeepEqual(c, Defaults()) {
64- if err == nil || !strings.Contains(err.Error(), tc.want) {103+ t.Fatalf("config changed: %+v", c)
65- t.Fatalf("got %v, want %s", err, tc.want)
66 }104 }
67 })105 })
68 }106 }
69 }107 }
70-func TestBindMerge(t *testing.T) {108+
71- path := filepath.Join(t.TempDir(), "config.toml")109+func TestDuplicateKeyLastWins(t *testing.T) {
72- if err := os.WriteFile(path, []byte("[binds]\n'Cmd+Return'='none'\n'Shift+Cmd+Left'='quit'\n'Alt+X'='spawn-terminal'\n"), 0600); err != nil {110+ c, w := parseString(t, "background = #000000\nbackground = #111111\n")
73- t.Fatal(err)111+ if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
112+ t.Fatal(c.Background, w)
74 }113 }
75- c, err := Load(path)114+}
76- if err != nil {115+
77- t.Fatal(err)116+func TestBinds(t *testing.T) {
117+ c, w := parseString(t, `
118+bind.CTRL+Cmd+E = spawn foo --bar
119+bind.cmd+; = close-window
120+bind.cmd+é = quit
121+bind.cmd+semicolon = quit
122+bind.cmd+equal = spawn-terminal
123+bind.cmd+# = none
124+bind.cmd++ = quit
125+bind.alt+f5 = quit
126+bind.cmd+return = none
127+`)
128+ want := map[string]string{
129+ "Cmd+Ctrl+e": "spawn foo --bar",
130+ "Cmd+semicolon": "quit",
131+ "Cmd+eacute": "quit",
132+ "Cmd+equal": "spawn-terminal",
133+ "Cmd+plus": "quit",
134+ "Alt+F5": "quit",
135+ }
136+ for combo, action := range want {
137+ if c.Binds[combo] != action {
138+ t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action)
139+ }
78 }140 }
79 if _, ok := c.Binds["Cmd+Return"]; ok {141 if _, ok := c.Binds["Cmd+Return"]; ok {
80- t.Fatal(c.Binds)142+ t.Error("none did not remove the default")
81 }143 }
82- if _, ok := c.Binds["Cmd+Shift+Left"]; ok {144+ if _, ok := c.Binds["Cmd+numbersign"]; ok {
83- t.Fatal(c.Binds)145+ t.Error("none kept")
84 }146 }
85- if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" {147+ // cmd+; then cmd+semicolon is the same key: warn and keep the last.
86- t.Fatal(c.Binds)148+ if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") {
149+ t.Fatal(w)
87 }150 }
88 }151 }
89 152
90-func TestOutputs(t *testing.T) {153+func TestBindReservedCharacter(t *testing.T) {
91- path := filepath.Join(t.TempDir(), "c.toml")154+ _, w := parseString(t, "bind.cmd+= = quit\n")
92- good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n"155+ if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") {
93- if err := os.WriteFile(path, []byte(good), 0o600); err != nil {156+ t.Fatal(w)
94- t.Fatal(err)
95 }157 }
96- c, err := Load(path)158+}
97- if err != nil {159+
98- t.Fatal(err)160+func TestBindResolvedCollision(t *testing.T) {
161+ c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
162+ if c.Binds["Ctrl+x"] != "quit" {
163+ t.Fatal(c.Binds)
99 }164 }
100- if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {165+ if _, ok := c.Binds["Cmd+x"]; ok {
101- t.Fatalf("%+v", c.Outputs)166+ t.Fatal(c.Binds)
102 }167 }
103- for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} {168+ if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
104- if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {169+ t.Fatal(c.Binds)
105- t.Fatal(err)
106- }
107- if _, err := Load(path); err == nil {
108- t.Errorf("accepted %q", bad)
109- }
110 }170 }
111- w, h, hz, err := ParseMode("3440x1440")171+ if len(w) != 2 || w[0].Line != 3 || w[1].Line != 4 {
112- if err != nil || w != 3440 || h != 1440 || hz != 0 {172+ t.Fatal(w)
113- t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114 }173 }
115 }174 }
116 175
117-func TestSpawnBind(t *testing.T) {176+func TestParseMode(t *testing.T) {
118- c := Defaults()177+ w, h, hz, err := ParseMode("3440x1440")
119- c.Binds = map[string]string{"Cmd+D": "spawn fuzzel --prompt >"}178+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
120- if err := Validate(c); err != nil {179+ t.Fatalf("%d %d %v %v", w, h, hz, err)
121- t.Fatal(err)
122 }180 }
123- for _, bad := range []string{"spawn", "spawn ", "spawnfuzzel"} {181+ if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
124- c.Binds = map[string]string{"Cmd+D": bad}182+ t.Fatal(hz, err)
125- if Validate(c) == nil {
126- t.Errorf("accepted %q", bad)
127- }
128 }183 }
129 }184 }
A internal/adapters/config/keysyms.go +133 -0
@@ -0,0 +1,133 @@
1+package config
2+
3+// runeKeysyms maps printable ASCII punctuation and Latin-1 characters to their
4+// xkb keysym names, so binds can be written as characters (cmd+; or cmd+é).
5+// Generated from xkb_utf32_to_keysym and xkb_keysym_get_name.
6+var runeKeysyms = map[rune]string{
7+ '!': "exclam",
8+ '"': "quotedbl",
9+ '#': "numbersign",
10+ '$': "dollar",
11+ '%': "percent",
12+ '&': "ampersand",
13+ '\'': "apostrophe",
14+ '(': "parenleft",
15+ ')': "parenright",
16+ '*': "asterisk",
17+ '+': "plus",
18+ ',': "comma",
19+ '-': "minus",
20+ '.': "period",
21+ '/': "slash",
22+ ':': "colon",
23+ ';': "semicolon",
24+ '<': "less",
25+ '=': "equal",
26+ '>': "greater",
27+ '?': "question",
28+ '@': "at",
29+ '[': "bracketleft",
30+ '\\': "backslash",
31+ ']': "bracketright",
32+ '^': "asciicircum",
33+ '_': "underscore",
34+ '`': "grave",
35+ '{': "braceleft",
36+ '|': "bar",
37+ '}': "braceright",
38+ '~': "asciitilde",
39+ '¡': "exclamdown",
40+ '¢': "cent",
41+ '£': "sterling",
42+ '¤': "currency",
43+ '¥': "yen",
44+ '¦': "brokenbar",
45+ '§': "section",
46+ '¨': "diaeresis",
47+ '©': "copyright",
48+ 'ª': "ordfeminine",
49+ '«': "guillemotleft",
50+ '¬': "notsign",
51+ '®': "registered",
52+ '¯': "macron",
53+ '°': "degree",
54+ '±': "plusminus",
55+ '²': "twosuperior",
56+ '³': "threesuperior",
57+ '´': "acute",
58+ 'µ': "mu",
59+ '¶': "paragraph",
60+ '·': "periodcentered",
61+ '¸': "cedilla",
62+ '¹': "onesuperior",
63+ 'º': "masculine",
64+ '»': "guillemotright",
65+ '¼': "onequarter",
66+ '½': "onehalf",
67+ '¾': "threequarters",
68+ '¿': "questiondown",
69+ 'À': "Agrave",
70+ 'Á': "Aacute",
71+ 'Â': "Acircumflex",
72+ 'Ã': "Atilde",
73+ 'Ä': "Adiaeresis",
74+ 'Å': "Aring",
75+ 'Æ': "AE",
76+ 'Ç': "Ccedilla",
77+ 'È': "Egrave",
78+ 'É': "Eacute",
79+ 'Ê': "Ecircumflex",
80+ 'Ë': "Ediaeresis",
81+ 'Ì': "Igrave",
82+ 'Í': "Iacute",
83+ 'Î': "Icircumflex",
84+ 'Ï': "Idiaeresis",
85+ 'Ð': "ETH",
86+ 'Ñ': "Ntilde",
87+ 'Ò': "Ograve",
88+ 'Ó': "Oacute",
89+ 'Ô': "Ocircumflex",
90+ 'Õ': "Otilde",
91+ 'Ö': "Odiaeresis",
92+ '×': "multiply",
93+ 'Ø': "Oslash",
94+ 'Ù': "Ugrave",
95+ 'Ú': "Uacute",
96+ 'Û': "Ucircumflex",
97+ 'Ü': "Udiaeresis",
98+ 'Ý': "Yacute",
99+ 'Þ': "THORN",
100+ 'ß': "ssharp",
101+ 'à': "agrave",
102+ 'á': "aacute",
103+ 'â': "acircumflex",
104+ 'ã': "atilde",
105+ 'ä': "adiaeresis",
106+ 'å': "aring",
107+ 'æ': "ae",
108+ 'ç': "ccedilla",
109+ 'è': "egrave",
110+ 'é': "eacute",
111+ 'ê': "ecircumflex",
112+ 'ë': "ediaeresis",
113+ 'ì': "igrave",
114+ 'í': "iacute",
115+ 'î': "icircumflex",
116+ 'ï': "idiaeresis",
117+ 'ð': "eth",
118+ 'ñ': "ntilde",
119+ 'ò': "ograve",
120+ 'ó': "oacute",
121+ 'ô': "ocircumflex",
122+ 'õ': "otilde",
123+ 'ö': "odiaeresis",
124+ '÷': "division",
125+ 'ø': "oslash",
126+ 'ù': "ugrave",
127+ 'ú': "uacute",
128+ 'û': "ucircumflex",
129+ 'ü': "udiaeresis",
130+ 'ý': "yacute",
131+ 'þ': "thorn",
132+ 'ÿ': "ydiaeresis",
133+}
@@ -0,0 +1,133 @@
1+package config
2+
3+// runeKeysyms maps printable ASCII punctuation and Latin-1 characters to their
4+// xkb keysym names, so binds can be written as characters (cmd+; or cmd+é).
5+// Generated from xkb_utf32_to_keysym and xkb_keysym_get_name.
6+var runeKeysyms = map[rune]string{
7+ '!': "exclam",
8+ '"': "quotedbl",
9+ '#': "numbersign",
10+ '$': "dollar",
11+ '%': "percent",
12+ '&': "ampersand",
13+ '\'': "apostrophe",
14+ '(': "parenleft",
15+ ')': "parenright",
16+ '*': "asterisk",
17+ '+': "plus",
18+ ',': "comma",
19+ '-': "minus",
20+ '.': "period",
21+ '/': "slash",
22+ ':': "colon",
23+ ';': "semicolon",
24+ '<': "less",
25+ '=': "equal",
26+ '>': "greater",
27+ '?': "question",
28+ '@': "at",
29+ '[': "bracketleft",
30+ '\\': "backslash",
31+ ']': "bracketright",
32+ '^': "asciicircum",
33+ '_': "underscore",
34+ '`': "grave",
35+ '{': "braceleft",
36+ '|': "bar",
37+ '}': "braceright",
38+ '~': "asciitilde",
39+ '¡': "exclamdown",
40+ '¢': "cent",
41+ '£': "sterling",
42+ '¤': "currency",
43+ '¥': "yen",
44+ '¦': "brokenbar",
45+ '§': "section",
46+ '¨': "diaeresis",
47+ '©': "copyright",
48+ 'ª': "ordfeminine",
49+ '«': "guillemotleft",
50+ '¬': "notsign",
51+ '®': "registered",
52+ '¯': "macron",
53+ '°': "degree",
54+ '±': "plusminus",
55+ '²': "twosuperior",
56+ '³': "threesuperior",
57+ '´': "acute",
58+ 'µ': "mu",
59+ '¶': "paragraph",
60+ '·': "periodcentered",
61+ '¸': "cedilla",
62+ '¹': "onesuperior",
63+ 'º': "masculine",
64+ '»': "guillemotright",
65+ '¼': "onequarter",
66+ '½': "onehalf",
67+ '¾': "threequarters",
68+ '¿': "questiondown",
69+ 'À': "Agrave",
70+ 'Á': "Aacute",
71+ 'Â': "Acircumflex",
72+ 'Ã': "Atilde",
73+ 'Ä': "Adiaeresis",
74+ 'Å': "Aring",
75+ 'Æ': "AE",
76+ 'Ç': "Ccedilla",
77+ 'È': "Egrave",
78+ 'É': "Eacute",
79+ 'Ê': "Ecircumflex",
80+ 'Ë': "Ediaeresis",
81+ 'Ì': "Igrave",
82+ 'Í': "Iacute",
83+ 'Î': "Icircumflex",
84+ 'Ï': "Idiaeresis",
85+ 'Ð': "ETH",
86+ 'Ñ': "Ntilde",
87+ 'Ò': "Ograve",
88+ 'Ó': "Oacute",
89+ 'Ô': "Ocircumflex",
90+ 'Õ': "Otilde",
91+ 'Ö': "Odiaeresis",
92+ '×': "multiply",
93+ 'Ø': "Oslash",
94+ 'Ù': "Ugrave",
95+ 'Ú': "Uacute",
96+ 'Û': "Ucircumflex",
97+ 'Ü': "Udiaeresis",
98+ 'Ý': "Yacute",
99+ 'Þ': "THORN",
100+ 'ß': "ssharp",
101+ 'à': "agrave",
102+ 'á': "aacute",
103+ 'â': "acircumflex",
104+ 'ã': "atilde",
105+ 'ä': "adiaeresis",
106+ 'å': "aring",
107+ 'æ': "ae",
108+ 'ç': "ccedilla",
109+ 'è': "egrave",
110+ 'é': "eacute",
111+ 'ê': "ecircumflex",
112+ 'ë': "ediaeresis",
113+ 'ì': "igrave",
114+ 'í': "iacute",
115+ 'î': "icircumflex",
116+ 'ï': "idiaeresis",
117+ 'ð': "eth",
118+ 'ñ': "ntilde",
119+ 'ò': "ograve",
120+ 'ó': "oacute",
121+ 'ô': "ocircumflex",
122+ 'õ': "otilde",
123+ 'ö': "odiaeresis",
124+ '÷': "division",
125+ 'ø': "oslash",
126+ 'ù': "ugrave",
127+ 'ú': "uacute",
128+ 'û': "ucircumflex",
129+ 'ü': "udiaeresis",
130+ 'ý': "yacute",
131+ 'þ': "thorn",
132+ 'ÿ': "ydiaeresis",
133+}
M internal/adapters/config/watch.go +49 -3
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "os"
77 "path/filepath"
8+ "sort"
89 "time"
910 "unsafe"
1011
@@ -15,6 +16,41 @@ import (
1516
1617 var directoryPollInterval = 2 * time.Second
1718
19+func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
20+ f, err := os.Open(path)
21+ if err != nil {
22+ return ports.Config{}, nil, nil, err
23+ }
24+ defer f.Close()
25+ return parse(f)
26+}
27+
28+// loadRaw returns the effective keys of the file at startup; missing means none.
29+func loadRaw(path string) map[string]string {
30+ _, raw, _, err := loadFile(path)
31+ if err != nil {
32+ return map[string]string{}
33+ }
34+ return raw
35+}
36+
37+// diff lists keys added, removed or changed, sorted.
38+func diff(old, cur map[string]string) []string {
39+ var keys []string
40+ for k, v := range cur {
41+ if o, ok := old[k]; !ok || o != v {
42+ keys = append(keys, k)
43+ }
44+ }
45+ for k := range old {
46+ if _, ok := cur[k]; !ok {
47+ keys = append(keys, k)
48+ }
49+ }
50+ sort.Strings(keys)
51+ return keys
52+}
53+
1854 // Watch observes the parent directory so atomic file replacements are detected.
1955 // If the directory is absent, it retries every two seconds until it appears.
2056 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
@@ -27,6 +63,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
2763 const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
2864 wd := -1
2965 var pending time.Time
66+ last := loadRaw(path)
3067 buf := make([]byte, 4096)
3168 for {
3269 if ctx.Err() != nil {
@@ -51,19 +88,28 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
5188 remaining := time.Until(pending)
5289 if remaining <= 0 {
5390 pending = time.Time{}
54- cfg, loadErr := Load(path)
91+ cfg, raw, warnings, loadErr := loadFile(path)
5592 switch {
5693 case os.IsNotExist(loadErr):
5794 log.Warn().Msg("config file removed; keeping current config")
5895 case loadErr != nil:
59- log.Warn().Err(loadErr).Msg("config reload rejected")
96+ log.Warn().Err(loadErr).Msg("config reload failed")
6097 default:
98+ for _, w := range warnings {
99+ log.Warn().Int("line", w.Line).Msg(w.Msg)
100+ }
101+ changed := diff(last, raw)
102+ last = raw
103+ if len(changed) == 0 {
104+ log.Debug().Msg("config unchanged")
105+ continue
106+ }
61107 select {
62108 case <-ctx.Done():
63109 return nil
64110 case out <- ports.ConfigChanged{Config: cfg}:
65111 }
66- log.Info().Msg("config reloaded")
112+ log.Info().Strs("changed", changed).Msg("config reloaded")
67113 }
68114 continue
69115 }
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "sort"
8 "time"9 "time"
9 "unsafe"10 "unsafe"
10 11
@@ -15,6 +16,41 @@ import (
15 16
16 var directoryPollInterval = 2 * time.Second17 var directoryPollInterval = 2 * time.Second
17 18
19+func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
20+ f, err := os.Open(path)
21+ if err != nil {
22+ return ports.Config{}, nil, nil, err
23+ }
24+ defer f.Close()
25+ return parse(f)
26+}
27+
28+// loadRaw returns the effective keys of the file at startup; missing means none.
29+func loadRaw(path string) map[string]string {
30+ _, raw, _, err := loadFile(path)
31+ if err != nil {
32+ return map[string]string{}
33+ }
34+ return raw
35+}
36+
37+// diff lists keys added, removed or changed, sorted.
38+func diff(old, cur map[string]string) []string {
39+ var keys []string
40+ for k, v := range cur {
41+ if o, ok := old[k]; !ok || o != v {
42+ keys = append(keys, k)
43+ }
44+ }
45+ for k := range old {
46+ if _, ok := cur[k]; !ok {
47+ keys = append(keys, k)
48+ }
49+ }
50+ sort.Strings(keys)
51+ return keys
52+}
53+
18 // Watch observes the parent directory so atomic file replacements are detected.54 // Watch observes the parent directory so atomic file replacements are detected.
19 // If the directory is absent, it retries every two seconds until it appears.55 // If the directory is absent, it retries every two seconds until it appears.
20 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {56 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
@@ -27,6 +63,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
27 const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM63 const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
28 wd := -164 wd := -1
29 var pending time.Time65 var pending time.Time
66+ last := loadRaw(path)
30 buf := make([]byte, 4096)67 buf := make([]byte, 4096)
31 for {68 for {
32 if ctx.Err() != nil {69 if ctx.Err() != nil {
@@ -51,19 +88,28 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
51 remaining := time.Until(pending)88 remaining := time.Until(pending)
52 if remaining <= 0 {89 if remaining <= 0 {
53 pending = time.Time{}90 pending = time.Time{}
54- cfg, loadErr := Load(path)91+ cfg, raw, warnings, loadErr := loadFile(path)
55 switch {92 switch {
56 case os.IsNotExist(loadErr):93 case os.IsNotExist(loadErr):
57 log.Warn().Msg("config file removed; keeping current config")94 log.Warn().Msg("config file removed; keeping current config")
58 case loadErr != nil:95 case loadErr != nil:
59- log.Warn().Err(loadErr).Msg("config reload rejected")96+ log.Warn().Err(loadErr).Msg("config reload failed")
60 default:97 default:
98+ for _, w := range warnings {
99+ log.Warn().Int("line", w.Line).Msg(w.Msg)
100+ }
101+ changed := diff(last, raw)
102+ last = raw
103+ if len(changed) == 0 {
104+ log.Debug().Msg("config unchanged")
105+ continue
106+ }
61 select {107 select {
62 case <-ctx.Done():108 case <-ctx.Done():
63 return nil109 return nil
64 case out <- ports.ConfigChanged{Config: cfg}:110 case out <- ports.ConfigChanged{Config: cfg}:
65 }111 }
66- log.Info().Msg("config reloaded")112+ log.Info().Strs("changed", changed).Msg("config reloaded")
67 }113 }
68 continue114 continue
69 }115 }
M internal/adapters/config/watch_test.go +18 -7
@@ -20,7 +20,7 @@ func TestWatch(t *testing.T) {
2020 t.Fatal(err)
2121 }
2222 }
23- path := filepath.Join(dir, "config.toml")
23+ path := filepath.Join(dir, "config")
2424 ctx, cancel := context.WithCancel(context.Background())
2525 out := make(chan ports.ConfigChanged, 8)
2626 done := make(chan error, 1)
@@ -49,7 +49,7 @@ func TestWatch(t *testing.T) {
4949 t.Fatal(err)
5050 }
5151 }
52- write(path, "[background]\ncolor='#000000'\n")
52+ write(path, "background = #000000\n")
5353 receive := func(want string) {
5454 t.Helper()
5555 select {
@@ -63,13 +63,13 @@ func TestWatch(t *testing.T) {
6363 }
6464 receive("#000000")
6565 tmp := filepath.Join(dir, "temp")
66- write(tmp, "[background]\ncolor='#ff0000'\n")
66+ write(tmp, "background = #ff0000\n")
6767 if err := os.Rename(tmp, path); err != nil {
6868 t.Fatal(err)
6969 }
7070 receive("#ff0000")
7171 for i := 0; i < 3; i++ {
72- write(path, "[background]\ncolor='#00ff00'\n")
72+ write(path, "background = #00ff00\n")
7373 time.Sleep(15 * time.Millisecond)
7474 }
7575 receive("#00ff00")
@@ -78,11 +78,22 @@ func TestWatch(t *testing.T) {
7878 t.Fatal("duplicate reload")
7979 case <-time.After(250 * time.Millisecond):
8080 }
81- write(path, "[background]\ncolor='bad'\n")
81+ // An invalid value keeps the default for that key; other keys still apply.
82+ write(path, "background = bad\nborder.width = 5\n")
83+ select {
84+ case c := <-out:
85+ if c.Config.Background.Color != "#1e1e2e" || c.Config.Border.Width != 5 {
86+ t.Fatalf("partial reload: %+v", c.Config)
87+ }
88+ case <-time.After(time.Second):
89+ t.Fatal("no partial reload")
90+ }
91+ // Rewriting the same content sends nothing.
92+ write(path, "background = bad\nborder.width = 5\n")
8293 select {
8394 case <-out:
84- t.Fatal("invalid config sent")
85- case <-time.After(500 * time.Millisecond):
95+ t.Fatal("unchanged config sent")
96+ case <-time.After(300 * time.Millisecond):
8697 }
8798 if err := os.Remove(path); err != nil {
8899 t.Fatal(err)
@@ -20,7 +20,7 @@ func TestWatch(t *testing.T) {
20 t.Fatal(err)20 t.Fatal(err)
21 }21 }
22 }22 }
23- path := filepath.Join(dir, "config.toml")23+ path := filepath.Join(dir, "config")
24 ctx, cancel := context.WithCancel(context.Background())24 ctx, cancel := context.WithCancel(context.Background())
25 out := make(chan ports.ConfigChanged, 8)25 out := make(chan ports.ConfigChanged, 8)
26 done := make(chan error, 1)26 done := make(chan error, 1)
@@ -49,7 +49,7 @@ func TestWatch(t *testing.T) {
49 t.Fatal(err)49 t.Fatal(err)
50 }50 }
51 }51 }
52- write(path, "[background]\ncolor='#000000'\n")52+ write(path, "background = #000000\n")
53 receive := func(want string) {53 receive := func(want string) {
54 t.Helper()54 t.Helper()
55 select {55 select {
@@ -63,13 +63,13 @@ func TestWatch(t *testing.T) {
63 }63 }
64 receive("#000000")64 receive("#000000")
65 tmp := filepath.Join(dir, "temp")65 tmp := filepath.Join(dir, "temp")
66- write(tmp, "[background]\ncolor='#ff0000'\n")66+ write(tmp, "background = #ff0000\n")
67 if err := os.Rename(tmp, path); err != nil {67 if err := os.Rename(tmp, path); err != nil {
68 t.Fatal(err)68 t.Fatal(err)
69 }69 }
70 receive("#ff0000")70 receive("#ff0000")
71 for i := 0; i < 3; i++ {71 for i := 0; i < 3; i++ {
72- write(path, "[background]\ncolor='#00ff00'\n")72+ write(path, "background = #00ff00\n")
73 time.Sleep(15 * time.Millisecond)73 time.Sleep(15 * time.Millisecond)
74 }74 }
75 receive("#00ff00")75 receive("#00ff00")
@@ -78,11 +78,22 @@ func TestWatch(t *testing.T) {
78 t.Fatal("duplicate reload")78 t.Fatal("duplicate reload")
79 case <-time.After(250 * time.Millisecond):79 case <-time.After(250 * time.Millisecond):
80 }80 }
81- write(path, "[background]\ncolor='bad'\n")81+ // An invalid value keeps the default for that key; other keys still apply.
82+ write(path, "background = bad\nborder.width = 5\n")
83+ select {
84+ case c := <-out:
85+ if c.Config.Background.Color != "#1e1e2e" || c.Config.Border.Width != 5 {
86+ t.Fatalf("partial reload: %+v", c.Config)
87+ }
88+ case <-time.After(time.Second):
89+ t.Fatal("no partial reload")
90+ }
91+ // Rewriting the same content sends nothing.
92+ write(path, "background = bad\nborder.width = 5\n")
82 select {93 select {
83 case <-out:94 case <-out:
84- t.Fatal("invalid config sent")95+ t.Fatal("unchanged config sent")
85- case <-time.After(500 * time.Millisecond):96+ case <-time.After(300 * time.Millisecond):
86 }97 }
87 if err := os.Remove(path); err != nil {98 if err := os.Remove(path); err != nil {
88 t.Fatal(err)99 t.Fatal(err)
M internal/adapters/headlessinput/input.go +8 -3
@@ -14,9 +14,10 @@ import (
1414 "github.com/bnema/zerowrap"
1515 )
1616
17-// Run owns the keymap and converts script lines into input events.
18-func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {
19- defer km.Close()
17+// Run owns the keymap and converts script lines into input events. A keymap
18+// received on keymaps replaces the current one, which is closed.
19+func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {
20+ defer func() { km.Close() }()
2021 emit := func(code uint32, down bool) error {
2122 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
2223 select {
@@ -73,6 +74,10 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<-
7374 select {
7475 case <-ctx.Done():
7576 return ctx.Err()
77+ case next := <-keymaps:
78+ km.Close()
79+ km = next
80+ log.Info().Msg("keymap replaced")
7681 case line, ok := <-script:
7782 if !ok {
7883 return nil
@@ -14,9 +14,10 @@ import (
14 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
15 )15 )
16 16
17-// Run owns the keymap and converts script lines into input events.17+// Run owns the keymap and converts script lines into input events. A keymap
18-func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {18+// received on keymaps replaces the current one, which is closed.
19- defer km.Close()19+func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {
20+ defer func() { km.Close() }()
20 emit := func(code uint32, down bool) error {21 emit := func(code uint32, down bool) error {
21 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))22 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
22 select {23 select {
@@ -73,6 +74,10 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<-
73 select {74 select {
74 case <-ctx.Done():75 case <-ctx.Done():
75 return ctx.Err()76 return ctx.Err()
77+ case next := <-keymaps:
78+ km.Close()
79+ km = next
80+ log.Info().Msg("keymap replaced")
76 case line, ok := <-script:81 case line, ok := <-script:
77 if !ok {82 if !ok {
78 return nil83 return nil
M internal/adapters/headlessinput/input_test.go +2 -2
@@ -31,7 +31,7 @@ func TestScript(t *testing.T) {
3131 script <- tt.line
3232 close(script)
3333 input := make(chan ports.InputEvent, 32)
34- if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {
34+ if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {
3535 t.Fatal(err)
3636 }
3737 if len(input) != len(tt.names) {
@@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) {
6767 }
6868 close(script)
6969 input := make(chan ports.InputEvent, 8)
70- if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {
70+ if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {
7171 t.Fatal(err)
7272 }
7373 if len(input) != 5 {
@@ -31,7 +31,7 @@ func TestScript(t *testing.T) {
31 script <- tt.line31 script <- tt.line
32 close(script)32 close(script)
33 input := make(chan ports.InputEvent, 32)33 input := make(chan ports.InputEvent, 32)
34- if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {34+ if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {
35 t.Fatal(err)35 t.Fatal(err)
36 }36 }
37 if len(input) != len(tt.names) {37 if len(input) != len(tt.names) {
@@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) {
67 }67 }
68 close(script)68 close(script)
69 input := make(chan ports.InputEvent, 8)69 input := make(chan ports.InputEvent, 8)
70- if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {70+ if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {
71 t.Fatal(err)71 t.Fatal(err)
72 }72 }
73 if len(input) != 5 {73 if len(input) != 5 {
M internal/adapters/libinput/libinput.go +10 -4
@@ -135,9 +135,11 @@ func goString(p *byte) string {
135135
136136 // Options configures the input adapter.
137137 type Options struct {
138- Seat Seat
139- SeatName string
140- Keymap *xkb.Keymap
138+ Seat Seat
139+ SeatName string
140+ Keymap *xkb.Keymap
141+ // Keymaps replaces Keymap live; Run takes ownership and closes the old one.
142+ Keymaps <-chan *xkb.Keymap
141143 Width, Height int
142144 Active <-chan bool
143145 Log zerowrap.Logger
@@ -145,7 +147,7 @@ type Options struct {
145147
146148 // Run owns libinput and the keymap, converting device events into input events.
147149 func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
148- defer opts.Keymap.Close()
150+ defer func() { opts.Keymap.Close() }()
149151 if err := load(); err != nil {
150152 return err
151153 }
@@ -179,6 +181,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
179181 suspend(li)
180182 }
181183 opts.Log.Info().Bool("active", on).Msg("input")
184+ case km := <-opts.Keymaps:
185+ opts.Keymap.Close()
186+ opts.Keymap = km
187+ opts.Log.Info().Msg("keymap replaced")
182188 default:
183189 }
184190 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
@@ -135,9 +135,11 @@ func goString(p *byte) string {
135 135
136 // Options configures the input adapter.136 // Options configures the input adapter.
137 type Options struct {137 type Options struct {
138- Seat Seat138+ Seat Seat
139- SeatName string139+ SeatName string
140- Keymap *xkb.Keymap140+ Keymap *xkb.Keymap
141+ // Keymaps replaces Keymap live; Run takes ownership and closes the old one.
142+ Keymaps <-chan *xkb.Keymap
141 Width, Height int143 Width, Height int
142 Active <-chan bool144 Active <-chan bool
143 Log zerowrap.Logger145 Log zerowrap.Logger
@@ -145,7 +147,7 @@ type Options struct {
145 147
146 // Run owns libinput and the keymap, converting device events into input events.148 // Run owns libinput and the keymap, converting device events into input events.
147 func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {149 func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
148- defer opts.Keymap.Close()150+ defer func() { opts.Keymap.Close() }()
149 if err := load(); err != nil {151 if err := load(); err != nil {
150 return err152 return err
151 }153 }
@@ -179,6 +181,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
179 suspend(li)181 suspend(li)
180 }182 }
181 opts.Log.Info().Bool("active", on).Msg("input")183 opts.Log.Info().Bool("active", on).Msg("input")
184+ case km := <-opts.Keymaps:
185+ opts.Keymap.Close()
186+ opts.Keymap = km
187+ opts.Log.Info().Msg("keymap replaced")
182 default:188 default:
183 }189 }
184 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}190 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
M internal/adapters/wayland/server.go +62 -22
@@ -116,33 +116,19 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
116116 }
117117 s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
118118 if opts.Keymap != "" {
119- if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {
120- cleanup()
121- d.Close()
122- return nil, fmt.Errorf("keymap too large")
123- }
124- fd, e := unix.MemfdCreate("nefertty-keymap", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING)
125- if e == nil {
126- var n int
127- n, e = unix.Write(fd, append([]byte(opts.Keymap), 0))
128- if e == nil && n != len(opts.Keymap)+1 {
129- e = fmt.Errorf("short keymap write")
130- }
131- if e == nil {
132- _, e = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL)
133- }
134- if e != nil {
135- unix.Close(fd)
136- } else {
137- s.keymapFD, s.keymapSize = fd, uint32(n)
138- }
139- }
119+ fd, size, e := keymapFile(opts.Keymap)
140120 if e != nil {
141121 cleanup()
142122 d.Close()
143123 return nil, e
144124 }
145- s.cleanup = func() { unix.Close(fd); cleanup() }
125+ s.keymapFD, s.keymapSize = fd, size
126+ }
127+ s.cleanup = func() {
128+ if s.keymapFD >= 0 {
129+ unix.Close(s.keymapFD)
130+ }
131+ cleanup()
146132 }
147133 if err = registerGlobals(d, opts, s); err != nil {
148134 s.cleanup()
@@ -341,6 +327,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
341327 pointerFrame(p)
342328 }
343329 }
330+ case ports.SetKeymap:
331+ s.setKeymap(c)
344332 case ports.FocusWindow:
345333 if s.focused != c.ID {
346334 s.changeFocus(c.ID)
@@ -427,6 +415,58 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
427415 return nil, nil
428416 }
429417
418+// keymapFile writes a sealed memfd holding the NUL-terminated keymap.
419+func keymapFile(keymap string) (int, uint32, error) {
420+ if uint64(len(keymap))+1 > uint64(^uint32(0)) {
421+ return -1, 0, fmt.Errorf("keymap too large")
422+ }
423+ fd, err := unix.MemfdCreate("nefertty-keymap", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING)
424+ if err != nil {
425+ return -1, 0, err
426+ }
427+ n, err := unix.Write(fd, append([]byte(keymap), 0))
428+ if err == nil && n != len(keymap)+1 {
429+ err = fmt.Errorf("short keymap write")
430+ }
431+ if err == nil {
432+ _, err = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL)
433+ }
434+ if err != nil {
435+ unix.Close(fd)
436+ return -1, 0, err
437+ }
438+ return fd, uint32(n), nil
439+}
440+
441+// setKeymap sends a new keymap and repeat info to every keyboard. Held keys and
442+// modifiers are reset: the focused client gets leave, then enter with no keys.
443+func (s *Server) setKeymap(c ports.SetKeymap) {
444+ fd, size, err := keymapFile(c.Keymap)
445+ if err != nil {
446+ s.log.Warn().Err(err).Msg("keymap update failed")
447+ return
448+ }
449+ if s.keymapFD >= 0 {
450+ unix.Close(s.keymapFD)
451+ }
452+ s.keymapFD, s.keymapSize = fd, size
453+ s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
454+ focused := s.focused
455+ s.changeFocus(0)
456+ s.heldKeys = nil
457+ s.modState = ports.ModState{}
458+ for _, list := range s.keyboards {
459+ for _, k := range list {
460+ k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
461+ if k.Version() >= 4 {
462+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
463+ }
464+ }
465+ }
466+ s.changeFocus(focused)
467+ s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated")
468+}
469+
430470 func (s *Server) sendModifiers(k *wayland.Keyboard) {
431471 m := s.modState
432472 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
@@ -116,33 +116,19 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
116 }116 }
117 s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}117 s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
118 if opts.Keymap != "" {118 if opts.Keymap != "" {
119- if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {119+ fd, size, e := keymapFile(opts.Keymap)
120- cleanup()
121- d.Close()
122- return nil, fmt.Errorf("keymap too large")
123- }
124- fd, e := unix.MemfdCreate("nefertty-keymap", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING)
125- if e == nil {
126- var n int
127- n, e = unix.Write(fd, append([]byte(opts.Keymap), 0))
128- if e == nil && n != len(opts.Keymap)+1 {
129- e = fmt.Errorf("short keymap write")
130- }
131- if e == nil {
132- _, e = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL)
133- }
134- if e != nil {
135- unix.Close(fd)
136- } else {
137- s.keymapFD, s.keymapSize = fd, uint32(n)
138- }
139- }
140 if e != nil {120 if e != nil {
141 cleanup()121 cleanup()
142 d.Close()122 d.Close()
143 return nil, e123 return nil, e
144 }124 }
145- s.cleanup = func() { unix.Close(fd); cleanup() }125+ s.keymapFD, s.keymapSize = fd, size
126+ }
127+ s.cleanup = func() {
128+ if s.keymapFD >= 0 {
129+ unix.Close(s.keymapFD)
130+ }
131+ cleanup()
146 }132 }
147 if err = registerGlobals(d, opts, s); err != nil {133 if err = registerGlobals(d, opts, s); err != nil {
148 s.cleanup()134 s.cleanup()
@@ -341,6 +327,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
341 pointerFrame(p)327 pointerFrame(p)
342 }328 }
343 }329 }
330+ case ports.SetKeymap:
331+ s.setKeymap(c)
344 case ports.FocusWindow:332 case ports.FocusWindow:
345 if s.focused != c.ID {333 if s.focused != c.ID {
346 s.changeFocus(c.ID)334 s.changeFocus(c.ID)
@@ -427,6 +415,58 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
427 return nil, nil415 return nil, nil
428 }416 }
429 417
418+// keymapFile writes a sealed memfd holding the NUL-terminated keymap.
419+func keymapFile(keymap string) (int, uint32, error) {
420+ if uint64(len(keymap))+1 > uint64(^uint32(0)) {
421+ return -1, 0, fmt.Errorf("keymap too large")
422+ }
423+ fd, err := unix.MemfdCreate("nefertty-keymap", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING)
424+ if err != nil {
425+ return -1, 0, err
426+ }
427+ n, err := unix.Write(fd, append([]byte(keymap), 0))
428+ if err == nil && n != len(keymap)+1 {
429+ err = fmt.Errorf("short keymap write")
430+ }
431+ if err == nil {
432+ _, err = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL)
433+ }
434+ if err != nil {
435+ unix.Close(fd)
436+ return -1, 0, err
437+ }
438+ return fd, uint32(n), nil
439+}
440+
441+// setKeymap sends a new keymap and repeat info to every keyboard. Held keys and
442+// modifiers are reset: the focused client gets leave, then enter with no keys.
443+func (s *Server) setKeymap(c ports.SetKeymap) {
444+ fd, size, err := keymapFile(c.Keymap)
445+ if err != nil {
446+ s.log.Warn().Err(err).Msg("keymap update failed")
447+ return
448+ }
449+ if s.keymapFD >= 0 {
450+ unix.Close(s.keymapFD)
451+ }
452+ s.keymapFD, s.keymapSize = fd, size
453+ s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
454+ focused := s.focused
455+ s.changeFocus(0)
456+ s.heldKeys = nil
457+ s.modState = ports.ModState{}
458+ for _, list := range s.keyboards {
459+ for _, k := range list {
460+ k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
461+ if k.Version() >= 4 {
462+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
463+ }
464+ }
465+ }
466+ s.changeFocus(focused)
467+ s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated")
468+}
469+
430 func (s *Server) sendModifiers(k *wayland.Keyboard) {470 func (s *Server) sendModifiers(k *wayland.Keyboard) {
431 m := s.modState471 m := s.modState
432 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)472 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
M internal/app/app.go +60 -3
@@ -23,6 +23,7 @@ import (
2323 "github.com/bnema/nefertty/internal/core"
2424 "github.com/bnema/nefertty/internal/logging"
2525 "github.com/bnema/nefertty/internal/ports"
26+ "github.com/bnema/zerowrap"
2627 )
2728
2829 type Options struct {
@@ -109,20 +110,27 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
109110 if path == "" {
110111 path = config.DefaultPath()
111112 }
113+ watched := make(chan ports.ConfigChanged, 8)
114+ keymaps := make(chan *xkb.Keymap, 1)
112115 go func() {
113116 defer workers.Done()
114- done <- config.Watch(ctx, path, configChanges, logging.For(ctx, "config"))
117+ done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
118+ }()
119+ workers.Add(1)
120+ go func() {
121+ defer workers.Done()
122+ relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
115123 }()
116124 script := make(chan string)
117125 go func() {
118126 defer workers.Done()
119127 if hw != nil {
120128 done <- safe("input", func() error {
121- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
129+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
122130 })
123131 return
124132 }
125- _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
133+ _ = headlessinput.Run(ctx, km, keymaps, script, input, logging.For(ctx, "input"))
126134 }()
127135 if opts.Script != nil {
128136 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
@@ -198,6 +206,55 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
198206 return result
199207 }
200208
209+// relayConfig forwards reloads to core. When keyboard settings change it builds the
210+// new keymap, hands it to the input goroutine and sends it to clients, so layout and
211+// repeat changes apply live. A keymap that fails to build keeps the previous one.
212+func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
213+ kb := cur.Keyboard
214+ for {
215+ var ev ports.ConfigChanged
216+ select {
217+ case <-ctx.Done():
218+ return
219+ case ev = <-in:
220+ }
221+ next := ev.Config.Keyboard
222+ if next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options || next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay {
223+ km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
224+ if err != nil {
225+ log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous keyboard")
226+ ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
227+ } else {
228+ text := km.String()
229+ // Drop a keymap the input goroutine has not taken yet; the newest wins.
230+ select {
231+ case old := <-keymaps:
232+ old.Close()
233+ default:
234+ }
235+ select {
236+ case keymaps <- km:
237+ case <-ctx.Done():
238+ km.Close()
239+ return
240+ }
241+ select {
242+ case commands <- ports.SetKeymap{Keymap: text, RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}:
243+ case <-ctx.Done():
244+ return
245+ }
246+ kb = next
247+ log.Info().Str("layout", next.Layout).Str("variant", next.Variant).Str("options", next.Options).Msg("keyboard reloaded")
248+ }
249+ }
250+ select {
251+ case out <- ev:
252+ case <-ctx.Done():
253+ return
254+ }
255+ }
256+}
257+
201258 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {
202259 log := logging.For(ctx, "core")
203260 configLog := logging.For(ctx, "config")
@@ -23,6 +23,7 @@ import (
23 "github.com/bnema/nefertty/internal/core"23 "github.com/bnema/nefertty/internal/core"
24 "github.com/bnema/nefertty/internal/logging"24 "github.com/bnema/nefertty/internal/logging"
25 "github.com/bnema/nefertty/internal/ports"25 "github.com/bnema/nefertty/internal/ports"
26+ "github.com/bnema/zerowrap"
26 )27 )
27 28
28 type Options struct {29 type Options struct {
@@ -109,20 +110,27 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
109 if path == "" {110 if path == "" {
110 path = config.DefaultPath()111 path = config.DefaultPath()
111 }112 }
113+ watched := make(chan ports.ConfigChanged, 8)
114+ keymaps := make(chan *xkb.Keymap, 1)
112 go func() {115 go func() {
113 defer workers.Done()116 defer workers.Done()
114- done <- config.Watch(ctx, path, configChanges, logging.For(ctx, "config"))117+ done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
118+ }()
119+ workers.Add(1)
120+ go func() {
121+ defer workers.Done()
122+ relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
115 }()123 }()
116 script := make(chan string)124 script := make(chan string)
117 go func() {125 go func() {
118 defer workers.Done()126 defer workers.Done()
119 if hw != nil {127 if hw != nil {
120 done <- safe("input", func() error {128 done <- safe("input", func() error {
121- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)129+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
122 })130 })
123 return131 return
124 }132 }
125- _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))133+ _ = headlessinput.Run(ctx, km, keymaps, script, input, logging.For(ctx, "input"))
126 }()134 }()
127 if opts.Script != nil {135 if opts.Script != nil {
128 go func() { <-ctx.Done(); _ = opts.Script.Close() }()136 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
@@ -198,6 +206,55 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
198 return result206 return result
199 }207 }
200 208
209+// relayConfig forwards reloads to core. When keyboard settings change it builds the
210+// new keymap, hands it to the input goroutine and sends it to clients, so layout and
211+// repeat changes apply live. A keymap that fails to build keeps the previous one.
212+func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
213+ kb := cur.Keyboard
214+ for {
215+ var ev ports.ConfigChanged
216+ select {
217+ case <-ctx.Done():
218+ return
219+ case ev = <-in:
220+ }
221+ next := ev.Config.Keyboard
222+ if next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options || next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay {
223+ km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
224+ if err != nil {
225+ log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous keyboard")
226+ ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
227+ } else {
228+ text := km.String()
229+ // Drop a keymap the input goroutine has not taken yet; the newest wins.
230+ select {
231+ case old := <-keymaps:
232+ old.Close()
233+ default:
234+ }
235+ select {
236+ case keymaps <- km:
237+ case <-ctx.Done():
238+ km.Close()
239+ return
240+ }
241+ select {
242+ case commands <- ports.SetKeymap{Keymap: text, RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}:
243+ case <-ctx.Done():
244+ return
245+ }
246+ kb = next
247+ log.Info().Str("layout", next.Layout).Str("variant", next.Variant).Str("options", next.Options).Msg("keyboard reloaded")
248+ }
249+ }
250+ select {
251+ case out <- ev:
252+ case <-ctx.Done():
253+ return
254+ }
255+ }
256+}
257+
201 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {258 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {
202 log := logging.For(ctx, "core")259 log := logging.For(ctx, "core")
203 configLog := logging.For(ctx, "config")260 configLog := logging.For(ctx, "config")
M internal/app/app_test.go +44 -4
@@ -13,17 +13,19 @@ import (
1313 "time"
1414
1515 "github.com/bnema/nefertty/internal/adapters/config"
16+ "github.com/bnema/nefertty/internal/adapters/xkb"
17+ "github.com/bnema/nefertty/internal/logging"
1618 "github.com/bnema/nefertty/internal/ports"
1719 )
1820
1921 func TestHeadlessConfigReload(t *testing.T) {
2022 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
2123 dir := t.TempDir()
22- path := filepath.Join(dir, "config.toml")
23- if err := os.WriteFile(path, []byte("[background]\ncolor='#000000'\n"), 0600); err != nil {
24+ path := filepath.Join(dir, "config")
25+ if err := os.WriteFile(path, []byte("background = #000000\n"), 0600); err != nil {
2426 t.Fatal(err)
2527 }
26- cfg, err := config.Load(path)
28+ cfg, _, err := config.Load(path)
2729 if err != nil {
2830 t.Fatal(err)
2931 }
@@ -73,7 +75,7 @@ func TestHeadlessConfigReload(t *testing.T) {
7375 if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) {
7476 t.Fatal("first black frame missing")
7577 }
76- if err := os.WriteFile(path, []byte("[background]\ncolor='#ff0000'\n"), 0600); err != nil {
78+ if err := os.WriteFile(path, []byte("background = #ff0000\nkeyboard.layout = fr\n"), 0600); err != nil {
7779 t.Fatal(err)
7880 }
7981 if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) {
@@ -258,3 +260,41 @@ func TestBlockedScriptShutdown(t *testing.T) {
258260 t.Fatal("blocked script prevented shutdown")
259261 }
260262 }
263+
264+func TestRelayConfigKeyboard(t *testing.T) {
265+ ctx, cancel := context.WithCancel(context.Background())
266+ defer cancel()
267+ cur := config.Defaults()
268+ in := make(chan ports.ConfigChanged, 1)
269+ out := make(chan ports.ConfigChanged, 1)
270+ keymaps := make(chan *xkb.Keymap, 1)
271+ commands := make(chan ports.ClientCommand, 1)
272+ go relayConfig(ctx, cur, in, out, keymaps, commands, logging.For(ctx, "config"))
273+
274+ next := config.Defaults()
275+ next.Background.Color = "#000000"
276+ in <- ports.ConfigChanged{Config: next}
277+ if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 {
278+ t.Fatalf("non-keyboard change rebuilt keymap: %+v", got)
279+ }
280+
281+ next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40
282+ in <- ports.ConfigChanged{Config: next}
283+ <-out
284+ km := <-keymaps
285+ defer km.Close()
286+ cmd := (<-commands).(ports.SetKeymap)
287+ if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "fr") {
288+ t.Fatalf("keymap command: rate=%d", cmd.RepeatRate)
289+ }
290+ if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 {
291+ t.Fatalf("fr keymap: a at %d", code)
292+ }
293+
294+ bad := next
295+ bad.Keyboard.Layout = "no-such-layout"
296+ in <- ports.ConfigChanged{Config: bad}
297+ if got := <-out; got.Config.Keyboard.Layout != "fr" || len(keymaps) != 0 {
298+ t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
299+ }
300+}
@@ -13,17 +13,19 @@ import (
13 "time"13 "time"
14 14
15 "github.com/bnema/nefertty/internal/adapters/config"15 "github.com/bnema/nefertty/internal/adapters/config"
16+ "github.com/bnema/nefertty/internal/adapters/xkb"
17+ "github.com/bnema/nefertty/internal/logging"
16 "github.com/bnema/nefertty/internal/ports"18 "github.com/bnema/nefertty/internal/ports"
17 )19 )
18 20
19 func TestHeadlessConfigReload(t *testing.T) {21 func TestHeadlessConfigReload(t *testing.T) {
20 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())22 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
21 dir := t.TempDir()23 dir := t.TempDir()
22- path := filepath.Join(dir, "config.toml")24+ path := filepath.Join(dir, "config")
23- if err := os.WriteFile(path, []byte("[background]\ncolor='#000000'\n"), 0600); err != nil {25+ if err := os.WriteFile(path, []byte("background = #000000\n"), 0600); err != nil {
24 t.Fatal(err)26 t.Fatal(err)
25 }27 }
26- cfg, err := config.Load(path)28+ cfg, _, err := config.Load(path)
27 if err != nil {29 if err != nil {
28 t.Fatal(err)30 t.Fatal(err)
29 }31 }
@@ -73,7 +75,7 @@ func TestHeadlessConfigReload(t *testing.T) {
73 if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) {75 if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) {
74 t.Fatal("first black frame missing")76 t.Fatal("first black frame missing")
75 }77 }
76- if err := os.WriteFile(path, []byte("[background]\ncolor='#ff0000'\n"), 0600); err != nil {78+ if err := os.WriteFile(path, []byte("background = #ff0000\nkeyboard.layout = fr\n"), 0600); err != nil {
77 t.Fatal(err)79 t.Fatal(err)
78 }80 }
79 if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) {81 if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) {
@@ -258,3 +260,41 @@ func TestBlockedScriptShutdown(t *testing.T) {
258 t.Fatal("blocked script prevented shutdown")260 t.Fatal("blocked script prevented shutdown")
259 }261 }
260 }262 }
263+
264+func TestRelayConfigKeyboard(t *testing.T) {
265+ ctx, cancel := context.WithCancel(context.Background())
266+ defer cancel()
267+ cur := config.Defaults()
268+ in := make(chan ports.ConfigChanged, 1)
269+ out := make(chan ports.ConfigChanged, 1)
270+ keymaps := make(chan *xkb.Keymap, 1)
271+ commands := make(chan ports.ClientCommand, 1)
272+ go relayConfig(ctx, cur, in, out, keymaps, commands, logging.For(ctx, "config"))
273+
274+ next := config.Defaults()
275+ next.Background.Color = "#000000"
276+ in <- ports.ConfigChanged{Config: next}
277+ if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 {
278+ t.Fatalf("non-keyboard change rebuilt keymap: %+v", got)
279+ }
280+
281+ next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40
282+ in <- ports.ConfigChanged{Config: next}
283+ <-out
284+ km := <-keymaps
285+ defer km.Close()
286+ cmd := (<-commands).(ports.SetKeymap)
287+ if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "fr") {
288+ t.Fatalf("keymap command: rate=%d", cmd.RepeatRate)
289+ }
290+ if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 {
291+ t.Fatalf("fr keymap: a at %d", code)
292+ }
293+
294+ bad := next
295+ bad.Keyboard.Layout = "no-such-layout"
296+ in <- ports.ConfigChanged{Config: bad}
297+ if got := <-out; got.Config.Keyboard.Layout != "fr" || len(keymaps) != 0 {
298+ t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
299+ }
300+}
M internal/ports/config.go +15 -15
@@ -11,35 +11,35 @@ type OutputConfig struct {
1111 Off bool
1212 }
1313
14-// Config is the parsed compositor configuration.
14+// Config is the parsed compositor configuration (see the config adapter for keys).
1515 type Config struct {
1616 Keyboard struct {
1717 Layout, Variant, Options string
18- RepeatRate int `toml:"repeat_rate"`
19- RepeatDelay int `toml:"repeat_delay"`
20- CmdKey string `toml:"cmd_key"`
21- } `toml:"keyboard"`
22- Terminal struct{ Command []string } `toml:"terminal"`
23- Background struct{ Color string } `toml:"background"`
18+ RepeatRate int
19+ RepeatDelay int
20+ CmdKey string
21+ }
22+ Terminal struct{ Command []string }
23+ Background struct{ Color string }
2424 // Border is drawn inside the window edge; Width 0 disables it.
2525 Border struct {
2626 Width int
2727 Active string
2828 Inactive string
29- } `toml:"border"`
29+ }
3030 Layout struct {
3131 Gaps int
32- DefaultColumnWidth string `toml:"default_column_width"`
32+ DefaultColumnWidth string
3333 Presets []string
34- } `toml:"layout"`
34+ }
3535 // Outputs selects and configures physical displays (drm backend).
36- Outputs []OutputConfig `toml:"output"`
37- Binds map[string]string `toml:"binds"`
36+ Outputs []OutputConfig
37+ Binds map[string]string
3838 Render struct {
39- DirectScanout bool `toml:"direct_scanout"`
40- } `toml:"render"`
39+ DirectScanout bool
40+ }
4141 Log struct {
4242 Level string
4343 Debug []string
44- } `toml:"log"`
44+ }
4545 }
@@ -11,35 +11,35 @@ type OutputConfig struct {
11 Off bool11 Off bool
12 }12 }
13 13
14-// Config is the parsed compositor configuration.14+// Config is the parsed compositor configuration (see the config adapter for keys).
15 type Config struct {15 type Config struct {
16 Keyboard struct {16 Keyboard struct {
17 Layout, Variant, Options string17 Layout, Variant, Options string
18- RepeatRate int `toml:"repeat_rate"`18+ RepeatRate int
19- RepeatDelay int `toml:"repeat_delay"`19+ RepeatDelay int
20- CmdKey string `toml:"cmd_key"`20+ CmdKey string
21- } `toml:"keyboard"`21+ }
22- Terminal struct{ Command []string } `toml:"terminal"`22+ Terminal struct{ Command []string }
23- Background struct{ Color string } `toml:"background"`23+ Background struct{ Color string }
24 // Border is drawn inside the window edge; Width 0 disables it.24 // Border is drawn inside the window edge; Width 0 disables it.
25 Border struct {25 Border struct {
26 Width int26 Width int
27 Active string27 Active string
28 Inactive string28 Inactive string
29- } `toml:"border"`29+ }
30 Layout struct {30 Layout struct {
31 Gaps int31 Gaps int
32- DefaultColumnWidth string `toml:"default_column_width"`32+ DefaultColumnWidth string
33 Presets []string33 Presets []string
34- } `toml:"layout"`34+ }
35 // Outputs selects and configures physical displays (drm backend).35 // Outputs selects and configures physical displays (drm backend).
36- Outputs []OutputConfig `toml:"output"`36+ Outputs []OutputConfig
37- Binds map[string]string `toml:"binds"`37+ Binds map[string]string
38 Render struct {38 Render struct {
39- DirectScanout bool `toml:"direct_scanout"`39+ DirectScanout bool
40- } `toml:"render"`40+ }
41 Log struct {41 Log struct {
42 Level string42 Level string
43 Debug []string43 Debug []string
44- } `toml:"log"`44+ }
45 }45 }
M internal/ports/messages.go +8 -0
@@ -181,6 +181,14 @@ type ForwardKey struct {
181181
182182 func (ForwardKey) clientCommand() {}
183183
184+// SetKeymap carries app → wayland a new xkb keymap (text format) and repeat info.
185+type SetKeymap struct {
186+ Keymap string
187+ RepeatRate, RepeatDelay int
188+}
189+
190+func (SetKeymap) clientCommand() {}
191+
184192 // SpawnRequest carries core → launcher process arguments.
185193 type SpawnRequest struct{ Argv []string }
186194
@@ -181,6 +181,14 @@ type ForwardKey struct {
181 181
182 func (ForwardKey) clientCommand() {}182 func (ForwardKey) clientCommand() {}
183 183
184+// SetKeymap carries app → wayland a new xkb keymap (text format) and repeat info.
185+type SetKeymap struct {
186+ Keymap string
187+ RepeatRate, RepeatDelay int
188+}
189+
190+func (SetKeymap) clientCommand() {}
191+
184 // SpawnRequest carries core → launcher process arguments.192 // SpawnRequest carries core → launcher process arguments.
185 type SpawnRequest struct{ Argv []string }193 type SpawnRequest struct{ Argv []string }
186 194

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 25

Tags 10

↑↓ moveenter openesc close