detached df18de2

feat(core): open apps straight into the stash

Add the 'stash <command>' bind action, which spawns with a SlotEnv claim for the workspace current at key press and puts the mapped window in that workspace's stash, selected and focused. Add stash.spawn-terminal (default on): toggle-stash-visible on an empty stash opens the terminal there, never twice while one is awaited.
commit
df18de2d8f42f9b49bcef4248822a69f0b884b34
author
brice · · 2026-10-14 20:29 UTC
parent
cb04ab1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +449 -20
M README.md +1 -0
@@ -206,6 +206,7 @@ stash.width = 80
206206 stash.gap = 2
207207 stash.dim = 0.5
208208 stash.capture = on
209+stash.spawn-terminal = on
209210
210211 # Input
211212 touchpad.natural-scroll = off
@@ -206,6 +206,7 @@ stash.width = 80
206 stash.gap = 2206 stash.gap = 2
207 stash.dim = 0.5207 stash.dim = 0.5
208 stash.capture = on208 stash.capture = on
209+stash.spawn-terminal = on
209 210
210 # Input211 # Input
211 touchpad.natural-scroll = off212 touchpad.natural-scroll = off
M docs/config.md +10 -0
@@ -38,6 +38,7 @@ See [examples/config](../examples/config) for every key with its default.
3838 | `stash.gap` | `2` | Gap around the selected stashed window, in % (0–10) |
3939 | `stash.dim` | `0.5` | Darkening of the stash neighbors, 0 to 1 |
4040 | `stash.capture` | `on` | A window opened while the stash shows joins the stash |
41+| `stash.spawn-terminal` | `on` | `toggle-stash-visible` on an empty stash opens `terminal` in it |
4142 | `touchpad.natural-scroll` | `off` | Content follows the fingers |
4243 | `touchpad.tap` | `on` | Tap to click |
4344 | `touchpad.accel-speed` | `0` | Pointer speed, `-1` to `1` |
@@ -173,6 +174,7 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
173174 |---|---|
174175 | `spawn <command>` | Run a program |
175176 | `spawn-terminal` | Run `terminal` |
177+| `stash <command>` | Run a program and open its window in the [stash](#stash) |
176178 | `close-window` | Close the focused window |
177179 | `toggle-fullscreen` | Fullscreen the focused window. Nothing shows above it except its own dialogs |
178180 | `toggle-floating` | Make the focused window [float](#free-floating-windows), or put it back in its column |
@@ -251,6 +253,14 @@ A cell is `1/max-columns` of the screen width. A column is 1 to 16 cells wide. C
251253
252254 Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it.
253255
256+`stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`.
257+
258+```ini
259+bind.cmd+shift+f = stash firefox
260+```
261+
262+Apps that hand a launch to an already running instance (single-instance browsers, `footclient`) open their window normally.
263+
254264 ### Free floating windows
255265
256266 `toggle-floating` turns the focused window into a floating window that stays where you put it. On a floating window, move binds move it by 50 pixels and resize binds resize it.
@@ -38,6 +38,7 @@ See [examples/config](../examples/config) for every key with its default.
38 | `stash.gap` | `2` | Gap around the selected stashed window, in % (0–10) |38 | `stash.gap` | `2` | Gap around the selected stashed window, in % (0–10) |
39 | `stash.dim` | `0.5` | Darkening of the stash neighbors, 0 to 1 |39 | `stash.dim` | `0.5` | Darkening of the stash neighbors, 0 to 1 |
40 | `stash.capture` | `on` | A window opened while the stash shows joins the stash |40 | `stash.capture` | `on` | A window opened while the stash shows joins the stash |
41+| `stash.spawn-terminal` | `on` | `toggle-stash-visible` on an empty stash opens `terminal` in it |
41 | `touchpad.natural-scroll` | `off` | Content follows the fingers |42 | `touchpad.natural-scroll` | `off` | Content follows the fingers |
42 | `touchpad.tap` | `on` | Tap to click |43 | `touchpad.tap` | `on` | Tap to click |
43 | `touchpad.accel-speed` | `0` | Pointer speed, `-1` to `1` |44 | `touchpad.accel-speed` | `0` | Pointer speed, `-1` to `1` |
@@ -173,6 +174,7 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
173 |---|---|174 |---|---|
174 | `spawn <command>` | Run a program |175 | `spawn <command>` | Run a program |
175 | `spawn-terminal` | Run `terminal` |176 | `spawn-terminal` | Run `terminal` |
177+| `stash <command>` | Run a program and open its window in the [stash](#stash) |
176 | `close-window` | Close the focused window |178 | `close-window` | Close the focused window |
177 | `toggle-fullscreen` | Fullscreen the focused window. Nothing shows above it except its own dialogs |179 | `toggle-fullscreen` | Fullscreen the focused window. Nothing shows above it except its own dialogs |
178 | `toggle-floating` | Make the focused window [float](#free-floating-windows), or put it back in its column |180 | `toggle-floating` | Make the focused window [float](#free-floating-windows), or put it back in its column |
@@ -251,6 +253,14 @@ A cell is `1/max-columns` of the screen width. A column is 1 to 16 cells wide. C
251 253
252 Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it.254 Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it.
253 255
256+`stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`.
257+
258+```ini
259+bind.cmd+shift+f = stash firefox
260+```
261+
262+Apps that hand a launch to an already running instance (single-instance browsers, `footclient`) open their window normally.
263+
254 ### Free floating windows264 ### Free floating windows
255 265
256 `toggle-floating` turns the focused window into a floating window that stays where you put it. On a floating window, move binds move it by 50 pixels and resize binds resize it.266 `toggle-floating` turns the focused window into a floating window that stays where you put it. On a floating window, move binds move it by 50 pixels and resize binds resize it.
M examples/config +3 -0
@@ -36,6 +36,9 @@ border.inactive = #111111
3636 # Black veil over tiles and lower layers when a float is visible (0 to 1).
3737 floating.dim = 0.3
3838
39+# on: toggle-stash-visible on an empty stash opens the terminal in it.
40+stash.spawn-terminal = on
41+
3942 layout.gaps = 0
4043 # Columns sharing the screen equally before the overflow applies: one cell is
4144 # a max-columns-th of the width, and columns, rules and slots are sized in
@@ -36,6 +36,9 @@ border.inactive = #111111
36 # Black veil over tiles and lower layers when a float is visible (0 to 1).36 # Black veil over tiles and lower layers when a float is visible (0 to 1).
37 floating.dim = 0.337 floating.dim = 0.3
38 38
39+# on: toggle-stash-visible on an empty stash opens the terminal in it.
40+stash.spawn-terminal = on
41+
39 layout.gaps = 042 layout.gaps = 0
40 # Columns sharing the screen equally before the overflow applies: one cell is43 # Columns sharing the screen equally before the overflow applies: one cell is
41 # a max-columns-th of the width, and columns, rules and slots are sized in44 # a max-columns-th of the width, and columns, rules and slots are sized in
M internal/adapters/config/config.go +14 -1
@@ -170,6 +170,7 @@ func Defaults() ports.Config {
170170 c.Stash.Gap = 2
171171 c.Stash.Dim = 0.5
172172 c.Stash.Capture = true
173+ c.Stash.SpawnTerminal = true
173174 c.Border.Width = 2
174175 c.Border.Active = "#808080"
175176 c.Border.Inactive = "#111111"
@@ -760,6 +761,12 @@ func set(c *ports.Config, key, v string) error {
760761 return err
761762 }
762763 c.Stash.Capture = b
764+ case "stash.spawn-terminal":
765+ b, err := onOff(v)
766+ if err != nil {
767+ return err
768+ }
769+ c.Stash.SpawnTerminal = b
763770 case "border.width":
764771 n, err := strconv.Atoi(v)
765772 if err != nil || n < 0 || n > 32 {
@@ -1207,6 +1214,12 @@ func checkAction(v string) error {
12071214 }
12081215 return nil
12091216 }
1217+ if rest, ok := strings.CutPrefix(v, "stash "); ok {
1218+ if len(ports.SplitCommands(rest)) == 0 {
1219+ return fmt.Errorf("stash needs a command")
1220+ }
1221+ return nil
1222+ }
12101223 if rest, ok := strings.CutPrefix(v, "workspace "); ok {
12111224 if !workspaceName.MatchString(strings.TrimSpace(rest)) {
12121225 return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
@@ -1239,7 +1252,7 @@ func checkAction(v string) error {
12391252 }
12401253 }
12411254 if !actions[v] {
1242- return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
1255+ return fmt.Errorf(`unknown action %q (or "spawn <command>", "stash <command>", "workspace <name>")`, v)
12431256 }
12441257 return nil
12451258 }
@@ -170,6 +170,7 @@ func Defaults() ports.Config {
170 c.Stash.Gap = 2170 c.Stash.Gap = 2
171 c.Stash.Dim = 0.5171 c.Stash.Dim = 0.5
172 c.Stash.Capture = true172 c.Stash.Capture = true
173+ c.Stash.SpawnTerminal = true
173 c.Border.Width = 2174 c.Border.Width = 2
174 c.Border.Active = "#808080"175 c.Border.Active = "#808080"
175 c.Border.Inactive = "#111111"176 c.Border.Inactive = "#111111"
@@ -760,6 +761,12 @@ func set(c *ports.Config, key, v string) error {
760 return err761 return err
761 }762 }
762 c.Stash.Capture = b763 c.Stash.Capture = b
764+ case "stash.spawn-terminal":
765+ b, err := onOff(v)
766+ if err != nil {
767+ return err
768+ }
769+ c.Stash.SpawnTerminal = b
763 case "border.width":770 case "border.width":
764 n, err := strconv.Atoi(v)771 n, err := strconv.Atoi(v)
765 if err != nil || n < 0 || n > 32 {772 if err != nil || n < 0 || n > 32 {
@@ -1207,6 +1214,12 @@ func checkAction(v string) error {
1207 }1214 }
1208 return nil1215 return nil
1209 }1216 }
1217+ if rest, ok := strings.CutPrefix(v, "stash "); ok {
1218+ if len(ports.SplitCommands(rest)) == 0 {
1219+ return fmt.Errorf("stash needs a command")
1220+ }
1221+ return nil
1222+ }
1210 if rest, ok := strings.CutPrefix(v, "workspace "); ok {1223 if rest, ok := strings.CutPrefix(v, "workspace "); ok {
1211 if !workspaceName.MatchString(strings.TrimSpace(rest)) {1224 if !workspaceName.MatchString(strings.TrimSpace(rest)) {
1212 return fmt.Errorf("workspace needs a name (letters, digits, - or _)")1225 return fmt.Errorf("workspace needs a name (letters, digits, - or _)")
@@ -1239,7 +1252,7 @@ func checkAction(v string) error {
1239 }1252 }
1240 }1253 }
1241 if !actions[v] {1254 if !actions[v] {
1242- return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)1255+ return fmt.Errorf(`unknown action %q (or "spawn <command>", "stash <command>", "workspace <name>")`, v)
1243 }1256 }
1244 return nil1257 return nil
1245 }1258 }
M internal/adapters/config/config_test.go +24 -0
@@ -296,6 +296,8 @@ func TestWarningsKeepDefaults(t *testing.T) {
296296 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
297297 {"bind.cmd+x = spawn ", "unknown action"},
298298 {"bind.cmd+x = spawn ; ;", "spawn needs a command"},
299+ {"bind.cmd+x = stash ", "unknown action"},
300+ {"bind.cmd+x = stash ; ;", "stash needs a command"},
299301 {"startup = ;", "must not be empty"},
300302 }
301303 for _, tc := range cases {
@@ -624,6 +626,28 @@ func TestStashCapture(t *testing.T) {
624626 }
625627 }
626628
629+func TestStashSpawnTerminal(t *testing.T) {
630+ if c, _ := parseString(t, ""); !c.Stash.SpawnTerminal {
631+ t.Fatal("stash.spawn-terminal is on by default")
632+ }
633+ if c, w := parseString(t, "stash.spawn-terminal = off"); c.Stash.SpawnTerminal || len(w) != 0 {
634+ t.Fatalf("off: %v %v", c.Stash.SpawnTerminal, w)
635+ }
636+ if c, w := parseString(t, "stash.spawn-terminal = on"); !c.Stash.SpawnTerminal || len(w) != 0 {
637+ t.Fatalf("on: %v %v", c.Stash.SpawnTerminal, w)
638+ }
639+ if c, w := parseString(t, "stash.spawn-terminal = yes"); !c.Stash.SpawnTerminal || len(w) == 0 {
640+ t.Fatalf("invalid: %v %v", c.Stash.SpawnTerminal, w)
641+ }
642+}
643+
644+func TestStashBind(t *testing.T) {
645+ c, w := parseString(t, "bind.cmd+shift+f = stash firefox --private-window")
646+ if len(w) != 0 || c.Binds["Cmd+Shift+f"] != "stash firefox --private-window" {
647+ t.Fatalf("stash bind: %v %v", c.Binds, w)
648+ }
649+}
650+
627651 func TestVRRFlipGap(t *testing.T) {
628652 for _, tc := range []struct {
629653 value string
@@ -296,6 +296,8 @@ func TestWarningsKeepDefaults(t *testing.T) {
296 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},296 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
297 {"bind.cmd+x = spawn ", "unknown action"},297 {"bind.cmd+x = spawn ", "unknown action"},
298 {"bind.cmd+x = spawn ; ;", "spawn needs a command"},298 {"bind.cmd+x = spawn ; ;", "spawn needs a command"},
299+ {"bind.cmd+x = stash ", "unknown action"},
300+ {"bind.cmd+x = stash ; ;", "stash needs a command"},
299 {"startup = ;", "must not be empty"},301 {"startup = ;", "must not be empty"},
300 }302 }
301 for _, tc := range cases {303 for _, tc := range cases {
@@ -624,6 +626,28 @@ func TestStashCapture(t *testing.T) {
624 }626 }
625 }627 }
626 628
629+func TestStashSpawnTerminal(t *testing.T) {
630+ if c, _ := parseString(t, ""); !c.Stash.SpawnTerminal {
631+ t.Fatal("stash.spawn-terminal is on by default")
632+ }
633+ if c, w := parseString(t, "stash.spawn-terminal = off"); c.Stash.SpawnTerminal || len(w) != 0 {
634+ t.Fatalf("off: %v %v", c.Stash.SpawnTerminal, w)
635+ }
636+ if c, w := parseString(t, "stash.spawn-terminal = on"); !c.Stash.SpawnTerminal || len(w) != 0 {
637+ t.Fatalf("on: %v %v", c.Stash.SpawnTerminal, w)
638+ }
639+ if c, w := parseString(t, "stash.spawn-terminal = yes"); !c.Stash.SpawnTerminal || len(w) == 0 {
640+ t.Fatalf("invalid: %v %v", c.Stash.SpawnTerminal, w)
641+ }
642+}
643+
644+func TestStashBind(t *testing.T) {
645+ c, w := parseString(t, "bind.cmd+shift+f = stash firefox --private-window")
646+ if len(w) != 0 || c.Binds["Cmd+Shift+f"] != "stash firefox --private-window" {
647+ t.Fatalf("stash bind: %v %v", c.Binds, w)
648+ }
649+}
650+
627 func TestVRRFlipGap(t *testing.T) {651 func TestVRRFlipGap(t *testing.T) {
628 for _, tc := range []struct {652 for _, tc := range []struct {
629 value string653 value string
M internal/core/action.go +25 -5
@@ -180,8 +180,17 @@ func NamedArg(a Action) (string, bool) {
180180 const spawnPrefix = "spawn "
181181
182182 // SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action.
183-func SpawnCommands(a Action) ([][]string, bool) {
184- rest, ok := strings.CutPrefix(string(a), spawnPrefix)
183+func SpawnCommands(a Action) ([][]string, bool) { return prefixedCommands(a, spawnPrefix) }
184+
185+// stashPrefix starts a bind action that runs commands and opens their
186+// windows in the stash: "stash firefox".
187+const stashPrefix = "stash "
188+
189+// StashCommands returns the commands of a "stash <cmd>[; <cmd>...]" action.
190+func StashCommands(a Action) ([][]string, bool) { return prefixedCommands(a, stashPrefix) }
191+
192+func prefixedCommands(a Action, prefix string) ([][]string, bool) {
193+ rest, ok := strings.CutPrefix(string(a), prefix)
185194 if !ok {
186195 return nil, false
187196 }
@@ -192,8 +201,11 @@ func SpawnCommands(a Action) ([][]string, bool) {
192201 type Effect struct {
193202 Spawn bool
194203 Commands [][]string // commands to run; nil means the configured terminal
195- Close WindowID
196- Quit bool
204+ // Stash sends the spawned windows to the stash of the workspace the
205+ // action applied to.
206+ Stash bool
207+ Close WindowID
208+ Quit bool
197209 }
198210
199211 // applyAction runs a bind action. Monitor actions change the focused
@@ -365,7 +377,12 @@ func (m *Monitor) Apply(a Action) Effect {
365377 m.Current().ToggleWindowStash()
366378 return Effect{}
367379 case ActionToggleStashVisible:
368- m.Current().ToggleStashVisible()
380+ w := m.Current()
381+ // An empty stash has nothing to show: open the terminal in it.
382+ if len(w.Stash) == 0 && m.stashTerminal {
383+ return Effect{Spawn: true, Stash: true}
384+ }
385+ w.ToggleStashVisible()
369386 return Effect{}
370387 case ActionToggleOverview:
371388 m.ToggleOverview()
@@ -433,6 +450,9 @@ func (w *Workspace) Apply(a Action) Effect {
433450 if commands, ok := SpawnCommands(a); ok {
434451 return Effect{Spawn: true, Commands: commands}
435452 }
453+ if commands, ok := StashCommands(a); ok {
454+ return Effect{Spawn: true, Stash: true, Commands: commands}
455+ }
436456 if w.freeAction(a) {
437457 return Effect{}
438458 }
@@ -180,8 +180,17 @@ func NamedArg(a Action) (string, bool) {
180 const spawnPrefix = "spawn "180 const spawnPrefix = "spawn "
181 181
182 // SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action.182 // SpawnCommands returns the commands of a "spawn <cmd>[; <cmd>...]" action.
183-func SpawnCommands(a Action) ([][]string, bool) {183+func SpawnCommands(a Action) ([][]string, bool) { return prefixedCommands(a, spawnPrefix) }
184- rest, ok := strings.CutPrefix(string(a), spawnPrefix)184+
185+// stashPrefix starts a bind action that runs commands and opens their
186+// windows in the stash: "stash firefox".
187+const stashPrefix = "stash "
188+
189+// StashCommands returns the commands of a "stash <cmd>[; <cmd>...]" action.
190+func StashCommands(a Action) ([][]string, bool) { return prefixedCommands(a, stashPrefix) }
191+
192+func prefixedCommands(a Action, prefix string) ([][]string, bool) {
193+ rest, ok := strings.CutPrefix(string(a), prefix)
185 if !ok {194 if !ok {
186 return nil, false195 return nil, false
187 }196 }
@@ -192,8 +201,11 @@ func SpawnCommands(a Action) ([][]string, bool) {
192 type Effect struct {201 type Effect struct {
193 Spawn bool202 Spawn bool
194 Commands [][]string // commands to run; nil means the configured terminal203 Commands [][]string // commands to run; nil means the configured terminal
195- Close WindowID204+ // Stash sends the spawned windows to the stash of the workspace the
196- Quit bool205+ // action applied to.
206+ Stash bool
207+ Close WindowID
208+ Quit bool
197 }209 }
198 210
199 // applyAction runs a bind action. Monitor actions change the focused211 // applyAction runs a bind action. Monitor actions change the focused
@@ -365,7 +377,12 @@ func (m *Monitor) Apply(a Action) Effect {
365 m.Current().ToggleWindowStash()377 m.Current().ToggleWindowStash()
366 return Effect{}378 return Effect{}
367 case ActionToggleStashVisible:379 case ActionToggleStashVisible:
368- m.Current().ToggleStashVisible()380+ w := m.Current()
381+ // An empty stash has nothing to show: open the terminal in it.
382+ if len(w.Stash) == 0 && m.stashTerminal {
383+ return Effect{Spawn: true, Stash: true}
384+ }
385+ w.ToggleStashVisible()
369 return Effect{}386 return Effect{}
370 case ActionToggleOverview:387 case ActionToggleOverview:
371 m.ToggleOverview()388 m.ToggleOverview()
@@ -433,6 +450,9 @@ func (w *Workspace) Apply(a Action) Effect {
433 if commands, ok := SpawnCommands(a); ok {450 if commands, ok := SpawnCommands(a); ok {
434 return Effect{Spawn: true, Commands: commands}451 return Effect{Spawn: true, Commands: commands}
435 }452 }
453+ if commands, ok := StashCommands(a); ok {
454+ return Effect{Spawn: true, Stash: true, Commands: commands}
455+ }
436 if w.freeAction(a) {456 if w.freeAction(a) {
437 return Effect{}457 return Effect{}
438 }458 }
M internal/core/core.go +5 -1
@@ -246,6 +246,10 @@ func (c *Core) apply(cfg ports.Config) error {
246246 binds[b] = Action(a)
247247 continue
248248 }
249+ if _, ok := StashCommands(Action(a)); ok {
250+ binds[b] = Action(a)
251+ continue
252+ }
249253 if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown {
250254 binds[b] = Action(a)
251255 continue
@@ -1247,7 +1251,7 @@ func (c *Core) spawnEmpty(ctx context.Context) error {
12471251 for i, req := range terms {
12481252 if err != nil || !c.trySpawn(ctx, req) {
12491253 for _, suppressed := range terms[i:] {
1250- c.cancelTerminal(suppressed)
1254+ c.cancelClaim(suppressed)
12511255 }
12521256 if i == 0 {
12531257 c.firstTerminalResolved = first
@@ -246,6 +246,10 @@ func (c *Core) apply(cfg ports.Config) error {
246 binds[b] = Action(a)246 binds[b] = Action(a)
247 continue247 continue
248 }248 }
249+ if _, ok := StashCommands(Action(a)); ok {
250+ binds[b] = Action(a)
251+ continue
252+ }
249 if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown {253 if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown {
250 binds[b] = Action(a)254 binds[b] = Action(a)
251 continue255 continue
@@ -1247,7 +1251,7 @@ func (c *Core) spawnEmpty(ctx context.Context) error {
1247 for i, req := range terms {1251 for i, req := range terms {
1248 if err != nil || !c.trySpawn(ctx, req) {1252 if err != nil || !c.trySpawn(ctx, req) {
1249 for _, suppressed := range terms[i:] {1253 for _, suppressed := range terms[i:] {
1250- c.cancelTerminal(suppressed)1254+ c.cancelClaim(suppressed)
1251 }1255 }
1252 if i == 0 {1256 if i == 0 {
1253 c.firstTerminalResolved = first1257 c.firstTerminalResolved = first
M internal/core/input.go +43 -5
@@ -346,11 +346,18 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
346346 if commands == nil {
347347 commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
348348 }
349- // Explicit actions are suppressed on a full launcher queue;
350- // never keep the input owner waiting across an epoch change.
351- for _, argv := range commands {
352- if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
353- break
349+ switch {
350+ case effect.Stash:
351+ if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil {
352+ return err
353+ }
354+ default:
355+ // Explicit actions are suppressed on a full launcher queue;
356+ // never keep the input owner waiting across an epoch change.
357+ for _, argv := range commands {
358+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
359+ break
360+ }
354361 }
355362 }
356363 if c.inputEpochChanged() {
@@ -372,6 +379,37 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
372379 return c.publish(ctx)
373380 }
374381
382+// spawnStash runs commands whose windows open in the stash of w, the
383+// workspace the bind acted on. Wayland reads the claim tokens only while it
384+// knows slots are pending, so that is published before the spawn, as
385+// spawnEmpty does. A send the gate suppresses leaves no claim behind. With
386+// terminal set (the terminal of an empty stash), nothing starts while the
387+// previous one is still awaited.
388+func (c *Core) spawnStash(ctx context.Context, w *Workspace, commands [][]string, terminal bool) error {
389+ if c.protectionRequested() {
390+ return nil
391+ }
392+ now := c.now()
393+ c.placement.expireWorkspaceClaims(now)
394+ if terminal && c.placement.workspacePending(w, spawnStash) {
395+ return nil
396+ }
397+ reqs := make([]ports.SpawnRequest, len(commands))
398+ for i, argv := range commands {
399+ reqs[i] = c.placement.request(argv, spawnTarget{kind: spawnStash, ws: w, at: now})
400+ }
401+ err := c.publishPending(ctx)
402+ for i, req := range reqs {
403+ if err != nil || !c.trySpawn(ctx, req) {
404+ for _, suppressed := range reqs[i:] {
405+ c.cancelClaim(suppressed)
406+ }
407+ break
408+ }
409+ }
410+ return err
411+}
412+
375413 // scaleBind steps the focused output's scale and asks for it to be saved.
376414 func (c *Core) scaleBind(ctx context.Context, action Action) error {
377415 dir := 1
@@ -346,11 +346,18 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
346 if commands == nil {346 if commands == nil {
347 commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}347 commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
348 }348 }
349- // Explicit actions are suppressed on a full launcher queue;349+ switch {
350- // never keep the input owner waiting across an epoch change.350+ case effect.Stash:
351- for _, argv := range commands {351+ if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil {
352- if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {352+ return err
353- break353+ }
354+ default:
355+ // Explicit actions are suppressed on a full launcher queue;
356+ // never keep the input owner waiting across an epoch change.
357+ for _, argv := range commands {
358+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
359+ break
360+ }
354 }361 }
355 }362 }
356 if c.inputEpochChanged() {363 if c.inputEpochChanged() {
@@ -372,6 +379,37 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
372 return c.publish(ctx)379 return c.publish(ctx)
373 }380 }
374 381
382+// spawnStash runs commands whose windows open in the stash of w, the
383+// workspace the bind acted on. Wayland reads the claim tokens only while it
384+// knows slots are pending, so that is published before the spawn, as
385+// spawnEmpty does. A send the gate suppresses leaves no claim behind. With
386+// terminal set (the terminal of an empty stash), nothing starts while the
387+// previous one is still awaited.
388+func (c *Core) spawnStash(ctx context.Context, w *Workspace, commands [][]string, terminal bool) error {
389+ if c.protectionRequested() {
390+ return nil
391+ }
392+ now := c.now()
393+ c.placement.expireWorkspaceClaims(now)
394+ if terminal && c.placement.workspacePending(w, spawnStash) {
395+ return nil
396+ }
397+ reqs := make([]ports.SpawnRequest, len(commands))
398+ for i, argv := range commands {
399+ reqs[i] = c.placement.request(argv, spawnTarget{kind: spawnStash, ws: w, at: now})
400+ }
401+ err := c.publishPending(ctx)
402+ for i, req := range reqs {
403+ if err != nil || !c.trySpawn(ctx, req) {
404+ for _, suppressed := range reqs[i:] {
405+ c.cancelClaim(suppressed)
406+ }
407+ break
408+ }
409+ }
410+ return err
411+}
412+
375 // scaleBind steps the focused output's scale and asks for it to be saved.413 // scaleBind steps the focused output's scale and asks for it to be saved.
376 func (c *Core) scaleBind(ctx context.Context, action Action) error {414 func (c *Core) scaleBind(ctx context.Context, action Action) error {
377 dir := 1415 dir := 1
M internal/core/monitor.go +7 -0
@@ -34,6 +34,9 @@ type Monitor struct {
3434 followMove bool
3535 // stashCapture puts new windows in the shown stash (stash.capture).
3636 stashCapture bool
37+ // stashTerminal makes toggle-stash-visible on an empty stash open the
38+ // terminal in it (stash.spawn-terminal, with a terminal command).
39+ stashTerminal bool
3740 // switchView slides the view between numbered workspaces: the view is
3841 // at Active+switchView.off, in workspaces.
3942 switchView slide
@@ -529,6 +532,10 @@ func (m *Monitor) SetStash(width, gap int) {
529532 // SetStashCapture makes new windows join the shown stash (stash.capture).
530533 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
531534
535+// SetStashTerminal makes toggle-stash-visible on an empty stash ask for a
536+// terminal in it (stash.spawn-terminal).
537+func (m *Monitor) SetStashTerminal(on bool) { m.stashTerminal = on }
538+
532539 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
533540 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
534541
@@ -34,6 +34,9 @@ type Monitor struct {
34 followMove bool34 followMove bool
35 // stashCapture puts new windows in the shown stash (stash.capture).35 // stashCapture puts new windows in the shown stash (stash.capture).
36 stashCapture bool36 stashCapture bool
37+ // stashTerminal makes toggle-stash-visible on an empty stash open the
38+ // terminal in it (stash.spawn-terminal, with a terminal command).
39+ stashTerminal bool
37 // switchView slides the view between numbered workspaces: the view is40 // switchView slides the view between numbered workspaces: the view is
38 // at Active+switchView.off, in workspaces.41 // at Active+switchView.off, in workspaces.
39 switchView slide42 switchView slide
@@ -529,6 +532,10 @@ func (m *Monitor) SetStash(width, gap int) {
529 // SetStashCapture makes new windows join the shown stash (stash.capture).532 // SetStashCapture makes new windows join the shown stash (stash.capture).
530 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }533 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
531 534
535+// SetStashTerminal makes toggle-stash-visible on an empty stash ask for a
536+// terminal in it (stash.spawn-terminal).
537+func (m *Monitor) SetStashTerminal(on bool) { m.stashTerminal = on }
538+
532 // SetFollowMove makes moves to another workspace show it (focus.follow-move).539 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
533 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }540 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
534 541
M internal/core/screens.go +1 -0
@@ -528,6 +528,7 @@ func (c *Core) settings(m *Monitor) {
528528 m.SetBorder(c.cfg.Border.Width)
529529 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
530530 m.SetStashCapture(c.cfg.Stash.Capture)
531+ m.SetStashTerminal(c.cfg.Stash.SpawnTerminal && len(c.cfg.Terminal.Command) > 0)
531532 m.SetFollowMove(c.cfg.Focus.FollowMove)
532533 }
533534
@@ -528,6 +528,7 @@ func (c *Core) settings(m *Monitor) {
528 m.SetBorder(c.cfg.Border.Width)528 m.SetBorder(c.cfg.Border.Width)
529 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)529 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
530 m.SetStashCapture(c.cfg.Stash.Capture)530 m.SetStashCapture(c.cfg.Stash.Capture)
531+ m.SetStashTerminal(c.cfg.Stash.SpawnTerminal && len(c.cfg.Terminal.Command) > 0)
531 m.SetFollowMove(c.cfg.Focus.FollowMove)532 m.SetFollowMove(c.cfg.Focus.FollowMove)
532 }533 }
533 534
M internal/core/spawn_placement.go +13 -0
@@ -23,6 +23,8 @@ const (
2323 spawnSlot spawnKind = iota
2424 // spawnTerminal is an automatic terminal for an empty workspace.
2525 spawnTerminal
26+ // spawnStash is a window opened straight into the stash of ws.
27+ spawnStash
2628 )
2729
2830 // spawnTarget identifies the destination of one pending spawn. slot is set
@@ -106,6 +108,8 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
106108 return
107109 }
108110 if v.Slot != "" {
111+ // A claim no window answered in time no longer places one.
112+ p.expireWorkspaceClaims(c.now())
109113 if target, ok := p.pending[v.Slot]; ok {
110114 switch target.kind {
111115 case spawnTerminal:
@@ -117,6 +121,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
117121 return
118122 }
119123 }
124+ case spawnStash:
125+ delete(p.pending, v.Slot)
126+ for _, s := range c.screens {
127+ if s.mon.has(target.ws) {
128+ target.ws.stashSpawned(v.ID)
129+ s.mon.normalize()
130+ return
131+ }
132+ }
120133 case spawnSlot:
121134 if st := c.slots[target.slot]; st != nil && st.window == 0 {
122135 if sc, w := c.byName(target.slot.workspace); w != nil {
@@ -23,6 +23,8 @@ const (
23 spawnSlot spawnKind = iota23 spawnSlot spawnKind = iota
24 // spawnTerminal is an automatic terminal for an empty workspace.24 // spawnTerminal is an automatic terminal for an empty workspace.
25 spawnTerminal25 spawnTerminal
26+ // spawnStash is a window opened straight into the stash of ws.
27+ spawnStash
26 )28 )
27 29
28 // spawnTarget identifies the destination of one pending spawn. slot is set30 // spawnTarget identifies the destination of one pending spawn. slot is set
@@ -106,6 +108,8 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
106 return108 return
107 }109 }
108 if v.Slot != "" {110 if v.Slot != "" {
111+ // A claim no window answered in time no longer places one.
112+ p.expireWorkspaceClaims(c.now())
109 if target, ok := p.pending[v.Slot]; ok {113 if target, ok := p.pending[v.Slot]; ok {
110 switch target.kind {114 switch target.kind {
111 case spawnTerminal:115 case spawnTerminal:
@@ -117,6 +121,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
117 return121 return
118 }122 }
119 }123 }
124+ case spawnStash:
125+ delete(p.pending, v.Slot)
126+ for _, s := range c.screens {
127+ if s.mon.has(target.ws) {
128+ target.ws.stashSpawned(v.ID)
129+ s.mon.normalize()
130+ return
131+ }
132+ }
120 case spawnSlot:133 case spawnSlot:
121 if st := c.slots[target.slot]; st != nil && st.window == 0 {134 if st := c.slots[target.slot]; st != nil && st.window == 0 {
122 if sc, w := c.byName(target.slot.workspace); w != nil {135 if sc, w := c.byName(target.slot.workspace); w != nil {
M internal/core/stash.go +10 -0
@@ -81,6 +81,16 @@ func (w *Workspace) captureStash(id WindowID) bool {
8181 return true
8282 }
8383
84+// stashSpawned puts a window opened by a stash spawn in the stash, selected
85+// and focused, with no former column: toggle-window-stash tiles it as a new
86+// column. Under a covering fullscreen window the stash shows over it.
87+func (w *Workspace) stashSpawned(id WindowID) {
88+ w.addStash(Float{ID: id})
89+ if w.cover() != 0 && w.stashIndex(w.cover()) < 0 {
90+ w.showStashOver()
91+ }
92+}
93+
8494 // showStash shows the stash and gives it the focus; a member whose
8595 // fullscreen waited while hidden is fullscreen again, selected, as if it
8696 // asked now.
@@ -81,6 +81,16 @@ func (w *Workspace) captureStash(id WindowID) bool {
81 return true81 return true
82 }82 }
83 83
84+// stashSpawned puts a window opened by a stash spawn in the stash, selected
85+// and focused, with no former column: toggle-window-stash tiles it as a new
86+// column. Under a covering fullscreen window the stash shows over it.
87+func (w *Workspace) stashSpawned(id WindowID) {
88+ w.addStash(Float{ID: id})
89+ if w.cover() != 0 && w.stashIndex(w.cover()) < 0 {
90+ w.showStashOver()
91+ }
92+}
93+
84 // showStash shows the stash and gives it the focus; a member whose94 // showStash shows the stash and gives it the focus; a member whose
85 // fullscreen waited while hidden is fullscreen again, selected, as if it95 // fullscreen waited while hidden is fullscreen again, selected, as if it
86 // asked now.96 // asked now.
A internal/core/stash_spawn_internal_test.go +109 -0
@@ -0,0 +1,109 @@
1+package core
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core {
13+ t.Helper()
14+ var cfg ports.Config
15+ cfg.Keyboard.CmdKey = "alt"
16+ cfg.Layout.MaxColumns = 2
17+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{})
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
22+ return c
23+}
24+
25+func claimToken(req ports.SpawnRequest) string {
26+ return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
27+}
28+
29+// Wayland reads tokens only while slots are pending: the flag is sent before
30+// the spawn can reach the launcher.
31+func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
32+ commands := make(chan ports.ClientCommand) // unbuffered: sends are observable
33+ spawn := make(chan ports.SpawnRequest, 1)
34+ c := stashSpawnCore(t, commands, spawn)
35+ done := make(chan error, 1)
36+ go func() {
37+ done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false)
38+ }()
39+ // The send blocks on the unread command: nothing may be spawned yet.
40+ select {
41+ case req := <-spawn:
42+ t.Fatalf("spawned before the pending flag was delivered: %v", req)
43+ case <-time.After(50 * time.Millisecond):
44+ }
45+ select {
46+ case v := <-commands:
47+ if p, ok := v.(ports.SlotsPending); !ok || !p.Pending {
48+ t.Fatalf("command %v", v)
49+ }
50+ case <-time.After(time.Second):
51+ t.Fatal("no SlotsPending")
52+ }
53+ if err := <-done; err != nil {
54+ t.Fatal(err)
55+ }
56+ if len(spawn) != 1 {
57+ t.Fatal("no spawn")
58+ }
59+}
60+
61+// A send the launcher queue suppresses leaves no claim behind, and the
62+// pending flag falls again at the next publish.
63+func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
64+ commands := make(chan ports.ClientCommand, 8)
65+ c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed
66+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
67+ t.Fatal(err)
68+ }
69+ if c.placement.anyPending() {
70+ t.Fatal("suppressed claims stayed pending")
71+ }
72+ if v := (<-commands).(ports.SlotsPending); !v.Pending {
73+ t.Fatal("pending not announced")
74+ }
75+ if err := c.publishPending(context.Background()); err != nil {
76+ t.Fatal(err)
77+ }
78+ if v := (<-commands).(ports.SlotsPending); v.Pending {
79+ t.Fatal("stale pending flag not republished")
80+ }
81+}
82+
83+// An unanswered claim expires like a terminal's, even if no publish ran
84+// between its age and the window mapping.
85+func TestStashClaimExpires(t *testing.T) {
86+ c := stashSpawnCore(t, nil, nil)
87+ m := c.cur().mon
88+ m.AddWindow(1)
89+ target := m.Current()
90+ m.show(m.Workspaces[1])
91+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)})
92+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
93+ if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 {
94+ t.Fatal("expired claim still placed the window in the stash")
95+ }
96+}
97+
98+// A workspace that left every screen no longer takes the window.
99+func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
100+ c := stashSpawnCore(t, nil, nil)
101+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()})
102+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
103+ if _, w := c.screenOf(5); w != c.cur().mon.Current() {
104+ t.Fatal("window did not map onto the current workspace")
105+ }
106+ if c.placement.anyPending() {
107+ t.Fatal("claim kept")
108+ }
109+}
@@ -0,0 +1,109 @@
1+package core
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core {
13+ t.Helper()
14+ var cfg ports.Config
15+ cfg.Keyboard.CmdKey = "alt"
16+ cfg.Layout.MaxColumns = 2
17+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{})
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
22+ return c
23+}
24+
25+func claimToken(req ports.SpawnRequest) string {
26+ return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
27+}
28+
29+// Wayland reads tokens only while slots are pending: the flag is sent before
30+// the spawn can reach the launcher.
31+func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
32+ commands := make(chan ports.ClientCommand) // unbuffered: sends are observable
33+ spawn := make(chan ports.SpawnRequest, 1)
34+ c := stashSpawnCore(t, commands, spawn)
35+ done := make(chan error, 1)
36+ go func() {
37+ done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false)
38+ }()
39+ // The send blocks on the unread command: nothing may be spawned yet.
40+ select {
41+ case req := <-spawn:
42+ t.Fatalf("spawned before the pending flag was delivered: %v", req)
43+ case <-time.After(50 * time.Millisecond):
44+ }
45+ select {
46+ case v := <-commands:
47+ if p, ok := v.(ports.SlotsPending); !ok || !p.Pending {
48+ t.Fatalf("command %v", v)
49+ }
50+ case <-time.After(time.Second):
51+ t.Fatal("no SlotsPending")
52+ }
53+ if err := <-done; err != nil {
54+ t.Fatal(err)
55+ }
56+ if len(spawn) != 1 {
57+ t.Fatal("no spawn")
58+ }
59+}
60+
61+// A send the launcher queue suppresses leaves no claim behind, and the
62+// pending flag falls again at the next publish.
63+func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
64+ commands := make(chan ports.ClientCommand, 8)
65+ c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed
66+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
67+ t.Fatal(err)
68+ }
69+ if c.placement.anyPending() {
70+ t.Fatal("suppressed claims stayed pending")
71+ }
72+ if v := (<-commands).(ports.SlotsPending); !v.Pending {
73+ t.Fatal("pending not announced")
74+ }
75+ if err := c.publishPending(context.Background()); err != nil {
76+ t.Fatal(err)
77+ }
78+ if v := (<-commands).(ports.SlotsPending); v.Pending {
79+ t.Fatal("stale pending flag not republished")
80+ }
81+}
82+
83+// An unanswered claim expires like a terminal's, even if no publish ran
84+// between its age and the window mapping.
85+func TestStashClaimExpires(t *testing.T) {
86+ c := stashSpawnCore(t, nil, nil)
87+ m := c.cur().mon
88+ m.AddWindow(1)
89+ target := m.Current()
90+ m.show(m.Workspaces[1])
91+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)})
92+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
93+ if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 {
94+ t.Fatal("expired claim still placed the window in the stash")
95+ }
96+}
97+
98+// A workspace that left every screen no longer takes the window.
99+func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
100+ c := stashSpawnCore(t, nil, nil)
101+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()})
102+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
103+ if _, w := c.screenOf(5); w != c.cur().mon.Current() {
104+ t.Fatal("window did not map onto the current workspace")
105+ }
106+ if c.placement.anyPending() {
107+ t.Fatal("claim kept")
108+ }
109+}
A internal/core/stash_spawn_test.go +171 -0
@@ -0,0 +1,171 @@
1+package core_test
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// stashSpawnRig has a stash bind on Alt+f and, as in the defaults, foot as
11+// the terminal. Tiles map without any automatic terminal.
12+func stashSpawnRig(t *testing.T, edit func(*ports.Config)) *multiRig {
13+ t.Helper()
14+ return startMulti(t, func(c *ports.Config) {
15+ c.Binds["Alt+Ctrl+Shift+f"] = "stash firefox --new-window"
16+ c.Stash.Capture = false
17+ if edit != nil {
18+ edit(c)
19+ }
20+ }, left)
21+}
22+
23+func slotToken(t *testing.T, req ports.SpawnRequest) string {
24+ t.Helper()
25+ if len(req.Env) != 1 || len(req.Env[0]) <= len(ports.SlotEnv)+1 || req.Env[0][:len(ports.SlotEnv)+1] != ports.SlotEnv+"=" {
26+ t.Fatalf("no slot token: %v", req.Env)
27+ }
28+ return req.Env[0][len(ports.SlotEnv)+1:]
29+}
30+
31+// pendingSent drains the command queue and reports the last SlotsPending.
32+func pendingSent(r *multiRig) (pending, sent bool) {
33+ for len(r.commands) > 0 {
34+ if v, ok := (<-r.commands).(ports.SlotsPending); ok {
35+ pending, sent = v.Pending, true
36+ }
37+ }
38+ return pending, sent
39+}
40+
41+func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
42+ for _, s := range set {
43+ for _, w := range s.Windows {
44+ if w.ID == id {
45+ return w, true
46+ }
47+ }
48+ }
49+ return ports.SceneWindow{}, false
50+}
51+
52+// The window of a stash bind opens in the stash of the workspace the key
53+// was pressed on, shown and focused, even when the user moved on before it
54+// mapped; toggle-window-stash then tiles it as a new column.
55+func TestStashBindOpensWindowInStash(t *testing.T) {
56+ r := stashSpawnRig(t, nil)
57+ r.mapWindow(t, 1)
58+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
59+ req := receive(t, r.spawn)
60+ if !slices.Equal(req.Argv, []string{"firefox", "--new-window"}) {
61+ t.Fatalf("argv %v", req.Argv)
62+ }
63+ token := slotToken(t, req)
64+ if pending, sent := pendingSent(r); !sent || !pending {
65+ t.Fatal("SlotsPending{true} was not sent")
66+ }
67+ r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2
68+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
69+ set := receive(t, r.scenes)
70+ if w, ok := sceneWindow(set, 2); ok && !w.Hidden {
71+ t.Fatalf("window shown on the workspace the user moved to: %+v", w)
72+ }
73+ set = r.key(t, "Prior", ports.ModAlt)
74+ w, ok := sceneWindow(set, 2)
75+ if !ok || w.Hidden || !w.Floating || !w.Focused {
76+ t.Fatalf("stashed window %+v", w)
77+ }
78+ if w1, _ := sceneWindow(set, 1); w1.Focused {
79+ t.Fatal("tile kept the focus")
80+ }
81+ if pending, sent := pendingSent(r); !sent || pending {
82+ t.Fatalf("pending flag not cleared: %v %v", pending, sent)
83+ }
84+ // Unstashing tiles it as a column of its own.
85+ set = r.key(t, "s", ports.ModAlt|ports.ModShift)
86+ if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {
87+ t.Fatalf("unstashed window %+v", w)
88+ }
89+ if len(r.spawn) != 0 {
90+ t.Fatal("unexpected spawn")
91+ }
92+}
93+
94+func TestStashBindRunsEveryCommand(t *testing.T) {
95+ r := stashSpawnRig(t, func(c *ports.Config) { c.Binds["Alt+Ctrl+Shift+f"] = "stash one; two" })
96+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
97+ a, b := receive(t, r.spawn), receive(t, r.spawn)
98+ if a.Argv[0] != "one" || b.Argv[0] != "two" || slotToken(t, a) == slotToken(t, b) {
99+ t.Fatal(a, b)
100+ }
101+}
102+
103+// With an empty stash, toggle-stash-visible opens the terminal in it, once
104+// while it is awaited.
105+func TestToggleEmptyStashOpensTerminal(t *testing.T) {
106+ r := stashSpawnRig(t, nil)
107+ r.mapWindow(t, 1)
108+ r.key(t, "s", ports.ModAlt)
109+ req := receive(t, r.spawn)
110+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
111+ t.Fatalf("argv %v", req.Argv)
112+ }
113+ token := slotToken(t, req)
114+ if pending, sent := pendingSent(r); !sent || !pending {
115+ t.Fatal("SlotsPending{true} was not sent")
116+ }
117+ r.key(t, "s", ports.ModAlt)
118+ if len(r.spawn) != 0 {
119+ t.Fatal("second terminal while the first is awaited")
120+ }
121+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
122+ set := receive(t, r.scenes)
123+ if w, ok := sceneWindow(set, 2); !ok || w.Hidden || !w.Floating || !w.Focused {
124+ t.Fatalf("terminal %+v", w)
125+ }
126+ // The stash has a window now: the toggle hides it, no spawn.
127+ set = r.key(t, "s", ports.ModAlt)
128+ if w, _ := sceneWindow(set, 2); !w.Hidden {
129+ t.Fatalf("stash not hidden: %+v", w)
130+ }
131+ if len(r.spawn) != 0 {
132+ t.Fatal("spawned for a stash holding a window")
133+ }
134+}
135+
136+func TestToggleNonEmptyStashDoesNotSpawn(t *testing.T) {
137+ r := stashSpawnRig(t, nil)
138+ r.mapWindow(t, 1)
139+ r.mapWindow(t, 2)
140+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
141+ r.key(t, "s", ports.ModAlt)
142+ r.key(t, "s", ports.ModAlt)
143+ if len(r.spawn) != 0 {
144+ t.Fatal("spawned for a non-empty stash")
145+ }
146+}
147+
148+func TestToggleEmptyStashRespectsOption(t *testing.T) {
149+ r := stashSpawnRig(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
150+ r.mapWindow(t, 1)
151+ r.key(t, "s", ports.ModAlt)
152+ if len(r.spawn) != 0 {
153+ t.Fatal("spawned with stash.spawn-terminal off")
154+ }
155+ r = stashSpawnRig(t, func(c *ports.Config) { c.Terminal.Command = nil })
156+ r.mapWindow(t, 1)
157+ r.key(t, "s", ports.ModAlt)
158+ if len(r.spawn) != 0 {
159+ t.Fatal("spawned without a terminal command")
160+ }
161+}
162+
163+func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) {
164+ r := stashSpawnRig(t, nil)
165+ r.mapWindow(t, 1)
166+ r.key(t, "o", ports.ModAlt)
167+ r.key(t, "s", ports.ModAlt)
168+ if len(r.spawn) != 0 {
169+ t.Fatal("spawned from the overview")
170+ }
171+}
@@ -0,0 +1,171 @@
1+package core_test
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// stashSpawnRig has a stash bind on Alt+f and, as in the defaults, foot as
11+// the terminal. Tiles map without any automatic terminal.
12+func stashSpawnRig(t *testing.T, edit func(*ports.Config)) *multiRig {
13+ t.Helper()
14+ return startMulti(t, func(c *ports.Config) {
15+ c.Binds["Alt+Ctrl+Shift+f"] = "stash firefox --new-window"
16+ c.Stash.Capture = false
17+ if edit != nil {
18+ edit(c)
19+ }
20+ }, left)
21+}
22+
23+func slotToken(t *testing.T, req ports.SpawnRequest) string {
24+ t.Helper()
25+ if len(req.Env) != 1 || len(req.Env[0]) <= len(ports.SlotEnv)+1 || req.Env[0][:len(ports.SlotEnv)+1] != ports.SlotEnv+"=" {
26+ t.Fatalf("no slot token: %v", req.Env)
27+ }
28+ return req.Env[0][len(ports.SlotEnv)+1:]
29+}
30+
31+// pendingSent drains the command queue and reports the last SlotsPending.
32+func pendingSent(r *multiRig) (pending, sent bool) {
33+ for len(r.commands) > 0 {
34+ if v, ok := (<-r.commands).(ports.SlotsPending); ok {
35+ pending, sent = v.Pending, true
36+ }
37+ }
38+ return pending, sent
39+}
40+
41+func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
42+ for _, s := range set {
43+ for _, w := range s.Windows {
44+ if w.ID == id {
45+ return w, true
46+ }
47+ }
48+ }
49+ return ports.SceneWindow{}, false
50+}
51+
52+// The window of a stash bind opens in the stash of the workspace the key
53+// was pressed on, shown and focused, even when the user moved on before it
54+// mapped; toggle-window-stash then tiles it as a new column.
55+func TestStashBindOpensWindowInStash(t *testing.T) {
56+ r := stashSpawnRig(t, nil)
57+ r.mapWindow(t, 1)
58+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
59+ req := receive(t, r.spawn)
60+ if !slices.Equal(req.Argv, []string{"firefox", "--new-window"}) {
61+ t.Fatalf("argv %v", req.Argv)
62+ }
63+ token := slotToken(t, req)
64+ if pending, sent := pendingSent(r); !sent || !pending {
65+ t.Fatal("SlotsPending{true} was not sent")
66+ }
67+ r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2
68+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
69+ set := receive(t, r.scenes)
70+ if w, ok := sceneWindow(set, 2); ok && !w.Hidden {
71+ t.Fatalf("window shown on the workspace the user moved to: %+v", w)
72+ }
73+ set = r.key(t, "Prior", ports.ModAlt)
74+ w, ok := sceneWindow(set, 2)
75+ if !ok || w.Hidden || !w.Floating || !w.Focused {
76+ t.Fatalf("stashed window %+v", w)
77+ }
78+ if w1, _ := sceneWindow(set, 1); w1.Focused {
79+ t.Fatal("tile kept the focus")
80+ }
81+ if pending, sent := pendingSent(r); !sent || pending {
82+ t.Fatalf("pending flag not cleared: %v %v", pending, sent)
83+ }
84+ // Unstashing tiles it as a column of its own.
85+ set = r.key(t, "s", ports.ModAlt|ports.ModShift)
86+ if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {
87+ t.Fatalf("unstashed window %+v", w)
88+ }
89+ if len(r.spawn) != 0 {
90+ t.Fatal("unexpected spawn")
91+ }
92+}
93+
94+func TestStashBindRunsEveryCommand(t *testing.T) {
95+ r := stashSpawnRig(t, func(c *ports.Config) { c.Binds["Alt+Ctrl+Shift+f"] = "stash one; two" })
96+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
97+ a, b := receive(t, r.spawn), receive(t, r.spawn)
98+ if a.Argv[0] != "one" || b.Argv[0] != "two" || slotToken(t, a) == slotToken(t, b) {
99+ t.Fatal(a, b)
100+ }
101+}
102+
103+// With an empty stash, toggle-stash-visible opens the terminal in it, once
104+// while it is awaited.
105+func TestToggleEmptyStashOpensTerminal(t *testing.T) {
106+ r := stashSpawnRig(t, nil)
107+ r.mapWindow(t, 1)
108+ r.key(t, "s", ports.ModAlt)
109+ req := receive(t, r.spawn)
110+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
111+ t.Fatalf("argv %v", req.Argv)
112+ }
113+ token := slotToken(t, req)
114+ if pending, sent := pendingSent(r); !sent || !pending {
115+ t.Fatal("SlotsPending{true} was not sent")
116+ }
117+ r.key(t, "s", ports.ModAlt)
118+ if len(r.spawn) != 0 {
119+ t.Fatal("second terminal while the first is awaited")
120+ }
121+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
122+ set := receive(t, r.scenes)
123+ if w, ok := sceneWindow(set, 2); !ok || w.Hidden || !w.Floating || !w.Focused {
124+ t.Fatalf("terminal %+v", w)
125+ }
126+ // The stash has a window now: the toggle hides it, no spawn.
127+ set = r.key(t, "s", ports.ModAlt)
128+ if w, _ := sceneWindow(set, 2); !w.Hidden {
129+ t.Fatalf("stash not hidden: %+v", w)
130+ }
131+ if len(r.spawn) != 0 {
132+ t.Fatal("spawned for a stash holding a window")
133+ }
134+}
135+
136+func TestToggleNonEmptyStashDoesNotSpawn(t *testing.T) {
137+ r := stashSpawnRig(t, nil)
138+ r.mapWindow(t, 1)
139+ r.mapWindow(t, 2)
140+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
141+ r.key(t, "s", ports.ModAlt)
142+ r.key(t, "s", ports.ModAlt)
143+ if len(r.spawn) != 0 {
144+ t.Fatal("spawned for a non-empty stash")
145+ }
146+}
147+
148+func TestToggleEmptyStashRespectsOption(t *testing.T) {
149+ r := stashSpawnRig(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
150+ r.mapWindow(t, 1)
151+ r.key(t, "s", ports.ModAlt)
152+ if len(r.spawn) != 0 {
153+ t.Fatal("spawned with stash.spawn-terminal off")
154+ }
155+ r = stashSpawnRig(t, func(c *ports.Config) { c.Terminal.Command = nil })
156+ r.mapWindow(t, 1)
157+ r.key(t, "s", ports.ModAlt)
158+ if len(r.spawn) != 0 {
159+ t.Fatal("spawned without a terminal command")
160+ }
161+}
162+
163+func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) {
164+ r := stashSpawnRig(t, nil)
165+ r.mapWindow(t, 1)
166+ r.key(t, "o", ports.ModAlt)
167+ r.key(t, "s", ports.ModAlt)
168+ if len(r.spawn) != 0 {
169+ t.Fatal("spawned from the overview")
170+ }
171+}
M internal/core/terminal.go +7 -4
@@ -49,12 +49,15 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
4949 return reqs
5050 }
5151
52-// cancelTerminal removes a claim minted for a send suppressed by the gate.
53-func (c *Core) cancelTerminal(req ports.SpawnRequest) {
52+// cancelClaim removes a workspace claim minted for a send suppressed by the
53+// gate. A terminal may be requested again at once.
54+func (c *Core) cancelClaim(req ports.SpawnRequest) {
5455 for _, env := range req.Env {
5556 if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok {
56- if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal {
57- target.ws.termAt = time.Time{}
57+ if target, ok := c.placement.pending[token]; ok && target.kind != spawnSlot {
58+ if target.kind == spawnTerminal {
59+ target.ws.termAt = time.Time{}
60+ }
5861 delete(c.placement.pending, token)
5962 }
6063 }
@@ -49,12 +49,15 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
49 return reqs49 return reqs
50 }50 }
51 51
52-// cancelTerminal removes a claim minted for a send suppressed by the gate.52+// cancelClaim removes a workspace claim minted for a send suppressed by the
53-func (c *Core) cancelTerminal(req ports.SpawnRequest) {53+// gate. A terminal may be requested again at once.
54+func (c *Core) cancelClaim(req ports.SpawnRequest) {
54 for _, env := range req.Env {55 for _, env := range req.Env {
55 if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok {56 if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok {
56- if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal {57+ if target, ok := c.placement.pending[token]; ok && target.kind != spawnSlot {
57- target.ws.termAt = time.Time{}58+ if target.kind == spawnTerminal {
59+ target.ws.termAt = time.Time{}
60+ }
58 delete(c.placement.pending, token)61 delete(c.placement.pending, token)
59 }62 }
60 }63 }
M internal/ports/config.go +6 -4
@@ -121,11 +121,13 @@ type Config struct {
121121 // Width is the width of its selected window (10 to 90) and Gap the
122122 // space between it and its neighbors (0 to 10), in percent of the
123123 // usable width; Dim darkens the neighbors, 0 to 1. Capture puts new
124- // windows in the stash while it is shown.
124+ // windows in the stash while it is shown. SpawnTerminal makes
125+ // toggle-stash-visible on an empty stash open the terminal in it.
125126 Stash struct {
126- Width, Gap int
127- Dim float64
128- Capture bool
127+ Width, Gap int
128+ Dim float64
129+ Capture bool
130+ SpawnTerminal bool
129131 }
130132 // Border is drawn inside the window edge; Width 0 disables it.
131133 Border struct {
@@ -121,11 +121,13 @@ type Config struct {
121 // Width is the width of its selected window (10 to 90) and Gap the121 // Width is the width of its selected window (10 to 90) and Gap the
122 // space between it and its neighbors (0 to 10), in percent of the122 // space between it and its neighbors (0 to 10), in percent of the
123 // usable width; Dim darkens the neighbors, 0 to 1. Capture puts new123 // usable width; Dim darkens the neighbors, 0 to 1. Capture puts new
124- // windows in the stash while it is shown.124+ // windows in the stash while it is shown. SpawnTerminal makes
125+ // toggle-stash-visible on an empty stash open the terminal in it.
125 Stash struct {126 Stash struct {
126- Width, Gap int127+ Width, Gap int
127- Dim float64128+ Dim float64
128- Capture bool129+ Capture bool
130+ SpawnTerminal bool
129 }131 }
130 // Border is drawn inside the window edge; Width 0 disables it.132 // Border is drawn inside the window edge; Width 0 disables it.
131 Border struct {133 Border struct {

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