feat(binds): spawn <command> action with a Ctrl+Cmd+space fuzzel default
- commit
- 039f0bad0d6a9d477bdb4509973b4364722d58e3
- author
- brice · · 2026-09-24 09:39 UTC
- parent
- 4dd986f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +53 -7
M examples/config.toml +2 -0
| @@ -54,3 +54,5 @@ debug = [] | |||
| 54 | 54 | "Cmd+Shift+F" = "toggle-fullscreen" | |
| 55 | 55 | "Cmd+Q" = "close-window" | |
| 56 | 56 | "Ctrl+Alt+BackSpace" = "quit" | |
| 57 | + | # "spawn <command>" runs a program; arguments split on spaces, no shell. | |
| 58 | + | "Ctrl+Cmd+space" = "spawn fuzzel" | |
| @@ -54,3 +54,5 @@ debug = [] | |||||
| 54 | "Cmd+Shift+F" = "toggle-fullscreen" | 54 | "Cmd+Shift+F" = "toggle-fullscreen" | ||
| 55 | "Cmd+Q" = "close-window" | 55 | "Cmd+Q" = "close-window" | ||
| 56 | "Ctrl+Alt+BackSpace" = "quit" | 56 | "Ctrl+Alt+BackSpace" = "quit" | ||
| 57 | + | # "spawn <command>" runs a program; arguments split on spaces, no shell. | |||
| 58 | + | "Ctrl+Cmd+space" = "spawn fuzzel" | |||
M internal/adapters/config/config.go +7 -3
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 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", | |
| 18 | + | "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel", | |
| 19 | 19 | } | |
| 20 | 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} | |
| 21 | 21 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| @@ -270,8 +270,12 @@ func Validate(c ports.Config) error { | |||
| 270 | 270 | combos[n] = k | |
| 271 | 271 | } | |
| 272 | 272 | } | |
| 273 | - | if !actions[v] { | |
| 274 | - | add("binds."+k, "invalid action") | |
| 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>")`) | |
| 275 | 279 | } | |
| 276 | 280 | } | |
| 277 | 281 | return errors.Join(errs...) | |
| @@ -15,7 +15,7 @@ import ( | |||||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | var defaultBinds = map[string]string{ | 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", | 18 | + | "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel", |
| 19 | } | 19 | } | ||
| 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} | 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} | ||
| 21 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": 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} | ||
| @@ -270,8 +270,12 @@ func Validate(c ports.Config) error { | |||||
| 270 | combos[n] = k | 270 | combos[n] = k | ||
| 271 | } | 271 | } | ||
| 272 | } | 272 | } | ||
| 273 | - | if !actions[v] { | 273 | + | if rest, ok := strings.CutPrefix(v, "spawn "); ok { |
| 274 | - | add("binds."+k, "invalid action") | 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>")`) | |||
| 275 | } | 279 | } | ||
| 276 | } | 280 | } | ||
| 277 | return errors.Join(errs...) | 281 | return errors.Join(errs...) | ||
M internal/adapters/config/config_test.go +15 -1
| @@ -13,7 +13,7 @@ func TestDefaultsAndLoad(t *testing.T) { | |||
| 13 | 13 | if err := Validate(d); err != nil { | |
| 14 | 14 | t.Fatal(err) | |
| 15 | 15 | } | |
| 16 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 11 { | |
| 16 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 12 { | |
| 17 | 17 | t.Fatalf("defaults: %+v", d) | |
| 18 | 18 | } | |
| 19 | 19 | path := filepath.Join(t.TempDir(), "missing") | |
| @@ -113,3 +113,17 @@ func TestOutputs(t *testing.T) { | |||
| 113 | 113 | t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err) | |
| 114 | 114 | } | |
| 115 | 115 | } | |
| 116 | + | ||
| 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) | |
| 122 | + | } | |
| 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 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| @@ -13,7 +13,7 @@ func TestDefaultsAndLoad(t *testing.T) { | |||||
| 13 | if err := Validate(d); err != nil { | 13 | if err := Validate(d); err != nil { | ||
| 14 | t.Fatal(err) | 14 | t.Fatal(err) | ||
| 15 | } | 15 | } | ||
| 16 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 11 { | 16 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 12 { |
| 17 | t.Fatalf("defaults: %+v", d) | 17 | t.Fatalf("defaults: %+v", d) | ||
| 18 | } | 18 | } | ||
| 19 | path := filepath.Join(t.TempDir(), "missing") | 19 | path := filepath.Join(t.TempDir(), "missing") | ||
| @@ -113,3 +113,17 @@ func TestOutputs(t *testing.T) { | |||||
| 113 | t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err) | 113 | t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err) | ||
| 114 | } | 114 | } | ||
| 115 | } | 115 | } | ||
| 116 | + | ||||
| 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) | |||
| 122 | + | } | |||
| 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 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
M internal/core/action.go +17 -0
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 3 | 5 | type Action string | |
| 4 | 6 | ||
| 5 | 7 | const ( | |
| @@ -16,13 +18,28 @@ const ( | |||
| 16 | 18 | ActionQuit Action = "quit" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 21 | + | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | |
| 22 | + | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | |
| 23 | + | const spawnPrefix = "spawn " | |
| 24 | + | ||
| 25 | + | // SpawnArgv returns the command of a "spawn <cmd>" action. | |
| 26 | + | func SpawnArgv(a Action) ([]string, bool) { | |
| 27 | + | rest, ok := strings.CutPrefix(string(a), spawnPrefix) | |
| 28 | + | argv := strings.Fields(rest) | |
| 29 | + | return argv, ok && len(argv) > 0 | |
| 30 | + | } | |
| 31 | + | ||
| 19 | 32 | type Effect struct { | |
| 20 | 33 | Spawn bool | |
| 34 | + | Argv []string // command to run; nil means the configured terminal | |
| 21 | 35 | Close WindowID | |
| 22 | 36 | Quit bool | |
| 23 | 37 | } | |
| 24 | 38 | ||
| 25 | 39 | func (w *Workspace) Apply(a Action) Effect { | |
| 40 | + | if argv, ok := SpawnArgv(a); ok { | |
| 41 | + | return Effect{Spawn: true, Argv: argv} | |
| 42 | + | } | |
| 26 | 43 | switch a { | |
| 27 | 44 | case ActionSpawnTerminal: | |
| 28 | 45 | return Effect{Spawn: true} | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 3 | type Action string | 5 | type Action string | ||
| 4 | 6 | ||||
| 5 | const ( | 7 | const ( | ||
| @@ -16,13 +18,28 @@ const ( | |||||
| 16 | ActionQuit Action = "quit" | 18 | ActionQuit Action = "quit" | ||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 21 | + | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | |||
| 22 | + | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | |||
| 23 | + | const spawnPrefix = "spawn " | |||
| 24 | + | ||||
| 25 | + | // SpawnArgv returns the command of a "spawn <cmd>" action. | |||
| 26 | + | func SpawnArgv(a Action) ([]string, bool) { | |||
| 27 | + | rest, ok := strings.CutPrefix(string(a), spawnPrefix) | |||
| 28 | + | argv := strings.Fields(rest) | |||
| 29 | + | return argv, ok && len(argv) > 0 | |||
| 30 | + | } | |||
| 31 | + | ||||
| 19 | type Effect struct { | 32 | type Effect struct { | ||
| 20 | Spawn bool | 33 | Spawn bool | ||
| 34 | + | Argv []string // command to run; nil means the configured terminal | |||
| 21 | Close WindowID | 35 | Close WindowID | ||
| 22 | Quit bool | 36 | Quit bool | ||
| 23 | } | 37 | } | ||
| 24 | 38 | ||||
| 25 | func (w *Workspace) Apply(a Action) Effect { | 39 | func (w *Workspace) Apply(a Action) Effect { | ||
| 40 | + | if argv, ok := SpawnArgv(a); ok { | |||
| 41 | + | return Effect{Spawn: true, Argv: argv} | |||
| 42 | + | } | |||
| 26 | switch a { | 43 | switch a { | ||
| 27 | case ActionSpawnTerminal: | 44 | case ActionSpawnTerminal: | ||
| 28 | return Effect{Spawn: true} | 45 | return Effect{Spawn: true} | ||
M internal/core/core.go +9 -1
| @@ -113,6 +113,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 113 | 113 | if _, ok := binds[b]; ok { | |
| 114 | 114 | return fmt.Errorf("duplicate bind %q", combo) | |
| 115 | 115 | } | |
| 116 | + | if _, ok := SpawnArgv(Action(a)); ok { | |
| 117 | + | binds[b] = Action(a) | |
| 118 | + | continue | |
| 119 | + | } | |
| 116 | 120 | switch Action(a) { | |
| 117 | 121 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | |
| 118 | 122 | default: | |
| @@ -364,10 +368,14 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 364 | 368 | return ErrQuit | |
| 365 | 369 | } | |
| 366 | 370 | if effect.Spawn { | |
| 371 | + | argv := effect.Argv | |
| 372 | + | if argv == nil { | |
| 373 | + | argv = append([]string(nil), c.cfg.Terminal.Command...) | |
| 374 | + | } | |
| 367 | 375 | select { | |
| 368 | 376 | case <-ctx.Done(): | |
| 369 | 377 | return nil | |
| 370 | - | case c.ch.Spawn <- ports.SpawnRequest{Argv: append([]string(nil), c.cfg.Terminal.Command...)}: | |
| 378 | + | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | |
| 371 | 379 | } | |
| 372 | 380 | } | |
| 373 | 381 | if effect.Close != 0 { | |
| @@ -113,6 +113,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 113 | if _, ok := binds[b]; ok { | 113 | if _, ok := binds[b]; ok { | ||
| 114 | return fmt.Errorf("duplicate bind %q", combo) | 114 | return fmt.Errorf("duplicate bind %q", combo) | ||
| 115 | } | 115 | } | ||
| 116 | + | if _, ok := SpawnArgv(Action(a)); ok { | |||
| 117 | + | binds[b] = Action(a) | |||
| 118 | + | continue | |||
| 119 | + | } | |||
| 116 | switch Action(a) { | 120 | switch Action(a) { | ||
| 117 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | 121 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | ||
| 118 | default: | 122 | default: | ||
| @@ -364,10 +368,14 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 364 | return ErrQuit | 368 | return ErrQuit | ||
| 365 | } | 369 | } | ||
| 366 | if effect.Spawn { | 370 | if effect.Spawn { | ||
| 371 | + | argv := effect.Argv | |||
| 372 | + | if argv == nil { | |||
| 373 | + | argv = append([]string(nil), c.cfg.Terminal.Command...) | |||
| 374 | + | } | |||
| 367 | select { | 375 | select { | ||
| 368 | case <-ctx.Done(): | 376 | case <-ctx.Done(): | ||
| 369 | return nil | 377 | return nil | ||
| 370 | - | case c.ch.Spawn <- ports.SpawnRequest{Argv: append([]string(nil), c.cfg.Terminal.Command...)}: | 378 | + | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: |
| 371 | } | 379 | } | ||
| 372 | } | 380 | } | ||
| 373 | if effect.Close != 0 { | 381 | if effect.Close != 0 { | ||
M internal/core/layout_test.go +3 -2
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | + | "reflect" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -124,8 +125,8 @@ func TestPresetsAndActions(t *testing.T) { | |||
| 124 | 125 | for _, tc := range []struct { | |
| 125 | 126 | a Action | |
| 126 | 127 | want Effect | |
| 127 | - | }{{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{}}} { | |
| 128 | - | if got := w.Apply(tc.a); got != tc.want { | |
| 128 | + | }{{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{}}} { | |
| 129 | + | if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) { | |
| 129 | 130 | t.Errorf("%s: %+v", tc.a, got) | |
| 130 | 131 | } | |
| 131 | 132 | } | |
| @@ -2,6 +2,7 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "math" | 4 | "math" | ||
| 5 | + | "reflect" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| @@ -124,8 +125,8 @@ func TestPresetsAndActions(t *testing.T) { | |||||
| 124 | for _, tc := range []struct { | 125 | for _, tc := range []struct { | ||
| 125 | a Action | 126 | a Action | ||
| 126 | want Effect | 127 | want Effect | ||
| 127 | - | }{{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{}}} { | 128 | + | }{{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{}}} { |
| 128 | - | if got := w.Apply(tc.a); got != tc.want { | 129 | + | if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) { |
| 129 | t.Errorf("%s: %+v", tc.a, got) | 130 | t.Errorf("%s: %+v", tc.a, got) | ||
| 130 | } | 131 | } | ||
| 131 | } | 132 | } | ||