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