feat(config): split startup and spawn commands on ;
- commit
- f7e6e81ebd59456f8dab2997474696ad7608c3bd
- author
- brice · · 2026-10-07 06:19 UTC
- parent
- c6b6c21
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +114 -33
M docs/config.md +2 -2
| @@ -18,7 +18,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 18 | 18 | | `keyboard.cmd` | `super` | Modifier that `cmd` means in binds: `super`, `alt` or `ctrl` | | |
| 19 | 19 | | `terminal` | `$TERMINAL`, or `foot` if unset/empty | Command for automatic requests and `spawn-terminal`; whitespace-separated | | |
| 20 | 20 | | `terminal.auto-open` | `first` | `first`: one request for the initial numbered workspace per session; `all`: visible empty workspaces without slots, with retry protection; `off`: none. `--no-terminal` disables automatic requests for this run only | | |
| 21 | - | | `startup` | none | Command run once at session start; repeat the key for more | | |
| 21 | + | | `startup` | none | Commands run once at session start; separate several with `;` or repeat the key | | |
| 22 | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | |
| 23 | 23 | | `background` | `#111111` | Solid background color | | |
| 24 | 24 | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | | |
| @@ -186,7 +186,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 186 | 186 | bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | |
| 187 | 187 | ``` | |
| 188 | 188 | - `none` removes a default bind. | |
| 189 | - | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | |
| 189 | + | - `spawn <command>` runs a program without a shell; `spawn a; b` runs several. See [Running commands](desktop.md#running-commands). | |
| 190 | 190 | ||
| 191 | 191 | ### Cascade | |
| 192 | 192 | ||
| @@ -18,7 +18,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 18 | | `keyboard.cmd` | `super` | Modifier that `cmd` means in binds: `super`, `alt` or `ctrl` | | 18 | | `keyboard.cmd` | `super` | Modifier that `cmd` means in binds: `super`, `alt` or `ctrl` | | ||
| 19 | | `terminal` | `$TERMINAL`, or `foot` if unset/empty | Command for automatic requests and `spawn-terminal`; whitespace-separated | | 19 | | `terminal` | `$TERMINAL`, or `foot` if unset/empty | Command for automatic requests and `spawn-terminal`; whitespace-separated | | ||
| 20 | | `terminal.auto-open` | `first` | `first`: one request for the initial numbered workspace per session; `all`: visible empty workspaces without slots, with retry protection; `off`: none. `--no-terminal` disables automatic requests for this run only | | 20 | | `terminal.auto-open` | `first` | `first`: one request for the initial numbered workspace per session; `all`: visible empty workspaces without slots, with retry protection; `off`: none. `--no-terminal` disables automatic requests for this run only | | ||
| 21 | - | | `startup` | none | Command run once at session start; repeat the key for more | | 21 | + | | `startup` | none | Commands run once at session start; separate several with `;` or repeat the key | |
| 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | ||
| 23 | | `background` | `#111111` | Solid background color | | 23 | | `background` | `#111111` | Solid background color | | ||
| 24 | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | | 24 | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | | ||
| @@ -186,7 +186,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 186 | bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | 186 | bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | ||
| 187 | ``` | 187 | ``` | ||
| 188 | - `none` removes a default bind. | 188 | - `none` removes a default bind. | ||
| 189 | - | - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands). | 189 | + | - `spawn <command>` runs a program without a shell; `spawn a; b` runs several. See [Running commands](desktop.md#running-commands). |
| 190 | 190 | ||||
| 191 | ### Cascade | 191 | ### Cascade | ||
| 192 | 192 | ||||
M docs/desktop.md +8 -1
| @@ -12,7 +12,14 @@ startup = waybar | |||
| 12 | 12 | startup = wl-paste --watch cliphist store | |
| 13 | 13 | ``` | |
| 14 | 14 | ||
| 15 | - | Pipes (`|`), `&&`, redirections, variables (`$HOME`), `~` and quotes have no special meaning: they reach the program as plain arguments. For those, write a script and run it: | |
| 15 | + | `spawn` binds and `startup` lines can hold several commands separated by `;`. They all start right away, in order; one does not wait for the previous one to finish. Empty commands are ignored. `terminal` takes a single command. | |
| 16 | + | ||
| 17 | + | ```text | |
| 18 | + | startup = waybar; mako; wl-paste --watch cliphist store | |
| 19 | + | bind.cmd+shift+s = spawn grim; notify-send Screenshot saved | |
| 20 | + | ``` | |
| 21 | + | ||
| 22 | + | Apart from `;`, pipes (`|`), `&&`, redirections, variables (`$HOME`), `~` and quotes have no special meaning: they reach the program as plain arguments. For those, write a script and run it: | |
| 16 | 23 | ||
| 17 | 24 | ```sh | |
| 18 | 25 | #!/bin/sh | |
| @@ -12,7 +12,14 @@ startup = waybar | |||||
| 12 | startup = wl-paste --watch cliphist store | 12 | startup = wl-paste --watch cliphist store | ||
| 13 | ``` | 13 | ``` | ||
| 14 | 14 | ||||
| 15 | - | Pipes (`|`), `&&`, redirections, variables (`$HOME`), `~` and quotes have no special meaning: they reach the program as plain arguments. For those, write a script and run it: | 15 | + | `spawn` binds and `startup` lines can hold several commands separated by `;`. They all start right away, in order; one does not wait for the previous one to finish. Empty commands are ignored. `terminal` takes a single command. |
| 16 | + | ||||
| 17 | + | ```text | |||
| 18 | + | startup = waybar; mako; wl-paste --watch cliphist store | |||
| 19 | + | bind.cmd+shift+s = spawn grim; notify-send Screenshot saved | |||
| 20 | + | ``` | |||
| 21 | + | ||||
| 22 | + | Apart from `;`, pipes (`|`), `&&`, redirections, variables (`$HOME`), `~` and quotes have no special meaning: they reach the program as plain arguments. For those, write a script and run it: | |||
| 16 | 23 | ||||
| 17 | ```sh | 24 | ```sh | ||
| 18 | #!/bin/sh | 25 | #!/bin/sh | ||
M examples/config +6 -5
| @@ -17,10 +17,11 @@ keyboard.cmd = super | |||
| 17 | 17 | # terminal = foot | |
| 18 | 18 | terminal.auto-open = first | |
| 19 | 19 | ||
| 20 | - | # Commands run once when the session starts, one line each. Like spawn, a | |
| 21 | - | # command runs without a shell: see "Running commands" in the README. | |
| 20 | + | # Commands run once when the session starts; separate several with `;` or | |
| 21 | + | # use one line each. Like spawn, a command runs without a shell: see | |
| 22 | + | # "Running commands" in the README. | |
| 22 | 23 | # Programs get WAYLAND_DISPLAY and NEFERWL_STATE. | |
| 23 | - | # startup = waybar | |
| 24 | + | # startup = waybar; mako | |
| 24 | 25 | # startup = wl-paste --watch cliphist store | |
| 25 | 26 | ||
| 26 | 27 | background = #111111 | |
| @@ -132,8 +133,8 @@ log.debug = | |||
| 132 | 133 | # keys right of 0 (- and = on US). | |
| 133 | 134 | # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space | |
| 134 | 135 | # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default. | |
| 135 | - | # `spawn <command>` runs a program; arguments split on spaces, no shell | |
| 136 | - | # (pipes and variables go in a script: see "Running commands" in the README). | |
| 136 | + | # `spawn <command>` runs a program; arguments split on spaces, `;` starts | |
| 137 | + | # another command, no shell (pipes and variables go in a script: see "Running commands" in the README). | |
| 137 | 138 | bind.cmd+return = spawn-terminal | |
| 138 | 139 | bind.cmd+left = focus-column-left | |
| 139 | 140 | bind.cmd+right = focus-column-right | |
| @@ -17,10 +17,11 @@ keyboard.cmd = super | |||||
| 17 | # terminal = foot | 17 | # terminal = foot | ||
| 18 | terminal.auto-open = first | 18 | terminal.auto-open = first | ||
| 19 | 19 | ||||
| 20 | - | # Commands run once when the session starts, one line each. Like spawn, a | 20 | + | # Commands run once when the session starts; separate several with `;` or |
| 21 | - | # command runs without a shell: see "Running commands" in the README. | 21 | + | # use one line each. Like spawn, a command runs without a shell: see |
| 22 | + | # "Running commands" in the README. | |||
| 22 | # Programs get WAYLAND_DISPLAY and NEFERWL_STATE. | 23 | # Programs get WAYLAND_DISPLAY and NEFERWL_STATE. | ||
| 23 | - | # startup = waybar | 24 | + | # startup = waybar; mako |
| 24 | # startup = wl-paste --watch cliphist store | 25 | # startup = wl-paste --watch cliphist store | ||
| 25 | 26 | ||||
| 26 | background = #111111 | 27 | background = #111111 | ||
| @@ -132,8 +133,8 @@ log.debug = | |||||
| 132 | # keys right of 0 (- and = on US). | 133 | # keys right of 0 (- and = on US). | ||
| 133 | # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space | 134 | # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space | ||
| 134 | # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default. | 135 | # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default. | ||
| 135 | - | # `spawn <command>` runs a program; arguments split on spaces, no shell | 136 | + | # `spawn <command>` runs a program; arguments split on spaces, `;` starts |
| 136 | - | # (pipes and variables go in a script: see "Running commands" in the README). | 137 | + | # another command, no shell (pipes and variables go in a script: see "Running commands" in the README). |
| 137 | bind.cmd+return = spawn-terminal | 138 | bind.cmd+return = spawn-terminal | ||
| 138 | bind.cmd+left = focus-column-left | 139 | bind.cmd+left = focus-column-left | ||
| 139 | bind.cmd+right = focus-column-right | 140 | bind.cmd+right = focus-column-right | ||
M internal/adapters/config/config.go +4 -4
| @@ -631,11 +631,11 @@ func set(c *ports.Config, key, v string) error { | |||
| 631 | 631 | } | |
| 632 | 632 | c.Keyboard.CmdKey = v | |
| 633 | 633 | case "startup": | |
| 634 | - | argv := strings.Fields(v) | |
| 635 | - | if len(argv) == 0 { | |
| 634 | + | commands := ports.SplitCommands(v) | |
| 635 | + | if len(commands) == 0 { | |
| 636 | 636 | return fmt.Errorf("must not be empty") | |
| 637 | 637 | } | |
| 638 | - | c.Startup = append(c.Startup, argv) | |
| 638 | + | c.Startup = append(c.Startup, commands...) | |
| 639 | 639 | case "terminal": | |
| 640 | 640 | argv := strings.Fields(v) | |
| 641 | 641 | if len(argv) == 0 { | |
| @@ -1068,7 +1068,7 @@ func stripComment(value string) string { | |||
| 1068 | 1068 | ||
| 1069 | 1069 | func checkAction(v string) error { | |
| 1070 | 1070 | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | |
| 1071 | - | if len(strings.Fields(rest)) == 0 { | |
| 1071 | + | if len(ports.SplitCommands(rest)) == 0 { | |
| 1072 | 1072 | return fmt.Errorf("spawn needs a command") | |
| 1073 | 1073 | } | |
| 1074 | 1074 | return nil | |
| @@ -631,11 +631,11 @@ func set(c *ports.Config, key, v string) error { | |||||
| 631 | } | 631 | } | ||
| 632 | c.Keyboard.CmdKey = v | 632 | c.Keyboard.CmdKey = v | ||
| 633 | case "startup": | 633 | case "startup": | ||
| 634 | - | argv := strings.Fields(v) | 634 | + | commands := ports.SplitCommands(v) |
| 635 | - | if len(argv) == 0 { | 635 | + | if len(commands) == 0 { |
| 636 | return fmt.Errorf("must not be empty") | 636 | return fmt.Errorf("must not be empty") | ||
| 637 | } | 637 | } | ||
| 638 | - | c.Startup = append(c.Startup, argv) | 638 | + | c.Startup = append(c.Startup, commands...) |
| 639 | case "terminal": | 639 | case "terminal": | ||
| 640 | argv := strings.Fields(v) | 640 | argv := strings.Fields(v) | ||
| 641 | if len(argv) == 0 { | 641 | if len(argv) == 0 { | ||
| @@ -1068,7 +1068,7 @@ func stripComment(value string) string { | |||||
| 1068 | 1068 | ||||
| 1069 | func checkAction(v string) error { | 1069 | func checkAction(v string) error { | ||
| 1070 | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | 1070 | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | ||
| 1071 | - | if len(strings.Fields(rest)) == 0 { | 1071 | + | if len(ports.SplitCommands(rest)) == 0 { |
| 1072 | return fmt.Errorf("spawn needs a command") | 1072 | return fmt.Errorf("spawn needs a command") | ||
| 1073 | } | 1073 | } | ||
| 1074 | return nil | 1074 | return nil | ||
M internal/adapters/config/config_test.go +4 -2
| @@ -236,12 +236,12 @@ render.direct-scanout = off | |||
| 236 | 236 | render.tearing = off | |
| 237 | 237 | render.vrr = off | |
| 238 | 238 | startup = wl-paste --watch cliphist store | |
| 239 | - | startup = wl-paste --primary --watch cliphist store | |
| 239 | + | startup = wl-paste --primary --watch cliphist store; waybar ;mako; | |
| 240 | 240 | `) | |
| 241 | 241 | if len(w) != 0 { | |
| 242 | 242 | t.Fatal(w) | |
| 243 | 243 | } | |
| 244 | - | if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}}) { | |
| 244 | + | if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}, {"waybar"}, {"mako"}}) { | |
| 245 | 245 | t.Fatalf("startup: %q", c.Startup) | |
| 246 | 246 | } | |
| 247 | 247 | if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR { | |
| @@ -288,6 +288,8 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 288 | 288 | {"bind.cmd+x = set-window-height -101%", "set-window-height needs"}, | |
| 289 | 289 | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | |
| 290 | 290 | {"bind.cmd+x = spawn ", "unknown action"}, | |
| 291 | + | {"bind.cmd+x = spawn ; ;", "spawn needs a command"}, | |
| 292 | + | {"startup = ;", "must not be empty"}, | |
| 291 | 293 | } | |
| 292 | 294 | for _, tc := range cases { | |
| 293 | 295 | t.Run(tc.line, func(t *testing.T) { | |
| @@ -236,12 +236,12 @@ render.direct-scanout = off | |||||
| 236 | render.tearing = off | 236 | render.tearing = off | ||
| 237 | render.vrr = off | 237 | render.vrr = off | ||
| 238 | startup = wl-paste --watch cliphist store | 238 | startup = wl-paste --watch cliphist store | ||
| 239 | - | startup = wl-paste --primary --watch cliphist store | 239 | + | startup = wl-paste --primary --watch cliphist store; waybar ;mako; |
| 240 | `) | 240 | `) | ||
| 241 | if len(w) != 0 { | 241 | if len(w) != 0 { | ||
| 242 | t.Fatal(w) | 242 | t.Fatal(w) | ||
| 243 | } | 243 | } | ||
| 244 | - | if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}}) { | 244 | + | if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}, {"waybar"}, {"mako"}}) { |
| 245 | t.Fatalf("startup: %q", c.Startup) | 245 | t.Fatalf("startup: %q", c.Startup) | ||
| 246 | } | 246 | } | ||
| 247 | if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR { | 247 | if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR { | ||
| @@ -288,6 +288,8 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||||
| 288 | {"bind.cmd+x = set-window-height -101%", "set-window-height needs"}, | 288 | {"bind.cmd+x = set-window-height -101%", "set-window-height needs"}, | ||
| 289 | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | 289 | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | ||
| 290 | {"bind.cmd+x = spawn ", "unknown action"}, | 290 | {"bind.cmd+x = spawn ", "unknown action"}, | ||
| 291 | + | {"bind.cmd+x = spawn ; ;", "spawn needs a command"}, | |||
| 292 | + | {"startup = ;", "must not be empty"}, | |||
| 291 | } | 293 | } | ||
| 292 | for _, tc := range cases { | 294 | for _, tc := range cases { | ||
| 293 | t.Run(tc.line, func(t *testing.T) { | 295 | t.Run(tc.line, func(t *testing.T) { | ||
M internal/core/action.go +17 -12
| @@ -3,6 +3,8 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | type Action string | |
| @@ -159,22 +161,25 @@ func NamedArg(a Action) (string, bool) { | |||
| 159 | 161 | return name, ok && name != "" && !strings.ContainsAny(name, " \t") | |
| 160 | 162 | } | |
| 161 | 163 | ||
| 162 | - | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | |
| 163 | - | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | |
| 164 | + | // spawnPrefix starts a bind action that runs commands: "spawn fuzzel --flag". | |
| 165 | + | // They are split by ports.SplitCommands; there is no shell. | |
| 164 | 166 | const spawnPrefix = "spawn " | |
| 165 | 167 | ||
| 166 | - | // SpawnArgv returns the command of a "spawn <cmd>" action. | |
| 167 | - | func SpawnArgv(a Action) ([]string, bool) { | |
| 168 | + | // SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action. | |
| 169 | + | func SpawnCommands(a Action) ([][]string, bool) { | |
| 168 | 170 | rest, ok := strings.CutPrefix(string(a), spawnPrefix) | |
| 169 | - | argv := strings.Fields(rest) | |
| 170 | - | return argv, ok && len(argv) > 0 | |
| 171 | + | if !ok { | |
| 172 | + | return nil, false | |
| 173 | + | } | |
| 174 | + | commands := ports.SplitCommands(rest) | |
| 175 | + | return commands, len(commands) > 0 | |
| 171 | 176 | } | |
| 172 | 177 | ||
| 173 | 178 | type Effect struct { | |
| 174 | - | Spawn bool | |
| 175 | - | Argv []string // command to run; nil means the configured terminal | |
| 176 | - | Close WindowID | |
| 177 | - | Quit bool | |
| 179 | + | Spawn bool | |
| 180 | + | Commands [][]string // commands to run; nil means the configured terminal | |
| 181 | + | Close WindowID | |
| 182 | + | Quit bool | |
| 178 | 183 | } | |
| 179 | 184 | ||
| 180 | 185 | // applyAction runs a bind action. Monitor actions change the focused | |
| @@ -395,8 +400,8 @@ func (m *Monitor) moveDest(a Action) (i int, column, ok bool) { | |||
| 395 | 400 | ||
| 396 | 401 | // Apply runs a bind action that only touches this workspace. | |
| 397 | 402 | func (w *Workspace) Apply(a Action) Effect { | |
| 398 | - | if argv, ok := SpawnArgv(a); ok { | |
| 399 | - | return Effect{Spawn: true, Argv: argv} | |
| 403 | + | if commands, ok := SpawnCommands(a); ok { | |
| 404 | + | return Effect{Spawn: true, Commands: commands} | |
| 400 | 405 | } | |
| 401 | 406 | if w.freeAction(a) { | |
| 402 | 407 | return Effect{} | |
| @@ -3,6 +3,8 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strconv" | 4 | "strconv" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | type Action string | 10 | type Action string | ||
| @@ -159,22 +161,25 @@ func NamedArg(a Action) (string, bool) { | |||||
| 159 | return name, ok && name != "" && !strings.ContainsAny(name, " \t") | 161 | return name, ok && name != "" && !strings.ContainsAny(name, " \t") | ||
| 160 | } | 162 | } | ||
| 161 | 163 | ||||
| 162 | - | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | 164 | + | // spawnPrefix starts a bind action that runs commands: "spawn fuzzel --flag". |
| 163 | - | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | 165 | + | // They are split by ports.SplitCommands; there is no shell. |
| 164 | const spawnPrefix = "spawn " | 166 | const spawnPrefix = "spawn " | ||
| 165 | 167 | ||||
| 166 | - | // SpawnArgv returns the command of a "spawn <cmd>" action. | 168 | + | // SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action. |
| 167 | - | func SpawnArgv(a Action) ([]string, bool) { | 169 | + | func SpawnCommands(a Action) ([][]string, bool) { |
| 168 | rest, ok := strings.CutPrefix(string(a), spawnPrefix) | 170 | rest, ok := strings.CutPrefix(string(a), spawnPrefix) | ||
| 169 | - | argv := strings.Fields(rest) | 171 | + | if !ok { |
| 170 | - | return argv, ok && len(argv) > 0 | 172 | + | return nil, false |
| 173 | + | } | |||
| 174 | + | commands := ports.SplitCommands(rest) | |||
| 175 | + | return commands, len(commands) > 0 | |||
| 171 | } | 176 | } | ||
| 172 | 177 | ||||
| 173 | type Effect struct { | 178 | type Effect struct { | ||
| 174 | - | Spawn bool | 179 | + | Spawn bool |
| 175 | - | Argv []string // command to run; nil means the configured terminal | 180 | + | Commands [][]string // commands to run; nil means the configured terminal |
| 176 | - | Close WindowID | 181 | + | Close WindowID |
| 177 | - | Quit bool | 182 | + | Quit bool |
| 178 | } | 183 | } | ||
| 179 | 184 | ||||
| 180 | // applyAction runs a bind action. Monitor actions change the focused | 185 | // applyAction runs a bind action. Monitor actions change the focused | ||
| @@ -395,8 +400,8 @@ func (m *Monitor) moveDest(a Action) (i int, column, ok bool) { | |||||
| 395 | 400 | ||||
| 396 | // Apply runs a bind action that only touches this workspace. | 401 | // Apply runs a bind action that only touches this workspace. | ||
| 397 | func (w *Workspace) Apply(a Action) Effect { | 402 | func (w *Workspace) Apply(a Action) Effect { | ||
| 398 | - | if argv, ok := SpawnArgv(a); ok { | 403 | + | if commands, ok := SpawnCommands(a); ok { |
| 399 | - | return Effect{Spawn: true, Argv: argv} | 404 | + | return Effect{Spawn: true, Commands: commands} |
| 400 | } | 405 | } | ||
| 401 | if w.freeAction(a) { | 406 | if w.freeAction(a) { | ||
| 402 | return Effect{} | 407 | return Effect{} | ||
M internal/core/core.go +1 -1
| @@ -221,7 +221,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 221 | 221 | if _, ok := binds[b]; ok { | |
| 222 | 222 | return fmt.Errorf("duplicate bind %q", combo) | |
| 223 | 223 | } | |
| 224 | - | if _, ok := SpawnArgv(Action(a)); ok { | |
| 224 | + | if _, ok := SpawnCommands(Action(a)); ok { | |
| 225 | 225 | binds[b] = Action(a) | |
| 226 | 226 | continue | |
| 227 | 227 | } | |
| @@ -221,7 +221,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 221 | if _, ok := binds[b]; ok { | 221 | if _, ok := binds[b]; ok { | ||
| 222 | return fmt.Errorf("duplicate bind %q", combo) | 222 | return fmt.Errorf("duplicate bind %q", combo) | ||
| 223 | } | 223 | } | ||
| 224 | - | if _, ok := SpawnArgv(Action(a)); ok { | 224 | + | if _, ok := SpawnCommands(Action(a)); ok { |
| 225 | binds[b] = Action(a) | 225 | binds[b] = Action(a) | ||
| 226 | continue | 226 | continue | ||
| 227 | } | 227 | } | ||
M internal/core/input.go +8 -4
| @@ -284,13 +284,17 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 284 | 284 | return ErrQuit | |
| 285 | 285 | } | |
| 286 | 286 | if effect.Spawn { | |
| 287 | - | argv := effect.Argv | |
| 288 | - | if argv == nil { | |
| 289 | - | argv = append([]string(nil), c.cfg.Terminal.Command...) | |
| 287 | + | commands := effect.Commands | |
| 288 | + | if commands == nil { | |
| 289 | + | commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)} | |
| 290 | 290 | } | |
| 291 | 291 | // Explicit actions are suppressed on a full launcher queue; | |
| 292 | 292 | // never keep the input owner waiting across an epoch change. | |
| 293 | - | c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) | |
| 293 | + | for _, argv := range commands { | |
| 294 | + | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) { | |
| 295 | + | break | |
| 296 | + | } | |
| 297 | + | } | |
| 294 | 298 | if c.inputEpochChanged() { | |
| 295 | 299 | return errSecurityChanged | |
| 296 | 300 | } | |
| @@ -284,13 +284,17 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 284 | return ErrQuit | 284 | return ErrQuit | ||
| 285 | } | 285 | } | ||
| 286 | if effect.Spawn { | 286 | if effect.Spawn { | ||
| 287 | - | argv := effect.Argv | 287 | + | commands := effect.Commands |
| 288 | - | if argv == nil { | 288 | + | if commands == nil { |
| 289 | - | argv = append([]string(nil), c.cfg.Terminal.Command...) | 289 | + | commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)} |
| 290 | } | 290 | } | ||
| 291 | // Explicit actions are suppressed on a full launcher queue; | 291 | // Explicit actions are suppressed on a full launcher queue; | ||
| 292 | // never keep the input owner waiting across an epoch change. | 292 | // never keep the input owner waiting across an epoch change. | ||
| 293 | - | c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) | 293 | + | for _, argv := range commands { |
| 294 | + | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) { | |||
| 295 | + | break | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 294 | if c.inputEpochChanged() { | 298 | if c.inputEpochChanged() { | ||
| 295 | return errSecurityChanged | 299 | return errSecurityChanged | ||
| 296 | } | 300 | } | ||
M internal/core/layout_test.go +1 -1
| @@ -183,7 +183,7 @@ func TestPresetsAndActions(t *testing.T) { | |||
| 183 | 183 | for _, tc := range []struct { | |
| 184 | 184 | a Action | |
| 185 | 185 | want Effect | |
| 186 | - | }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Argv: []string{"fuzzel", "--prompt", "x"}}}, {"spawn ", Effect{}}} { | |
| 186 | + | }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Commands: [][]string{{"fuzzel", "--prompt", "x"}}}}, {"spawn a -x; ; b", Effect{Spawn: true, Commands: [][]string{{"a", "-x"}, {"b"}}}}, {"spawn ", Effect{}}, {"spawn ; ", Effect{}}} { | |
| 187 | 187 | if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) { | |
| 188 | 188 | t.Errorf("%s: %+v", tc.a, got) | |
| 189 | 189 | } | |
| @@ -183,7 +183,7 @@ func TestPresetsAndActions(t *testing.T) { | |||||
| 183 | for _, tc := range []struct { | 183 | for _, tc := range []struct { | ||
| 184 | a Action | 184 | a Action | ||
| 185 | want Effect | 185 | want Effect | ||
| 186 | - | }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Argv: []string{"fuzzel", "--prompt", "x"}}}, {"spawn ", Effect{}}} { | 186 | + | }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Commands: [][]string{{"fuzzel", "--prompt", "x"}}}}, {"spawn a -x; ; b", Effect{Spawn: true, Commands: [][]string{{"a", "-x"}, {"b"}}}}, {"spawn ", Effect{}}, {"spawn ; ", Effect{}}} { |
| 187 | if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) { | 187 | if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) { | ||
| 188 | t.Errorf("%s: %+v", tc.a, got) | 188 | t.Errorf("%s: %+v", tc.a, got) | ||
| 189 | } | 189 | } | ||
M internal/core/slots_test.go +27 -0
| @@ -439,6 +439,33 @@ func TestStartupCommands(t *testing.T) { | |||
| 439 | 439 | } | |
| 440 | 440 | } | |
| 441 | 441 | ||
| 442 | + | // A spawn bind with several commands sends each one, in order. | |
| 443 | + | func TestSpawnBindSeveralCommands(t *testing.T) { | |
| 444 | + | cfg := altCmdDefaults() | |
| 445 | + | cfg.Terminal.AutoOpen = "off" | |
| 446 | + | cfg.Binds["Alt+p"] = "spawn grim -g x; notify-send done" | |
| 447 | + | input := make(chan ports.InputEvent, 4) | |
| 448 | + | output := make(chan ports.OutputEvent, 1) | |
| 449 | + | scenes := make(chan []ports.Scene, 1) | |
| 450 | + | spawn := make(chan ports.SpawnRequest, 4) | |
| 451 | + | c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: input, Output: output, Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: scenes, ConfigErrors: make(chan error, 1)}, core.Options{}) | |
| 452 | + | if err != nil { | |
| 453 | + | t.Fatal(err) | |
| 454 | + | } | |
| 455 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 456 | + | done := make(chan error, 1) | |
| 457 | + | go func() { done <- c.Run(ctx) }() | |
| 458 | + | defer func() { cancel(); <-done }() | |
| 459 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 460 | + | scene(t, scenes) | |
| 461 | + | input <- ports.KeyEvent{Keysym: "p", Mods: ports.ModAlt, Pressed: true} | |
| 462 | + | for _, want := range [][]string{{"grim", "-g", "x"}, {"notify-send", "done"}} { | |
| 463 | + | if got := receive(t, spawn); !slices.Equal(got.Argv, want) { | |
| 464 | + | t.Fatalf("spawned %q, want %q", got.Argv, want) | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 442 | 469 | // A fullscreen slot window (fixed overflow) keeps its slot: coming back to | |
| 443 | 470 | // dev spawns no duplicate. | |
| 444 | 471 | func TestSlotKeptWhileFullscreen(t *testing.T) { | |
| @@ -439,6 +439,33 @@ func TestStartupCommands(t *testing.T) { | |||||
| 439 | } | 439 | } | ||
| 440 | } | 440 | } | ||
| 441 | 441 | ||||
| 442 | + | // A spawn bind with several commands sends each one, in order. | |||
| 443 | + | func TestSpawnBindSeveralCommands(t *testing.T) { | |||
| 444 | + | cfg := altCmdDefaults() | |||
| 445 | + | cfg.Terminal.AutoOpen = "off" | |||
| 446 | + | cfg.Binds["Alt+p"] = "spawn grim -g x; notify-send done" | |||
| 447 | + | input := make(chan ports.InputEvent, 4) | |||
| 448 | + | output := make(chan ports.OutputEvent, 1) | |||
| 449 | + | scenes := make(chan []ports.Scene, 1) | |||
| 450 | + | spawn := make(chan ports.SpawnRequest, 4) | |||
| 451 | + | c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: input, Output: output, Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: scenes, ConfigErrors: make(chan error, 1)}, core.Options{}) | |||
| 452 | + | if err != nil { | |||
| 453 | + | t.Fatal(err) | |||
| 454 | + | } | |||
| 455 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 456 | + | done := make(chan error, 1) | |||
| 457 | + | go func() { done <- c.Run(ctx) }() | |||
| 458 | + | defer func() { cancel(); <-done }() | |||
| 459 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 460 | + | scene(t, scenes) | |||
| 461 | + | input <- ports.KeyEvent{Keysym: "p", Mods: ports.ModAlt, Pressed: true} | |||
| 462 | + | for _, want := range [][]string{{"grim", "-g", "x"}, {"notify-send", "done"}} { | |||
| 463 | + | if got := receive(t, spawn); !slices.Equal(got.Argv, want) { | |||
| 464 | + | t.Fatalf("spawned %q, want %q", got.Argv, want) | |||
| 465 | + | } | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 468 | + | ||||
| 442 | // A fullscreen slot window (fixed overflow) keeps its slot: coming back to | 469 | // A fullscreen slot window (fixed overflow) keeps its slot: coming back to | ||
| 443 | // dev spawns no duplicate. | 470 | // dev spawns no duplicate. | ||
| 444 | func TestSlotKeptWhileFullscreen(t *testing.T) { | 471 | func TestSlotKeptWhileFullscreen(t *testing.T) { | ||
M internal/ports/config.go +18 -0
| @@ -2,6 +2,7 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | + | "strings" | |
| 5 | 6 | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -111,6 +112,7 @@ type Config struct { | |||
| 111 | 112 | // disables X11. | |
| 112 | 113 | Xwayland string | |
| 113 | 114 | // Startup are commands run once when the session starts, in order. | |
| 115 | + | // One startup line may hold several, split by SplitCommands. | |
| 114 | 116 | Startup [][]string | |
| 115 | 117 | Background struct{ Color string } | |
| 116 | 118 | Floating struct{ Dim float64 } | |
| @@ -231,6 +233,22 @@ type WorkspaceConfig struct { | |||
| 231 | 233 | LayoutRules | |
| 232 | 234 | } | |
| 233 | 235 | ||
| 236 | + | // CommandSeparator separates commands in config values that run programs | |
| 237 | + | // (startup, spawn binds): "waybar; mako" starts both, without waiting. | |
| 238 | + | const CommandSeparator = ";" | |
| 239 | + | ||
| 240 | + | // SplitCommands splits v on CommandSeparator, then each command on | |
| 241 | + | // whitespace. Empty commands are skipped; no shell is involved. | |
| 242 | + | func SplitCommands(v string) [][]string { | |
| 243 | + | var commands [][]string | |
| 244 | + | for part := range strings.SplitSeq(v, CommandSeparator) { | |
| 245 | + | if argv := strings.Fields(part); len(argv) > 0 { | |
| 246 | + | commands = append(commands, argv) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | return commands | |
| 250 | + | } | |
| 251 | + | ||
| 234 | 252 | // SlotConfig reserves column N of a workspace for the window of one command. | |
| 235 | 253 | // neferwl spawns the command at startup; the window it opens goes to the | |
| 236 | 254 | // slot. Width is a layout width (fraction, percentage or pixels). | |
| @@ -2,6 +2,7 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "image" | 4 | "image" | ||
| 5 | + | "strings" | |||
| 5 | "time" | 6 | "time" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| @@ -111,6 +112,7 @@ type Config struct { | |||||
| 111 | // disables X11. | 112 | // disables X11. | ||
| 112 | Xwayland string | 113 | Xwayland string | ||
| 113 | // Startup are commands run once when the session starts, in order. | 114 | // Startup are commands run once when the session starts, in order. | ||
| 115 | + | // One startup line may hold several, split by SplitCommands. | |||
| 114 | Startup [][]string | 116 | Startup [][]string | ||
| 115 | Background struct{ Color string } | 117 | Background struct{ Color string } | ||
| 116 | Floating struct{ Dim float64 } | 118 | Floating struct{ Dim float64 } | ||
| @@ -231,6 +233,22 @@ type WorkspaceConfig struct { | |||||
| 231 | LayoutRules | 233 | LayoutRules | ||
| 232 | } | 234 | } | ||
| 233 | 235 | ||||
| 236 | + | // CommandSeparator separates commands in config values that run programs | |||
| 237 | + | // (startup, spawn binds): "waybar; mako" starts both, without waiting. | |||
| 238 | + | const CommandSeparator = ";" | |||
| 239 | + | ||||
| 240 | + | // SplitCommands splits v on CommandSeparator, then each command on | |||
| 241 | + | // whitespace. Empty commands are skipped; no shell is involved. | |||
| 242 | + | func SplitCommands(v string) [][]string { | |||
| 243 | + | var commands [][]string | |||
| 244 | + | for part := range strings.SplitSeq(v, CommandSeparator) { | |||
| 245 | + | if argv := strings.Fields(part); len(argv) > 0 { | |||
| 246 | + | commands = append(commands, argv) | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | return commands | |||
| 250 | + | } | |||
| 251 | + | ||||
| 234 | // SlotConfig reserves column N of a workspace for the window of one command. | 252 | // SlotConfig reserves column N of a workspace for the window of one command. | ||
| 235 | // neferwl spawns the command at startup; the window it opens goes to the | 253 | // neferwl spawns the command at startup; the window it opens goes to the | ||
| 236 | // slot. Width is a layout width (fraction, percentage or pixels). | 254 | // slot. Width is a layout width (fraction, percentage or pixels). | ||
M internal/ports/config_test.go +18 -1
| @@ -1,6 +1,23 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestSplitCommands(t *testing.T) { | |
| 9 | + | for v, want := range map[string][][]string{ | |
| 10 | + | "": nil, | |
| 11 | + | " ; ;": nil, | |
| 12 | + | "waybar": {{"waybar"}}, | |
| 13 | + | "a --x=1,2;b c ; ;d": {{"a", "--x=1,2"}, {"b", "c"}, {"d"}}, | |
| 14 | + | "wl-paste --watch c s ;": {{"wl-paste", "--watch", "c", "s"}}, | |
| 15 | + | } { | |
| 16 | + | if got := SplitCommands(v); !reflect.DeepEqual(got, want) { | |
| 17 | + | t.Errorf("%q: %q, want %q", v, got, want) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | } | |
| 4 | 21 | ||
| 5 | 22 | func TestLayoutRulesOver(t *testing.T) { | |
| 6 | 23 | defaults := LayoutRules{MaxColumns: 2, Overflow: "scroll"} | |
| @@ -1,6 +1,23 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestSplitCommands(t *testing.T) { | |||
| 9 | + | for v, want := range map[string][][]string{ | |||
| 10 | + | "": nil, | |||
| 11 | + | " ; ;": nil, | |||
| 12 | + | "waybar": {{"waybar"}}, | |||
| 13 | + | "a --x=1,2;b c ; ;d": {{"a", "--x=1,2"}, {"b", "c"}, {"d"}}, | |||
| 14 | + | "wl-paste --watch c s ;": {{"wl-paste", "--watch", "c", "s"}}, | |||
| 15 | + | } { | |||
| 16 | + | if got := SplitCommands(v); !reflect.DeepEqual(got, want) { | |||
| 17 | + | t.Errorf("%q: %q, want %q", v, got, want) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 4 | 21 | ||||
| 5 | func TestLayoutRulesOver(t *testing.T) { | 22 | func TestLayoutRulesOver(t *testing.T) { | ||
| 6 | defaults := LayoutRules{MaxColumns: 2, Overflow: "scroll"} | 23 | defaults := LayoutRules{MaxColumns: 2, Overflow: "scroll"} | ||