detached 45a1428

merge: kvconfig (flat key=value config, live keyboard reload)

commit
45a1428b5b17cd437a8333a41240c16aef90fa8f
author
brice · · 2026-09-24 13:48 UTC
merge of
2172e12 + 538caad · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +1200 -407
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 +16 -6
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/nefertty/internal/adapters/config"
1616 "github.com/bnema/nefertty/internal/app"
1717 "github.com/bnema/nefertty/internal/logging"
18+ "github.com/bnema/nefertty/internal/ports"
1819 )
1920
2021 type usageError struct{ error }
@@ -50,17 +51,22 @@ func run() error {
5051 return err
5152 }
5253 path := config.DefaultPath()
53- var err error
54+ load := config.LoadDefault
5455 if len(os.Args) == 3 {
5556 path = os.Args[2]
56- _, err = config.Load(path)
57- } else {
58- _, err = config.LoadDefault()
57+ load = func() (ports.Config, []config.Warning, error) { return config.Load(path) }
5958 }
59+ _, warnings, err := load()
6060 if err != nil {
6161 fmt.Fprintln(os.Stderr, err)
6262 return err
6363 }
64+ for _, w := range warnings {
65+ fmt.Fprintf(os.Stderr, "%s:%s\n", path, w)
66+ }
67+ if len(warnings) > 0 {
68+ return fmt.Errorf("%d warning(s)", len(warnings))
69+ }
6470 fmt.Println("ok: " + path)
6571 return nil
6672 }
@@ -132,11 +138,12 @@ func run() error {
132138 path := *configFlag
133139 var cfgErr error
134140 var cfg = config.Defaults()
141+ var warnings []config.Warning
135142 if path == "" {
136143 path = config.DefaultPath()
137- cfg, cfgErr = config.LoadDefault()
144+ cfg, warnings, cfgErr = config.LoadDefault()
138145 } else {
139- cfg, cfgErr = config.Load(path)
146+ cfg, warnings, cfgErr = config.Load(path)
140147 }
141148 if cfgErr != nil {
142149 fmt.Fprintln(os.Stderr, cfgErr)
@@ -180,6 +187,9 @@ func run() error {
180187 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")
181188 configLog := logging.For(ctx, "config")
182189 configLog.Info().Str("path", path).Msg("loaded config")
190+ for _, w := range warnings {
191+ configLog.Warn().Int("line", w.Line).Msg(w.Msg)
192+ }
183193 err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script})
184194 // SIGINT and SIGTERM cancel the context and are clean exits.
185195 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/nefertty/internal/adapters/config"15 "github.com/bnema/nefertty/internal/adapters/config"
16 "github.com/bnema/nefertty/internal/app"16 "github.com/bnema/nefertty/internal/app"
17 "github.com/bnema/nefertty/internal/logging"17 "github.com/bnema/nefertty/internal/logging"
18+ "github.com/bnema/nefertty/internal/ports"
18 )19 )
19 20
20 type usageError struct{ error }21 type usageError struct{ error }
@@ -50,17 +51,22 @@ func run() error {
50 return err51 return err
51 }52 }
52 path := config.DefaultPath()53 path := config.DefaultPath()
53- var err error54+ load := config.LoadDefault
54 if len(os.Args) == 3 {55 if len(os.Args) == 3 {
55 path = os.Args[2]56 path = os.Args[2]
56- _, err = config.Load(path)57+ load = func() (ports.Config, []config.Warning, error) { return config.Load(path) }
57- } else {
58- _, err = config.LoadDefault()
59 }58 }
59+ _, warnings, err := load()
60 if err != nil {60 if err != nil {
61 fmt.Fprintln(os.Stderr, err)61 fmt.Fprintln(os.Stderr, err)
62 return err62 return err
63 }63 }
64+ for _, w := range warnings {
65+ fmt.Fprintf(os.Stderr, "%s:%s\n", path, w)
66+ }
67+ if len(warnings) > 0 {
68+ return fmt.Errorf("%d warning(s)", len(warnings))
69+ }
64 fmt.Println("ok: " + path)70 fmt.Println("ok: " + path)
65 return nil71 return nil
66 }72 }
@@ -132,11 +138,12 @@ func run() error {
132 path := *configFlag138 path := *configFlag
133 var cfgErr error139 var cfgErr error
134 var cfg = config.Defaults()140 var cfg = config.Defaults()
141+ var warnings []config.Warning
135 if path == "" {142 if path == "" {
136 path = config.DefaultPath()143 path = config.DefaultPath()
137- cfg, cfgErr = config.LoadDefault()144+ cfg, warnings, cfgErr = config.LoadDefault()
138 } else {145 } else {
139- cfg, cfgErr = config.Load(path)146+ cfg, warnings, cfgErr = config.Load(path)
140 }147 }
141 if cfgErr != nil {148 if cfgErr != nil {
142 fmt.Fprintln(os.Stderr, cfgErr)149 fmt.Fprintln(os.Stderr, cfgErr)
@@ -180,6 +187,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")187 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")188 configLog := logging.For(ctx, "config")
182 configLog.Info().Str("path", path).Msg("loaded config")189 configLog.Info().Str("path", path).Msg("loaded config")
190+ for _, w := range warnings {
191+ configLog.Warn().Int("line", w.Line).Msg(w.Msg)
192+ }
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})193 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.194 // SIGINT and SIGTERM cancel the context and are clean exits.
185 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {195 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 +417 -185
@@ -1,26 +1,57 @@
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"
912 "sort"
1013 "strconv"
1114 "strings"
15+ "unicode"
1216
13- "github.com/BurntSushi/toml"
1417 "github.com/bnema/nefertty/internal/ports"
1518 )
1619
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",
20+// defaultBinds use the config syntax and go through the same parser as user binds.
21+var defaultBinds = []struct{ combo, action string }{
22+ {"cmd+return", "spawn-terminal"},
23+ {"cmd+left", "focus-column-left"},
24+ {"cmd+right", "focus-column-right"},
25+ {"cmd+up", "focus-window-up"},
26+ {"cmd+down", "focus-window-down"},
27+ {"cmd+shift+left", "move-column-left"},
28+ {"cmd+shift+right", "move-column-right"},
29+ {"cmd+r", "cycle-column-width"},
30+ {"cmd+shift+f", "toggle-fullscreen"},
31+ {"cmd+q", "close-window"},
32+ {"ctrl+alt+backspace", "quit"},
33+ {"ctrl+cmd+space", "spawn fuzzel"},
1934 }
2035 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}
2136 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
2237 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
2338
39+// namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
40+var namedKeys = map[string]string{
41+ "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape",
42+ "backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End",
43+ "pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print",
44+ "left": "Left", "right": "Right", "up": "Up", "down": "Down",
45+}
46+
47+// Warning is a non-fatal problem at one line; the key keeps its default.
48+type Warning struct {
49+ Line int
50+ Msg string
51+}
52+
53+func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) }
54+
2455 func DefaultPath() string {
2556 base := os.Getenv("XDG_CONFIG_HOME")
2657 if base == "" {
@@ -29,8 +60,9 @@ func DefaultPath() string {
2960 base = filepath.Join(home, ".config")
3061 }
3162 }
32- return filepath.Join(base, "nefertty", "config.toml")
63+ return filepath.Join(base, "nefertty", "config")
3364 }
65+
3466 func Defaults() ports.Config {
3567 var c ports.Config
3668 c.Keyboard.RepeatRate = 25
@@ -40,111 +72,181 @@ func Defaults() ports.Config {
4072 c.Background.Color = "#1e1e2e"
4173 c.Border.Width = 2
4274 c.Border.Active = "#b4befe"
43- c.Border.Inactive = ""
44- c.Layout.Gaps = 0
4575 c.Layout.DefaultColumnWidth = "1/2"
4676 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
77+ c.Binds = map[string]string{}
78+ for _, b := range defaultBinds {
79+ combo, err := parseCombo(b.combo)
80+ if err != nil {
81+ panic(err)
82+ }
83+ c.Binds[combo] = b.action
5084 }
5185 c.Render.DirectScanout = true
5286 c.Log.Level = "info"
5387 c.Log.Debug = []string{}
5488 return c
5589 }
56-func LoadDefault() (ports.Config, error) {
57- c, err := Load(DefaultPath())
90+
91+// LoadDefault loads DefaultPath; a missing file means defaults.
92+func LoadDefault() (ports.Config, []Warning, error) {
93+ c, w, err := Load(DefaultPath())
5894 if os.IsNotExist(err) {
59- return Defaults(), nil
95+ return Defaults(), nil, nil
6096 }
61- return c, err
97+ return c, w, err
6298 }
63-func Load(path string) (ports.Config, error) {
64- c := Defaults()
65- data, err := os.ReadFile(path)
99+
100+// Load reads a config file. The error is only for I/O; bad lines become warnings.
101+func Load(path string) (ports.Config, []Warning, error) {
102+ f, err := os.Open(path)
66103 if err != nil {
67- return c, err
104+ return Defaults(), nil, err
68105 }
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- }
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)
106+ defer f.Close()
107+ return Parse(f)
108+}
109+
110+// Parse reads `key = value` lines on top of the defaults.
111+func Parse(r io.Reader) (ports.Config, []Warning, error) {
112+ c, _, w, err := parse(r)
113+ return c, w, err
114+}
115+
116+// parse also returns the effective key/value pairs, used to log what changed on reload.
117+func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
118+ c := Defaults()
119+ var warnings []Warning
120+ raw := map[string]string{}
121+ seen := map[string]int{}
122+ outputs := map[string]int{}
123+ scanner := bufio.NewScanner(r)
124+ for n := 1; scanner.Scan(); n++ {
125+ line := strings.TrimSpace(scanner.Text())
126+ if line == "" || strings.HasPrefix(line, "#") {
127+ continue
128+ }
129+ warn := func(format string, a ...any) {
130+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
131+ }
132+ key, value, ok := strings.Cut(line, "=")
133+ key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
134+ if !ok || key == "" {
135+ warn("expected key = value")
136+ continue
137+ }
138+ if combo, ok := strings.CutPrefix(key, "bind."); ok {
139+ if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
140+ warn("%s: write %sequal instead of %s=", key, combo, combo)
141+ continue
142+ }
143+ canon, err := parseCombo(combo)
144+ if err != nil {
145+ warn("%s: %v", key, err)
146+ continue
147+ }
148+ if err := checkAction(value); err != nil {
149+ warn("%s: %v", key, err)
150+ continue
151+ }
152+ key = "bind." + canon
153+ if prev, dup := seen[key]; dup {
154+ warn("%s: overrides line %d", key, prev)
155+ }
156+ seen[key] = n
157+ raw[key] = value
158+ c.Binds[canon] = value
159+ continue
160+ }
161+ override := func() {
162+ if prev, dup := seen[key]; dup {
163+ warn("%s: overrides line %d", key, prev)
164+ }
165+ seen[key] = n
166+ raw[key] = value
167+ }
168+ if name, ok := strings.CutPrefix(key, "output."); ok {
169+ o := ports.OutputConfig{Name: name}
170+ if value == "off" {
171+ o.Off = true
172+ } else if _, _, _, err := ParseMode(value); err != nil {
173+ warn("%s: %v", key, err)
174+ continue
175+ } else if value != "preferred" {
176+ o.Mode = value
97177 }
178+ override()
179+ if i, ok := outputs[name]; ok {
180+ c.Outputs[i] = o
181+ } else {
182+ outputs[name] = len(c.Outputs)
183+ c.Outputs = append(c.Outputs, o)
184+ }
185+ continue
98186 }
99- if v != "none" {
100- c.Binds[k] = v
187+ if err := set(&c, key, value); err != nil {
188+ warn("%s: %v", key, err)
189+ continue
101190 }
191+ override()
102192 }
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)
193+ if err := scanner.Err(); err != nil {
194+ return c, raw, warnings, err
114195 }
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)
196+ // "none" only removes a default bind.
197+ for combo, a := range c.Binds {
198+ if a == "none" {
199+ delete(c.Binds, combo)
118200 }
119201 }
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
202+ // Cmd resolves to a real modifier; drop binds that then collide. As for any key,
203+ // the later line wins; defaults (no line) lose to every user line.
204+ combos := make([]string, 0, len(c.Binds))
205+ for combo := range c.Binds {
206+ combos = append(combos, combo)
130207 }
131- p := strings.Split(s, "/")
132- if len(p) != 2 {
133- return false
208+ order := func(combo string) int {
209+ if n := seen["bind."+combo]; n > 0 {
210+ return -n
211+ }
212+ return math.MaxInt
213+ }
214+ sort.Slice(combos, func(i, j int) bool {
215+ a, b := order(combos[i]), order(combos[j])
216+ return a < b || a == b && combos[i] < combos[j]
217+ })
218+ resolved := map[string]string{}
219+ for _, combo := range combos {
220+ r, err := expandCmd(combo, c.Keyboard.CmdKey)
221+ if prev, dup := resolved[r]; err == nil && dup {
222+ err = fmt.Errorf("overridden by bind.%s (same keys with keyboard.cmd = %s)", prev, c.Keyboard.CmdKey)
223+ }
224+ if err != nil {
225+ if n := seen["bind."+combo]; n > 0 {
226+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)})
227+ }
228+ delete(c.Binds, combo)
229+ delete(raw, "bind."+combo)
230+ continue
231+ }
232+ resolved[r] = combo
134233 }
135- a, e1 := strconv.Atoi(p[0])
136- b, e2 := strconv.Atoi(p[1])
137- return e1 == nil && e2 == nil && a > 0 && a <= b
234+ return c, raw, warnings, nil
138235 }
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- }
236+
237+// expandCmd replaces Cmd with the configured modifier and re-sorts.
238+func expandCmd(combo, cmd string) (string, error) {
239+ parts := strings.Split(combo, "+")
240+ key := parts[len(parts)-1]
241+ // cmd is super, alt or ctrl; its modifier name is the capitalized form.
242+ cmdName := strings.ToUpper(cmd[:1]) + cmd[1:]
144243 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")
244+ for _, m := range parts[:len(parts)-1] {
245+ if m == "Cmd" {
246+ m = cmdName
247+ }
248+ if seen[m] {
249+ return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd)
148250 }
149251 seen[m] = true
150252 }
@@ -153,130 +255,260 @@ func normalize(s string) (string, error) {
153255 mods = append(mods, m)
154256 }
155257 sort.Strings(mods)
156- return strings.Join(append(mods, p[len(p)-1]), "+"), nil
258+ return strings.Join(append(mods, key), "+"), nil
157259 }
158260
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")
261+// set applies one scalar key. Unknown keys and invalid values are errors.
262+func set(c *ports.Config, key, v string) error {
263+ switch key {
264+ case "keyboard.layout":
265+ c.Keyboard.Layout = v
266+ case "keyboard.variant":
267+ c.Keyboard.Variant = v
268+ case "keyboard.options":
269+ c.Keyboard.Options = v
270+ case "keyboard.repeat-rate":
271+ return positive(&c.Keyboard.RepeatRate, v, 1000)
272+ case "keyboard.repeat-delay":
273+ return positive(&c.Keyboard.RepeatDelay, v, 10000)
274+ case "keyboard.cmd":
275+ if v != "super" && v != "alt" && v != "ctrl" {
276+ return fmt.Errorf("must be super, alt or ctrl")
277+ }
278+ c.Keyboard.CmdKey = v
279+ case "terminal":
280+ argv := strings.Fields(v)
281+ if len(argv) == 0 {
282+ return fmt.Errorf("must not be empty")
283+ }
284+ c.Terminal.Command = argv
285+ case "background":
286+ if !color.MatchString(v) {
287+ return fmt.Errorf("must be #rrggbb")
288+ }
289+ c.Background.Color = v
290+ case "border.width":
291+ n, err := strconv.Atoi(v)
292+ if err != nil || n < 0 || n > 32 {
293+ return fmt.Errorf("must be between 0 and 32")
294+ }
295+ c.Border.Width = n
296+ case "border.active":
297+ if !color.MatchString(v) {
298+ return fmt.Errorf("must be #rrggbb")
299+ }
300+ c.Border.Active = v
301+ case "border.inactive":
302+ if v != "" && !color.MatchString(v) {
303+ return fmt.Errorf("must be #rrggbb or empty (none)")
304+ }
305+ c.Border.Inactive = v
306+ case "layout.gaps":
307+ n, err := strconv.Atoi(v)
308+ if err != nil || n < 0 || n > 200 {
309+ return fmt.Errorf("must be between 0 and 200")
310+ }
311+ c.Layout.Gaps = n
312+ case "layout.default-width":
313+ if !width(v) {
314+ return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px")
315+ }
316+ c.Layout.DefaultColumnWidth = v
317+ case "layout.presets":
318+ list := splitList(v)
319+ if len(list) == 0 {
320+ return fmt.Errorf("must not be empty")
321+ }
322+ for _, s := range list {
323+ if !width(s) {
324+ return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s)
174325 }
175326 }
176- if mods[m] {
177- return "", fmt.Errorf("duplicate resolved modifier")
327+ c.Layout.Presets = list
328+ case "render.direct-scanout":
329+ b, err := onOff(v)
330+ if err != nil {
331+ return err
178332 }
179- mods[m] = true
333+ c.Render.DirectScanout = b
334+ case "log.level":
335+ if v != "debug" && v != "info" && v != "warn" && v != "error" {
336+ return fmt.Errorf("must be debug, info, warn or error")
337+ }
338+ c.Log.Level = v
339+ case "log.debug":
340+ list := splitList(v)
341+ for _, s := range list {
342+ if !components[s] && s != "all" {
343+ return fmt.Errorf("unknown component %q", s)
344+ }
345+ }
346+ c.Log.Debug = list
347+ default:
348+ return fmt.Errorf("unknown key")
349+ }
350+ return nil
351+}
352+
353+func positive(dst *int, v string, max int) error {
354+ n, err := strconv.Atoi(v)
355+ if err != nil || n <= 0 || n > max {
356+ return fmt.Errorf("must be between 1 and %d", max)
180357 }
181- ordered := make([]string, 0, len(mods))
182- for m := range mods {
183- ordered = append(ordered, m)
358+ *dst = n
359+ return nil
360+}
361+
362+func onOff(v string) (bool, error) {
363+ switch v {
364+ case "on":
365+ return true, nil
366+ case "off":
367+ return false, nil
368+ }
369+ return false, fmt.Errorf("must be on or off")
370+}
371+
372+func splitList(v string) []string {
373+ list := []string{}
374+ for _, s := range strings.Split(v, ",") {
375+ if s = strings.TrimSpace(s); s != "" {
376+ list = append(list, s)
377+ }
184378 }
185- sort.Strings(ordered)
186- return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil
379+ return list
187380 }
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- }
381+
382+// stripComment drops a trailing ` # comment` from a value. A # that starts the
383+// value is kept, so colors like #1e1e2e need no quotes.
384+func stripComment(value string) string {
385+ for i := 1; i < len(value); i++ {
386+ if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
387+ return strings.TrimSpace(value[:i])
208388 }
209389 }
210- if !color.MatchString(c.Background.Color) {
211- add("background.color", "must be #rrggbb")
390+ return value
391+}
392+
393+func checkAction(v string) error {
394+ if rest, ok := strings.CutPrefix(v, "spawn "); ok {
395+ if len(strings.Fields(rest)) == 0 {
396+ return fmt.Errorf("spawn needs a command")
397+ }
398+ return nil
212399 }
213- if c.Border.Width < 0 || c.Border.Width > 32 {
214- add("border.width", "must be between 0 and 32")
400+ if !actions[v] {
401+ return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
215402 }
216- if !color.MatchString(c.Border.Active) {
217- add("border.active", "must be #rrggbb")
403+ return nil
404+}
405+
406+// parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
407+// modifiers sorted, the key as an xkb keysym name.
408+func parseCombo(s string) (string, error) {
409+ parts := strings.Split(s, "+")
410+ key := parts[len(parts)-1]
411+ if len(parts) > 1 && key == "" {
412+ // "cmd++" means the + key.
413+ if len(parts) > 2 && parts[len(parts)-2] == "" {
414+ parts, key = parts[:len(parts)-1], "+"
415+ } else {
416+ return "", fmt.Errorf("missing key")
417+ }
218418 }
219- if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {
220- add("border.inactive", `must be #rrggbb or "" (none)`)
419+ if key == "" {
420+ return "", fmt.Errorf("missing key")
221421 }
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")
422+ seen := map[string]bool{}
423+ for _, m := range parts[:len(parts)-1] {
424+ var name string
425+ switch strings.ToLower(m) {
426+ case "cmd":
427+ name = "Cmd"
428+ case "shift":
429+ name = "Shift"
430+ case "ctrl":
431+ name = "Ctrl"
432+ case "alt":
433+ name = "Alt"
434+ case "super":
435+ name = "Super"
436+ default:
437+ return "", fmt.Errorf("unknown modifier %q", m)
226438 }
227- if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {
228- add(path+".mode", err.Error())
439+ if seen[name] {
440+ return "", fmt.Errorf("duplicate modifier %q", m)
229441 }
442+ seen[name] = true
230443 }
231- if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
232- add("layout.gaps", "must be between 0 and 200")
233- }
234- if !width(c.Layout.DefaultColumnWidth) {
235- add("layout.default_column_width", "must be a positive fraction or pixel width")
444+ mods := make([]string, 0, len(seen))
445+ for m := range seen {
446+ mods = append(mods, m)
236447 }
237- if len(c.Layout.Presets) == 0 {
238- add("layout.presets", "must not be empty")
448+ sort.Strings(mods)
449+ name, err := keysym(key)
450+ if err != nil {
451+ return "", err
239452 }
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")
453+ return strings.Join(append(mods, name), "+"), nil
454+}
455+
456+// keysym maps a written key to its xkb keysym name.
457+func keysym(k string) (string, error) {
458+ if r := []rune(k); len(r) == 1 {
459+ // Binds match the unshifted keysym, so É means é.
460+ if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
461+ return name, nil
243462 }
463+ if r[0] > unicode.MaxASCII {
464+ return "", fmt.Errorf("write %q by its xkb keysym name", k)
465+ }
466+ return strings.ToLower(k), nil
244467 }
245- if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" {
246- add("log.level", "invalid level")
468+ if name, ok := namedKeys[strings.ToLower(k)]; ok {
469+ return name, nil
247470 }
248- for i, s := range c.Log.Debug {
249- if !components[s] {
250- add(fmt.Sprintf("log.debug[%d]", i), "invalid component")
471+ if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
472+ if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
473+ return "F" + k[1:], nil
251474 }
252475 }
253- keys := make([]string, 0, len(c.Binds))
254- for k := range c.Binds {
255- keys = append(keys, k)
476+ return k, nil
477+}
478+
479+// ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
480+func ParseMode(s string) (w, h int, hz float64, err error) {
481+ if s == "preferred" {
482+ return 0, 0, 0, nil
256483 }
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>")`)
484+ size, rate, hasRate := strings.Cut(s, "@")
485+ ws, hs, ok := strings.Cut(size, "x")
486+ w, e1 := strconv.Atoi(ws)
487+ h, e2 := strconv.Atoi(hs)
488+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
489+ return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s)
490+ }
491+ if hasRate {
492+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
493+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
279494 }
280495 }
281- return errors.Join(errs...)
496+ return w, h, hz, nil
497+}
498+
499+func width(s string) bool {
500+ if s == "1" {
501+ return true
502+ }
503+ if strings.HasSuffix(s, "px") {
504+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
505+ return e == nil && n > 0
506+ }
507+ p := strings.Split(s, "/")
508+ if len(p) != 2 {
509+ return false
510+ }
511+ a, e1 := strconv.Atoi(p[0])
512+ b, e2 := strconv.Atoi(p[1])
513+ return e1 == nil && e2 == nil && a > 0 && a <= b
282514 }
@@ -1,26 +1,57 @@
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"
9 "sort"12 "sort"
10 "strconv"13 "strconv"
11 "strings"14 "strings"
15+ "unicode"
12 16
13- "github.com/BurntSushi/toml"
14 "github.com/bnema/nefertty/internal/ports"17 "github.com/bnema/nefertty/internal/ports"
15 )18 )
16 19
17-var defaultBinds = map[string]string{20+// 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",21+var defaultBinds = []struct{ combo, action string }{
22+ {"cmd+return", "spawn-terminal"},
23+ {"cmd+left", "focus-column-left"},
24+ {"cmd+right", "focus-column-right"},
25+ {"cmd+up", "focus-window-up"},
26+ {"cmd+down", "focus-window-down"},
27+ {"cmd+shift+left", "move-column-left"},
28+ {"cmd+shift+right", "move-column-right"},
29+ {"cmd+r", "cycle-column-width"},
30+ {"cmd+shift+f", "toggle-fullscreen"},
31+ {"cmd+q", "close-window"},
32+ {"ctrl+alt+backspace", "quit"},
33+ {"ctrl+cmd+space", "spawn fuzzel"},
19 }34 }
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}35 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}36 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}$`)37 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
23 38
39+// namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
40+var namedKeys = map[string]string{
41+ "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape",
42+ "backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End",
43+ "pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print",
44+ "left": "Left", "right": "Right", "up": "Up", "down": "Down",
45+}
46+
47+// Warning is a non-fatal problem at one line; the key keeps its default.
48+type Warning struct {
49+ Line int
50+ Msg string
51+}
52+
53+func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) }
54+
24 func DefaultPath() string {55 func DefaultPath() string {
25 base := os.Getenv("XDG_CONFIG_HOME")56 base := os.Getenv("XDG_CONFIG_HOME")
26 if base == "" {57 if base == "" {
@@ -29,8 +60,9 @@ func DefaultPath() string {
29 base = filepath.Join(home, ".config")60 base = filepath.Join(home, ".config")
30 }61 }
31 }62 }
32- return filepath.Join(base, "nefertty", "config.toml")63+ return filepath.Join(base, "nefertty", "config")
33 }64 }
65+
34 func Defaults() ports.Config {66 func Defaults() ports.Config {
35 var c ports.Config67 var c ports.Config
36 c.Keyboard.RepeatRate = 2568 c.Keyboard.RepeatRate = 25
@@ -40,111 +72,181 @@ func Defaults() ports.Config {
40 c.Background.Color = "#1e1e2e"72 c.Background.Color = "#1e1e2e"
41 c.Border.Width = 273 c.Border.Width = 2
42 c.Border.Active = "#b4befe"74 c.Border.Active = "#b4befe"
43- c.Border.Inactive = ""
44- c.Layout.Gaps = 0
45 c.Layout.DefaultColumnWidth = "1/2"75 c.Layout.DefaultColumnWidth = "1/2"
46 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}76 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
47- c.Binds = make(map[string]string, len(defaultBinds))77+ c.Binds = map[string]string{}
48- for k, v := range defaultBinds {78+ for _, b := range defaultBinds {
49- c.Binds[k] = v79+ combo, err := parseCombo(b.combo)
80+ if err != nil {
81+ panic(err)
82+ }
83+ c.Binds[combo] = b.action
50 }84 }
51 c.Render.DirectScanout = true85 c.Render.DirectScanout = true
52 c.Log.Level = "info"86 c.Log.Level = "info"
53 c.Log.Debug = []string{}87 c.Log.Debug = []string{}
54 return c88 return c
55 }89 }
56-func LoadDefault() (ports.Config, error) {90+
57- c, err := Load(DefaultPath())91+// LoadDefault loads DefaultPath; a missing file means defaults.
92+func LoadDefault() (ports.Config, []Warning, error) {
93+ c, w, err := Load(DefaultPath())
58 if os.IsNotExist(err) {94 if os.IsNotExist(err) {
59- return Defaults(), nil95+ return Defaults(), nil, nil
60 }96 }
61- return c, err97+ return c, w, err
62 }98 }
63-func Load(path string) (ports.Config, error) {99+
64- c := Defaults()100+// Load reads a config file. The error is only for I/O; bad lines become warnings.
65- data, err := os.ReadFile(path)101+func Load(path string) (ports.Config, []Warning, error) {
102+ f, err := os.Open(path)
66 if err != nil {103 if err != nil {
67- return c, err104+ return Defaults(), nil, err
68 }105 }
69- // Decode binds separately so user entries can remove or override defaults.106+ defer f.Close()
70- var raw struct {107+ return Parse(f)
71- Binds map[string]string `toml:"binds"`108+}
72- }109+
73- if _, err = toml.Decode(string(data), &raw); err != nil {110+// Parse reads `key = value` lines on top of the defaults.
74- return c, err111+func Parse(r io.Reader) (ports.Config, []Warning, error) {
75- }112+ c, _, w, err := parse(r)
76- c.Binds = make(map[string]string)113+ return c, w, err
77- md, err := toml.Decode(string(data), &c)114+}
78- if err != nil {115+
79- return c, err116+// parse also returns the effective key/value pairs, used to log what changed on reload.
80- }117+func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
81- var errs []error118+ c := Defaults()
82- for _, key := range md.Undecoded() {119+ var warnings []Warning
83- errs = append(errs, fmt.Errorf("%s: unknown key", key.String()))120+ raw := map[string]string{}
84- }121+ seen := map[string]int{}
85- // Validate user bindings before merging to catch normalized collisions.122+ outputs := map[string]int{}
86- errs = append(errs, Validate(c))123+ scanner := bufio.NewScanner(r)
87- if err = errors.Join(errs...); err != nil {124+ for n := 1; scanner.Scan(); n++ {
88- return c, err125+ line := strings.TrimSpace(scanner.Text())
89- }126+ if line == "" || strings.HasPrefix(line, "#") {
90- c.Binds = Defaults().Binds127+ continue
91- for k, v := range raw.Binds {128+ }
92- normalized, _ := normalize(k)129+ warn := func(format string, a ...any) {
93- for old := range c.Binds {130+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
94- n, _ := normalize(old)131+ }
95- if n == normalized {132+ key, value, ok := strings.Cut(line, "=")
96- delete(c.Binds, old)133+ key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
134+ if !ok || key == "" {
135+ warn("expected key = value")
136+ continue
137+ }
138+ if combo, ok := strings.CutPrefix(key, "bind."); ok {
139+ if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
140+ warn("%s: write %sequal instead of %s=", key, combo, combo)
141+ continue
142+ }
143+ canon, err := parseCombo(combo)
144+ if err != nil {
145+ warn("%s: %v", key, err)
146+ continue
147+ }
148+ if err := checkAction(value); err != nil {
149+ warn("%s: %v", key, err)
150+ continue
151+ }
152+ key = "bind." + canon
153+ if prev, dup := seen[key]; dup {
154+ warn("%s: overrides line %d", key, prev)
155+ }
156+ seen[key] = n
157+ raw[key] = value
158+ c.Binds[canon] = value
159+ continue
160+ }
161+ override := func() {
162+ if prev, dup := seen[key]; dup {
163+ warn("%s: overrides line %d", key, prev)
164+ }
165+ seen[key] = n
166+ raw[key] = value
167+ }
168+ if name, ok := strings.CutPrefix(key, "output."); ok {
169+ o := ports.OutputConfig{Name: name}
170+ if value == "off" {
171+ o.Off = true
172+ } else if _, _, _, err := ParseMode(value); err != nil {
173+ warn("%s: %v", key, err)
174+ continue
175+ } else if value != "preferred" {
176+ o.Mode = value
97 }177 }
178+ override()
179+ if i, ok := outputs[name]; ok {
180+ c.Outputs[i] = o
181+ } else {
182+ outputs[name] = len(c.Outputs)
183+ c.Outputs = append(c.Outputs, o)
184+ }
185+ continue
98 }186 }
99- if v != "none" {187+ if err := set(&c, key, value); err != nil {
100- c.Binds[k] = v188+ warn("%s: %v", key, err)
189+ continue
101 }190 }
191+ override()
102 }192 }
103- return c, nil193+ if err := scanner.Err(); err != nil {
104-}194+ 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 }195 }
115- if hasRate {196+ // "none" only removes a default bind.
116- if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {197+ for combo, a := range c.Binds {
117- return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)198+ if a == "none" {
199+ delete(c.Binds, combo)
118 }200 }
119 }201 }
120- return w, h, hz, nil202+ // Cmd resolves to a real modifier; drop binds that then collide. As for any key,
121-}203+ // the later line wins; defaults (no line) lose to every user line.
122-204+ combos := make([]string, 0, len(c.Binds))
123-func width(s string) bool {205+ for combo := range c.Binds {
124- if s == "1" {206+ 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 }207 }
131- p := strings.Split(s, "/")208+ order := func(combo string) int {
132- if len(p) != 2 {209+ if n := seen["bind."+combo]; n > 0 {
133- return false210+ return -n
211+ }
212+ return math.MaxInt
213+ }
214+ sort.Slice(combos, func(i, j int) bool {
215+ a, b := order(combos[i]), order(combos[j])
216+ return a < b || a == b && combos[i] < combos[j]
217+ })
218+ resolved := map[string]string{}
219+ for _, combo := range combos {
220+ r, err := expandCmd(combo, c.Keyboard.CmdKey)
221+ if prev, dup := resolved[r]; err == nil && dup {
222+ err = fmt.Errorf("overridden by bind.%s (same keys with keyboard.cmd = %s)", prev, c.Keyboard.CmdKey)
223+ }
224+ if err != nil {
225+ if n := seen["bind."+combo]; n > 0 {
226+ warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)})
227+ }
228+ delete(c.Binds, combo)
229+ delete(raw, "bind."+combo)
230+ continue
231+ }
232+ resolved[r] = combo
134 }233 }
135- a, e1 := strconv.Atoi(p[0])234+ return c, raw, warnings, nil
136- b, e2 := strconv.Atoi(p[1])
137- return e1 == nil && e2 == nil && a > 0 && a <= b
138 }235 }
139-func normalize(s string) (string, error) {236+
140- p := strings.Split(s, "+")237+// expandCmd replaces Cmd with the configured modifier and re-sorts.
141- if len(p) < 2 || p[len(p)-1] == "" {238+func expandCmd(combo, cmd string) (string, error) {
142- return "", fmt.Errorf("invalid key combo")239+ parts := strings.Split(combo, "+")
143- }240+ key := parts[len(parts)-1]
241+ // cmd is super, alt or ctrl; its modifier name is the capitalized form.
242+ cmdName := strings.ToUpper(cmd[:1]) + cmd[1:]
144 seen := map[string]bool{}243 seen := map[string]bool{}
145- for _, m := range p[:len(p)-1] {244+ for _, m := range parts[:len(parts)-1] {
146- if m != "Cmd" && m != "Shift" && m != "Ctrl" && m != "Alt" || seen[m] {245+ if m == "Cmd" {
147- return "", fmt.Errorf("invalid key combo")246+ m = cmdName
247+ }
248+ if seen[m] {
249+ return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd)
148 }250 }
149 seen[m] = true251 seen[m] = true
150 }252 }
@@ -153,130 +255,260 @@ func normalize(s string) (string, error) {
153 mods = append(mods, m)255 mods = append(mods, m)
154 }256 }
155 sort.Strings(mods)257 sort.Strings(mods)
156- return strings.Join(append(mods, p[len(p)-1]), "+"), nil258+ return strings.Join(append(mods, key), "+"), nil
157 }259 }
158 260
159-// resolvedCombo canonicalizes modifiers after resolving Cmd, so aliases collide.261+// set applies one scalar key. Unknown keys and invalid values are errors.
160-func resolvedCombo(s, cmd string) (string, error) {262+func set(c *ports.Config, key, v string) error {
161- parts := strings.Split(s, "+")263+ switch key {
162- mods := map[string]bool{}264+ case "keyboard.layout":
163- for _, m := range parts[:len(parts)-1] {265+ c.Keyboard.Layout = v
164- if m == "Cmd" {266+ case "keyboard.variant":
165- switch cmd {267+ c.Keyboard.Variant = v
166- case "super":268+ case "keyboard.options":
167- m = "Super"269+ c.Keyboard.Options = v
168- case "alt":270+ case "keyboard.repeat-rate":
169- m = "Alt"271+ return positive(&c.Keyboard.RepeatRate, v, 1000)
170- case "ctrl":272+ case "keyboard.repeat-delay":
171- m = "Ctrl"273+ return positive(&c.Keyboard.RepeatDelay, v, 10000)
172- default:274+ case "keyboard.cmd":
173- return "", fmt.Errorf("invalid cmd key")275+ if v != "super" && v != "alt" && v != "ctrl" {
276+ return fmt.Errorf("must be super, alt or ctrl")
277+ }
278+ c.Keyboard.CmdKey = v
279+ case "terminal":
280+ argv := strings.Fields(v)
281+ if len(argv) == 0 {
282+ return fmt.Errorf("must not be empty")
283+ }
284+ c.Terminal.Command = argv
285+ case "background":
286+ if !color.MatchString(v) {
287+ return fmt.Errorf("must be #rrggbb")
288+ }
289+ c.Background.Color = v
290+ case "border.width":
291+ n, err := strconv.Atoi(v)
292+ if err != nil || n < 0 || n > 32 {
293+ return fmt.Errorf("must be between 0 and 32")
294+ }
295+ c.Border.Width = n
296+ case "border.active":
297+ if !color.MatchString(v) {
298+ return fmt.Errorf("must be #rrggbb")
299+ }
300+ c.Border.Active = v
301+ case "border.inactive":
302+ if v != "" && !color.MatchString(v) {
303+ return fmt.Errorf("must be #rrggbb or empty (none)")
304+ }
305+ c.Border.Inactive = v
306+ case "layout.gaps":
307+ n, err := strconv.Atoi(v)
308+ if err != nil || n < 0 || n > 200 {
309+ return fmt.Errorf("must be between 0 and 200")
310+ }
311+ c.Layout.Gaps = n
312+ case "layout.default-width":
313+ if !width(v) {
314+ return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px")
315+ }
316+ c.Layout.DefaultColumnWidth = v
317+ case "layout.presets":
318+ list := splitList(v)
319+ if len(list) == 0 {
320+ return fmt.Errorf("must not be empty")
321+ }
322+ for _, s := range list {
323+ if !width(s) {
324+ return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s)
174 }325 }
175 }326 }
176- if mods[m] {327+ c.Layout.Presets = list
177- return "", fmt.Errorf("duplicate resolved modifier")328+ case "render.direct-scanout":
329+ b, err := onOff(v)
330+ if err != nil {
331+ return err
178 }332 }
179- mods[m] = true333+ c.Render.DirectScanout = b
334+ case "log.level":
335+ if v != "debug" && v != "info" && v != "warn" && v != "error" {
336+ return fmt.Errorf("must be debug, info, warn or error")
337+ }
338+ c.Log.Level = v
339+ case "log.debug":
340+ list := splitList(v)
341+ for _, s := range list {
342+ if !components[s] && s != "all" {
343+ return fmt.Errorf("unknown component %q", s)
344+ }
345+ }
346+ c.Log.Debug = list
347+ default:
348+ return fmt.Errorf("unknown key")
349+ }
350+ return nil
351+}
352+
353+func positive(dst *int, v string, max int) error {
354+ n, err := strconv.Atoi(v)
355+ if err != nil || n <= 0 || n > max {
356+ return fmt.Errorf("must be between 1 and %d", max)
180 }357 }
181- ordered := make([]string, 0, len(mods))358+ *dst = n
182- for m := range mods {359+ return nil
183- ordered = append(ordered, m)360+}
361+
362+func onOff(v string) (bool, error) {
363+ switch v {
364+ case "on":
365+ return true, nil
366+ case "off":
367+ return false, nil
368+ }
369+ return false, fmt.Errorf("must be on or off")
370+}
371+
372+func splitList(v string) []string {
373+ list := []string{}
374+ for _, s := range strings.Split(v, ",") {
375+ if s = strings.TrimSpace(s); s != "" {
376+ list = append(list, s)
377+ }
184 }378 }
185- sort.Strings(ordered)379+ return list
186- return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil
187 }380 }
188-func Validate(c ports.Config) error {381+
189- var errs []error382+// stripComment drops a trailing ` # comment` from a value. A # that starts the
190- add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) }383+// value is kept, so colors like #1e1e2e need no quotes.
191- if c.Keyboard.RepeatRate <= 0 {384+func stripComment(value string) string {
192- add("keyboard.repeat_rate", "must be positive")385+ for i := 1; i < len(value); i++ {
193- }386+ if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
194- if c.Keyboard.RepeatDelay <= 0 {387+ return strings.TrimSpace(value[:i])
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- }
208 }388 }
209 }389 }
210- if !color.MatchString(c.Background.Color) {390+ return value
211- add("background.color", "must be #rrggbb")391+}
392+
393+func checkAction(v string) error {
394+ if rest, ok := strings.CutPrefix(v, "spawn "); ok {
395+ if len(strings.Fields(rest)) == 0 {
396+ return fmt.Errorf("spawn needs a command")
397+ }
398+ return nil
212 }399 }
213- if c.Border.Width < 0 || c.Border.Width > 32 {400+ if !actions[v] {
214- add("border.width", "must be between 0 and 32")401+ return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v)
215 }402 }
216- if !color.MatchString(c.Border.Active) {403+ return nil
217- add("border.active", "must be #rrggbb")404+}
405+
406+// parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
407+// modifiers sorted, the key as an xkb keysym name.
408+func parseCombo(s string) (string, error) {
409+ parts := strings.Split(s, "+")
410+ key := parts[len(parts)-1]
411+ if len(parts) > 1 && key == "" {
412+ // "cmd++" means the + key.
413+ if len(parts) > 2 && parts[len(parts)-2] == "" {
414+ parts, key = parts[:len(parts)-1], "+"
415+ } else {
416+ return "", fmt.Errorf("missing key")
417+ }
218 }418 }
219- if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) {419+ if key == "" {
220- add("border.inactive", `must be #rrggbb or "" (none)`)420+ return "", fmt.Errorf("missing key")
221 }421 }
222- for i, o := range c.Outputs {422+ seen := map[string]bool{}
223- path := fmt.Sprintf("output[%d]", i)423+ for _, m := range parts[:len(parts)-1] {
224- if o.Name == "" {424+ var name string
225- add(path+".name", "must not be empty")425+ switch strings.ToLower(m) {
426+ case "cmd":
427+ name = "Cmd"
428+ case "shift":
429+ name = "Shift"
430+ case "ctrl":
431+ name = "Ctrl"
432+ case "alt":
433+ name = "Alt"
434+ case "super":
435+ name = "Super"
436+ default:
437+ return "", fmt.Errorf("unknown modifier %q", m)
226 }438 }
227- if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {439+ if seen[name] {
228- add(path+".mode", err.Error())440+ return "", fmt.Errorf("duplicate modifier %q", m)
229 }441 }
442+ seen[name] = true
230 }443 }
231- if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {444+ mods := make([]string, 0, len(seen))
232- add("layout.gaps", "must be between 0 and 200")445+ for m := range seen {
233- }446+ mods = append(mods, m)
234- if !width(c.Layout.DefaultColumnWidth) {
235- add("layout.default_column_width", "must be a positive fraction or pixel width")
236 }447 }
237- if len(c.Layout.Presets) == 0 {448+ sort.Strings(mods)
238- add("layout.presets", "must not be empty")449+ name, err := keysym(key)
450+ if err != nil {
451+ return "", err
239 }452 }
240- for i, s := range c.Layout.Presets {453+ return strings.Join(append(mods, name), "+"), nil
241- if !width(s) {454+}
242- add(fmt.Sprintf("layout.presets[%d]", i), "must be a positive fraction or pixel width")455+
456+// keysym maps a written key to its xkb keysym name.
457+func keysym(k string) (string, error) {
458+ if r := []rune(k); len(r) == 1 {
459+ // Binds match the unshifted keysym, so É means é.
460+ if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
461+ return name, nil
243 }462 }
463+ if r[0] > unicode.MaxASCII {
464+ return "", fmt.Errorf("write %q by its xkb keysym name", k)
465+ }
466+ return strings.ToLower(k), nil
244 }467 }
245- if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" {468+ if name, ok := namedKeys[strings.ToLower(k)]; ok {
246- add("log.level", "invalid level")469+ return name, nil
247 }470 }
248- for i, s := range c.Log.Debug {471+ if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
249- if !components[s] {472+ if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
250- add(fmt.Sprintf("log.debug[%d]", i), "invalid component")473+ return "F" + k[1:], nil
251 }474 }
252 }475 }
253- keys := make([]string, 0, len(c.Binds))476+ return k, nil
254- for k := range c.Binds {477+}
255- keys = append(keys, k)478+
479+// ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
480+func ParseMode(s string) (w, h int, hz float64, err error) {
481+ if s == "preferred" {
482+ return 0, 0, 0, nil
256 }483 }
257- sort.Strings(keys)484+ size, rate, hasRate := strings.Cut(s, "@")
258- combos := map[string]string{}485+ ws, hs, ok := strings.Cut(size, "x")
259- for _, k := range keys {486+ w, e1 := strconv.Atoi(ws)
260- v := c.Binds[k]487+ h, e2 := strconv.Atoi(hs)
261- if _, e := normalize(k); e != nil {488+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
262- add("binds."+k, e.Error())489+ return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s)
263- } else {490+ }
264- n, e := resolvedCombo(k, c.Keyboard.CmdKey)491+ if hasRate {
265- if e != nil {492+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
266- add("binds."+k, e.Error())493+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
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 }494 }
280 }495 }
281- return errors.Join(errs...)496+ return w, h, hz, nil
497+}
498+
499+func width(s string) bool {
500+ if s == "1" {
501+ return true
502+ }
503+ if strings.HasSuffix(s, "px") {
504+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
505+ return e == nil && n > 0
506+ }
507+ p := strings.Split(s, "/")
508+ if len(p) != 2 {
509+ return false
510+ }
511+ a, e1 := strconv.Atoi(p[0])
512+ b, e2 := strconv.Atoi(p[1])
513+ return e1 == nil && e2 == nil && a > 0 && a <= b
282 }514 }
M internal/adapters/config/config_test.go +160 -82
@@ -6,124 +6,202 @@ 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 {
142+ t.Error("none did not remove the default")
143+ }
144+ if _, ok := c.Binds["Cmd+numbersign"]; ok {
145+ t.Error("none kept")
146+ }
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)
150+ }
151+}
152+
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)
157+ }
158+}
159+
160+func TestBindResolvedCollision(t *testing.T) {
161+ // The later line wins, like any duplicate key.
162+ c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
163+ if c.Binds["Cmd+x"] != "close-window" {
80164 t.Fatal(c.Binds)
81165 }
82- if _, ok := c.Binds["Cmd+Shift+Left"]; ok {
166+ if _, ok := c.Binds["Ctrl+x"]; ok {
83167 t.Fatal(c.Binds)
84168 }
85- if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" {
169+ if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
86170 t.Fatal(c.Binds)
87171 }
88-}
89-
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)
172+ if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
173+ t.Fatal(w)
95174 }
96- c, err := Load(path)
97- if err != nil {
98- t.Fatal(err)
175+ // A user bind replaces a default with the same resolved keys, without a warning.
176+ c, w = parseString(t, "bind.super+q = quit\n")
177+ if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
178+ t.Fatal(c.Binds, w)
99179 }
100- if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {
101- t.Fatalf("%+v", c.Outputs)
180+}
181+
182+func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
183+ c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
184+ if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
185+ t.Fatal(c.Background, w)
102186 }
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- }
187+}
188+
189+func TestBindCharacters(t *testing.T) {
190+ c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
191+ if c.Binds["Cmd+eacute"] != "quit" {
192+ t.Fatal(c.Binds)
110193 }
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)
194+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
195+ t.Fatal(w)
114196 }
115197 }
116198
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)
199+func TestParseMode(t *testing.T) {
200+ w, h, hz, err := ParseMode("3440x1440")
201+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
202+ t.Fatalf("%d %d %v %v", w, h, hz, err)
122203 }
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- }
204+ if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
205+ t.Fatal(hz, err)
128206 }
129207 }
@@ -6,124 +6,202 @@ 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 {
142+ t.Error("none did not remove the default")
143+ }
144+ if _, ok := c.Binds["Cmd+numbersign"]; ok {
145+ t.Error("none kept")
146+ }
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)
150+ }
151+}
152+
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)
157+ }
158+}
159+
160+func TestBindResolvedCollision(t *testing.T) {
161+ // The later line wins, like any duplicate key.
162+ c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
163+ if c.Binds["Cmd+x"] != "close-window" {
80 t.Fatal(c.Binds)164 t.Fatal(c.Binds)
81 }165 }
82- if _, ok := c.Binds["Cmd+Shift+Left"]; ok {166+ if _, ok := c.Binds["Ctrl+x"]; ok {
83 t.Fatal(c.Binds)167 t.Fatal(c.Binds)
84 }168 }
85- if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" {169+ if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
86 t.Fatal(c.Binds)170 t.Fatal(c.Binds)
87 }171 }
88-}172+ if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
89-173+ t.Fatal(w)
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)
95 }174 }
96- c, err := Load(path)175+ // A user bind replaces a default with the same resolved keys, without a warning.
97- if err != nil {176+ c, w = parseString(t, "bind.super+q = quit\n")
98- t.Fatal(err)177+ if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
178+ t.Fatal(c.Binds, w)
99 }179 }
100- if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {180+}
101- t.Fatalf("%+v", c.Outputs)181+
182+func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
183+ c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
184+ if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
185+ t.Fatal(c.Background, w)
102 }186 }
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"} {187+}
104- if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {188+
105- t.Fatal(err)189+func TestBindCharacters(t *testing.T) {
106- }190+ c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
107- if _, err := Load(path); err == nil {191+ if c.Binds["Cmd+eacute"] != "quit" {
108- t.Errorf("accepted %q", bad)192+ t.Fatal(c.Binds)
109- }
110 }193 }
111- w, h, hz, err := ParseMode("3440x1440")194+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
112- if err != nil || w != 3440 || h != 1440 || hz != 0 {195+ t.Fatal(w)
113- t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114 }196 }
115 }197 }
116 198
117-func TestSpawnBind(t *testing.T) {199+func TestParseMode(t *testing.T) {
118- c := Defaults()200+ w, h, hz, err := ParseMode("3440x1440")
119- c.Binds = map[string]string{"Cmd+D": "spawn fuzzel --prompt >"}201+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
120- if err := Validate(c); err != nil {202+ t.Fatalf("%d %d %v %v", w, h, hz, err)
121- t.Fatal(err)
122 }203 }
123- for _, bad := range []string{"spawn", "spawn ", "spawnfuzzel"} {204+ if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
124- c.Binds = map[string]string{"Cmd+D": bad}205+ t.Fatal(hz, err)
125- if Validate(c) == nil {
126- t.Errorf("accepted %q", bad)
127- }
128 }206 }
129 }207 }
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/keyboard_test.go +92 -0
@@ -5,6 +5,7 @@ import (
55 "os"
66 "os/exec"
77 "regexp"
8+ "slices"
89 "strings"
910 "testing"
1011 "time"
@@ -118,6 +119,97 @@ func TestKeyboardKeymapProtocol(t *testing.T) {
118119 t.Fatal("missing keymap event")
119120 }
120121 }
122+
123+// events records keyboard event opcodes; a keymap event also checks its content.
124+type eventsProxy struct {
125+ wlturbo.BaseProxy
126+ opcodes []uint16
127+ keymaps []string
128+}
129+
130+func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
131+ p.opcodes = append(p.opcodes, e.Opcode)
132+ if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
133+ return
134+ }
135+ e.Uint32()
136+ fd := int(e.Fd())
137+ size := e.Uint32()
138+ defer unix.Close(fd)
139+ data := make([]byte, size)
140+ if _, err := unix.Pread(fd, data, 0); err == nil {
141+ p.keymaps = append(p.keymaps, strings.TrimRight(string(data), "\x00"))
142+ }
143+}
144+
145+func TestSetKeymap(t *testing.T) {
146+ s, _, commands, dir := keyboardServer(t)
147+ c := protocolClient(t, s, dir)
148+ // Version 4 so the keyboard receives repeat_info.
149+ g, ok := c.Registry().FindGlobal("wl_seat")
150+ if !ok {
151+ t.Fatal("missing wl_seat")
152+ }
153+ seat, err := c.Registry().BindID(g.Name, g.Interface, 4)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ registerProtocol(t, c, seat)
158+ id := c.AllocateID()
159+ proxy := &eventsProxy{}
160+ proxy.SetID(id)
161+ c.Context().Register(proxy)
162+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
163+ if err := c.Roundtrip(); err != nil {
164+ t.Fatal(err)
165+ }
166+ proxy.opcodes, proxy.keymaps = nil, nil
167+ sync := func() {
168+ t.Helper()
169+ // Commands apply on the display goroutine; wait until it has run them.
170+ deadline := time.Now().Add(time.Second)
171+ for len(commands) > 0 && time.Now().Before(deadline) {
172+ time.Sleep(5 * time.Millisecond)
173+ }
174+ time.Sleep(20 * time.Millisecond)
175+ if err := c.Roundtrip(); err != nil {
176+ t.Fatal(err)
177+ }
178+ }
179+
180+ // Repeat-only: one repeat_info, no keymap.
181+ commands <- ports.SetKeymap{RepeatRate: 40, RepeatDelay: 300}
182+ sync()
183+ if len(proxy.opcodes) != 1 || proxy.opcodes[0] != uint16(wayland.KeyboardEventRepeatInfo) {
184+ t.Fatalf("repeat-only events %v", proxy.opcodes)
185+ }
186+
187+ // New keymap: keymap then repeat_info, and the new content.
188+ proxy.opcodes = nil
189+ next := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
190+ commands <- ports.SetKeymap{Keymap: next, RepeatRate: 40, RepeatDelay: 300}
191+ sync()
192+ want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventRepeatInfo)}
193+ if !slices.Equal(proxy.opcodes, want) {
194+ t.Fatalf("keymap events %v, want %v", proxy.opcodes, want)
195+ }
196+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != next {
197+ t.Fatalf("keymap content not replaced (%d keymaps)", len(proxy.keymaps))
198+ }
199+ // A keyboard created afterwards gets the new keymap.
200+ id2 := c.AllocateID()
201+ late := &eventsProxy{}
202+ late.SetID(id2)
203+ c.Context().Register(late)
204+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
205+ if err := c.Roundtrip(); err != nil {
206+ t.Fatal(err)
207+ }
208+ if len(late.keymaps) != 1 || late.keymaps[0] != next {
209+ t.Fatal("late keyboard got the old keymap")
210+ }
211+}
212+
121213 func TestFootKeyboard(t *testing.T) {
122214 tool, err := exec.LookPath("foot")
123215 if err != nil {
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 "os/exec"6 "os/exec"
7 "regexp"7 "regexp"
8+ "slices"
8 "strings"9 "strings"
9 "testing"10 "testing"
10 "time"11 "time"
@@ -118,6 +119,97 @@ func TestKeyboardKeymapProtocol(t *testing.T) {
118 t.Fatal("missing keymap event")119 t.Fatal("missing keymap event")
119 }120 }
120 }121 }
122+
123+// events records keyboard event opcodes; a keymap event also checks its content.
124+type eventsProxy struct {
125+ wlturbo.BaseProxy
126+ opcodes []uint16
127+ keymaps []string
128+}
129+
130+func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
131+ p.opcodes = append(p.opcodes, e.Opcode)
132+ if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
133+ return
134+ }
135+ e.Uint32()
136+ fd := int(e.Fd())
137+ size := e.Uint32()
138+ defer unix.Close(fd)
139+ data := make([]byte, size)
140+ if _, err := unix.Pread(fd, data, 0); err == nil {
141+ p.keymaps = append(p.keymaps, strings.TrimRight(string(data), "\x00"))
142+ }
143+}
144+
145+func TestSetKeymap(t *testing.T) {
146+ s, _, commands, dir := keyboardServer(t)
147+ c := protocolClient(t, s, dir)
148+ // Version 4 so the keyboard receives repeat_info.
149+ g, ok := c.Registry().FindGlobal("wl_seat")
150+ if !ok {
151+ t.Fatal("missing wl_seat")
152+ }
153+ seat, err := c.Registry().BindID(g.Name, g.Interface, 4)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ registerProtocol(t, c, seat)
158+ id := c.AllocateID()
159+ proxy := &eventsProxy{}
160+ proxy.SetID(id)
161+ c.Context().Register(proxy)
162+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
163+ if err := c.Roundtrip(); err != nil {
164+ t.Fatal(err)
165+ }
166+ proxy.opcodes, proxy.keymaps = nil, nil
167+ sync := func() {
168+ t.Helper()
169+ // Commands apply on the display goroutine; wait until it has run them.
170+ deadline := time.Now().Add(time.Second)
171+ for len(commands) > 0 && time.Now().Before(deadline) {
172+ time.Sleep(5 * time.Millisecond)
173+ }
174+ time.Sleep(20 * time.Millisecond)
175+ if err := c.Roundtrip(); err != nil {
176+ t.Fatal(err)
177+ }
178+ }
179+
180+ // Repeat-only: one repeat_info, no keymap.
181+ commands <- ports.SetKeymap{RepeatRate: 40, RepeatDelay: 300}
182+ sync()
183+ if len(proxy.opcodes) != 1 || proxy.opcodes[0] != uint16(wayland.KeyboardEventRepeatInfo) {
184+ t.Fatalf("repeat-only events %v", proxy.opcodes)
185+ }
186+
187+ // New keymap: keymap then repeat_info, and the new content.
188+ proxy.opcodes = nil
189+ next := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
190+ commands <- ports.SetKeymap{Keymap: next, RepeatRate: 40, RepeatDelay: 300}
191+ sync()
192+ want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventRepeatInfo)}
193+ if !slices.Equal(proxy.opcodes, want) {
194+ t.Fatalf("keymap events %v, want %v", proxy.opcodes, want)
195+ }
196+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != next {
197+ t.Fatalf("keymap content not replaced (%d keymaps)", len(proxy.keymaps))
198+ }
199+ // A keyboard created afterwards gets the new keymap.
200+ id2 := c.AllocateID()
201+ late := &eventsProxy{}
202+ late.SetID(id2)
203+ c.Context().Register(late)
204+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
205+ if err := c.Roundtrip(); err != nil {
206+ t.Fatal(err)
207+ }
208+ if len(late.keymaps) != 1 || late.keymaps[0] != next {
209+ t.Fatal("late keyboard got the old keymap")
210+ }
211+}
212+
121 func TestFootKeyboard(t *testing.T) {213 func TestFootKeyboard(t *testing.T) {
122 tool, err := exec.LookPath("foot")214 tool, err := exec.LookPath("foot")
123 if err != nil {215 if err != nil {
M internal/adapters/wayland/server.go +74 -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,70 @@ 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 repeat info, and a new keymap when one is given, to every keyboard.
442+// A new keymap resets held keys and modifiers: the focused client gets leave, then
443+// enter with no keys.
444+func (s *Server) setKeymap(c ports.SetKeymap) {
445+ s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
446+ if c.Keymap == "" {
447+ for _, list := range s.keyboards {
448+ for _, k := range list {
449+ if k.Version() >= 4 {
450+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
451+ }
452+ }
453+ }
454+ s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated")
455+ return
456+ }
457+ fd, size, err := keymapFile(c.Keymap)
458+ if err != nil {
459+ s.log.Warn().Err(err).Msg("keymap update failed")
460+ return
461+ }
462+ if s.keymapFD >= 0 {
463+ unix.Close(s.keymapFD)
464+ }
465+ s.keymapFD, s.keymapSize = fd, size
466+ focused := s.focused
467+ s.changeFocus(0)
468+ s.heldKeys = nil
469+ s.modState = ports.ModState{}
470+ for _, list := range s.keyboards {
471+ for _, k := range list {
472+ k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
473+ if k.Version() >= 4 {
474+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
475+ }
476+ }
477+ }
478+ s.changeFocus(focused)
479+ s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated")
480+}
481+
430482 func (s *Server) sendModifiers(k *wayland.Keyboard) {
431483 m := s.modState
432484 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,70 @@ 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 repeat info, and a new keymap when one is given, to every keyboard.
442+// A new keymap resets held keys and modifiers: the focused client gets leave, then
443+// enter with no keys.
444+func (s *Server) setKeymap(c ports.SetKeymap) {
445+ s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
446+ if c.Keymap == "" {
447+ for _, list := range s.keyboards {
448+ for _, k := range list {
449+ if k.Version() >= 4 {
450+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
451+ }
452+ }
453+ }
454+ s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated")
455+ return
456+ }
457+ fd, size, err := keymapFile(c.Keymap)
458+ if err != nil {
459+ s.log.Warn().Err(err).Msg("keymap update failed")
460+ return
461+ }
462+ if s.keymapFD >= 0 {
463+ unix.Close(s.keymapFD)
464+ }
465+ s.keymapFD, s.keymapSize = fd, size
466+ focused := s.focused
467+ s.changeFocus(0)
468+ s.heldKeys = nil
469+ s.modState = ports.ModState{}
470+ for _, list := range s.keyboards {
471+ for _, k := range list {
472+ k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
473+ if k.Version() >= 4 {
474+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
475+ }
476+ }
477+ }
478+ s.changeFocus(focused)
479+ s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated")
480+}
481+
430 func (s *Server) sendModifiers(k *wayland.Keyboard) {482 func (s *Server) sendModifiers(k *wayland.Keyboard) {
431 m := s.modState483 m := s.modState
432 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)484 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
M internal/app/app.go +75 -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() }()
@@ -186,6 +194,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
186194 }
187195 cancel()
188196 workers.Wait()
197+ // A keymap the input goroutine never took is still ours to free.
198+ select {
199+ case km := <-keymaps:
200+ km.Close()
201+ default:
202+ }
189203 for len(done) > 0 {
190204 err := <-done
191205 if result == nil && err != nil {
@@ -198,6 +212,64 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
198212 return result
199213 }
200214
215+// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
216+// to the input goroutine and sends it to clients; a repeat-only change just updates
217+// clients. A keymap that fails to build keeps the previous layout.
218+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) {
219+ kb := cur.Keyboard
220+ for {
221+ var ev ports.ConfigChanged
222+ select {
223+ case <-ctx.Done():
224+ return
225+ case ev = <-in:
226+ }
227+ next := ev.Config.Keyboard
228+ layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
229+ repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
230+ cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
231+ if layoutChanged {
232+ km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
233+ if err != nil {
234+ log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
235+ ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
236+ layoutChanged = false
237+ } else {
238+ cmd.Keymap = km.String()
239+ // Drop a keymap the input goroutine has not taken yet; the newest wins.
240+ select {
241+ case old := <-keymaps:
242+ old.Close()
243+ default:
244+ }
245+ select {
246+ case keymaps <- km:
247+ case <-ctx.Done():
248+ km.Close()
249+ return
250+ }
251+ }
252+ }
253+ if layoutChanged || repeatChanged {
254+ select {
255+ case commands <- cmd:
256+ case <-ctx.Done():
257+ return
258+ }
259+ if layoutChanged {
260+ kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
261+ }
262+ kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
263+ log.Info().Str("layout", kb.Layout).Str("variant", kb.Variant).Str("options", kb.Options).Int("rate", kb.RepeatRate).Int("delay", kb.RepeatDelay).Bool("keymap", layoutChanged).Msg("keyboard reloaded")
264+ }
265+ select {
266+ case out <- ev:
267+ case <-ctx.Done():
268+ return
269+ }
270+ }
271+}
272+
201273 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {
202274 log := logging.For(ctx, "core")
203275 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() }()
@@ -186,6 +194,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
186 }194 }
187 cancel()195 cancel()
188 workers.Wait()196 workers.Wait()
197+ // A keymap the input goroutine never took is still ours to free.
198+ select {
199+ case km := <-keymaps:
200+ km.Close()
201+ default:
202+ }
189 for len(done) > 0 {203 for len(done) > 0 {
190 err := <-done204 err := <-done
191 if result == nil && err != nil {205 if result == nil && err != nil {
@@ -198,6 +212,64 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
198 return result212 return result
199 }213 }
200 214
215+// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
216+// to the input goroutine and sends it to clients; a repeat-only change just updates
217+// clients. A keymap that fails to build keeps the previous layout.
218+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) {
219+ kb := cur.Keyboard
220+ for {
221+ var ev ports.ConfigChanged
222+ select {
223+ case <-ctx.Done():
224+ return
225+ case ev = <-in:
226+ }
227+ next := ev.Config.Keyboard
228+ layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
229+ repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
230+ cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
231+ if layoutChanged {
232+ km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
233+ if err != nil {
234+ log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
235+ ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
236+ layoutChanged = false
237+ } else {
238+ cmd.Keymap = km.String()
239+ // Drop a keymap the input goroutine has not taken yet; the newest wins.
240+ select {
241+ case old := <-keymaps:
242+ old.Close()
243+ default:
244+ }
245+ select {
246+ case keymaps <- km:
247+ case <-ctx.Done():
248+ km.Close()
249+ return
250+ }
251+ }
252+ }
253+ if layoutChanged || repeatChanged {
254+ select {
255+ case commands <- cmd:
256+ case <-ctx.Done():
257+ return
258+ }
259+ if layoutChanged {
260+ kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
261+ }
262+ kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
263+ log.Info().Str("layout", kb.Layout).Str("variant", kb.Variant).Str("options", kb.Options).Int("rate", kb.RepeatRate).Int("delay", kb.RepeatDelay).Bool("keymap", layoutChanged).Msg("keyboard reloaded")
264+ }
265+ select {
266+ case out <- ev:
267+ case <-ctx.Done():
268+ return
269+ }
270+ }
271+}
272+
201 func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {273 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")274 log := logging.For(ctx, "core")
203 configLog := logging.For(ctx, "config")275 configLog := logging.For(ctx, "config")
M internal/app/app_test.go +56 -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,53 @@ 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, "xkb_keymap") {
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+ // Repeat-only changes update clients without a new keymap.
295+ next.Keyboard.RepeatDelay = 300
296+ in <- ports.ConfigChanged{Config: next}
297+ <-out
298+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatDelay != 300 || len(keymaps) != 0 {
299+ t.Fatalf("repeat-only: keymap=%t delay=%d", cmd.Keymap != "", cmd.RepeatDelay)
300+ }
301+
302+ // A bad layout keeps the previous one; its repeat change still applies.
303+ bad := next
304+ bad.Keyboard.Layout, bad.Keyboard.RepeatRate = "no-such-layout", 50
305+ in <- ports.ConfigChanged{Config: bad}
306+ if got := <-out; got.Config.Keyboard.Layout != "fr" || got.Config.Keyboard.RepeatRate != 50 || len(keymaps) != 0 {
307+ t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
308+ }
309+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatRate != 50 {
310+ t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)
311+ }
312+}
@@ -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,53 @@ 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, "xkb_keymap") {
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+ // Repeat-only changes update clients without a new keymap.
295+ next.Keyboard.RepeatDelay = 300
296+ in <- ports.ConfigChanged{Config: next}
297+ <-out
298+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatDelay != 300 || len(keymaps) != 0 {
299+ t.Fatalf("repeat-only: keymap=%t delay=%d", cmd.Keymap != "", cmd.RepeatDelay)
300+ }
301+
302+ // A bad layout keeps the previous one; its repeat change still applies.
303+ bad := next
304+ bad.Keyboard.Layout, bad.Keyboard.RepeatRate = "no-such-layout", 50
305+ in <- ports.ConfigChanged{Config: bad}
306+ if got := <-out; got.Config.Keyboard.Layout != "fr" || got.Config.Keyboard.RepeatRate != 50 || len(keymaps) != 0 {
307+ t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
308+ }
309+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatRate != 50 {
310+ t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)
311+ }
312+}
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 +9 -0
@@ -181,6 +181,15 @@ type ForwardKey struct {
181181
182182 func (ForwardKey) clientCommand() {}
183183
184+// SetKeymap carries app → wayland repeat info and, unless Keymap is empty, a new
185+// xkb keymap in text format.
186+type SetKeymap struct {
187+ Keymap string
188+ RepeatRate, RepeatDelay int
189+}
190+
191+func (SetKeymap) clientCommand() {}
192+
184193 // SpawnRequest carries core → launcher process arguments.
185194 type SpawnRequest struct{ Argv []string }
186195
@@ -181,6 +181,15 @@ type ForwardKey struct {
181 181
182 func (ForwardKey) clientCommand() {}182 func (ForwardKey) clientCommand() {}
183 183
184+// SetKeymap carries app → wayland repeat info and, unless Keymap is empty, a new
185+// xkb keymap in text format.
186+type SetKeymap struct {
187+ Keymap string
188+ RepeatRate, RepeatDelay int
189+}
190+
191+func (SetKeymap) clientCommand() {}
192+
184 // SpawnRequest carries core → launcher process arguments.193 // SpawnRequest carries core → launcher process arguments.
185 type SpawnRequest struct{ Argv []string }194 type SpawnRequest struct{ Argv []string }
186 195

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close