detached 039f0ba

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +53 -7
M examples/config.toml +2 -0
@@ -54,3 +54,5 @@ debug = []
5454 "Cmd+Shift+F" = "toggle-fullscreen"
5555 "Cmd+Q" = "close-window"
5656 "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 (
1515 )
1616
1717 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",
1919 }
2020 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}
2121 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 {
270270 combos[n] = k
271271 }
272272 }
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>")`)
275279 }
276280 }
277281 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] = k270 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) {
1313 if err := Validate(d); err != nil {
1414 t.Fatal(err)
1515 }
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 {
1717 t.Fatalf("defaults: %+v", d)
1818 }
1919 path := filepath.Join(t.TempDir(), "missing")
@@ -113,3 +113,17 @@ func TestOutputs(t *testing.T) {
113113 t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114114 }
115115 }
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 @@
11 package core
22
3+import "strings"
4+
35 type Action string
46
57 const (
@@ -16,13 +18,28 @@ const (
1618 ActionQuit Action = "quit"
1719 )
1820
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+
1932 type Effect struct {
2033 Spawn bool
34+ Argv []string // command to run; nil means the configured terminal
2135 Close WindowID
2236 Quit bool
2337 }
2438
2539 func (w *Workspace) Apply(a Action) Effect {
40+ if argv, ok := SpawnArgv(a); ok {
41+ return Effect{Spawn: true, Argv: argv}
42+ }
2643 switch a {
2744 case ActionSpawnTerminal:
2845 return Effect{Spawn: true}
@@ -1,5 +1,7 @@
1 package core1 package core
2 2
3+import "strings"
4+
3 type Action string5 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 bool33 Spawn bool
34+ Argv []string // command to run; nil means the configured terminal
21 Close WindowID35 Close WindowID
22 Quit bool36 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 {
113113 if _, ok := binds[b]; ok {
114114 return fmt.Errorf("duplicate bind %q", combo)
115115 }
116+ if _, ok := SpawnArgv(Action(a)); ok {
117+ binds[b] = Action(a)
118+ continue
119+ }
116120 switch Action(a) {
117121 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:
118122 default:
@@ -364,10 +368,14 @@ func (c *Core) Run(ctx context.Context) error {
364368 return ErrQuit
365369 }
366370 if effect.Spawn {
371+ argv := effect.Argv
372+ if argv == nil {
373+ argv = append([]string(nil), c.cfg.Terminal.Command...)
374+ }
367375 select {
368376 case <-ctx.Done():
369377 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}:
371379 }
372380 }
373381 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 ErrQuit368 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 nil377 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
22
33 import (
44 "math"
5+ "reflect"
56 "testing"
67 )
78
@@ -124,8 +125,8 @@ func TestPresetsAndActions(t *testing.T) {
124125 for _, tc := range []struct {
125126 a Action
126127 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) {
129130 t.Errorf("%s: %+v", tc.a, got)
130131 }
131132 }
@@ -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 Action126 a Action
126 want Effect127 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 }

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 23

Tags 10

↑↓ moveenter openesc close