feat(config): flat key=value config with live keyboard reload
Replaces TOML with the vev-style format (ADR 012): bind.<keys> = action, keys as xkb keysyms of the active layout, invalid lines warn and keep defaults, reloads log changed keys. Keyboard layout and repeat changes rebuild the keymap and send it to clients live.
- commit
- 830fa72a4c691dbd5a66b9a28b8b48f781788fc8
- author
- brice · · 2026-09-24 13:38 UTC
- parent
- 2172e12
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +1031 -408
M README.md +9 -7
| @@ -10,16 +10,18 @@ Run: `nefertty --backend=headless --timeout 5s` | |||
| 10 | 10 | ||
| 11 | 11 | ## 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 | - | ```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 | |
| 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 | 26 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | |
| 25 | 27 | ||
| @@ -10,16 +10,18 @@ Run: `nefertty --backend=headless --timeout 5s` | |||||
| 10 | 10 | ||||
| 11 | ## Configuration | 11 | ## 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 | - | ```toml | 15 | + | One `key = value` per line; `#` starts a comment: |
| 16 | - | [terminal] | 16 | + | |
| 17 | - | command = ["foot"] | 17 | + | ```text |
| 18 | - | [layout] | 18 | + | keyboard.layout = fr |
| 19 | - | gaps = 0 | 19 | + | terminal = foot |
| 20 | + | bind.cmd+return = spawn-terminal | |||
| 21 | + | bind.ctrl+cmd+space = spawn fuzzel | |||
| 20 | ``` | 22 | ``` | ||
| 21 | 23 | ||||
| 22 | - | See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place. | 24 | + | See `examples/config` for every key and its default. A missing file means defaults. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. Every key applies live when the file changes, including the keyboard layout; each reload logs the keys that changed. |
| 23 | 25 | ||||
| 24 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | 26 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | ||
| 25 | 27 | ||||
M cmd/nefertty/main.go +13 -6
| @@ -50,17 +50,20 @@ func run() error { | |||
| 50 | 50 | return err | |
| 51 | 51 | } | |
| 52 | 52 | path := config.DefaultPath() | |
| 53 | - | var err error | |
| 54 | 53 | if len(os.Args) == 3 { | |
| 55 | 54 | path = os.Args[2] | |
| 56 | - | _, err = config.Load(path) | |
| 57 | - | } else { | |
| 58 | - | _, err = config.LoadDefault() | |
| 59 | 55 | } | |
| 56 | + | _, warnings, err := config.Load(path) | |
| 60 | 57 | if err != nil { | |
| 61 | 58 | fmt.Fprintln(os.Stderr, err) | |
| 62 | 59 | return err | |
| 63 | 60 | } | |
| 61 | + | for _, w := range warnings { | |
| 62 | + | fmt.Fprintf(os.Stderr, "%s:%s\n", path, w) | |
| 63 | + | } | |
| 64 | + | if len(warnings) > 0 { | |
| 65 | + | return fmt.Errorf("%d warning(s)", len(warnings)) | |
| 66 | + | } | |
| 64 | 67 | fmt.Println("ok: " + path) | |
| 65 | 68 | return nil | |
| 66 | 69 | } | |
| @@ -132,11 +135,12 @@ func run() error { | |||
| 132 | 135 | path := *configFlag | |
| 133 | 136 | var cfgErr error | |
| 134 | 137 | var cfg = config.Defaults() | |
| 138 | + | var warnings []config.Warning | |
| 135 | 139 | if path == "" { | |
| 136 | 140 | path = config.DefaultPath() | |
| 137 | - | cfg, cfgErr = config.LoadDefault() | |
| 141 | + | cfg, warnings, cfgErr = config.LoadDefault() | |
| 138 | 142 | } else { | |
| 139 | - | cfg, cfgErr = config.Load(path) | |
| 143 | + | cfg, warnings, cfgErr = config.Load(path) | |
| 140 | 144 | } | |
| 141 | 145 | if cfgErr != nil { | |
| 142 | 146 | fmt.Fprintln(os.Stderr, cfgErr) | |
| @@ -180,6 +184,9 @@ func run() error { | |||
| 180 | 184 | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | |
| 181 | 185 | configLog := logging.For(ctx, "config") | |
| 182 | 186 | configLog.Info().Str("path", path).Msg("loaded config") | |
| 187 | + | for _, w := range warnings { | |
| 188 | + | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | |
| 189 | + | } | |
| 183 | 190 | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script}) | |
| 184 | 191 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 185 | 192 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| @@ -50,17 +50,20 @@ func run() error { | |||||
| 50 | return err | 50 | return err | ||
| 51 | } | 51 | } | ||
| 52 | path := config.DefaultPath() | 52 | path := config.DefaultPath() | ||
| 53 | - | var err error | |||
| 54 | if len(os.Args) == 3 { | 53 | if len(os.Args) == 3 { | ||
| 55 | path = os.Args[2] | 54 | path = os.Args[2] | ||
| 56 | - | _, err = config.Load(path) | |||
| 57 | - | } else { | |||
| 58 | - | _, err = config.LoadDefault() | |||
| 59 | } | 55 | } | ||
| 56 | + | _, warnings, err := config.Load(path) | |||
| 60 | if err != nil { | 57 | if err != nil { | ||
| 61 | fmt.Fprintln(os.Stderr, err) | 58 | fmt.Fprintln(os.Stderr, err) | ||
| 62 | return err | 59 | return err | ||
| 63 | } | 60 | } | ||
| 61 | + | for _, w := range warnings { | |||
| 62 | + | fmt.Fprintf(os.Stderr, "%s:%s\n", path, w) | |||
| 63 | + | } | |||
| 64 | + | if len(warnings) > 0 { | |||
| 65 | + | return fmt.Errorf("%d warning(s)", len(warnings)) | |||
| 66 | + | } | |||
| 64 | fmt.Println("ok: " + path) | 67 | fmt.Println("ok: " + path) | ||
| 65 | return nil | 68 | return nil | ||
| 66 | } | 69 | } | ||
| @@ -132,11 +135,12 @@ func run() error { | |||||
| 132 | path := *configFlag | 135 | path := *configFlag | ||
| 133 | var cfgErr error | 136 | var cfgErr error | ||
| 134 | var cfg = config.Defaults() | 137 | var cfg = config.Defaults() | ||
| 138 | + | var warnings []config.Warning | |||
| 135 | if path == "" { | 139 | if path == "" { | ||
| 136 | path = config.DefaultPath() | 140 | path = config.DefaultPath() | ||
| 137 | - | cfg, cfgErr = config.LoadDefault() | 141 | + | cfg, warnings, cfgErr = config.LoadDefault() |
| 138 | } else { | 142 | } else { | ||
| 139 | - | cfg, cfgErr = config.Load(path) | 143 | + | cfg, warnings, cfgErr = config.Load(path) |
| 140 | } | 144 | } | ||
| 141 | if cfgErr != nil { | 145 | if cfgErr != nil { | ||
| 142 | fmt.Fprintln(os.Stderr, cfgErr) | 146 | fmt.Fprintln(os.Stderr, cfgErr) | ||
| @@ -180,6 +184,9 @@ func run() error { | |||||
| 180 | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | 184 | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | ||
| 181 | configLog := logging.For(ctx, "config") | 185 | configLog := logging.For(ctx, "config") | ||
| 182 | configLog.Info().Str("path", path).Msg("loaded config") | 186 | configLog.Info().Str("path", path).Msg("loaded config") | ||
| 187 | + | for _, w := range warnings { | |||
| 188 | + | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | |||
| 189 | + | } | |||
| 183 | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script}) | 190 | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script}) | ||
| 184 | // SIGINT and SIGTERM cancel the context and are clean exits. | 191 | // SIGINT and SIGTERM cancel the context and are clean exits. | ||
| 185 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | 192 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | ||
M cmd/nefertty/main_test.go +2 -2
| @@ -24,8 +24,8 @@ func TestExitCodes(t *testing.T) { | |||
| 24 | 24 | {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | |
| 25 | 25 | {[]string{"nefertty", "--timeout=-1s"}, 2}, | |
| 26 | 26 | {[]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}, | |
| 29 | 29 | } { | |
| 30 | 30 | old := os.Args | |
| 31 | 31 | 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.Args | 30 | old := os.Args | ||
| 31 | os.Args = tc.args | 31 | 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 | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/BurntSushi/toml v1.5.0 | |
| 6 | + | github.com/bnema/purego v0.11.0-bnema.4 | |
| 7 | 7 | github.com/bnema/purego-libwayland v0.0.0 | |
| 8 | 8 | github.com/bnema/purego-vulkan v0.0.0 | |
| 9 | 9 | github.com/bnema/wlturbo v0.2.0 | |
| @@ -14,7 +14,6 @@ require ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | require ( | |
| 17 | - | github.com/bnema/purego v0.11.0-bnema.4 // indirect | |
| 18 | 17 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 19 | 18 | github.com/mattn/go-isatty v0.0.22 // indirect | |
| 20 | 19 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| @@ -3,7 +3,7 @@ module github.com/bnema/nefertty | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/BurntSushi/toml v1.5.0 | 6 | + | github.com/bnema/purego v0.11.0-bnema.4 |
| 7 | github.com/bnema/purego-libwayland v0.0.0 | 7 | github.com/bnema/purego-libwayland v0.0.0 | ||
| 8 | github.com/bnema/purego-vulkan v0.0.0 | 8 | github.com/bnema/purego-vulkan v0.0.0 | ||
| 9 | github.com/bnema/wlturbo v0.2.0 | 9 | 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 // indirect | 17 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 19 | github.com/mattn/go-isatty v0.0.22 // indirect | 18 | github.com/mattn/go-isatty v0.0.22 // indirect | ||
| 20 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | 19 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | ||
M go.sum +0 -2 generated file: collapsed
M internal/adapters/config/config.go +405 -185
| @@ -1,8 +1,11 @@ | |||
| 1 | + | // Package config reads the flat `key = value` config file (ADR 012). | |
| 1 | 2 | package config | |
| 2 | 3 | ||
| 3 | 4 | import ( | |
| 4 | - | "errors" | |
| 5 | + | "bufio" | |
| 5 | 6 | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "math" | |
| 6 | 9 | "os" | |
| 7 | 10 | "path/filepath" | |
| 8 | 11 | "regexp" | |
| @@ -10,17 +13,44 @@ import ( | |||
| 10 | 13 | "strconv" | |
| 11 | 14 | "strings" | |
| 12 | 15 | ||
| 13 | - | "github.com/BurntSushi/toml" | |
| 14 | 16 | "github.com/bnema/nefertty/internal/ports" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| 17 | - | var defaultBinds = map[string]string{ | |
| 18 | - | "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel", | |
| 19 | + | // defaultBinds use the config syntax and go through the same parser as user binds. | |
| 20 | + | var defaultBinds = []struct{ combo, action string }{ | |
| 21 | + | {"cmd+return", "spawn-terminal"}, | |
| 22 | + | {"cmd+left", "focus-column-left"}, | |
| 23 | + | {"cmd+right", "focus-column-right"}, | |
| 24 | + | {"cmd+up", "focus-window-up"}, | |
| 25 | + | {"cmd+down", "focus-window-down"}, | |
| 26 | + | {"cmd+shift+left", "move-column-left"}, | |
| 27 | + | {"cmd+shift+right", "move-column-right"}, | |
| 28 | + | {"cmd+r", "cycle-column-width"}, | |
| 29 | + | {"cmd+shift+f", "toggle-fullscreen"}, | |
| 30 | + | {"cmd+q", "close-window"}, | |
| 31 | + | {"ctrl+alt+backspace", "quit"}, | |
| 32 | + | {"ctrl+cmd+space", "spawn fuzzel"}, | |
| 19 | 33 | } | |
| 20 | 34 | var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | |
| 21 | 35 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| 22 | 36 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 23 | 37 | ||
| 38 | + | // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive. | |
| 39 | + | var namedKeys = map[string]string{ | |
| 40 | + | "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape", | |
| 41 | + | "backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End", | |
| 42 | + | "pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print", | |
| 43 | + | "left": "Left", "right": "Right", "up": "Up", "down": "Down", | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Warning is a non-fatal problem at one line; the key keeps its default. | |
| 47 | + | type Warning struct { | |
| 48 | + | Line int | |
| 49 | + | Msg string | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) } | |
| 53 | + | ||
| 24 | 54 | func DefaultPath() string { | |
| 25 | 55 | base := os.Getenv("XDG_CONFIG_HOME") | |
| 26 | 56 | if base == "" { | |
| @@ -29,8 +59,9 @@ func DefaultPath() string { | |||
| 29 | 59 | base = filepath.Join(home, ".config") | |
| 30 | 60 | } | |
| 31 | 61 | } | |
| 32 | - | return filepath.Join(base, "nefertty", "config.toml") | |
| 62 | + | return filepath.Join(base, "nefertty", "config") | |
| 33 | 63 | } | |
| 64 | + | ||
| 34 | 65 | func Defaults() ports.Config { | |
| 35 | 66 | var c ports.Config | |
| 36 | 67 | c.Keyboard.RepeatRate = 25 | |
| @@ -40,111 +71,178 @@ func Defaults() ports.Config { | |||
| 40 | 71 | c.Background.Color = "#1e1e2e" | |
| 41 | 72 | c.Border.Width = 2 | |
| 42 | 73 | c.Border.Active = "#b4befe" | |
| 43 | - | c.Border.Inactive = "" | |
| 44 | - | c.Layout.Gaps = 0 | |
| 45 | 74 | c.Layout.DefaultColumnWidth = "1/2" | |
| 46 | 75 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 47 | - | c.Binds = make(map[string]string, len(defaultBinds)) | |
| 48 | - | for k, v := range defaultBinds { | |
| 49 | - | c.Binds[k] = v | |
| 76 | + | c.Binds = map[string]string{} | |
| 77 | + | for _, b := range defaultBinds { | |
| 78 | + | combo, err := parseCombo(b.combo) | |
| 79 | + | if err != nil { | |
| 80 | + | panic(err) | |
| 81 | + | } | |
| 82 | + | c.Binds[combo] = b.action | |
| 50 | 83 | } | |
| 51 | 84 | c.Render.DirectScanout = true | |
| 52 | 85 | c.Log.Level = "info" | |
| 53 | 86 | c.Log.Debug = []string{} | |
| 54 | 87 | return c | |
| 55 | 88 | } | |
| 56 | - | func LoadDefault() (ports.Config, error) { | |
| 57 | - | c, err := Load(DefaultPath()) | |
| 89 | + | ||
| 90 | + | // LoadDefault loads DefaultPath; a missing file means defaults. | |
| 91 | + | func LoadDefault() (ports.Config, []Warning, error) { | |
| 92 | + | c, w, err := Load(DefaultPath()) | |
| 58 | 93 | if os.IsNotExist(err) { | |
| 59 | - | return Defaults(), nil | |
| 94 | + | return Defaults(), nil, nil | |
| 60 | 95 | } | |
| 61 | - | return c, err | |
| 96 | + | return c, w, err | |
| 62 | 97 | } | |
| 63 | - | func Load(path string) (ports.Config, error) { | |
| 64 | - | c := Defaults() | |
| 65 | - | data, err := os.ReadFile(path) | |
| 98 | + | ||
| 99 | + | // Load reads a config file. The error is only for I/O; bad lines become warnings. | |
| 100 | + | func Load(path string) (ports.Config, []Warning, error) { | |
| 101 | + | f, err := os.Open(path) | |
| 66 | 102 | if err != nil { | |
| 67 | - | return c, err | |
| 68 | - | } | |
| 69 | - | // Decode binds separately so user entries can remove or override defaults. | |
| 70 | - | var raw struct { | |
| 71 | - | Binds map[string]string `toml:"binds"` | |
| 72 | - | } | |
| 73 | - | if _, err = toml.Decode(string(data), &raw); err != nil { | |
| 74 | - | return c, err | |
| 103 | + | return Defaults(), nil, err | |
| 75 | 104 | } | |
| 76 | - | c.Binds = make(map[string]string) | |
| 77 | - | md, err := toml.Decode(string(data), &c) | |
| 78 | - | if err != nil { | |
| 79 | - | return c, err | |
| 80 | - | } | |
| 81 | - | var errs []error | |
| 82 | - | for _, key := range md.Undecoded() { | |
| 83 | - | errs = append(errs, fmt.Errorf("%s: unknown key", key.String())) | |
| 84 | - | } | |
| 85 | - | // Validate user bindings before merging to catch normalized collisions. | |
| 86 | - | errs = append(errs, Validate(c)) | |
| 87 | - | if err = errors.Join(errs...); err != nil { | |
| 88 | - | return c, err | |
| 89 | - | } | |
| 90 | - | c.Binds = Defaults().Binds | |
| 91 | - | for k, v := range raw.Binds { | |
| 92 | - | normalized, _ := normalize(k) | |
| 93 | - | for old := range c.Binds { | |
| 94 | - | n, _ := normalize(old) | |
| 95 | - | if n == normalized { | |
| 96 | - | delete(c.Binds, old) | |
| 105 | + | defer f.Close() | |
| 106 | + | return Parse(f) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Parse reads `key = value` lines on top of the defaults. | |
| 110 | + | func Parse(r io.Reader) (ports.Config, []Warning, error) { | |
| 111 | + | c, _, w, err := parse(r) | |
| 112 | + | return c, w, err | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // parse also returns the effective key/value pairs, used to log what changed on reload. | |
| 116 | + | func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |
| 117 | + | c := Defaults() | |
| 118 | + | var warnings []Warning | |
| 119 | + | raw := map[string]string{} | |
| 120 | + | seen := map[string]int{} | |
| 121 | + | outputs := map[string]int{} | |
| 122 | + | scanner := bufio.NewScanner(r) | |
| 123 | + | for n := 1; scanner.Scan(); n++ { | |
| 124 | + | line := strings.TrimSpace(scanner.Text()) | |
| 125 | + | if line == "" || strings.HasPrefix(line, "#") { | |
| 126 | + | continue | |
| 127 | + | } | |
| 128 | + | warn := func(format string, a ...any) { | |
| 129 | + | warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)}) | |
| 130 | + | } | |
| 131 | + | key, value, ok := strings.Cut(line, "=") | |
| 132 | + | key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value)) | |
| 133 | + | if !ok || key == "" { | |
| 134 | + | warn("expected key = value") | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | if combo, ok := strings.CutPrefix(key, "bind."); ok { | |
| 138 | + | if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") { | |
| 139 | + | warn("%s: write %sequal instead of %s=", key, combo, combo) | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | canon, err := parseCombo(combo) | |
| 143 | + | if err != nil { | |
| 144 | + | warn("%s: %v", key, err) | |
| 145 | + | continue | |
| 146 | + | } | |
| 147 | + | if err := checkAction(value); err != nil { | |
| 148 | + | warn("%s: %v", key, err) | |
| 149 | + | continue | |
| 150 | + | } | |
| 151 | + | key = "bind." + canon | |
| 152 | + | if prev, dup := seen[key]; dup { | |
| 153 | + | warn("%s: overrides line %d", key, prev) | |
| 154 | + | } | |
| 155 | + | seen[key] = n | |
| 156 | + | raw[key] = value | |
| 157 | + | c.Binds[canon] = value | |
| 158 | + | continue | |
| 159 | + | } | |
| 160 | + | if prev, dup := seen[key]; dup { | |
| 161 | + | warn("%s: overrides line %d", key, prev) | |
| 162 | + | } | |
| 163 | + | seen[key] = n | |
| 164 | + | if name, ok := strings.CutPrefix(key, "output."); ok { | |
| 165 | + | o := ports.OutputConfig{Name: name} | |
| 166 | + | if value == "off" { | |
| 167 | + | o.Off = true | |
| 168 | + | } else if _, _, _, err := ParseMode(value); err != nil { | |
| 169 | + | warn("%s: %v", key, err) | |
| 170 | + | continue | |
| 171 | + | } else if value != "preferred" { | |
| 172 | + | o.Mode = value | |
| 97 | 173 | } | |
| 174 | + | raw[key] = value | |
| 175 | + | if i, ok := outputs[name]; ok { | |
| 176 | + | c.Outputs[i] = o | |
| 177 | + | } else { | |
| 178 | + | outputs[name] = len(c.Outputs) | |
| 179 | + | c.Outputs = append(c.Outputs, o) | |
| 180 | + | } | |
| 181 | + | continue | |
| 98 | 182 | } | |
| 99 | - | if v != "none" { | |
| 100 | - | c.Binds[k] = v | |
| 183 | + | if err := set(&c, key, value); err != nil { | |
| 184 | + | warn("%s: %v", key, err) | |
| 185 | + | continue | |
| 101 | 186 | } | |
| 187 | + | raw[key] = value | |
| 102 | 188 | } | |
| 103 | - | return c, nil | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted. | |
| 107 | - | func ParseMode(s string) (w, h int, hz float64, err error) { | |
| 108 | - | size, rate, hasRate := strings.Cut(s, "@") | |
| 109 | - | ws, hs, ok := strings.Cut(size, "x") | |
| 110 | - | w, e1 := strconv.Atoi(ws) | |
| 111 | - | h, e2 := strconv.Atoi(hs) | |
| 112 | - | if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 { | |
| 113 | - | return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s) | |
| 189 | + | if err := scanner.Err(); err != nil { | |
| 190 | + | return c, raw, warnings, err | |
| 114 | 191 | } | |
| 115 | - | if hasRate { | |
| 116 | - | if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 { | |
| 117 | - | return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s) | |
| 192 | + | // "none" only removes a default bind. | |
| 193 | + | for combo, a := range c.Binds { | |
| 194 | + | if a == "none" { | |
| 195 | + | delete(c.Binds, combo) | |
| 118 | 196 | } | |
| 119 | 197 | } | |
| 120 | - | return w, h, hz, nil | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | func width(s string) bool { | |
| 124 | - | if s == "1" { | |
| 125 | - | return true | |
| 126 | - | } | |
| 127 | - | if strings.HasSuffix(s, "px") { | |
| 128 | - | n, e := strconv.Atoi(strings.TrimSuffix(s, "px")) | |
| 129 | - | return e == nil && n > 0 | |
| 198 | + | // Cmd resolves to a real modifier; drop binds that then collide. User lines win over | |
| 199 | + | // defaults (line 0), then the earliest line wins. | |
| 200 | + | combos := make([]string, 0, len(c.Binds)) | |
| 201 | + | for combo := range c.Binds { | |
| 202 | + | combos = append(combos, combo) | |
| 130 | 203 | } | |
| 131 | - | p := strings.Split(s, "/") | |
| 132 | - | if len(p) != 2 { | |
| 133 | - | return false | |
| 204 | + | order := func(combo string) int { | |
| 205 | + | if n := seen["bind."+combo]; n > 0 { | |
| 206 | + | return n | |
| 207 | + | } | |
| 208 | + | return math.MaxInt | |
| 209 | + | } | |
| 210 | + | sort.Slice(combos, func(i, j int) bool { | |
| 211 | + | a, b := order(combos[i]), order(combos[j]) | |
| 212 | + | return a < b || a == b && combos[i] < combos[j] | |
| 213 | + | }) | |
| 214 | + | resolved := map[string]string{} | |
| 215 | + | for _, combo := range combos { | |
| 216 | + | r, err := expandCmd(combo, c.Keyboard.CmdKey) | |
| 217 | + | if prev, dup := resolved[r]; err == nil && dup { | |
| 218 | + | err = fmt.Errorf("same keys as %s with keyboard.cmd = %s", prev, c.Keyboard.CmdKey) | |
| 219 | + | } | |
| 220 | + | if err != nil { | |
| 221 | + | if n := seen["bind."+combo]; n > 0 { | |
| 222 | + | warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)}) | |
| 223 | + | } | |
| 224 | + | delete(c.Binds, combo) | |
| 225 | + | delete(raw, "bind."+combo) | |
| 226 | + | continue | |
| 227 | + | } | |
| 228 | + | resolved[r] = combo | |
| 134 | 229 | } | |
| 135 | - | a, e1 := strconv.Atoi(p[0]) | |
| 136 | - | b, e2 := strconv.Atoi(p[1]) | |
| 137 | - | return e1 == nil && e2 == nil && a > 0 && a <= b | |
| 230 | + | return c, raw, warnings, nil | |
| 138 | 231 | } | |
| 139 | - | func normalize(s string) (string, error) { | |
| 140 | - | p := strings.Split(s, "+") | |
| 141 | - | if len(p) < 2 || p[len(p)-1] == "" { | |
| 142 | - | return "", fmt.Errorf("invalid key combo") | |
| 143 | - | } | |
| 232 | + | ||
| 233 | + | // expandCmd replaces Cmd with the configured modifier and re-sorts. | |
| 234 | + | func expandCmd(combo, cmd string) (string, error) { | |
| 235 | + | parts := strings.Split(combo, "+") | |
| 236 | + | key := parts[len(parts)-1] | |
| 237 | + | // cmd is super, alt or ctrl; its modifier name is the capitalized form. | |
| 238 | + | cmdName := strings.ToUpper(cmd[:1]) + cmd[1:] | |
| 144 | 239 | seen := map[string]bool{} | |
| 145 | - | for _, m := range p[:len(p)-1] { | |
| 146 | - | if m != "Cmd" && m != "Shift" && m != "Ctrl" && m != "Alt" || seen[m] { | |
| 147 | - | return "", fmt.Errorf("invalid key combo") | |
| 240 | + | for _, m := range parts[:len(parts)-1] { | |
| 241 | + | if m == "Cmd" { | |
| 242 | + | m = cmdName | |
| 243 | + | } | |
| 244 | + | if seen[m] { | |
| 245 | + | return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd) | |
| 148 | 246 | } | |
| 149 | 247 | seen[m] = true | |
| 150 | 248 | } | |
| @@ -153,130 +251,252 @@ func normalize(s string) (string, error) { | |||
| 153 | 251 | mods = append(mods, m) | |
| 154 | 252 | } | |
| 155 | 253 | sort.Strings(mods) | |
| 156 | - | return strings.Join(append(mods, p[len(p)-1]), "+"), nil | |
| 254 | + | return strings.Join(append(mods, key), "+"), nil | |
| 157 | 255 | } | |
| 158 | 256 | ||
| 159 | - | // resolvedCombo canonicalizes modifiers after resolving Cmd, so aliases collide. | |
| 160 | - | func resolvedCombo(s, cmd string) (string, error) { | |
| 161 | - | parts := strings.Split(s, "+") | |
| 162 | - | mods := map[string]bool{} | |
| 163 | - | for _, m := range parts[:len(parts)-1] { | |
| 164 | - | if m == "Cmd" { | |
| 165 | - | switch cmd { | |
| 166 | - | case "super": | |
| 167 | - | m = "Super" | |
| 168 | - | case "alt": | |
| 169 | - | m = "Alt" | |
| 170 | - | case "ctrl": | |
| 171 | - | m = "Ctrl" | |
| 172 | - | default: | |
| 173 | - | return "", fmt.Errorf("invalid cmd key") | |
| 257 | + | // set applies one scalar key. Unknown keys and invalid values are errors. | |
| 258 | + | func set(c *ports.Config, key, v string) error { | |
| 259 | + | switch key { | |
| 260 | + | case "keyboard.layout": | |
| 261 | + | c.Keyboard.Layout = v | |
| 262 | + | case "keyboard.variant": | |
| 263 | + | c.Keyboard.Variant = v | |
| 264 | + | case "keyboard.options": | |
| 265 | + | c.Keyboard.Options = v | |
| 266 | + | case "keyboard.repeat-rate": | |
| 267 | + | return positive(&c.Keyboard.RepeatRate, v, 1000) | |
| 268 | + | case "keyboard.repeat-delay": | |
| 269 | + | return positive(&c.Keyboard.RepeatDelay, v, 10000) | |
| 270 | + | case "keyboard.cmd": | |
| 271 | + | if v != "super" && v != "alt" && v != "ctrl" { | |
| 272 | + | return fmt.Errorf("must be super, alt or ctrl") | |
| 273 | + | } | |
| 274 | + | c.Keyboard.CmdKey = v | |
| 275 | + | case "terminal": | |
| 276 | + | argv := strings.Fields(v) | |
| 277 | + | if len(argv) == 0 { | |
| 278 | + | return fmt.Errorf("must not be empty") | |
| 279 | + | } | |
| 280 | + | c.Terminal.Command = argv | |
| 281 | + | case "background": | |
| 282 | + | if !color.MatchString(v) { | |
| 283 | + | return fmt.Errorf("must be #rrggbb") | |
| 284 | + | } | |
| 285 | + | c.Background.Color = v | |
| 286 | + | case "border.width": | |
| 287 | + | n, err := strconv.Atoi(v) | |
| 288 | + | if err != nil || n < 0 || n > 32 { | |
| 289 | + | return fmt.Errorf("must be between 0 and 32") | |
| 290 | + | } | |
| 291 | + | c.Border.Width = n | |
| 292 | + | case "border.active": | |
| 293 | + | if !color.MatchString(v) { | |
| 294 | + | return fmt.Errorf("must be #rrggbb") | |
| 295 | + | } | |
| 296 | + | c.Border.Active = v | |
| 297 | + | case "border.inactive": | |
| 298 | + | if v != "" && !color.MatchString(v) { | |
| 299 | + | return fmt.Errorf("must be #rrggbb or empty (none)") | |
| 300 | + | } | |
| 301 | + | c.Border.Inactive = v | |
| 302 | + | case "layout.gaps": | |
| 303 | + | n, err := strconv.Atoi(v) | |
| 304 | + | if err != nil || n < 0 || n > 200 { | |
| 305 | + | return fmt.Errorf("must be between 0 and 200") | |
| 306 | + | } | |
| 307 | + | c.Layout.Gaps = n | |
| 308 | + | case "layout.default-width": | |
| 309 | + | if !width(v) { | |
| 310 | + | return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px") | |
| 311 | + | } | |
| 312 | + | c.Layout.DefaultColumnWidth = v | |
| 313 | + | case "layout.presets": | |
| 314 | + | list := splitList(v) | |
| 315 | + | if len(list) == 0 { | |
| 316 | + | return fmt.Errorf("must not be empty") | |
| 317 | + | } | |
| 318 | + | for _, s := range list { | |
| 319 | + | if !width(s) { | |
| 320 | + | return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s) | |
| 174 | 321 | } | |
| 175 | 322 | } | |
| 176 | - | if mods[m] { | |
| 177 | - | return "", fmt.Errorf("duplicate resolved modifier") | |
| 323 | + | c.Layout.Presets = list | |
| 324 | + | case "render.direct-scanout": | |
| 325 | + | b, err := onOff(v) | |
| 326 | + | if err != nil { | |
| 327 | + | return err | |
| 328 | + | } | |
| 329 | + | c.Render.DirectScanout = b | |
| 330 | + | case "log.level": | |
| 331 | + | if v != "debug" && v != "info" && v != "warn" && v != "error" { | |
| 332 | + | return fmt.Errorf("must be debug, info, warn or error") | |
| 333 | + | } | |
| 334 | + | c.Log.Level = v | |
| 335 | + | case "log.debug": | |
| 336 | + | list := splitList(v) | |
| 337 | + | for _, s := range list { | |
| 338 | + | if !components[s] && s != "all" { | |
| 339 | + | return fmt.Errorf("unknown component %q", s) | |
| 340 | + | } | |
| 178 | 341 | } | |
| 179 | - | mods[m] = true | |
| 342 | + | c.Log.Debug = list | |
| 343 | + | default: | |
| 344 | + | return fmt.Errorf("unknown key") | |
| 180 | 345 | } | |
| 181 | - | ordered := make([]string, 0, len(mods)) | |
| 182 | - | for m := range mods { | |
| 183 | - | ordered = append(ordered, m) | |
| 346 | + | return nil | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func positive(dst *int, v string, max int) error { | |
| 350 | + | n, err := strconv.Atoi(v) | |
| 351 | + | if err != nil || n <= 0 || n > max { | |
| 352 | + | return fmt.Errorf("must be between 1 and %d", max) | |
| 184 | 353 | } | |
| 185 | - | sort.Strings(ordered) | |
| 186 | - | return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil | |
| 354 | + | *dst = n | |
| 355 | + | return nil | |
| 187 | 356 | } | |
| 188 | - | func Validate(c ports.Config) error { | |
| 189 | - | var errs []error | |
| 190 | - | add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) } | |
| 191 | - | if c.Keyboard.RepeatRate <= 0 { | |
| 192 | - | add("keyboard.repeat_rate", "must be positive") | |
| 193 | - | } | |
| 194 | - | if c.Keyboard.RepeatDelay <= 0 { | |
| 195 | - | add("keyboard.repeat_delay", "must be positive") | |
| 196 | - | } | |
| 197 | - | if c.Keyboard.CmdKey != "super" && c.Keyboard.CmdKey != "alt" && c.Keyboard.CmdKey != "ctrl" { | |
| 198 | - | add("keyboard.cmd_key", "must be super, alt or ctrl") | |
| 199 | - | } | |
| 200 | - | if len(c.Terminal.Command) == 0 { | |
| 201 | - | add("terminal.command", "must not be empty") | |
| 202 | - | } else { | |
| 203 | - | for _, s := range c.Terminal.Command { | |
| 204 | - | if s == "" { | |
| 205 | - | add("terminal.command", "must not contain empty entries") | |
| 206 | - | break | |
| 207 | - | } | |
| 357 | + | ||
| 358 | + | func onOff(v string) (bool, error) { | |
| 359 | + | switch v { | |
| 360 | + | case "on": | |
| 361 | + | return true, nil | |
| 362 | + | case "off": | |
| 363 | + | return false, nil | |
| 364 | + | } | |
| 365 | + | return false, fmt.Errorf("must be on or off") | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func splitList(v string) []string { | |
| 369 | + | list := []string{} | |
| 370 | + | for _, s := range strings.Split(v, ",") { | |
| 371 | + | if s = strings.TrimSpace(s); s != "" { | |
| 372 | + | list = append(list, s) | |
| 208 | 373 | } | |
| 209 | 374 | } | |
| 210 | - | if !color.MatchString(c.Background.Color) { | |
| 211 | - | add("background.color", "must be #rrggbb") | |
| 375 | + | return list | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // stripComment drops a trailing ` # comment` from a value. A # that starts the | |
| 379 | + | // value is kept, so colors like #1e1e2e need no quotes. | |
| 380 | + | func stripComment(value string) string { | |
| 381 | + | for i := 1; i < len(value); i++ { | |
| 382 | + | if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') { | |
| 383 | + | return strings.TrimSpace(value[:i]) | |
| 384 | + | } | |
| 212 | 385 | } | |
| 213 | - | if c.Border.Width < 0 || c.Border.Width > 32 { | |
| 214 | - | add("border.width", "must be between 0 and 32") | |
| 386 | + | return value | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func checkAction(v string) error { | |
| 390 | + | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | |
| 391 | + | if len(strings.Fields(rest)) == 0 { | |
| 392 | + | return fmt.Errorf("spawn needs a command") | |
| 393 | + | } | |
| 394 | + | return nil | |
| 215 | 395 | } | |
| 216 | - | if !color.MatchString(c.Border.Active) { | |
| 217 | - | add("border.active", "must be #rrggbb") | |
| 396 | + | if !actions[v] { | |
| 397 | + | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | |
| 398 | + | } | |
| 399 | + | return nil | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`: | |
| 403 | + | // modifiers sorted, the key as an xkb keysym name. | |
| 404 | + | func parseCombo(s string) (string, error) { | |
| 405 | + | parts := strings.Split(s, "+") | |
| 406 | + | key := parts[len(parts)-1] | |
| 407 | + | if len(parts) > 1 && key == "" { | |
| 408 | + | // "cmd++" means the + key. | |
| 409 | + | if len(parts) > 2 && parts[len(parts)-2] == "" { | |
| 410 | + | parts, key = parts[:len(parts)-1], "+" | |
| 411 | + | } else { | |
| 412 | + | return "", fmt.Errorf("missing key") | |
| 413 | + | } | |
| 218 | 414 | } | |
| 219 | - | if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) { | |
| 220 | - | add("border.inactive", `must be #rrggbb or "" (none)`) | |
| 415 | + | if key == "" { | |
| 416 | + | return "", fmt.Errorf("missing key") | |
| 221 | 417 | } | |
| 222 | - | for i, o := range c.Outputs { | |
| 223 | - | path := fmt.Sprintf("output[%d]", i) | |
| 224 | - | if o.Name == "" { | |
| 225 | - | add(path+".name", "must not be empty") | |
| 418 | + | seen := map[string]bool{} | |
| 419 | + | for _, m := range parts[:len(parts)-1] { | |
| 420 | + | var name string | |
| 421 | + | switch strings.ToLower(m) { | |
| 422 | + | case "cmd": | |
| 423 | + | name = "Cmd" | |
| 424 | + | case "shift": | |
| 425 | + | name = "Shift" | |
| 426 | + | case "ctrl": | |
| 427 | + | name = "Ctrl" | |
| 428 | + | case "alt": | |
| 429 | + | name = "Alt" | |
| 430 | + | case "super": | |
| 431 | + | name = "Super" | |
| 432 | + | default: | |
| 433 | + | return "", fmt.Errorf("unknown modifier %q", m) | |
| 226 | 434 | } | |
| 227 | - | if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil { | |
| 228 | - | add(path+".mode", err.Error()) | |
| 435 | + | if seen[name] { | |
| 436 | + | return "", fmt.Errorf("duplicate modifier %q", m) | |
| 229 | 437 | } | |
| 438 | + | seen[name] = true | |
| 230 | 439 | } | |
| 231 | - | if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 { | |
| 232 | - | add("layout.gaps", "must be between 0 and 200") | |
| 440 | + | mods := make([]string, 0, len(seen)) | |
| 441 | + | for m := range seen { | |
| 442 | + | mods = append(mods, m) | |
| 233 | 443 | } | |
| 234 | - | if !width(c.Layout.DefaultColumnWidth) { | |
| 235 | - | add("layout.default_column_width", "must be a positive fraction or pixel width") | |
| 444 | + | sort.Strings(mods) | |
| 445 | + | return strings.Join(append(mods, keysym(key)), "+"), nil | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // keysym maps a written key to its xkb keysym name. | |
| 449 | + | func keysym(k string) string { | |
| 450 | + | if r := []rune(k); len(r) == 1 { | |
| 451 | + | if name, ok := runeKeysyms[r[0]]; ok { | |
| 452 | + | return name | |
| 453 | + | } | |
| 454 | + | return strings.ToLower(k) | |
| 236 | 455 | } | |
| 237 | - | if len(c.Layout.Presets) == 0 { | |
| 238 | - | add("layout.presets", "must not be empty") | |
| 456 | + | if name, ok := namedKeys[strings.ToLower(k)]; ok { | |
| 457 | + | return name | |
| 239 | 458 | } | |
| 240 | - | for i, s := range c.Layout.Presets { | |
| 241 | - | if !width(s) { | |
| 242 | - | add(fmt.Sprintf("layout.presets[%d]", i), "must be a positive fraction or pixel width") | |
| 459 | + | if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') { | |
| 460 | + | if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 { | |
| 461 | + | return "F" + k[1:] | |
| 243 | 462 | } | |
| 244 | 463 | } | |
| 245 | - | if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" { | |
| 246 | - | add("log.level", "invalid level") | |
| 464 | + | return k | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted. | |
| 468 | + | func ParseMode(s string) (w, h int, hz float64, err error) { | |
| 469 | + | if s == "preferred" { | |
| 470 | + | return 0, 0, 0, nil | |
| 471 | + | } | |
| 472 | + | size, rate, hasRate := strings.Cut(s, "@") | |
| 473 | + | ws, hs, ok := strings.Cut(size, "x") | |
| 474 | + | w, e1 := strconv.Atoi(ws) | |
| 475 | + | h, e2 := strconv.Atoi(hs) | |
| 476 | + | if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 { | |
| 477 | + | return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s) | |
| 247 | 478 | } | |
| 248 | - | for i, s := range c.Log.Debug { | |
| 249 | - | if !components[s] { | |
| 250 | - | add(fmt.Sprintf("log.debug[%d]", i), "invalid component") | |
| 479 | + | if hasRate { | |
| 480 | + | if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 { | |
| 481 | + | return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s) | |
| 251 | 482 | } | |
| 252 | 483 | } | |
| 253 | - | keys := make([]string, 0, len(c.Binds)) | |
| 254 | - | for k := range c.Binds { | |
| 255 | - | keys = append(keys, k) | |
| 484 | + | return w, h, hz, nil | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func width(s string) bool { | |
| 488 | + | if s == "1" { | |
| 489 | + | return true | |
| 256 | 490 | } | |
| 257 | - | sort.Strings(keys) | |
| 258 | - | combos := map[string]string{} | |
| 259 | - | for _, k := range keys { | |
| 260 | - | v := c.Binds[k] | |
| 261 | - | if _, e := normalize(k); e != nil { | |
| 262 | - | add("binds."+k, e.Error()) | |
| 263 | - | } else { | |
| 264 | - | n, e := resolvedCombo(k, c.Keyboard.CmdKey) | |
| 265 | - | if e != nil { | |
| 266 | - | add("binds."+k, e.Error()) | |
| 267 | - | } else if old, ok := combos[n]; ok { | |
| 268 | - | add("binds."+k, "duplicates "+old) | |
| 269 | - | } else { | |
| 270 | - | combos[n] = k | |
| 271 | - | } | |
| 272 | - | } | |
| 273 | - | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | |
| 274 | - | if len(strings.Fields(rest)) == 0 { | |
| 275 | - | add("binds."+k, "spawn needs a command") | |
| 276 | - | } | |
| 277 | - | } else if !actions[v] { | |
| 278 | - | add("binds."+k, `invalid action (or "spawn <command>")`) | |
| 279 | - | } | |
| 491 | + | if strings.HasSuffix(s, "px") { | |
| 492 | + | n, e := strconv.Atoi(strings.TrimSuffix(s, "px")) | |
| 493 | + | return e == nil && n > 0 | |
| 494 | + | } | |
| 495 | + | p := strings.Split(s, "/") | |
| 496 | + | if len(p) != 2 { | |
| 497 | + | return false | |
| 280 | 498 | } | |
| 281 | - | return errors.Join(errs...) | |
| 499 | + | a, e1 := strconv.Atoi(p[0]) | |
| 500 | + | b, e2 := strconv.Atoi(p[1]) | |
| 501 | + | return e1 == nil && e2 == nil && a > 0 && a <= b | |
| 282 | 502 | } | |
| @@ -1,8 +1,11 @@ | |||||
| 1 | + | // Package config reads the flat `key = value` config file (ADR 012). | |||
| 1 | package config | 2 | package config | ||
| 2 | 3 | ||||
| 3 | import ( | 4 | import ( | ||
| 4 | - | "errors" | 5 | + | "bufio" |
| 5 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 8 | + | "math" | |||
| 6 | "os" | 9 | "os" | ||
| 7 | "path/filepath" | 10 | "path/filepath" | ||
| 8 | "regexp" | 11 | "regexp" | ||
| @@ -10,17 +13,44 @@ import ( | |||||
| 10 | "strconv" | 13 | "strconv" | ||
| 11 | "strings" | 14 | "strings" | ||
| 12 | 15 | ||||
| 13 | - | "github.com/BurntSushi/toml" | |||
| 14 | "github.com/bnema/nefertty/internal/ports" | 16 | "github.com/bnema/nefertty/internal/ports" | ||
| 15 | ) | 17 | ) | ||
| 16 | 18 | ||||
| 17 | - | var defaultBinds = map[string]string{ | 19 | + | // defaultBinds use the config syntax and go through the same parser as user binds. |
| 18 | - | "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel", | 20 | + | var defaultBinds = []struct{ combo, action string }{ |
| 21 | + | {"cmd+return", "spawn-terminal"}, | |||
| 22 | + | {"cmd+left", "focus-column-left"}, | |||
| 23 | + | {"cmd+right", "focus-column-right"}, | |||
| 24 | + | {"cmd+up", "focus-window-up"}, | |||
| 25 | + | {"cmd+down", "focus-window-down"}, | |||
| 26 | + | {"cmd+shift+left", "move-column-left"}, | |||
| 27 | + | {"cmd+shift+right", "move-column-right"}, | |||
| 28 | + | {"cmd+r", "cycle-column-width"}, | |||
| 29 | + | {"cmd+shift+f", "toggle-fullscreen"}, | |||
| 30 | + | {"cmd+q", "close-window"}, | |||
| 31 | + | {"ctrl+alt+backspace", "quit"}, | |||
| 32 | + | {"ctrl+cmd+space", "spawn fuzzel"}, | |||
| 19 | } | 33 | } | ||
| 20 | var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | 34 | var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true} | ||
| 21 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | 35 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | ||
| 22 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 36 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | ||
| 23 | 37 | ||||
| 38 | + | // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive. | |||
| 39 | + | var namedKeys = map[string]string{ | |||
| 40 | + | "return": "Return", "enter": "Return", "space": "space", "tab": "Tab", "escape": "Escape", "esc": "Escape", | |||
| 41 | + | "backspace": "BackSpace", "delete": "Delete", "insert": "Insert", "home": "Home", "end": "End", | |||
| 42 | + | "pageup": "Prior", "prior": "Prior", "pagedown": "Next", "next": "Next", "print": "Print", | |||
| 43 | + | "left": "Left", "right": "Right", "up": "Up", "down": "Down", | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Warning is a non-fatal problem at one line; the key keeps its default. | |||
| 47 | + | type Warning struct { | |||
| 48 | + | Line int | |||
| 49 | + | Msg string | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (w Warning) String() string { return fmt.Sprintf("line %d: %s", w.Line, w.Msg) } | |||
| 53 | + | ||||
| 24 | func DefaultPath() string { | 54 | func DefaultPath() string { | ||
| 25 | base := os.Getenv("XDG_CONFIG_HOME") | 55 | base := os.Getenv("XDG_CONFIG_HOME") | ||
| 26 | if base == "" { | 56 | if base == "" { | ||
| @@ -29,8 +59,9 @@ func DefaultPath() string { | |||||
| 29 | base = filepath.Join(home, ".config") | 59 | base = filepath.Join(home, ".config") | ||
| 30 | } | 60 | } | ||
| 31 | } | 61 | } | ||
| 32 | - | return filepath.Join(base, "nefertty", "config.toml") | 62 | + | return filepath.Join(base, "nefertty", "config") |
| 33 | } | 63 | } | ||
| 64 | + | ||||
| 34 | func Defaults() ports.Config { | 65 | func Defaults() ports.Config { | ||
| 35 | var c ports.Config | 66 | var c ports.Config | ||
| 36 | c.Keyboard.RepeatRate = 25 | 67 | c.Keyboard.RepeatRate = 25 | ||
| @@ -40,111 +71,178 @@ func Defaults() ports.Config { | |||||
| 40 | c.Background.Color = "#1e1e2e" | 71 | c.Background.Color = "#1e1e2e" | ||
| 41 | c.Border.Width = 2 | 72 | c.Border.Width = 2 | ||
| 42 | c.Border.Active = "#b4befe" | 73 | c.Border.Active = "#b4befe" | ||
| 43 | - | c.Border.Inactive = "" | |||
| 44 | - | c.Layout.Gaps = 0 | |||
| 45 | c.Layout.DefaultColumnWidth = "1/2" | 74 | c.Layout.DefaultColumnWidth = "1/2" | ||
| 46 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | 75 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | ||
| 47 | - | c.Binds = make(map[string]string, len(defaultBinds)) | 76 | + | c.Binds = map[string]string{} |
| 48 | - | for k, v := range defaultBinds { | 77 | + | for _, b := range defaultBinds { |
| 49 | - | c.Binds[k] = v | 78 | + | combo, err := parseCombo(b.combo) |
| 79 | + | if err != nil { | |||
| 80 | + | panic(err) | |||
| 81 | + | } | |||
| 82 | + | c.Binds[combo] = b.action | |||
| 50 | } | 83 | } | ||
| 51 | c.Render.DirectScanout = true | 84 | c.Render.DirectScanout = true | ||
| 52 | c.Log.Level = "info" | 85 | c.Log.Level = "info" | ||
| 53 | c.Log.Debug = []string{} | 86 | c.Log.Debug = []string{} | ||
| 54 | return c | 87 | return c | ||
| 55 | } | 88 | } | ||
| 56 | - | func LoadDefault() (ports.Config, error) { | 89 | + | |
| 57 | - | c, err := Load(DefaultPath()) | 90 | + | // LoadDefault loads DefaultPath; a missing file means defaults. |
| 91 | + | func LoadDefault() (ports.Config, []Warning, error) { | |||
| 92 | + | c, w, err := Load(DefaultPath()) | |||
| 58 | if os.IsNotExist(err) { | 93 | if os.IsNotExist(err) { | ||
| 59 | - | return Defaults(), nil | 94 | + | return Defaults(), nil, nil |
| 60 | } | 95 | } | ||
| 61 | - | return c, err | 96 | + | return c, w, err |
| 62 | } | 97 | } | ||
| 63 | - | func Load(path string) (ports.Config, error) { | 98 | + | |
| 64 | - | c := Defaults() | 99 | + | // Load reads a config file. The error is only for I/O; bad lines become warnings. |
| 65 | - | data, err := os.ReadFile(path) | 100 | + | func Load(path string) (ports.Config, []Warning, error) { |
| 101 | + | f, err := os.Open(path) | |||
| 66 | if err != nil { | 102 | if err != nil { | ||
| 67 | - | return c, err | 103 | + | return Defaults(), nil, err |
| 68 | - | } | |||
| 69 | - | // Decode binds separately so user entries can remove or override defaults. | |||
| 70 | - | var raw struct { | |||
| 71 | - | Binds map[string]string `toml:"binds"` | |||
| 72 | - | } | |||
| 73 | - | if _, err = toml.Decode(string(data), &raw); err != nil { | |||
| 74 | - | return c, err | |||
| 75 | } | 104 | } | ||
| 76 | - | c.Binds = make(map[string]string) | 105 | + | defer f.Close() |
| 77 | - | md, err := toml.Decode(string(data), &c) | 106 | + | return Parse(f) |
| 78 | - | if err != nil { | 107 | + | } |
| 79 | - | return c, err | 108 | + | |
| 80 | - | } | 109 | + | // Parse reads `key = value` lines on top of the defaults. |
| 81 | - | var errs []error | 110 | + | func Parse(r io.Reader) (ports.Config, []Warning, error) { |
| 82 | - | for _, key := range md.Undecoded() { | 111 | + | c, _, w, err := parse(r) |
| 83 | - | errs = append(errs, fmt.Errorf("%s: unknown key", key.String())) | 112 | + | return c, w, err |
| 84 | - | } | 113 | + | } |
| 85 | - | // Validate user bindings before merging to catch normalized collisions. | 114 | + | |
| 86 | - | errs = append(errs, Validate(c)) | 115 | + | // parse also returns the effective key/value pairs, used to log what changed on reload. |
| 87 | - | if err = errors.Join(errs...); err != nil { | 116 | + | func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { |
| 88 | - | return c, err | 117 | + | c := Defaults() |
| 89 | - | } | 118 | + | var warnings []Warning |
| 90 | - | c.Binds = Defaults().Binds | 119 | + | raw := map[string]string{} |
| 91 | - | for k, v := range raw.Binds { | 120 | + | seen := map[string]int{} |
| 92 | - | normalized, _ := normalize(k) | 121 | + | outputs := map[string]int{} |
| 93 | - | for old := range c.Binds { | 122 | + | scanner := bufio.NewScanner(r) |
| 94 | - | n, _ := normalize(old) | 123 | + | for n := 1; scanner.Scan(); n++ { |
| 95 | - | if n == normalized { | 124 | + | line := strings.TrimSpace(scanner.Text()) |
| 96 | - | delete(c.Binds, old) | 125 | + | if line == "" || strings.HasPrefix(line, "#") { |
| 126 | + | continue | |||
| 127 | + | } | |||
| 128 | + | warn := func(format string, a ...any) { | |||
| 129 | + | warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)}) | |||
| 130 | + | } | |||
| 131 | + | key, value, ok := strings.Cut(line, "=") | |||
| 132 | + | key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value)) | |||
| 133 | + | if !ok || key == "" { | |||
| 134 | + | warn("expected key = value") | |||
| 135 | + | continue | |||
| 136 | + | } | |||
| 137 | + | if combo, ok := strings.CutPrefix(key, "bind."); ok { | |||
| 138 | + | if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") { | |||
| 139 | + | warn("%s: write %sequal instead of %s=", key, combo, combo) | |||
| 140 | + | continue | |||
| 141 | + | } | |||
| 142 | + | canon, err := parseCombo(combo) | |||
| 143 | + | if err != nil { | |||
| 144 | + | warn("%s: %v", key, err) | |||
| 145 | + | continue | |||
| 146 | + | } | |||
| 147 | + | if err := checkAction(value); err != nil { | |||
| 148 | + | warn("%s: %v", key, err) | |||
| 149 | + | continue | |||
| 150 | + | } | |||
| 151 | + | key = "bind." + canon | |||
| 152 | + | if prev, dup := seen[key]; dup { | |||
| 153 | + | warn("%s: overrides line %d", key, prev) | |||
| 154 | + | } | |||
| 155 | + | seen[key] = n | |||
| 156 | + | raw[key] = value | |||
| 157 | + | c.Binds[canon] = value | |||
| 158 | + | continue | |||
| 159 | + | } | |||
| 160 | + | if prev, dup := seen[key]; dup { | |||
| 161 | + | warn("%s: overrides line %d", key, prev) | |||
| 162 | + | } | |||
| 163 | + | seen[key] = n | |||
| 164 | + | if name, ok := strings.CutPrefix(key, "output."); ok { | |||
| 165 | + | o := ports.OutputConfig{Name: name} | |||
| 166 | + | if value == "off" { | |||
| 167 | + | o.Off = true | |||
| 168 | + | } else if _, _, _, err := ParseMode(value); err != nil { | |||
| 169 | + | warn("%s: %v", key, err) | |||
| 170 | + | continue | |||
| 171 | + | } else if value != "preferred" { | |||
| 172 | + | o.Mode = value | |||
| 97 | } | 173 | } | ||
| 174 | + | raw[key] = value | |||
| 175 | + | if i, ok := outputs[name]; ok { | |||
| 176 | + | c.Outputs[i] = o | |||
| 177 | + | } else { | |||
| 178 | + | outputs[name] = len(c.Outputs) | |||
| 179 | + | c.Outputs = append(c.Outputs, o) | |||
| 180 | + | } | |||
| 181 | + | continue | |||
| 98 | } | 182 | } | ||
| 99 | - | if v != "none" { | 183 | + | if err := set(&c, key, value); err != nil { |
| 100 | - | c.Binds[k] = v | 184 | + | warn("%s: %v", key, err) |
| 185 | + | continue | |||
| 101 | } | 186 | } | ||
| 187 | + | raw[key] = value | |||
| 102 | } | 188 | } | ||
| 103 | - | return c, nil | 189 | + | if err := scanner.Err(); err != nil { |
| 104 | - | } | 190 | + | return c, raw, warnings, err |
| 105 | - | ||||
| 106 | - | // ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted. | |||
| 107 | - | func ParseMode(s string) (w, h int, hz float64, err error) { | |||
| 108 | - | size, rate, hasRate := strings.Cut(s, "@") | |||
| 109 | - | ws, hs, ok := strings.Cut(size, "x") | |||
| 110 | - | w, e1 := strconv.Atoi(ws) | |||
| 111 | - | h, e2 := strconv.Atoi(hs) | |||
| 112 | - | if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 { | |||
| 113 | - | return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s) | |||
| 114 | } | 191 | } | ||
| 115 | - | if hasRate { | 192 | + | // "none" only removes a default bind. |
| 116 | - | if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 { | 193 | + | for combo, a := range c.Binds { |
| 117 | - | return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s) | 194 | + | if a == "none" { |
| 195 | + | delete(c.Binds, combo) | |||
| 118 | } | 196 | } | ||
| 119 | } | 197 | } | ||
| 120 | - | return w, h, hz, nil | 198 | + | // Cmd resolves to a real modifier; drop binds that then collide. User lines win over |
| 121 | - | } | 199 | + | // defaults (line 0), then the earliest line wins. |
| 122 | - | 200 | + | combos := make([]string, 0, len(c.Binds)) | |
| 123 | - | func width(s string) bool { | 201 | + | for combo := range c.Binds { |
| 124 | - | if s == "1" { | 202 | + | combos = append(combos, combo) |
| 125 | - | return true | |||
| 126 | - | } | |||
| 127 | - | if strings.HasSuffix(s, "px") { | |||
| 128 | - | n, e := strconv.Atoi(strings.TrimSuffix(s, "px")) | |||
| 129 | - | return e == nil && n > 0 | |||
| 130 | } | 203 | } | ||
| 131 | - | p := strings.Split(s, "/") | 204 | + | order := func(combo string) int { |
| 132 | - | if len(p) != 2 { | 205 | + | if n := seen["bind."+combo]; n > 0 { |
| 133 | - | return false | 206 | + | return n |
| 207 | + | } | |||
| 208 | + | return math.MaxInt | |||
| 209 | + | } | |||
| 210 | + | sort.Slice(combos, func(i, j int) bool { | |||
| 211 | + | a, b := order(combos[i]), order(combos[j]) | |||
| 212 | + | return a < b || a == b && combos[i] < combos[j] | |||
| 213 | + | }) | |||
| 214 | + | resolved := map[string]string{} | |||
| 215 | + | for _, combo := range combos { | |||
| 216 | + | r, err := expandCmd(combo, c.Keyboard.CmdKey) | |||
| 217 | + | if prev, dup := resolved[r]; err == nil && dup { | |||
| 218 | + | err = fmt.Errorf("same keys as %s with keyboard.cmd = %s", prev, c.Keyboard.CmdKey) | |||
| 219 | + | } | |||
| 220 | + | if err != nil { | |||
| 221 | + | if n := seen["bind."+combo]; n > 0 { | |||
| 222 | + | warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf("bind.%s: %v", combo, err)}) | |||
| 223 | + | } | |||
| 224 | + | delete(c.Binds, combo) | |||
| 225 | + | delete(raw, "bind."+combo) | |||
| 226 | + | continue | |||
| 227 | + | } | |||
| 228 | + | resolved[r] = combo | |||
| 134 | } | 229 | } | ||
| 135 | - | a, e1 := strconv.Atoi(p[0]) | 230 | + | return c, raw, warnings, nil |
| 136 | - | b, e2 := strconv.Atoi(p[1]) | |||
| 137 | - | return e1 == nil && e2 == nil && a > 0 && a <= b | |||
| 138 | } | 231 | } | ||
| 139 | - | func normalize(s string) (string, error) { | 232 | + | |
| 140 | - | p := strings.Split(s, "+") | 233 | + | // expandCmd replaces Cmd with the configured modifier and re-sorts. |
| 141 | - | if len(p) < 2 || p[len(p)-1] == "" { | 234 | + | func expandCmd(combo, cmd string) (string, error) { |
| 142 | - | return "", fmt.Errorf("invalid key combo") | 235 | + | parts := strings.Split(combo, "+") |
| 143 | - | } | 236 | + | key := parts[len(parts)-1] |
| 237 | + | // cmd is super, alt or ctrl; its modifier name is the capitalized form. | |||
| 238 | + | cmdName := strings.ToUpper(cmd[:1]) + cmd[1:] | |||
| 144 | seen := map[string]bool{} | 239 | seen := map[string]bool{} | ||
| 145 | - | for _, m := range p[:len(p)-1] { | 240 | + | for _, m := range parts[:len(parts)-1] { |
| 146 | - | if m != "Cmd" && m != "Shift" && m != "Ctrl" && m != "Alt" || seen[m] { | 241 | + | if m == "Cmd" { |
| 147 | - | return "", fmt.Errorf("invalid key combo") | 242 | + | m = cmdName |
| 243 | + | } | |||
| 244 | + | if seen[m] { | |||
| 245 | + | return "", fmt.Errorf("%s is already cmd (keyboard.cmd = %s)", strings.ToLower(m), cmd) | |||
| 148 | } | 246 | } | ||
| 149 | seen[m] = true | 247 | seen[m] = true | ||
| 150 | } | 248 | } | ||
| @@ -153,130 +251,252 @@ func normalize(s string) (string, error) { | |||||
| 153 | mods = append(mods, m) | 251 | mods = append(mods, m) | ||
| 154 | } | 252 | } | ||
| 155 | sort.Strings(mods) | 253 | sort.Strings(mods) | ||
| 156 | - | return strings.Join(append(mods, p[len(p)-1]), "+"), nil | 254 | + | return strings.Join(append(mods, key), "+"), nil |
| 157 | } | 255 | } | ||
| 158 | 256 | ||||
| 159 | - | // resolvedCombo canonicalizes modifiers after resolving Cmd, so aliases collide. | 257 | + | // set applies one scalar key. Unknown keys and invalid values are errors. |
| 160 | - | func resolvedCombo(s, cmd string) (string, error) { | 258 | + | func set(c *ports.Config, key, v string) error { |
| 161 | - | parts := strings.Split(s, "+") | 259 | + | switch key { |
| 162 | - | mods := map[string]bool{} | 260 | + | case "keyboard.layout": |
| 163 | - | for _, m := range parts[:len(parts)-1] { | 261 | + | c.Keyboard.Layout = v |
| 164 | - | if m == "Cmd" { | 262 | + | case "keyboard.variant": |
| 165 | - | switch cmd { | 263 | + | c.Keyboard.Variant = v |
| 166 | - | case "super": | 264 | + | case "keyboard.options": |
| 167 | - | m = "Super" | 265 | + | c.Keyboard.Options = v |
| 168 | - | case "alt": | 266 | + | case "keyboard.repeat-rate": |
| 169 | - | m = "Alt" | 267 | + | return positive(&c.Keyboard.RepeatRate, v, 1000) |
| 170 | - | case "ctrl": | 268 | + | case "keyboard.repeat-delay": |
| 171 | - | m = "Ctrl" | 269 | + | return positive(&c.Keyboard.RepeatDelay, v, 10000) |
| 172 | - | default: | 270 | + | case "keyboard.cmd": |
| 173 | - | return "", fmt.Errorf("invalid cmd key") | 271 | + | if v != "super" && v != "alt" && v != "ctrl" { |
| 272 | + | return fmt.Errorf("must be super, alt or ctrl") | |||
| 273 | + | } | |||
| 274 | + | c.Keyboard.CmdKey = v | |||
| 275 | + | case "terminal": | |||
| 276 | + | argv := strings.Fields(v) | |||
| 277 | + | if len(argv) == 0 { | |||
| 278 | + | return fmt.Errorf("must not be empty") | |||
| 279 | + | } | |||
| 280 | + | c.Terminal.Command = argv | |||
| 281 | + | case "background": | |||
| 282 | + | if !color.MatchString(v) { | |||
| 283 | + | return fmt.Errorf("must be #rrggbb") | |||
| 284 | + | } | |||
| 285 | + | c.Background.Color = v | |||
| 286 | + | case "border.width": | |||
| 287 | + | n, err := strconv.Atoi(v) | |||
| 288 | + | if err != nil || n < 0 || n > 32 { | |||
| 289 | + | return fmt.Errorf("must be between 0 and 32") | |||
| 290 | + | } | |||
| 291 | + | c.Border.Width = n | |||
| 292 | + | case "border.active": | |||
| 293 | + | if !color.MatchString(v) { | |||
| 294 | + | return fmt.Errorf("must be #rrggbb") | |||
| 295 | + | } | |||
| 296 | + | c.Border.Active = v | |||
| 297 | + | case "border.inactive": | |||
| 298 | + | if v != "" && !color.MatchString(v) { | |||
| 299 | + | return fmt.Errorf("must be #rrggbb or empty (none)") | |||
| 300 | + | } | |||
| 301 | + | c.Border.Inactive = v | |||
| 302 | + | case "layout.gaps": | |||
| 303 | + | n, err := strconv.Atoi(v) | |||
| 304 | + | if err != nil || n < 0 || n > 200 { | |||
| 305 | + | return fmt.Errorf("must be between 0 and 200") | |||
| 306 | + | } | |||
| 307 | + | c.Layout.Gaps = n | |||
| 308 | + | case "layout.default-width": | |||
| 309 | + | if !width(v) { | |||
| 310 | + | return fmt.Errorf("must be a fraction like 1/2, 1, or pixels like 800px") | |||
| 311 | + | } | |||
| 312 | + | c.Layout.DefaultColumnWidth = v | |||
| 313 | + | case "layout.presets": | |||
| 314 | + | list := splitList(v) | |||
| 315 | + | if len(list) == 0 { | |||
| 316 | + | return fmt.Errorf("must not be empty") | |||
| 317 | + | } | |||
| 318 | + | for _, s := range list { | |||
| 319 | + | if !width(s) { | |||
| 320 | + | return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s) | |||
| 174 | } | 321 | } | ||
| 175 | } | 322 | } | ||
| 176 | - | if mods[m] { | 323 | + | c.Layout.Presets = list |
| 177 | - | return "", fmt.Errorf("duplicate resolved modifier") | 324 | + | case "render.direct-scanout": |
| 325 | + | b, err := onOff(v) | |||
| 326 | + | if err != nil { | |||
| 327 | + | return err | |||
| 328 | + | } | |||
| 329 | + | c.Render.DirectScanout = b | |||
| 330 | + | case "log.level": | |||
| 331 | + | if v != "debug" && v != "info" && v != "warn" && v != "error" { | |||
| 332 | + | return fmt.Errorf("must be debug, info, warn or error") | |||
| 333 | + | } | |||
| 334 | + | c.Log.Level = v | |||
| 335 | + | case "log.debug": | |||
| 336 | + | list := splitList(v) | |||
| 337 | + | for _, s := range list { | |||
| 338 | + | if !components[s] && s != "all" { | |||
| 339 | + | return fmt.Errorf("unknown component %q", s) | |||
| 340 | + | } | |||
| 178 | } | 341 | } | ||
| 179 | - | mods[m] = true | 342 | + | c.Log.Debug = list |
| 343 | + | default: | |||
| 344 | + | return fmt.Errorf("unknown key") | |||
| 180 | } | 345 | } | ||
| 181 | - | ordered := make([]string, 0, len(mods)) | 346 | + | return nil |
| 182 | - | for m := range mods { | 347 | + | } |
| 183 | - | ordered = append(ordered, m) | 348 | + | |
| 349 | + | func positive(dst *int, v string, max int) error { | |||
| 350 | + | n, err := strconv.Atoi(v) | |||
| 351 | + | if err != nil || n <= 0 || n > max { | |||
| 352 | + | return fmt.Errorf("must be between 1 and %d", max) | |||
| 184 | } | 353 | } | ||
| 185 | - | sort.Strings(ordered) | 354 | + | *dst = n |
| 186 | - | return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil | 355 | + | return nil |
| 187 | } | 356 | } | ||
| 188 | - | func Validate(c ports.Config) error { | 357 | + | |
| 189 | - | var errs []error | 358 | + | func onOff(v string) (bool, error) { |
| 190 | - | add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) } | 359 | + | switch v { |
| 191 | - | if c.Keyboard.RepeatRate <= 0 { | 360 | + | case "on": |
| 192 | - | add("keyboard.repeat_rate", "must be positive") | 361 | + | return true, nil |
| 193 | - | } | 362 | + | case "off": |
| 194 | - | if c.Keyboard.RepeatDelay <= 0 { | 363 | + | return false, nil |
| 195 | - | add("keyboard.repeat_delay", "must be positive") | 364 | + | } |
| 196 | - | } | 365 | + | return false, fmt.Errorf("must be on or off") |
| 197 | - | if c.Keyboard.CmdKey != "super" && c.Keyboard.CmdKey != "alt" && c.Keyboard.CmdKey != "ctrl" { | 366 | + | } |
| 198 | - | add("keyboard.cmd_key", "must be super, alt or ctrl") | 367 | + | |
| 199 | - | } | 368 | + | func splitList(v string) []string { |
| 200 | - | if len(c.Terminal.Command) == 0 { | 369 | + | list := []string{} |
| 201 | - | add("terminal.command", "must not be empty") | 370 | + | for _, s := range strings.Split(v, ",") { |
| 202 | - | } else { | 371 | + | if s = strings.TrimSpace(s); s != "" { |
| 203 | - | for _, s := range c.Terminal.Command { | 372 | + | list = append(list, s) |
| 204 | - | if s == "" { | |||
| 205 | - | add("terminal.command", "must not contain empty entries") | |||
| 206 | - | break | |||
| 207 | - | } | |||
| 208 | } | 373 | } | ||
| 209 | } | 374 | } | ||
| 210 | - | if !color.MatchString(c.Background.Color) { | 375 | + | return list |
| 211 | - | add("background.color", "must be #rrggbb") | 376 | + | } |
| 377 | + | ||||
| 378 | + | // stripComment drops a trailing ` # comment` from a value. A # that starts the | |||
| 379 | + | // value is kept, so colors like #1e1e2e need no quotes. | |||
| 380 | + | func stripComment(value string) string { | |||
| 381 | + | for i := 1; i < len(value); i++ { | |||
| 382 | + | if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') { | |||
| 383 | + | return strings.TrimSpace(value[:i]) | |||
| 384 | + | } | |||
| 212 | } | 385 | } | ||
| 213 | - | if c.Border.Width < 0 || c.Border.Width > 32 { | 386 | + | return value |
| 214 | - | add("border.width", "must be between 0 and 32") | 387 | + | } |
| 388 | + | ||||
| 389 | + | func checkAction(v string) error { | |||
| 390 | + | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | |||
| 391 | + | if len(strings.Fields(rest)) == 0 { | |||
| 392 | + | return fmt.Errorf("spawn needs a command") | |||
| 393 | + | } | |||
| 394 | + | return nil | |||
| 215 | } | 395 | } | ||
| 216 | - | if !color.MatchString(c.Border.Active) { | 396 | + | if !actions[v] { |
| 217 | - | add("border.active", "must be #rrggbb") | 397 | + | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) |
| 398 | + | } | |||
| 399 | + | return nil | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`: | |||
| 403 | + | // modifiers sorted, the key as an xkb keysym name. | |||
| 404 | + | func parseCombo(s string) (string, error) { | |||
| 405 | + | parts := strings.Split(s, "+") | |||
| 406 | + | key := parts[len(parts)-1] | |||
| 407 | + | if len(parts) > 1 && key == "" { | |||
| 408 | + | // "cmd++" means the + key. | |||
| 409 | + | if len(parts) > 2 && parts[len(parts)-2] == "" { | |||
| 410 | + | parts, key = parts[:len(parts)-1], "+" | |||
| 411 | + | } else { | |||
| 412 | + | return "", fmt.Errorf("missing key") | |||
| 413 | + | } | |||
| 218 | } | 414 | } | ||
| 219 | - | if c.Border.Inactive != "" && !color.MatchString(c.Border.Inactive) { | 415 | + | if key == "" { |
| 220 | - | add("border.inactive", `must be #rrggbb or "" (none)`) | 416 | + | return "", fmt.Errorf("missing key") |
| 221 | } | 417 | } | ||
| 222 | - | for i, o := range c.Outputs { | 418 | + | seen := map[string]bool{} |
| 223 | - | path := fmt.Sprintf("output[%d]", i) | 419 | + | for _, m := range parts[:len(parts)-1] { |
| 224 | - | if o.Name == "" { | 420 | + | var name string |
| 225 | - | add(path+".name", "must not be empty") | 421 | + | switch strings.ToLower(m) { |
| 422 | + | case "cmd": | |||
| 423 | + | name = "Cmd" | |||
| 424 | + | case "shift": | |||
| 425 | + | name = "Shift" | |||
| 426 | + | case "ctrl": | |||
| 427 | + | name = "Ctrl" | |||
| 428 | + | case "alt": | |||
| 429 | + | name = "Alt" | |||
| 430 | + | case "super": | |||
| 431 | + | name = "Super" | |||
| 432 | + | default: | |||
| 433 | + | return "", fmt.Errorf("unknown modifier %q", m) | |||
| 226 | } | 434 | } | ||
| 227 | - | if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil { | 435 | + | if seen[name] { |
| 228 | - | add(path+".mode", err.Error()) | 436 | + | return "", fmt.Errorf("duplicate modifier %q", m) |
| 229 | } | 437 | } | ||
| 438 | + | seen[name] = true | |||
| 230 | } | 439 | } | ||
| 231 | - | if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 { | 440 | + | mods := make([]string, 0, len(seen)) |
| 232 | - | add("layout.gaps", "must be between 0 and 200") | 441 | + | for m := range seen { |
| 442 | + | mods = append(mods, m) | |||
| 233 | } | 443 | } | ||
| 234 | - | if !width(c.Layout.DefaultColumnWidth) { | 444 | + | sort.Strings(mods) |
| 235 | - | add("layout.default_column_width", "must be a positive fraction or pixel width") | 445 | + | return strings.Join(append(mods, keysym(key)), "+"), nil |
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // keysym maps a written key to its xkb keysym name. | |||
| 449 | + | func keysym(k string) string { | |||
| 450 | + | if r := []rune(k); len(r) == 1 { | |||
| 451 | + | if name, ok := runeKeysyms[r[0]]; ok { | |||
| 452 | + | return name | |||
| 453 | + | } | |||
| 454 | + | return strings.ToLower(k) | |||
| 236 | } | 455 | } | ||
| 237 | - | if len(c.Layout.Presets) == 0 { | 456 | + | if name, ok := namedKeys[strings.ToLower(k)]; ok { |
| 238 | - | add("layout.presets", "must not be empty") | 457 | + | return name |
| 239 | } | 458 | } | ||
| 240 | - | for i, s := range c.Layout.Presets { | 459 | + | if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') { |
| 241 | - | if !width(s) { | 460 | + | if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 { |
| 242 | - | add(fmt.Sprintf("layout.presets[%d]", i), "must be a positive fraction or pixel width") | 461 | + | return "F" + k[1:] |
| 243 | } | 462 | } | ||
| 244 | } | 463 | } | ||
| 245 | - | if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" { | 464 | + | return k |
| 246 | - | add("log.level", "invalid level") | 465 | + | } |
| 466 | + | ||||
| 467 | + | // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted. | |||
| 468 | + | func ParseMode(s string) (w, h int, hz float64, err error) { | |||
| 469 | + | if s == "preferred" { | |||
| 470 | + | return 0, 0, 0, nil | |||
| 471 | + | } | |||
| 472 | + | size, rate, hasRate := strings.Cut(s, "@") | |||
| 473 | + | ws, hs, ok := strings.Cut(size, "x") | |||
| 474 | + | w, e1 := strconv.Atoi(ws) | |||
| 475 | + | h, e2 := strconv.Atoi(hs) | |||
| 476 | + | if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 { | |||
| 477 | + | return 0, 0, 0, fmt.Errorf("must be off, preferred, WxH or WxH@Hz, got %q", s) | |||
| 247 | } | 478 | } | ||
| 248 | - | for i, s := range c.Log.Debug { | 479 | + | if hasRate { |
| 249 | - | if !components[s] { | 480 | + | if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 { |
| 250 | - | add(fmt.Sprintf("log.debug[%d]", i), "invalid component") | 481 | + | return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s) |
| 251 | } | 482 | } | ||
| 252 | } | 483 | } | ||
| 253 | - | keys := make([]string, 0, len(c.Binds)) | 484 | + | return w, h, hz, nil |
| 254 | - | for k := range c.Binds { | 485 | + | } |
| 255 | - | keys = append(keys, k) | 486 | + | |
| 487 | + | func width(s string) bool { | |||
| 488 | + | if s == "1" { | |||
| 489 | + | return true | |||
| 256 | } | 490 | } | ||
| 257 | - | sort.Strings(keys) | 491 | + | if strings.HasSuffix(s, "px") { |
| 258 | - | combos := map[string]string{} | 492 | + | n, e := strconv.Atoi(strings.TrimSuffix(s, "px")) |
| 259 | - | for _, k := range keys { | 493 | + | return e == nil && n > 0 |
| 260 | - | v := c.Binds[k] | 494 | + | } |
| 261 | - | if _, e := normalize(k); e != nil { | 495 | + | p := strings.Split(s, "/") |
| 262 | - | add("binds."+k, e.Error()) | 496 | + | if len(p) != 2 { |
| 263 | - | } else { | 497 | + | return false |
| 264 | - | n, e := resolvedCombo(k, c.Keyboard.CmdKey) | |||
| 265 | - | if e != nil { | |||
| 266 | - | add("binds."+k, e.Error()) | |||
| 267 | - | } else if old, ok := combos[n]; ok { | |||
| 268 | - | add("binds."+k, "duplicates "+old) | |||
| 269 | - | } else { | |||
| 270 | - | combos[n] = k | |||
| 271 | - | } | |||
| 272 | - | } | |||
| 273 | - | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | |||
| 274 | - | if len(strings.Fields(rest)) == 0 { | |||
| 275 | - | add("binds."+k, "spawn needs a command") | |||
| 276 | - | } | |||
| 277 | - | } else if !actions[v] { | |||
| 278 | - | add("binds."+k, `invalid action (or "spawn <command>")`) | |||
| 279 | - | } | |||
| 280 | } | 498 | } | ||
| 281 | - | return errors.Join(errs...) | 499 | + | a, e1 := strconv.Atoi(p[0]) |
| 500 | + | b, e2 := strconv.Atoi(p[1]) | |||
| 501 | + | return e1 == nil && e2 == nil && a > 0 && a <= b | |||
| 282 | } | 502 | } | ||
M internal/adapters/config/config_test.go +138 -83
| @@ -6,124 +6,179 @@ import ( | |||
| 6 | 6 | "reflect" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefertty/internal/ports" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 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 { | |
| 14 | 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 | 25 | t.Fatalf("defaults: %+v", d) | |
| 18 | 26 | } | |
| 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) | |
| 23 | 29 | } | |
| 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) | |
| 28 | 32 | } | |
| 29 | 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 | 35 | t.Fatal(DefaultPath()) | |
| 32 | 36 | } | |
| 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) | |
| 36 | 73 | } | |
| 37 | 74 | } | |
| 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"}, | |
| 56 | 95 | } | |
| 57 | 96 | 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) | |
| 62 | 101 | } | |
| 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) | |
| 66 | 104 | } | |
| 67 | 105 | }) | |
| 68 | 106 | } | |
| 69 | 107 | } | |
| 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) | |
| 74 | 113 | } | |
| 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 | + | } | |
| 78 | 140 | } | |
| 79 | 141 | if _, ok := c.Binds["Cmd+Return"]; ok { | |
| 80 | - | t.Fatal(c.Binds) | |
| 142 | + | t.Error("none did not remove the default") | |
| 81 | 143 | } | |
| 82 | - | if _, ok := c.Binds["Cmd+Shift+Left"]; ok { | |
| 83 | - | t.Fatal(c.Binds) | |
| 144 | + | if _, ok := c.Binds["Cmd+numbersign"]; ok { | |
| 145 | + | t.Error("none kept") | |
| 84 | 146 | } | |
| 85 | - | if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" { | |
| 86 | - | t.Fatal(c.Binds) | |
| 147 | + | // cmd+; then cmd+semicolon is the same key: warn and keep the last. | |
| 148 | + | if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") { | |
| 149 | + | t.Fatal(w) | |
| 87 | 150 | } | |
| 88 | 151 | } | |
| 89 | 152 | ||
| 90 | - | func TestOutputs(t *testing.T) { | |
| 91 | - | path := filepath.Join(t.TempDir(), "c.toml") | |
| 92 | - | good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n" | |
| 93 | - | if err := os.WriteFile(path, []byte(good), 0o600); err != nil { | |
| 94 | - | t.Fatal(err) | |
| 153 | + | func TestBindReservedCharacter(t *testing.T) { | |
| 154 | + | _, w := parseString(t, "bind.cmd+= = quit\n") | |
| 155 | + | if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") { | |
| 156 | + | t.Fatal(w) | |
| 95 | 157 | } | |
| 96 | - | c, err := Load(path) | |
| 97 | - | if err != nil { | |
| 98 | - | t.Fatal(err) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestBindResolvedCollision(t *testing.T) { | |
| 161 | + | c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n") | |
| 162 | + | if c.Binds["Ctrl+x"] != "quit" { | |
| 163 | + | t.Fatal(c.Binds) | |
| 99 | 164 | } | |
| 100 | - | if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off { | |
| 101 | - | t.Fatalf("%+v", c.Outputs) | |
| 165 | + | if _, ok := c.Binds["Cmd+x"]; ok { | |
| 166 | + | t.Fatal(c.Binds) | |
| 102 | 167 | } | |
| 103 | - | for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} { | |
| 104 | - | if err := os.WriteFile(path, []byte(bad), 0o600); err != nil { | |
| 105 | - | t.Fatal(err) | |
| 106 | - | } | |
| 107 | - | if _, err := Load(path); err == nil { | |
| 108 | - | t.Errorf("accepted %q", bad) | |
| 109 | - | } | |
| 168 | + | if _, ok := c.Binds["Cmd+Ctrl+y"]; ok { | |
| 169 | + | t.Fatal(c.Binds) | |
| 110 | 170 | } | |
| 111 | - | w, h, hz, err := ParseMode("3440x1440") | |
| 112 | - | if err != nil || w != 3440 || h != 1440 || hz != 0 { | |
| 113 | - | t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err) | |
| 171 | + | if len(w) != 2 || w[0].Line != 3 || w[1].Line != 4 { | |
| 172 | + | t.Fatal(w) | |
| 114 | 173 | } | |
| 115 | 174 | } | |
| 116 | 175 | ||
| 117 | - | func TestSpawnBind(t *testing.T) { | |
| 118 | - | c := Defaults() | |
| 119 | - | c.Binds = map[string]string{"Cmd+D": "spawn fuzzel --prompt >"} | |
| 120 | - | if err := Validate(c); err != nil { | |
| 121 | - | t.Fatal(err) | |
| 176 | + | func TestParseMode(t *testing.T) { | |
| 177 | + | w, h, hz, err := ParseMode("3440x1440") | |
| 178 | + | if err != nil || w != 3440 || h != 1440 || hz != 0 { | |
| 179 | + | t.Fatalf("%d %d %v %v", w, h, hz, err) | |
| 122 | 180 | } | |
| 123 | - | for _, bad := range []string{"spawn", "spawn ", "spawnfuzzel"} { | |
| 124 | - | c.Binds = map[string]string{"Cmd+D": bad} | |
| 125 | - | if Validate(c) == nil { | |
| 126 | - | t.Errorf("accepted %q", bad) | |
| 127 | - | } | |
| 181 | + | if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 { | |
| 182 | + | t.Fatal(hz, err) | |
| 128 | 183 | } | |
| 129 | 184 | } | |
| @@ -6,124 +6,179 @@ import ( | |||||
| 6 | "reflect" | 6 | "reflect" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | - | func TestDefaultsAndLoad(t *testing.T) { | 13 | + | func parseString(t *testing.T, s string) (ports.Config, []Warning) { |
| 12 | - | d := Defaults() | 14 | + | t.Helper() |
| 13 | - | if err := Validate(d); err != nil { | 15 | + | c, w, err := Parse(strings.NewReader(s)) |
| 16 | + | if err != nil { | |||
| 14 | t.Fatal(err) | 17 | t.Fatal(err) | ||
| 15 | } | 18 | } | ||
| 16 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 12 { | 19 | + | return c, w |
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestDefaultsAndLoad(t *testing.T) { | |||
| 23 | + | d := Defaults() | |||
| 24 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 12 { | |||
| 17 | t.Fatalf("defaults: %+v", d) | 25 | t.Fatalf("defaults: %+v", d) | ||
| 18 | } | 26 | } | ||
| 19 | - | path := filepath.Join(t.TempDir(), "missing") | 27 | + | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { |
| 20 | - | _, err := Load(path) | 28 | + | t.Fatal(d.Binds) |
| 21 | - | if !os.IsNotExist(err) { | |||
| 22 | - | t.Fatalf("explicit missing: %v", err) | |||
| 23 | } | 29 | } | ||
| 24 | - | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | 30 | + | if _, _, err := Load(filepath.Join(t.TempDir(), "missing")); !os.IsNotExist(err) { |
| 25 | - | got, err := LoadDefault() | 31 | + | t.Fatalf("explicit missing: %v", err) |
| 26 | - | if err != nil || !reflect.DeepEqual(got, d) { | |||
| 27 | - | t.Fatalf("missing: %v %+v", err, got) | |||
| 28 | } | 32 | } | ||
| 29 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | 33 | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 30 | - | if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config.toml") { | 34 | + | if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config") { |
| 31 | t.Fatal(DefaultPath()) | 35 | t.Fatal(DefaultPath()) | ||
| 32 | } | 36 | } | ||
| 33 | - | example, err := Load("../../../examples/config.toml") | 37 | + | got, w, err := LoadDefault() |
| 34 | - | if err != nil || !reflect.DeepEqual(example, d) { | 38 | + | if err != nil || len(w) != 0 || !reflect.DeepEqual(got, d) { |
| 35 | - | t.Fatalf("example: %v %+v", err, example) | 39 | + | t.Fatalf("missing: %v %v %+v", err, w, got) |
| 40 | + | } | |||
| 41 | + | example, w, err := Load("../../../examples/config") | |||
| 42 | + | if err != nil || len(w) != 0 || !reflect.DeepEqual(example, d) { | |||
| 43 | + | t.Fatalf("example: %v %v\n%+v\n%+v", err, w, example, d) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestParse(t *testing.T) { | |||
| 48 | + | c, w := parseString(t, ` | |||
| 49 | + | # comment | |||
| 50 | + | keyboard.layout = fr | |||
| 51 | + | keyboard.repeat-rate = 40 # inline comment | |||
| 52 | + | terminal = foot --server | |||
| 53 | + | background = #000000 | |||
| 54 | + | border.inactive = | |||
| 55 | + | layout.presets = 1/3, 1/2 ,1 | |||
| 56 | + | log.debug = core, input | |||
| 57 | + | output.DP-2 = 5120x2160@165.058 | |||
| 58 | + | output.HDMI-A-1 = off | |||
| 59 | + | output.DP-1 = preferred | |||
| 60 | + | render.direct-scanout = off | |||
| 61 | + | `) | |||
| 62 | + | if len(w) != 0 { | |||
| 63 | + | t.Fatal(w) | |||
| 64 | + | } | |||
| 65 | + | if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout { | |||
| 66 | + | t.Fatalf("%+v", c) | |||
| 67 | + | } | |||
| 68 | + | if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) { | |||
| 69 | + | t.Fatalf("%+v", c) | |||
| 70 | + | } | |||
| 71 | + | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off { | |||
| 72 | + | t.Fatalf("%+v", c.Outputs) | |||
| 36 | } | 73 | } | ||
| 37 | } | 74 | } | ||
| 38 | - | func TestValidation(t *testing.T) { | 75 | + | |
| 39 | - | cases := []struct{ name, content, want string }{ | 76 | + | func TestWarningsKeepDefaults(t *testing.T) { |
| 40 | - | {"rate", "[keyboard]\nrepeat_rate=0", "keyboard.repeat_rate"}, | 77 | + | cases := []struct{ line, want string }{ |
| 41 | - | {"delay", "[keyboard]\nrepeat_delay=0", "keyboard.repeat_delay"}, | 78 | + | {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"}, |
| 42 | - | {"cmd", "[keyboard]\ncmd_key='meta'", "keyboard.cmd_key"}, | 79 | + | {"keyboard.cmd = meta", "keyboard.cmd"}, |
| 43 | - | {"terminal", "[terminal]\ncommand=[]", "terminal.command"}, | 80 | + | {"terminal =", "terminal"}, |
| 44 | - | {"color", "[background]\ncolor='red'", "background.color"}, | 81 | + | {"background = red", "background"}, |
| 45 | - | {"gaps", "[layout]\ngaps=201", "layout.gaps"}, | 82 | + | {"layout.gaps = 201", "layout.gaps"}, |
| 46 | - | {"width", "[layout]\ndefault_column_width='2/1'", "layout.default_column_width"}, | 83 | + | {"layout.default-width = 2/1", "layout.default-width"}, |
| 47 | - | {"preset", "[layout]\npresets=['0px']", "layout.presets[0]"}, | 84 | + | {"layout.presets = 0px", "layout.presets"}, |
| 48 | - | {"level", "[log]\nlevel='trace'", "log.level"}, | 85 | + | {"log.level = trace", "log.level"}, |
| 49 | - | {"debug", "[log]\ndebug=['nope']", "log.debug[0]"}, | 86 | + | {"log.debug = nope", "log.debug"}, |
| 50 | - | {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"}, | 87 | + | {"output.DP-2 = big", "output.DP-2"}, |
| 51 | - | {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"}, | 88 | + | {"output.DP-2 = 1920x1080@0", "output.DP-2"}, |
| 52 | - | {"resolved duplicate", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+X'='quit'\n'Ctrl+X'='quit'", "duplicates"}, | 89 | + | {"nope = 1", "nope"}, |
| 53 | - | {"duplicate modifier", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+Ctrl+X'='quit'", "duplicate resolved modifier"}, | 90 | + | {"just text", "expected key = value"}, |
| 54 | - | {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"}, | 91 | + | {"bind.cmd+ = quit", "missing key"}, |
| 55 | - | {"unknown", "[layout]\nnope=1", "layout.nope"}, | 92 | + | {"bind.hyper+x = quit", "unknown modifier"}, |
| 93 | + | {"bind.cmd+x = bogus", "unknown action"}, | |||
| 94 | + | {"bind.cmd+x = spawn ", "unknown action"}, | |||
| 56 | } | 95 | } | ||
| 57 | for _, tc := range cases { | 96 | for _, tc := range cases { | ||
| 58 | - | t.Run(tc.name, func(t *testing.T) { | 97 | + | t.Run(tc.line, func(t *testing.T) { |
| 59 | - | path := filepath.Join(t.TempDir(), "config.toml") | 98 | + | c, w := parseString(t, tc.line) |
| 60 | - | if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil { | 99 | + | if len(w) != 1 || w[0].Line != 1 || !strings.Contains(w[0].Msg, tc.want) { |
| 61 | - | t.Fatal(err) | 100 | + | t.Fatalf("warnings %v, want %q", w, tc.want) |
| 62 | } | 101 | } | ||
| 63 | - | _, err := Load(path) | 102 | + | if !reflect.DeepEqual(c, Defaults()) { |
| 64 | - | if err == nil || !strings.Contains(err.Error(), tc.want) { | 103 | + | t.Fatalf("config changed: %+v", c) |
| 65 | - | t.Fatalf("got %v, want %s", err, tc.want) | |||
| 66 | } | 104 | } | ||
| 67 | }) | 105 | }) | ||
| 68 | } | 106 | } | ||
| 69 | } | 107 | } | ||
| 70 | - | func TestBindMerge(t *testing.T) { | 108 | + | |
| 71 | - | path := filepath.Join(t.TempDir(), "config.toml") | 109 | + | func TestDuplicateKeyLastWins(t *testing.T) { |
| 72 | - | if err := os.WriteFile(path, []byte("[binds]\n'Cmd+Return'='none'\n'Shift+Cmd+Left'='quit'\n'Alt+X'='spawn-terminal'\n"), 0600); err != nil { | 110 | + | c, w := parseString(t, "background = #000000\nbackground = #111111\n") |
| 73 | - | t.Fatal(err) | 111 | + | if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 { |
| 112 | + | t.Fatal(c.Background, w) | |||
| 74 | } | 113 | } | ||
| 75 | - | c, err := Load(path) | 114 | + | } |
| 76 | - | if err != nil { | 115 | + | |
| 77 | - | t.Fatal(err) | 116 | + | func TestBinds(t *testing.T) { |
| 117 | + | c, w := parseString(t, ` | |||
| 118 | + | bind.CTRL+Cmd+E = spawn foo --bar | |||
| 119 | + | bind.cmd+; = close-window | |||
| 120 | + | bind.cmd+é = quit | |||
| 121 | + | bind.cmd+semicolon = quit | |||
| 122 | + | bind.cmd+equal = spawn-terminal | |||
| 123 | + | bind.cmd+# = none | |||
| 124 | + | bind.cmd++ = quit | |||
| 125 | + | bind.alt+f5 = quit | |||
| 126 | + | bind.cmd+return = none | |||
| 127 | + | `) | |||
| 128 | + | want := map[string]string{ | |||
| 129 | + | "Cmd+Ctrl+e": "spawn foo --bar", | |||
| 130 | + | "Cmd+semicolon": "quit", | |||
| 131 | + | "Cmd+eacute": "quit", | |||
| 132 | + | "Cmd+equal": "spawn-terminal", | |||
| 133 | + | "Cmd+plus": "quit", | |||
| 134 | + | "Alt+F5": "quit", | |||
| 135 | + | } | |||
| 136 | + | for combo, action := range want { | |||
| 137 | + | if c.Binds[combo] != action { | |||
| 138 | + | t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action) | |||
| 139 | + | } | |||
| 78 | } | 140 | } | ||
| 79 | if _, ok := c.Binds["Cmd+Return"]; ok { | 141 | if _, ok := c.Binds["Cmd+Return"]; ok { | ||
| 80 | - | t.Fatal(c.Binds) | 142 | + | t.Error("none did not remove the default") |
| 81 | } | 143 | } | ||
| 82 | - | if _, ok := c.Binds["Cmd+Shift+Left"]; ok { | 144 | + | if _, ok := c.Binds["Cmd+numbersign"]; ok { |
| 83 | - | t.Fatal(c.Binds) | 145 | + | t.Error("none kept") |
| 84 | } | 146 | } | ||
| 85 | - | if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" { | 147 | + | // cmd+; then cmd+semicolon is the same key: warn and keep the last. |
| 86 | - | t.Fatal(c.Binds) | 148 | + | if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") { |
| 149 | + | t.Fatal(w) | |||
| 87 | } | 150 | } | ||
| 88 | } | 151 | } | ||
| 89 | 152 | ||||
| 90 | - | func TestOutputs(t *testing.T) { | 153 | + | func TestBindReservedCharacter(t *testing.T) { |
| 91 | - | path := filepath.Join(t.TempDir(), "c.toml") | 154 | + | _, w := parseString(t, "bind.cmd+= = quit\n") |
| 92 | - | good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n" | 155 | + | if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") { |
| 93 | - | if err := os.WriteFile(path, []byte(good), 0o600); err != nil { | 156 | + | t.Fatal(w) |
| 94 | - | t.Fatal(err) | |||
| 95 | } | 157 | } | ||
| 96 | - | c, err := Load(path) | 158 | + | } |
| 97 | - | if err != nil { | 159 | + | |
| 98 | - | t.Fatal(err) | 160 | + | func TestBindResolvedCollision(t *testing.T) { |
| 161 | + | c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n") | |||
| 162 | + | if c.Binds["Ctrl+x"] != "quit" { | |||
| 163 | + | t.Fatal(c.Binds) | |||
| 99 | } | 164 | } | ||
| 100 | - | if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off { | 165 | + | if _, ok := c.Binds["Cmd+x"]; ok { |
| 101 | - | t.Fatalf("%+v", c.Outputs) | 166 | + | t.Fatal(c.Binds) |
| 102 | } | 167 | } | ||
| 103 | - | for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} { | 168 | + | if _, ok := c.Binds["Cmd+Ctrl+y"]; ok { |
| 104 | - | if err := os.WriteFile(path, []byte(bad), 0o600); err != nil { | 169 | + | t.Fatal(c.Binds) |
| 105 | - | t.Fatal(err) | |||
| 106 | - | } | |||
| 107 | - | if _, err := Load(path); err == nil { | |||
| 108 | - | t.Errorf("accepted %q", bad) | |||
| 109 | - | } | |||
| 110 | } | 170 | } | ||
| 111 | - | w, h, hz, err := ParseMode("3440x1440") | 171 | + | if len(w) != 2 || w[0].Line != 3 || w[1].Line != 4 { |
| 112 | - | if err != nil || w != 3440 || h != 1440 || hz != 0 { | 172 | + | t.Fatal(w) |
| 113 | - | t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err) | |||
| 114 | } | 173 | } | ||
| 115 | } | 174 | } | ||
| 116 | 175 | ||||
| 117 | - | func TestSpawnBind(t *testing.T) { | 176 | + | func TestParseMode(t *testing.T) { |
| 118 | - | c := Defaults() | 177 | + | w, h, hz, err := ParseMode("3440x1440") |
| 119 | - | c.Binds = map[string]string{"Cmd+D": "spawn fuzzel --prompt >"} | 178 | + | if err != nil || w != 3440 || h != 1440 || hz != 0 { |
| 120 | - | if err := Validate(c); err != nil { | 179 | + | t.Fatalf("%d %d %v %v", w, h, hz, err) |
| 121 | - | t.Fatal(err) | |||
| 122 | } | 180 | } | ||
| 123 | - | for _, bad := range []string{"spawn", "spawn ", "spawnfuzzel"} { | 181 | + | if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 { |
| 124 | - | c.Binds = map[string]string{"Cmd+D": bad} | 182 | + | t.Fatal(hz, err) |
| 125 | - | if Validate(c) == nil { | |||
| 126 | - | t.Errorf("accepted %q", bad) | |||
| 127 | - | } | |||
| 128 | } | 183 | } | ||
| 129 | } | 184 | } | ||
A internal/adapters/config/keysyms.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | // runeKeysyms maps printable ASCII punctuation and Latin-1 characters to their | |
| 4 | + | // xkb keysym names, so binds can be written as characters (cmd+; or cmd+é). | |
| 5 | + | // Generated from xkb_utf32_to_keysym and xkb_keysym_get_name. | |
| 6 | + | var runeKeysyms = map[rune]string{ | |
| 7 | + | '!': "exclam", | |
| 8 | + | '"': "quotedbl", | |
| 9 | + | '#': "numbersign", | |
| 10 | + | '$': "dollar", | |
| 11 | + | '%': "percent", | |
| 12 | + | '&': "ampersand", | |
| 13 | + | '\'': "apostrophe", | |
| 14 | + | '(': "parenleft", | |
| 15 | + | ')': "parenright", | |
| 16 | + | '*': "asterisk", | |
| 17 | + | '+': "plus", | |
| 18 | + | ',': "comma", | |
| 19 | + | '-': "minus", | |
| 20 | + | '.': "period", | |
| 21 | + | '/': "slash", | |
| 22 | + | ':': "colon", | |
| 23 | + | ';': "semicolon", | |
| 24 | + | '<': "less", | |
| 25 | + | '=': "equal", | |
| 26 | + | '>': "greater", | |
| 27 | + | '?': "question", | |
| 28 | + | '@': "at", | |
| 29 | + | '[': "bracketleft", | |
| 30 | + | '\\': "backslash", | |
| 31 | + | ']': "bracketright", | |
| 32 | + | '^': "asciicircum", | |
| 33 | + | '_': "underscore", | |
| 34 | + | '`': "grave", | |
| 35 | + | '{': "braceleft", | |
| 36 | + | '|': "bar", | |
| 37 | + | '}': "braceright", | |
| 38 | + | '~': "asciitilde", | |
| 39 | + | '¡': "exclamdown", | |
| 40 | + | '¢': "cent", | |
| 41 | + | '£': "sterling", | |
| 42 | + | '¤': "currency", | |
| 43 | + | '¥': "yen", | |
| 44 | + | '¦': "brokenbar", | |
| 45 | + | '§': "section", | |
| 46 | + | '¨': "diaeresis", | |
| 47 | + | '©': "copyright", | |
| 48 | + | 'ª': "ordfeminine", | |
| 49 | + | '«': "guillemotleft", | |
| 50 | + | '¬': "notsign", | |
| 51 | + | '®': "registered", | |
| 52 | + | '¯': "macron", | |
| 53 | + | '°': "degree", | |
| 54 | + | '±': "plusminus", | |
| 55 | + | '²': "twosuperior", | |
| 56 | + | '³': "threesuperior", | |
| 57 | + | '´': "acute", | |
| 58 | + | 'µ': "mu", | |
| 59 | + | '¶': "paragraph", | |
| 60 | + | '·': "periodcentered", | |
| 61 | + | '¸': "cedilla", | |
| 62 | + | '¹': "onesuperior", | |
| 63 | + | 'º': "masculine", | |
| 64 | + | '»': "guillemotright", | |
| 65 | + | '¼': "onequarter", | |
| 66 | + | '½': "onehalf", | |
| 67 | + | '¾': "threequarters", | |
| 68 | + | '¿': "questiondown", | |
| 69 | + | 'À': "Agrave", | |
| 70 | + | 'Á': "Aacute", | |
| 71 | + | 'Â': "Acircumflex", | |
| 72 | + | 'Ã': "Atilde", | |
| 73 | + | 'Ä': "Adiaeresis", | |
| 74 | + | 'Å': "Aring", | |
| 75 | + | 'Æ': "AE", | |
| 76 | + | 'Ç': "Ccedilla", | |
| 77 | + | 'È': "Egrave", | |
| 78 | + | 'É': "Eacute", | |
| 79 | + | 'Ê': "Ecircumflex", | |
| 80 | + | 'Ë': "Ediaeresis", | |
| 81 | + | 'Ì': "Igrave", | |
| 82 | + | 'Í': "Iacute", | |
| 83 | + | 'Î': "Icircumflex", | |
| 84 | + | 'Ï': "Idiaeresis", | |
| 85 | + | 'Ð': "ETH", | |
| 86 | + | 'Ñ': "Ntilde", | |
| 87 | + | 'Ò': "Ograve", | |
| 88 | + | 'Ó': "Oacute", | |
| 89 | + | 'Ô': "Ocircumflex", | |
| 90 | + | 'Õ': "Otilde", | |
| 91 | + | 'Ö': "Odiaeresis", | |
| 92 | + | '×': "multiply", | |
| 93 | + | 'Ø': "Oslash", | |
| 94 | + | 'Ù': "Ugrave", | |
| 95 | + | 'Ú': "Uacute", | |
| 96 | + | 'Û': "Ucircumflex", | |
| 97 | + | 'Ü': "Udiaeresis", | |
| 98 | + | 'Ý': "Yacute", | |
| 99 | + | 'Þ': "THORN", | |
| 100 | + | 'ß': "ssharp", | |
| 101 | + | 'à': "agrave", | |
| 102 | + | 'á': "aacute", | |
| 103 | + | 'â': "acircumflex", | |
| 104 | + | 'ã': "atilde", | |
| 105 | + | 'ä': "adiaeresis", | |
| 106 | + | 'å': "aring", | |
| 107 | + | 'æ': "ae", | |
| 108 | + | 'ç': "ccedilla", | |
| 109 | + | 'è': "egrave", | |
| 110 | + | 'é': "eacute", | |
| 111 | + | 'ê': "ecircumflex", | |
| 112 | + | 'ë': "ediaeresis", | |
| 113 | + | 'ì': "igrave", | |
| 114 | + | 'í': "iacute", | |
| 115 | + | 'î': "icircumflex", | |
| 116 | + | 'ï': "idiaeresis", | |
| 117 | + | 'ð': "eth", | |
| 118 | + | 'ñ': "ntilde", | |
| 119 | + | 'ò': "ograve", | |
| 120 | + | 'ó': "oacute", | |
| 121 | + | 'ô': "ocircumflex", | |
| 122 | + | 'õ': "otilde", | |
| 123 | + | 'ö': "odiaeresis", | |
| 124 | + | '÷': "division", | |
| 125 | + | 'ø': "oslash", | |
| 126 | + | 'ù': "ugrave", | |
| 127 | + | 'ú': "uacute", | |
| 128 | + | 'û': "ucircumflex", | |
| 129 | + | 'ü': "udiaeresis", | |
| 130 | + | 'ý': "yacute", | |
| 131 | + | 'þ': "thorn", | |
| 132 | + | 'ÿ': "ydiaeresis", | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | // runeKeysyms maps printable ASCII punctuation and Latin-1 characters to their | |||
| 4 | + | // xkb keysym names, so binds can be written as characters (cmd+; or cmd+é). | |||
| 5 | + | // Generated from xkb_utf32_to_keysym and xkb_keysym_get_name. | |||
| 6 | + | var runeKeysyms = map[rune]string{ | |||
| 7 | + | '!': "exclam", | |||
| 8 | + | '"': "quotedbl", | |||
| 9 | + | '#': "numbersign", | |||
| 10 | + | '$': "dollar", | |||
| 11 | + | '%': "percent", | |||
| 12 | + | '&': "ampersand", | |||
| 13 | + | '\'': "apostrophe", | |||
| 14 | + | '(': "parenleft", | |||
| 15 | + | ')': "parenright", | |||
| 16 | + | '*': "asterisk", | |||
| 17 | + | '+': "plus", | |||
| 18 | + | ',': "comma", | |||
| 19 | + | '-': "minus", | |||
| 20 | + | '.': "period", | |||
| 21 | + | '/': "slash", | |||
| 22 | + | ':': "colon", | |||
| 23 | + | ';': "semicolon", | |||
| 24 | + | '<': "less", | |||
| 25 | + | '=': "equal", | |||
| 26 | + | '>': "greater", | |||
| 27 | + | '?': "question", | |||
| 28 | + | '@': "at", | |||
| 29 | + | '[': "bracketleft", | |||
| 30 | + | '\\': "backslash", | |||
| 31 | + | ']': "bracketright", | |||
| 32 | + | '^': "asciicircum", | |||
| 33 | + | '_': "underscore", | |||
| 34 | + | '`': "grave", | |||
| 35 | + | '{': "braceleft", | |||
| 36 | + | '|': "bar", | |||
| 37 | + | '}': "braceright", | |||
| 38 | + | '~': "asciitilde", | |||
| 39 | + | '¡': "exclamdown", | |||
| 40 | + | '¢': "cent", | |||
| 41 | + | '£': "sterling", | |||
| 42 | + | '¤': "currency", | |||
| 43 | + | '¥': "yen", | |||
| 44 | + | '¦': "brokenbar", | |||
| 45 | + | '§': "section", | |||
| 46 | + | '¨': "diaeresis", | |||
| 47 | + | '©': "copyright", | |||
| 48 | + | 'ª': "ordfeminine", | |||
| 49 | + | '«': "guillemotleft", | |||
| 50 | + | '¬': "notsign", | |||
| 51 | + | '®': "registered", | |||
| 52 | + | '¯': "macron", | |||
| 53 | + | '°': "degree", | |||
| 54 | + | '±': "plusminus", | |||
| 55 | + | '²': "twosuperior", | |||
| 56 | + | '³': "threesuperior", | |||
| 57 | + | '´': "acute", | |||
| 58 | + | 'µ': "mu", | |||
| 59 | + | '¶': "paragraph", | |||
| 60 | + | '·': "periodcentered", | |||
| 61 | + | '¸': "cedilla", | |||
| 62 | + | '¹': "onesuperior", | |||
| 63 | + | 'º': "masculine", | |||
| 64 | + | '»': "guillemotright", | |||
| 65 | + | '¼': "onequarter", | |||
| 66 | + | '½': "onehalf", | |||
| 67 | + | '¾': "threequarters", | |||
| 68 | + | '¿': "questiondown", | |||
| 69 | + | 'À': "Agrave", | |||
| 70 | + | 'Á': "Aacute", | |||
| 71 | + | 'Â': "Acircumflex", | |||
| 72 | + | 'Ã': "Atilde", | |||
| 73 | + | 'Ä': "Adiaeresis", | |||
| 74 | + | 'Å': "Aring", | |||
| 75 | + | 'Æ': "AE", | |||
| 76 | + | 'Ç': "Ccedilla", | |||
| 77 | + | 'È': "Egrave", | |||
| 78 | + | 'É': "Eacute", | |||
| 79 | + | 'Ê': "Ecircumflex", | |||
| 80 | + | 'Ë': "Ediaeresis", | |||
| 81 | + | 'Ì': "Igrave", | |||
| 82 | + | 'Í': "Iacute", | |||
| 83 | + | 'Î': "Icircumflex", | |||
| 84 | + | 'Ï': "Idiaeresis", | |||
| 85 | + | 'Ð': "ETH", | |||
| 86 | + | 'Ñ': "Ntilde", | |||
| 87 | + | 'Ò': "Ograve", | |||
| 88 | + | 'Ó': "Oacute", | |||
| 89 | + | 'Ô': "Ocircumflex", | |||
| 90 | + | 'Õ': "Otilde", | |||
| 91 | + | 'Ö': "Odiaeresis", | |||
| 92 | + | '×': "multiply", | |||
| 93 | + | 'Ø': "Oslash", | |||
| 94 | + | 'Ù': "Ugrave", | |||
| 95 | + | 'Ú': "Uacute", | |||
| 96 | + | 'Û': "Ucircumflex", | |||
| 97 | + | 'Ü': "Udiaeresis", | |||
| 98 | + | 'Ý': "Yacute", | |||
| 99 | + | 'Þ': "THORN", | |||
| 100 | + | 'ß': "ssharp", | |||
| 101 | + | 'à': "agrave", | |||
| 102 | + | 'á': "aacute", | |||
| 103 | + | 'â': "acircumflex", | |||
| 104 | + | 'ã': "atilde", | |||
| 105 | + | 'ä': "adiaeresis", | |||
| 106 | + | 'å': "aring", | |||
| 107 | + | 'æ': "ae", | |||
| 108 | + | 'ç': "ccedilla", | |||
| 109 | + | 'è': "egrave", | |||
| 110 | + | 'é': "eacute", | |||
| 111 | + | 'ê': "ecircumflex", | |||
| 112 | + | 'ë': "ediaeresis", | |||
| 113 | + | 'ì': "igrave", | |||
| 114 | + | 'í': "iacute", | |||
| 115 | + | 'î': "icircumflex", | |||
| 116 | + | 'ï': "idiaeresis", | |||
| 117 | + | 'ð': "eth", | |||
| 118 | + | 'ñ': "ntilde", | |||
| 119 | + | 'ò': "ograve", | |||
| 120 | + | 'ó': "oacute", | |||
| 121 | + | 'ô': "ocircumflex", | |||
| 122 | + | 'õ': "otilde", | |||
| 123 | + | 'ö': "odiaeresis", | |||
| 124 | + | '÷': "division", | |||
| 125 | + | 'ø': "oslash", | |||
| 126 | + | 'ù': "ugrave", | |||
| 127 | + | 'ú': "uacute", | |||
| 128 | + | 'û': "ucircumflex", | |||
| 129 | + | 'ü': "udiaeresis", | |||
| 130 | + | 'ý': "yacute", | |||
| 131 | + | 'þ': "thorn", | |||
| 132 | + | 'ÿ': "ydiaeresis", | |||
| 133 | + | } | |||
M internal/adapters/config/watch.go +49 -3
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "sort" | |
| 8 | 9 | "time" | |
| 9 | 10 | "unsafe" | |
| 10 | 11 | ||
| @@ -15,6 +16,41 @@ import ( | |||
| 15 | 16 | ||
| 16 | 17 | 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 | 54 | // Watch observes the parent directory so atomic file replacements are detected. | |
| 19 | 55 | // If the directory is absent, it retries every two seconds until it appears. | |
| 20 | 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 | 63 | const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |
| 28 | 64 | wd := -1 | |
| 29 | 65 | var pending time.Time | |
| 66 | + | last := loadRaw(path) | |
| 30 | 67 | buf := make([]byte, 4096) | |
| 31 | 68 | for { | |
| 32 | 69 | if ctx.Err() != nil { | |
| @@ -51,19 +88,28 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||
| 51 | 88 | remaining := time.Until(pending) | |
| 52 | 89 | if remaining <= 0 { | |
| 53 | 90 | pending = time.Time{} | |
| 54 | - | cfg, loadErr := Load(path) | |
| 91 | + | cfg, raw, warnings, loadErr := loadFile(path) | |
| 55 | 92 | switch { | |
| 56 | 93 | case os.IsNotExist(loadErr): | |
| 57 | 94 | log.Warn().Msg("config file removed; keeping current config") | |
| 58 | 95 | case loadErr != nil: | |
| 59 | - | log.Warn().Err(loadErr).Msg("config reload rejected") | |
| 96 | + | log.Warn().Err(loadErr).Msg("config reload failed") | |
| 60 | 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 | 107 | select { | |
| 62 | 108 | case <-ctx.Done(): | |
| 63 | 109 | return nil | |
| 64 | 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 | 114 | continue | |
| 69 | 115 | } | |
| @@ -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.Second | 17 | 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_FROM | 63 | const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | ||
| 28 | wd := -1 | 64 | wd := -1 | ||
| 29 | var pending time.Time | 65 | 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 nil | 109 | 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 | continue | 114 | continue | ||
| 69 | } | 115 | } | ||
M internal/adapters/config/watch_test.go +18 -7
| @@ -20,7 +20,7 @@ func TestWatch(t *testing.T) { | |||
| 20 | 20 | t.Fatal(err) | |
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | - | path := filepath.Join(dir, "config.toml") | |
| 23 | + | path := filepath.Join(dir, "config") | |
| 24 | 24 | ctx, cancel := context.WithCancel(context.Background()) | |
| 25 | 25 | out := make(chan ports.ConfigChanged, 8) | |
| 26 | 26 | done := make(chan error, 1) | |
| @@ -49,7 +49,7 @@ func TestWatch(t *testing.T) { | |||
| 49 | 49 | t.Fatal(err) | |
| 50 | 50 | } | |
| 51 | 51 | } | |
| 52 | - | write(path, "[background]\ncolor='#000000'\n") | |
| 52 | + | write(path, "background = #000000\n") | |
| 53 | 53 | receive := func(want string) { | |
| 54 | 54 | t.Helper() | |
| 55 | 55 | select { | |
| @@ -63,13 +63,13 @@ func TestWatch(t *testing.T) { | |||
| 63 | 63 | } | |
| 64 | 64 | receive("#000000") | |
| 65 | 65 | tmp := filepath.Join(dir, "temp") | |
| 66 | - | write(tmp, "[background]\ncolor='#ff0000'\n") | |
| 66 | + | write(tmp, "background = #ff0000\n") | |
| 67 | 67 | if err := os.Rename(tmp, path); err != nil { | |
| 68 | 68 | t.Fatal(err) | |
| 69 | 69 | } | |
| 70 | 70 | receive("#ff0000") | |
| 71 | 71 | for i := 0; i < 3; i++ { | |
| 72 | - | write(path, "[background]\ncolor='#00ff00'\n") | |
| 72 | + | write(path, "background = #00ff00\n") | |
| 73 | 73 | time.Sleep(15 * time.Millisecond) | |
| 74 | 74 | } | |
| 75 | 75 | receive("#00ff00") | |
| @@ -78,11 +78,22 @@ func TestWatch(t *testing.T) { | |||
| 78 | 78 | t.Fatal("duplicate reload") | |
| 79 | 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 | 93 | select { | |
| 83 | 94 | 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): | |
| 86 | 97 | } | |
| 87 | 98 | if err := os.Remove(path); err != nil { | |
| 88 | 99 | 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 ( | |||
| 14 | 14 | "github.com/bnema/zerowrap" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 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() }() | |
| 20 | 21 | emit := func(code uint32, down bool) error { | |
| 21 | 22 | ev := km.Key(code, down, uint32(time.Now().UnixMilli())) | |
| 22 | 23 | select { | |
| @@ -73,6 +74,10 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- | |||
| 73 | 74 | select { | |
| 74 | 75 | case <-ctx.Done(): | |
| 75 | 76 | return ctx.Err() | |
| 77 | + | case next := <-keymaps: | |
| 78 | + | km.Close() | |
| 79 | + | km = next | |
| 80 | + | log.Info().Msg("keymap replaced") | |
| 76 | 81 | case line, ok := <-script: | |
| 77 | 82 | if !ok { | |
| 78 | 83 | 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 nil | 83 | return nil | ||
M internal/adapters/headlessinput/input_test.go +2 -2
| @@ -31,7 +31,7 @@ func TestScript(t *testing.T) { | |||
| 31 | 31 | script <- tt.line | |
| 32 | 32 | close(script) | |
| 33 | 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 | 35 | t.Fatal(err) | |
| 36 | 36 | } | |
| 37 | 37 | if len(input) != len(tt.names) { | |
| @@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) { | |||
| 67 | 67 | } | |
| 68 | 68 | close(script) | |
| 69 | 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 | 71 | t.Fatal(err) | |
| 72 | 72 | } | |
| 73 | 73 | if len(input) != 5 { | |
| @@ -31,7 +31,7 @@ func TestScript(t *testing.T) { | |||||
| 31 | script <- tt.line | 31 | 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 { | |||
| 135 | 135 | ||
| 136 | 136 | // Options configures the input adapter. | |
| 137 | 137 | 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 | |
| 141 | 143 | Width, Height int | |
| 142 | 144 | Active <-chan bool | |
| 143 | 145 | Log zerowrap.Logger | |
| @@ -145,7 +147,7 @@ type Options struct { | |||
| 145 | 147 | ||
| 146 | 148 | // Run owns libinput and the keymap, converting device events into input events. | |
| 147 | 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 | 151 | if err := load(); err != nil { | |
| 150 | 152 | return err | |
| 151 | 153 | } | |
| @@ -179,6 +181,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 179 | 181 | suspend(li) | |
| 180 | 182 | } | |
| 181 | 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 | 188 | default: | |
| 183 | 189 | } | |
| 184 | 190 | 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 Seat | 138 | + | Seat Seat |
| 139 | - | SeatName string | 139 | + | SeatName string |
| 140 | - | Keymap *xkb.Keymap | 140 | + | Keymap *xkb.Keymap |
| 141 | + | // Keymaps replaces Keymap live; Run takes ownership and closes the old one. | |||
| 142 | + | Keymaps <-chan *xkb.Keymap | |||
| 141 | Width, Height int | 143 | Width, Height int | ||
| 142 | Active <-chan bool | 144 | Active <-chan bool | ||
| 143 | Log zerowrap.Logger | 145 | 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 err | 152 | return err | ||
| 151 | } | 153 | } | ||
| @@ -179,6 +181,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 179 | suspend(li) | 181 | suspend(li) | ||
| 180 | } | 182 | } | ||
| 181 | opts.Log.Info().Bool("active", on).Msg("input") | 183 | opts.Log.Info().Bool("active", on).Msg("input") | ||
| 184 | + | case km := <-opts.Keymaps: | |||
| 185 | + | opts.Keymap.Close() | |||
| 186 | + | opts.Keymap = km | |||
| 187 | + | opts.Log.Info().Msg("keymap replaced") | |||
| 182 | default: | 188 | default: | ||
| 183 | } | 189 | } | ||
| 184 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | 190 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | ||
M internal/adapters/wayland/server.go +62 -22
| @@ -116,33 +116,19 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 116 | 116 | } | |
| 117 | 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 | 118 | 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) | |
| 140 | 120 | if e != nil { | |
| 141 | 121 | cleanup() | |
| 142 | 122 | d.Close() | |
| 143 | 123 | 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 | 133 | if err = registerGlobals(d, opts, s); err != nil { | |
| 148 | 134 | s.cleanup() | |
| @@ -341,6 +327,8 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 341 | 327 | pointerFrame(p) | |
| 342 | 328 | } | |
| 343 | 329 | } | |
| 330 | + | case ports.SetKeymap: | |
| 331 | + | s.setKeymap(c) | |
| 344 | 332 | case ports.FocusWindow: | |
| 345 | 333 | if s.focused != c.ID { | |
| 346 | 334 | s.changeFocus(c.ID) | |
| @@ -427,6 +415,58 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke | |||
| 427 | 415 | return nil, nil | |
| 428 | 416 | } | |
| 429 | 417 | ||
| 418 | + | // keymapFile writes a sealed memfd holding the NUL-terminated keymap. | |
| 419 | + | func keymapFile(keymap string) (int, uint32, error) { | |
| 420 | + | if uint64(len(keymap))+1 > uint64(^uint32(0)) { | |
| 421 | + | return -1, 0, fmt.Errorf("keymap too large") | |
| 422 | + | } | |
| 423 | + | fd, err := unix.MemfdCreate("nefertty-keymap", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) | |
| 424 | + | if err != nil { | |
| 425 | + | return -1, 0, err | |
| 426 | + | } | |
| 427 | + | n, err := unix.Write(fd, append([]byte(keymap), 0)) | |
| 428 | + | if err == nil && n != len(keymap)+1 { | |
| 429 | + | err = fmt.Errorf("short keymap write") | |
| 430 | + | } | |
| 431 | + | if err == nil { | |
| 432 | + | _, err = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL) | |
| 433 | + | } | |
| 434 | + | if err != nil { | |
| 435 | + | unix.Close(fd) | |
| 436 | + | return -1, 0, err | |
| 437 | + | } | |
| 438 | + | return fd, uint32(n), nil | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // setKeymap sends a new keymap and repeat info to every keyboard. Held keys and | |
| 442 | + | // modifiers are reset: the focused client gets leave, then enter with no keys. | |
| 443 | + | func (s *Server) setKeymap(c ports.SetKeymap) { | |
| 444 | + | fd, size, err := keymapFile(c.Keymap) | |
| 445 | + | if err != nil { | |
| 446 | + | s.log.Warn().Err(err).Msg("keymap update failed") | |
| 447 | + | return | |
| 448 | + | } | |
| 449 | + | if s.keymapFD >= 0 { | |
| 450 | + | unix.Close(s.keymapFD) | |
| 451 | + | } | |
| 452 | + | s.keymapFD, s.keymapSize = fd, size | |
| 453 | + | s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay | |
| 454 | + | focused := s.focused | |
| 455 | + | s.changeFocus(0) | |
| 456 | + | s.heldKeys = nil | |
| 457 | + | s.modState = ports.ModState{} | |
| 458 | + | for _, list := range s.keyboards { | |
| 459 | + | for _, k := range list { | |
| 460 | + | k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize) | |
| 461 | + | if k.Version() >= 4 { | |
| 462 | + | k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay)) | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | s.changeFocus(focused) | |
| 467 | + | s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated") | |
| 468 | + | } | |
| 469 | + | ||
| 430 | 470 | func (s *Server) sendModifiers(k *wayland.Keyboard) { | |
| 431 | 471 | m := s.modState | |
| 432 | 472 | 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, e | 123 | return nil, e | ||
| 144 | } | 124 | } | ||
| 145 | - | s.cleanup = func() { unix.Close(fd); cleanup() } | 125 | + | s.keymapFD, s.keymapSize = fd, size |
| 126 | + | } | |||
| 127 | + | s.cleanup = func() { | |||
| 128 | + | if s.keymapFD >= 0 { | |||
| 129 | + | unix.Close(s.keymapFD) | |||
| 130 | + | } | |||
| 131 | + | cleanup() | |||
| 146 | } | 132 | } | ||
| 147 | if err = registerGlobals(d, opts, s); err != nil { | 133 | if err = registerGlobals(d, opts, s); err != nil { | ||
| 148 | s.cleanup() | 134 | s.cleanup() | ||
| @@ -341,6 +327,8 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 341 | pointerFrame(p) | 327 | pointerFrame(p) | ||
| 342 | } | 328 | } | ||
| 343 | } | 329 | } | ||
| 330 | + | case ports.SetKeymap: | |||
| 331 | + | s.setKeymap(c) | |||
| 344 | case ports.FocusWindow: | 332 | case ports.FocusWindow: | ||
| 345 | if s.focused != c.ID { | 333 | if s.focused != c.ID { | ||
| 346 | s.changeFocus(c.ID) | 334 | s.changeFocus(c.ID) | ||
| @@ -427,6 +415,58 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke | |||||
| 427 | return nil, nil | 415 | return nil, nil | ||
| 428 | } | 416 | } | ||
| 429 | 417 | ||||
| 418 | + | // keymapFile writes a sealed memfd holding the NUL-terminated keymap. | |||
| 419 | + | func keymapFile(keymap string) (int, uint32, error) { | |||
| 420 | + | if uint64(len(keymap))+1 > uint64(^uint32(0)) { | |||
| 421 | + | return -1, 0, fmt.Errorf("keymap too large") | |||
| 422 | + | } | |||
| 423 | + | fd, err := unix.MemfdCreate("nefertty-keymap", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) | |||
| 424 | + | if err != nil { | |||
| 425 | + | return -1, 0, err | |||
| 426 | + | } | |||
| 427 | + | n, err := unix.Write(fd, append([]byte(keymap), 0)) | |||
| 428 | + | if err == nil && n != len(keymap)+1 { | |||
| 429 | + | err = fmt.Errorf("short keymap write") | |||
| 430 | + | } | |||
| 431 | + | if err == nil { | |||
| 432 | + | _, err = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL) | |||
| 433 | + | } | |||
| 434 | + | if err != nil { | |||
| 435 | + | unix.Close(fd) | |||
| 436 | + | return -1, 0, err | |||
| 437 | + | } | |||
| 438 | + | return fd, uint32(n), nil | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // setKeymap sends a new keymap and repeat info to every keyboard. Held keys and | |||
| 442 | + | // modifiers are reset: the focused client gets leave, then enter with no keys. | |||
| 443 | + | func (s *Server) setKeymap(c ports.SetKeymap) { | |||
| 444 | + | fd, size, err := keymapFile(c.Keymap) | |||
| 445 | + | if err != nil { | |||
| 446 | + | s.log.Warn().Err(err).Msg("keymap update failed") | |||
| 447 | + | return | |||
| 448 | + | } | |||
| 449 | + | if s.keymapFD >= 0 { | |||
| 450 | + | unix.Close(s.keymapFD) | |||
| 451 | + | } | |||
| 452 | + | s.keymapFD, s.keymapSize = fd, size | |||
| 453 | + | s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay | |||
| 454 | + | focused := s.focused | |||
| 455 | + | s.changeFocus(0) | |||
| 456 | + | s.heldKeys = nil | |||
| 457 | + | s.modState = ports.ModState{} | |||
| 458 | + | for _, list := range s.keyboards { | |||
| 459 | + | for _, k := range list { | |||
| 460 | + | k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize) | |||
| 461 | + | if k.Version() >= 4 { | |||
| 462 | + | k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay)) | |||
| 463 | + | } | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | s.changeFocus(focused) | |||
| 467 | + | s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated") | |||
| 468 | + | } | |||
| 469 | + | ||||
| 430 | func (s *Server) sendModifiers(k *wayland.Keyboard) { | 470 | func (s *Server) sendModifiers(k *wayland.Keyboard) { | ||
| 431 | m := s.modState | 471 | m := s.modState | ||
| 432 | k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group) | 472 | k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group) | ||
M internal/app/app.go +60 -3
| @@ -23,6 +23,7 @@ import ( | |||
| 23 | 23 | "github.com/bnema/nefertty/internal/core" | |
| 24 | 24 | "github.com/bnema/nefertty/internal/logging" | |
| 25 | 25 | "github.com/bnema/nefertty/internal/ports" | |
| 26 | + | "github.com/bnema/zerowrap" | |
| 26 | 27 | ) | |
| 27 | 28 | ||
| 28 | 29 | type Options struct { | |
| @@ -109,20 +110,27 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 109 | 110 | if path == "" { | |
| 110 | 111 | path = config.DefaultPath() | |
| 111 | 112 | } | |
| 113 | + | watched := make(chan ports.ConfigChanged, 8) | |
| 114 | + | keymaps := make(chan *xkb.Keymap, 1) | |
| 112 | 115 | go func() { | |
| 113 | 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 | 124 | script := make(chan string) | |
| 117 | 125 | go func() { | |
| 118 | 126 | defer workers.Done() | |
| 119 | 127 | if hw != nil { | |
| 120 | 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 | 131 | 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 | 135 | if opts.Script != nil { | |
| 128 | 136 | go func() { <-ctx.Done(); _ = opts.Script.Close() }() | |
| @@ -198,6 +206,55 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 198 | 206 | return result | |
| 199 | 207 | } | |
| 200 | 208 | ||
| 209 | + | // relayConfig forwards reloads to core. When keyboard settings change it builds the | |
| 210 | + | // new keymap, hands it to the input goroutine and sends it to clients, so layout and | |
| 211 | + | // repeat changes apply live. A keymap that fails to build keeps the previous one. | |
| 212 | + | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | |
| 213 | + | kb := cur.Keyboard | |
| 214 | + | for { | |
| 215 | + | var ev ports.ConfigChanged | |
| 216 | + | select { | |
| 217 | + | case <-ctx.Done(): | |
| 218 | + | return | |
| 219 | + | case ev = <-in: | |
| 220 | + | } | |
| 221 | + | next := ev.Config.Keyboard | |
| 222 | + | if next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options || next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay { | |
| 223 | + | km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options}) | |
| 224 | + | if err != nil { | |
| 225 | + | log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous keyboard") | |
| 226 | + | ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options | |
| 227 | + | } else { | |
| 228 | + | text := km.String() | |
| 229 | + | // Drop a keymap the input goroutine has not taken yet; the newest wins. | |
| 230 | + | select { | |
| 231 | + | case old := <-keymaps: | |
| 232 | + | old.Close() | |
| 233 | + | default: | |
| 234 | + | } | |
| 235 | + | select { | |
| 236 | + | case keymaps <- km: | |
| 237 | + | case <-ctx.Done(): | |
| 238 | + | km.Close() | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | select { | |
| 242 | + | case commands <- ports.SetKeymap{Keymap: text, RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}: | |
| 243 | + | case <-ctx.Done(): | |
| 244 | + | return | |
| 245 | + | } | |
| 246 | + | kb = next | |
| 247 | + | log.Info().Str("layout", next.Layout).Str("variant", next.Variant).Str("options", next.Options).Msg("keyboard reloaded") | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | select { | |
| 251 | + | case out <- ev: | |
| 252 | + | case <-ctx.Done(): | |
| 253 | + | return | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 201 | 258 | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { | |
| 202 | 259 | log := logging.For(ctx, "core") | |
| 203 | 260 | 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 | return | 131 | return | ||
| 124 | } | 132 | } | ||
| 125 | - | _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input")) | 133 | + | _ = headlessinput.Run(ctx, km, keymaps, script, input, logging.For(ctx, "input")) |
| 126 | }() | 134 | }() | ||
| 127 | if opts.Script != nil { | 135 | if opts.Script != nil { | ||
| 128 | go func() { <-ctx.Done(); _ = opts.Script.Close() }() | 136 | go func() { <-ctx.Done(); _ = opts.Script.Close() }() | ||
| @@ -198,6 +206,55 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 198 | return result | 206 | return result | ||
| 199 | } | 207 | } | ||
| 200 | 208 | ||||
| 209 | + | // relayConfig forwards reloads to core. When keyboard settings change it builds the | |||
| 210 | + | // new keymap, hands it to the input goroutine and sends it to clients, so layout and | |||
| 211 | + | // repeat changes apply live. A keymap that fails to build keeps the previous one. | |||
| 212 | + | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | |||
| 213 | + | kb := cur.Keyboard | |||
| 214 | + | for { | |||
| 215 | + | var ev ports.ConfigChanged | |||
| 216 | + | select { | |||
| 217 | + | case <-ctx.Done(): | |||
| 218 | + | return | |||
| 219 | + | case ev = <-in: | |||
| 220 | + | } | |||
| 221 | + | next := ev.Config.Keyboard | |||
| 222 | + | if next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options || next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay { | |||
| 223 | + | km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options}) | |||
| 224 | + | if err != nil { | |||
| 225 | + | log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous keyboard") | |||
| 226 | + | ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options | |||
| 227 | + | } else { | |||
| 228 | + | text := km.String() | |||
| 229 | + | // Drop a keymap the input goroutine has not taken yet; the newest wins. | |||
| 230 | + | select { | |||
| 231 | + | case old := <-keymaps: | |||
| 232 | + | old.Close() | |||
| 233 | + | default: | |||
| 234 | + | } | |||
| 235 | + | select { | |||
| 236 | + | case keymaps <- km: | |||
| 237 | + | case <-ctx.Done(): | |||
| 238 | + | km.Close() | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | select { | |||
| 242 | + | case commands <- ports.SetKeymap{Keymap: text, RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}: | |||
| 243 | + | case <-ctx.Done(): | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | kb = next | |||
| 247 | + | log.Info().Str("layout", next.Layout).Str("variant", next.Variant).Str("options", next.Options).Msg("keyboard reloaded") | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | select { | |||
| 251 | + | case out <- ev: | |||
| 252 | + | case <-ctx.Done(): | |||
| 253 | + | return | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 201 | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { | 258 | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { | ||
| 202 | log := logging.For(ctx, "core") | 259 | log := logging.For(ctx, "core") | ||
| 203 | configLog := logging.For(ctx, "config") | 260 | configLog := logging.For(ctx, "config") | ||
M internal/app/app_test.go +44 -4
| @@ -13,17 +13,19 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 16 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| 17 | + | "github.com/bnema/nefertty/internal/logging" | |
| 16 | 18 | "github.com/bnema/nefertty/internal/ports" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | func TestHeadlessConfigReload(t *testing.T) { | |
| 20 | 22 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 21 | 23 | 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 { | |
| 24 | 26 | t.Fatal(err) | |
| 25 | 27 | } | |
| 26 | - | cfg, err := config.Load(path) | |
| 28 | + | cfg, _, err := config.Load(path) | |
| 27 | 29 | if err != nil { | |
| 28 | 30 | t.Fatal(err) | |
| 29 | 31 | } | |
| @@ -73,7 +75,7 @@ func TestHeadlessConfigReload(t *testing.T) { | |||
| 73 | 75 | if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) { | |
| 74 | 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 | 79 | t.Fatal(err) | |
| 78 | 80 | } | |
| 79 | 81 | if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) { | |
| @@ -258,3 +260,41 @@ func TestBlockedScriptShutdown(t *testing.T) { | |||
| 258 | 260 | t.Fatal("blocked script prevented shutdown") | |
| 259 | 261 | } | |
| 260 | 262 | } | |
| 263 | + | ||
| 264 | + | func TestRelayConfigKeyboard(t *testing.T) { | |
| 265 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 266 | + | defer cancel() | |
| 267 | + | cur := config.Defaults() | |
| 268 | + | in := make(chan ports.ConfigChanged, 1) | |
| 269 | + | out := make(chan ports.ConfigChanged, 1) | |
| 270 | + | keymaps := make(chan *xkb.Keymap, 1) | |
| 271 | + | commands := make(chan ports.ClientCommand, 1) | |
| 272 | + | go relayConfig(ctx, cur, in, out, keymaps, commands, logging.For(ctx, "config")) | |
| 273 | + | ||
| 274 | + | next := config.Defaults() | |
| 275 | + | next.Background.Color = "#000000" | |
| 276 | + | in <- ports.ConfigChanged{Config: next} | |
| 277 | + | if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 { | |
| 278 | + | t.Fatalf("non-keyboard change rebuilt keymap: %+v", got) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40 | |
| 282 | + | in <- ports.ConfigChanged{Config: next} | |
| 283 | + | <-out | |
| 284 | + | km := <-keymaps | |
| 285 | + | defer km.Close() | |
| 286 | + | cmd := (<-commands).(ports.SetKeymap) | |
| 287 | + | if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "fr") { | |
| 288 | + | t.Fatalf("keymap command: rate=%d", cmd.RepeatRate) | |
| 289 | + | } | |
| 290 | + | if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 { | |
| 291 | + | t.Fatalf("fr keymap: a at %d", code) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | bad := next | |
| 295 | + | bad.Keyboard.Layout = "no-such-layout" | |
| 296 | + | in <- ports.ConfigChanged{Config: bad} | |
| 297 | + | if got := <-out; got.Config.Keyboard.Layout != "fr" || len(keymaps) != 0 { | |
| 298 | + | t.Fatalf("bad layout applied: %+v", got.Config.Keyboard) | |
| 299 | + | } | |
| 300 | + | } | |
| @@ -13,17 +13,19 @@ import ( | |||||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/nefertty/internal/adapters/config" | 15 | "github.com/bnema/nefertty/internal/adapters/config" | ||
| 16 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |||
| 17 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 16 | "github.com/bnema/nefertty/internal/ports" | 18 | "github.com/bnema/nefertty/internal/ports" | ||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 19 | func TestHeadlessConfigReload(t *testing.T) { | 21 | func TestHeadlessConfigReload(t *testing.T) { | ||
| 20 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | 22 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | ||
| 21 | dir := t.TempDir() | 23 | dir := t.TempDir() | ||
| 22 | - | path := filepath.Join(dir, "config.toml") | 24 | + | path := filepath.Join(dir, "config") |
| 23 | - | if err := os.WriteFile(path, []byte("[background]\ncolor='#000000'\n"), 0600); err != nil { | 25 | + | if err := os.WriteFile(path, []byte("background = #000000\n"), 0600); err != nil { |
| 24 | t.Fatal(err) | 26 | t.Fatal(err) | ||
| 25 | } | 27 | } | ||
| 26 | - | cfg, err := config.Load(path) | 28 | + | cfg, _, err := config.Load(path) |
| 27 | if err != nil { | 29 | if err != nil { | ||
| 28 | t.Fatal(err) | 30 | t.Fatal(err) | ||
| 29 | } | 31 | } | ||
| @@ -73,7 +75,7 @@ func TestHeadlessConfigReload(t *testing.T) { | |||||
| 73 | if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) { | 75 | if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) { | ||
| 74 | t.Fatal("first black frame missing") | 76 | t.Fatal("first black frame missing") | ||
| 75 | } | 77 | } | ||
| 76 | - | if err := os.WriteFile(path, []byte("[background]\ncolor='#ff0000'\n"), 0600); err != nil { | 78 | + | if err := os.WriteFile(path, []byte("background = #ff0000\nkeyboard.layout = fr\n"), 0600); err != nil { |
| 77 | t.Fatal(err) | 79 | t.Fatal(err) | ||
| 78 | } | 80 | } | ||
| 79 | if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) { | 81 | if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) { | ||
| @@ -258,3 +260,41 @@ func TestBlockedScriptShutdown(t *testing.T) { | |||||
| 258 | t.Fatal("blocked script prevented shutdown") | 260 | t.Fatal("blocked script prevented shutdown") | ||
| 259 | } | 261 | } | ||
| 260 | } | 262 | } | ||
| 263 | + | ||||
| 264 | + | func TestRelayConfigKeyboard(t *testing.T) { | |||
| 265 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 266 | + | defer cancel() | |||
| 267 | + | cur := config.Defaults() | |||
| 268 | + | in := make(chan ports.ConfigChanged, 1) | |||
| 269 | + | out := make(chan ports.ConfigChanged, 1) | |||
| 270 | + | keymaps := make(chan *xkb.Keymap, 1) | |||
| 271 | + | commands := make(chan ports.ClientCommand, 1) | |||
| 272 | + | go relayConfig(ctx, cur, in, out, keymaps, commands, logging.For(ctx, "config")) | |||
| 273 | + | ||||
| 274 | + | next := config.Defaults() | |||
| 275 | + | next.Background.Color = "#000000" | |||
| 276 | + | in <- ports.ConfigChanged{Config: next} | |||
| 277 | + | if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 { | |||
| 278 | + | t.Fatalf("non-keyboard change rebuilt keymap: %+v", got) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40 | |||
| 282 | + | in <- ports.ConfigChanged{Config: next} | |||
| 283 | + | <-out | |||
| 284 | + | km := <-keymaps | |||
| 285 | + | defer km.Close() | |||
| 286 | + | cmd := (<-commands).(ports.SetKeymap) | |||
| 287 | + | if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "fr") { | |||
| 288 | + | t.Fatalf("keymap command: rate=%d", cmd.RepeatRate) | |||
| 289 | + | } | |||
| 290 | + | if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 { | |||
| 291 | + | t.Fatalf("fr keymap: a at %d", code) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | bad := next | |||
| 295 | + | bad.Keyboard.Layout = "no-such-layout" | |||
| 296 | + | in <- ports.ConfigChanged{Config: bad} | |||
| 297 | + | if got := <-out; got.Config.Keyboard.Layout != "fr" || len(keymaps) != 0 { | |||
| 298 | + | t.Fatalf("bad layout applied: %+v", got.Config.Keyboard) | |||
| 299 | + | } | |||
| 300 | + | } | |||
M internal/ports/config.go +15 -15
| @@ -11,35 +11,35 @@ type OutputConfig struct { | |||
| 11 | 11 | 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 | 15 | type Config struct { | |
| 16 | 16 | Keyboard struct { | |
| 17 | 17 | 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 } | |
| 24 | 24 | // Border is drawn inside the window edge; Width 0 disables it. | |
| 25 | 25 | Border struct { | |
| 26 | 26 | Width int | |
| 27 | 27 | Active string | |
| 28 | 28 | Inactive string | |
| 29 | - | } `toml:"border"` | |
| 29 | + | } | |
| 30 | 30 | Layout struct { | |
| 31 | 31 | Gaps int | |
| 32 | - | DefaultColumnWidth string `toml:"default_column_width"` | |
| 32 | + | DefaultColumnWidth string | |
| 33 | 33 | Presets []string | |
| 34 | - | } `toml:"layout"` | |
| 34 | + | } | |
| 35 | 35 | // 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 | |
| 38 | 38 | Render struct { | |
| 39 | - | DirectScanout bool `toml:"direct_scanout"` | |
| 40 | - | } `toml:"render"` | |
| 39 | + | DirectScanout bool | |
| 40 | + | } | |
| 41 | 41 | Log struct { | |
| 42 | 42 | Level string | |
| 43 | 43 | Debug []string | |
| 44 | - | } `toml:"log"` | |
| 44 | + | } | |
| 45 | 45 | } | |
| @@ -11,35 +11,35 @@ type OutputConfig struct { | |||||
| 11 | Off bool | 11 | 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 string | 17 | 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 int | 26 | Width int | ||
| 27 | Active string | 27 | Active string | ||
| 28 | Inactive string | 28 | Inactive string | ||
| 29 | - | } `toml:"border"` | 29 | + | } |
| 30 | Layout struct { | 30 | Layout struct { | ||
| 31 | Gaps int | 31 | Gaps int | ||
| 32 | - | DefaultColumnWidth string `toml:"default_column_width"` | 32 | + | DefaultColumnWidth string |
| 33 | Presets []string | 33 | 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 string | 42 | Level string | ||
| 43 | Debug []string | 43 | Debug []string | ||
| 44 | - | } `toml:"log"` | 44 | + | } |
| 45 | } | 45 | } | ||
M internal/ports/messages.go +8 -0
| @@ -181,6 +181,14 @@ type ForwardKey struct { | |||
| 181 | 181 | ||
| 182 | 182 | func (ForwardKey) clientCommand() {} | |
| 183 | 183 | ||
| 184 | + | // SetKeymap carries app → wayland a new xkb keymap (text format) and repeat info. | |
| 185 | + | type SetKeymap struct { | |
| 186 | + | Keymap string | |
| 187 | + | RepeatRate, RepeatDelay int | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (SetKeymap) clientCommand() {} | |
| 191 | + | ||
| 184 | 192 | // SpawnRequest carries core → launcher process arguments. | |
| 185 | 193 | type SpawnRequest struct{ Argv []string } | |
| 186 | 194 | ||
| @@ -181,6 +181,14 @@ type ForwardKey struct { | |||||
| 181 | 181 | ||||
| 182 | func (ForwardKey) clientCommand() {} | 182 | func (ForwardKey) clientCommand() {} | ||
| 183 | 183 | ||||
| 184 | + | // SetKeymap carries app → wayland a new xkb keymap (text format) and repeat info. | |||
| 185 | + | type SetKeymap struct { | |||
| 186 | + | Keymap string | |||
| 187 | + | RepeatRate, RepeatDelay int | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (SetKeymap) clientCommand() {} | |||
| 191 | + | ||||
| 184 | // SpawnRequest carries core → launcher process arguments. | 192 | // SpawnRequest carries core → launcher process arguments. | ||
| 185 | type SpawnRequest struct{ Argv []string } | 193 | type SpawnRequest struct{ Argv []string } | ||
| 186 | 194 | ||||