detached f7e6e81

feat(config): split startup and spawn commands on ;

commit
f7e6e81ebd59456f8dab2997474696ad7608c3bd
author
brice · · 2026-10-07 06:19 UTC
parent
c6b6c21
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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`).
1818 | `keyboard.cmd` | `super` | Modifier that `cmd` means in binds: `super`, `alt` or `ctrl` |
1919 | `terminal` | `$TERMINAL`, or `foot` if unset/empty | Command for automatic requests and `spawn-terminal`; whitespace-separated |
2020 | `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 |
2222 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
2323 | `background` | `#111111` | Solid background color |
2424 | `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
186186 bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
187187 ```
188188 - `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).
190190
191191 ### Cascade
192192
@@ -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 ### Cascade191 ### Cascade
192 192
M docs/desktop.md +8 -1
@@ -12,7 +12,14 @@ startup = waybar
1212 startup = wl-paste --watch cliphist store
1313 ```
1414
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:
1623
1724 ```sh
1825 #!/bin/sh
@@ -12,7 +12,14 @@ startup = waybar
12 startup = wl-paste --watch cliphist store12 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 ```sh24 ```sh
18 #!/bin/sh25 #!/bin/sh
M examples/config +6 -5
@@ -17,10 +17,11 @@ keyboard.cmd = super
1717 # terminal = foot
1818 terminal.auto-open = first
1919
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.
2223 # Programs get WAYLAND_DISPLAY and NEFERWL_STATE.
23-# startup = waybar
24+# startup = waybar; mako
2425 # startup = wl-paste --watch cliphist store
2526
2627 background = #111111
@@ -132,8 +133,8 @@ log.debug =
132133 # keys right of 0 (- and = on US).
133134 # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space
134135 # 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).
137138 bind.cmd+return = spawn-terminal
138139 bind.cmd+left = focus-column-left
139140 bind.cmd+right = focus-column-right
@@ -17,10 +17,11 @@ keyboard.cmd = super
17 # terminal = foot17 # terminal = foot
18 terminal.auto-open = first18 terminal.auto-open = first
19 19
20-# Commands run once when the session starts, one line each. Like spawn, a20+# 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 = waybar24+# startup = waybar; mako
24 # startup = wl-paste --watch cliphist store25 # startup = wl-paste --watch cliphist store
25 26
26 background = #11111127 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 space134 # 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 shell136+# `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-terminal138 bind.cmd+return = spawn-terminal
138 bind.cmd+left = focus-column-left139 bind.cmd+left = focus-column-left
139 bind.cmd+right = focus-column-right140 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 {
631631 }
632632 c.Keyboard.CmdKey = v
633633 case "startup":
634- argv := strings.Fields(v)
635- if len(argv) == 0 {
634+ commands := ports.SplitCommands(v)
635+ if len(commands) == 0 {
636636 return fmt.Errorf("must not be empty")
637637 }
638- c.Startup = append(c.Startup, argv)
638+ c.Startup = append(c.Startup, commands...)
639639 case "terminal":
640640 argv := strings.Fields(v)
641641 if len(argv) == 0 {
@@ -1068,7 +1068,7 @@ func stripComment(value string) string {
10681068
10691069 func checkAction(v string) error {
10701070 if rest, ok := strings.CutPrefix(v, "spawn "); ok {
1071- if len(strings.Fields(rest)) == 0 {
1071+ if len(ports.SplitCommands(rest)) == 0 {
10721072 return fmt.Errorf("spawn needs a command")
10731073 }
10741074 return nil
@@ -631,11 +631,11 @@ func set(c *ports.Config, key, v string) error {
631 }631 }
632 c.Keyboard.CmdKey = v632 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 nil1074 return nil
M internal/adapters/config/config_test.go +4 -2
@@ -236,12 +236,12 @@ render.direct-scanout = off
236236 render.tearing = off
237237 render.vrr = off
238238 startup = wl-paste --watch cliphist store
239-startup = wl-paste --primary --watch cliphist store
239+startup = wl-paste --primary --watch cliphist store; waybar ;mako;
240240 `)
241241 if len(w) != 0 {
242242 t.Fatal(w)
243243 }
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"}}) {
245245 t.Fatalf("startup: %q", c.Startup)
246246 }
247247 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) {
288288 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
289289 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
290290 {"bind.cmd+x = spawn ", "unknown action"},
291+ {"bind.cmd+x = spawn ; ;", "spawn needs a command"},
292+ {"startup = ;", "must not be empty"},
291293 }
292294 for _, tc := range cases {
293295 t.Run(tc.line, func(t *testing.T) {
@@ -236,12 +236,12 @@ render.direct-scanout = off
236 render.tearing = off236 render.tearing = off
237 render.vrr = off237 render.vrr = off
238 startup = wl-paste --watch cliphist store238 startup = wl-paste --watch cliphist store
239-startup = wl-paste --primary --watch cliphist store239+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
33 import (
44 "strconv"
55 "strings"
6+
7+ "github.com/bnema/neferwl/internal/ports"
68 )
79
810 type Action string
@@ -159,22 +161,25 @@ func NamedArg(a Action) (string, bool) {
159161 return name, ok && name != "" && !strings.ContainsAny(name, " \t")
160162 }
161163
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.
164166 const spawnPrefix = "spawn "
165167
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) {
168170 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
171176 }
172177
173178 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
178183 }
179184
180185 // 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) {
395400
396401 // Apply runs a bind action that only touches this workspace.
397402 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}
400405 }
401406 if w.freeAction(a) {
402407 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 string10 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) > 0172+ 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 bool179+ Spawn bool
175- Argv []string // command to run; nil means the configured terminal180+ Commands [][]string // commands to run; nil means the configured terminal
176- Close WindowID181+ Close WindowID
177- Quit bool182+ Quit bool
178 }183 }
179 184
180 // applyAction runs a bind action. Monitor actions change the focused185 // 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 {
221221 if _, ok := binds[b]; ok {
222222 return fmt.Errorf("duplicate bind %q", combo)
223223 }
224- if _, ok := SpawnArgv(Action(a)); ok {
224+ if _, ok := SpawnCommands(Action(a)); ok {
225225 binds[b] = Action(a)
226226 continue
227227 }
@@ -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 continue226 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 {
284284 return ErrQuit
285285 }
286286 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...)}
290290 }
291291 // Explicit actions are suppressed on a full launcher queue;
292292 // 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+ }
294298 if c.inputEpochChanged() {
295299 return errSecurityChanged
296300 }
@@ -284,13 +284,17 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
284 return ErrQuit284 return ErrQuit
285 }285 }
286 if effect.Spawn {286 if effect.Spawn {
287- argv := effect.Argv287+ 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 errSecurityChanged299 return errSecurityChanged
296 }300 }
M internal/core/layout_test.go +1 -1
@@ -183,7 +183,7 @@ func TestPresetsAndActions(t *testing.T) {
183183 for _, tc := range []struct {
184184 a Action
185185 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{}}} {
187187 if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) {
188188 t.Errorf("%s: %+v", tc.a, got)
189189 }
@@ -183,7 +183,7 @@ func TestPresetsAndActions(t *testing.T) {
183 for _, tc := range []struct {183 for _, tc := range []struct {
184 a Action184 a Action
185 want Effect185 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) {
439439 }
440440 }
441441
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+
442469 // A fullscreen slot window (fixed overflow) keeps its slot: coming back to
443470 // dev spawns no duplicate.
444471 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 to469 // 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
22
33 import (
44 "image"
5+ "strings"
56 "time"
67 )
78
@@ -111,6 +112,7 @@ type Config struct {
111112 // disables X11.
112113 Xwayland string
113114 // Startup are commands run once when the session starts, in order.
115+ // One startup line may hold several, split by SplitCommands.
114116 Startup [][]string
115117 Background struct{ Color string }
116118 Floating struct{ Dim float64 }
@@ -231,6 +233,22 @@ type WorkspaceConfig struct {
231233 LayoutRules
232234 }
233235
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+
234252 // SlotConfig reserves column N of a workspace for the window of one command.
235253 // neferwl spawns the command at startup; the window it opens goes to the
236254 // 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 string113 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 [][]string116 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 LayoutRules233 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 the253 // 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 @@
11 package ports
22
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+}
421
522 func TestLayoutRulesOver(t *testing.T) {
623 defaults := LayoutRules{MaxColumns: 2, Overflow: "scroll"}
@@ -1,6 +1,23 @@
1 package ports1 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"}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close