detached ea46f39

Merge pull request 'feat(core): open apps straight into the stash' (#52) from feat/stash-spawn-terminal into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/52
commit
ea46f397f340d64c76c4c7f84ed22769b61cc15b
author
bnema · · 2026-10-14 20:55 UTC
merge of
24a63d3 + 1e7b844 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
20 files · +790 -87
M AGENTS.md +11 -0
@@ -24,6 +24,17 @@ Test doubles are always generated by Mockery v3 (`testify` template). Never hand
2424 - production constructor parameters such as `NewRenderer func(w, h) (ports.Renderer, error)`, where tests return a generated mock;
2525 - channels passed to `app.run` to inject input or observe scenes (channel ports).
2626
27+## End-to-end tests (headless backend)
28+
29+Unit tests are not enough for user-facing behavior (binds, gestures, window placement, config keys). Test it end to end on the headless backend before declaring it done; see [docs/headless.md](docs/headless.md).
30+
31+- Build the branch binary (`CGO_ENABLED=0 go build -o <tmp>/neferwl ./cmd/neferwl`) and run it with a throwaway config: `<tmp>/neferwl --backend=headless --config <tmp>/config --no-xwayland --screenshot <tmp>/shots --input <script> --timeout 10s`.
32+- Input scripts drive keys and gestures (`key Super+s`, `swipe4 down`, `sleep 1s`); see the table in docs/headless.md.
33+- Logs go to `$XDG_STATE_HOME/neferwl/runs/headless/latest.log`, not stderr. Read the headless `WAYLAND_DISPLAY` from its `listening` entry.
34+- Isolate from the user's live session: run the headless compositor, its clients and `neferwl state` with `env -u NEFERWL_STATE -u NEFERWL_SLOT WAYLAND_DISPLAY=<headless>`. `NEFERWL_STATE` wins over `WAYLAND_DISPLAY` and would read the live session's state.
35+- Check results with `WAYLAND_DISPLAY=<headless> neferwl state` (windows, stash index/count, visibility) and the `latest.png` screenshot.
36+
2737 ## Checks before merge
2838
2939 `make check`, `make race`, `make mocks-check` and `staticcheck ./...` must pass.
40+User-facing changes are also tested end to end on the headless backend.
@@ -24,6 +24,17 @@ Test doubles are always generated by Mockery v3 (`testify` template). Never hand
24 - production constructor parameters such as `NewRenderer func(w, h) (ports.Renderer, error)`, where tests return a generated mock;24 - production constructor parameters such as `NewRenderer func(w, h) (ports.Renderer, error)`, where tests return a generated mock;
25 - channels passed to `app.run` to inject input or observe scenes (channel ports).25 - channels passed to `app.run` to inject input or observe scenes (channel ports).
26 26
27+## End-to-end tests (headless backend)
28+
29+Unit tests are not enough for user-facing behavior (binds, gestures, window placement, config keys). Test it end to end on the headless backend before declaring it done; see [docs/headless.md](docs/headless.md).
30+
31+- Build the branch binary (`CGO_ENABLED=0 go build -o <tmp>/neferwl ./cmd/neferwl`) and run it with a throwaway config: `<tmp>/neferwl --backend=headless --config <tmp>/config --no-xwayland --screenshot <tmp>/shots --input <script> --timeout 10s`.
32+- Input scripts drive keys and gestures (`key Super+s`, `swipe4 down`, `sleep 1s`); see the table in docs/headless.md.
33+- Logs go to `$XDG_STATE_HOME/neferwl/runs/headless/latest.log`, not stderr. Read the headless `WAYLAND_DISPLAY` from its `listening` entry.
34+- Isolate from the user's live session: run the headless compositor, its clients and `neferwl state` with `env -u NEFERWL_STATE -u NEFERWL_SLOT WAYLAND_DISPLAY=<headless>`. `NEFERWL_STATE` wins over `WAYLAND_DISPLAY` and would read the live session's state.
35+- Check results with `WAYLAND_DISPLAY=<headless> neferwl state` (windows, stash index/count, visibility) and the `latest.png` screenshot.
36+
27 ## Checks before merge37 ## Checks before merge
28 38
29 `make check`, `make race`, `make mocks-check` and `staticcheck ./...` must pass.39 `make check`, `make race`, `make mocks-check` and `staticcheck ./...` must pass.
40+User-facing changes are also tested end to end on the headless backend.
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` or the four-finger swipe down on an empty stash does the same with `terminal`.
257+
258+```ini
259+bind.cmd+shift+f = stash firefox
260+```
261+
262+The window must appear within 5 seconds, otherwise it opens normally. 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` or the four-finger swipe down on an empty stash does the same with `terminal`.
257+
258+```ini
259+bind.cmd+shift+f = stash firefox
260+```
261+
262+The window must appear within 5 seconds, otherwise it opens normally. 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 +23 -9
@@ -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
@@ -1243,19 +1247,29 @@ func (c *Core) spawnEmpty(ctx context.Context) error {
12431247 }
12441248 first := c.firstTerminalResolved
12451249 terms := c.fillEmpty()
1246- err := c.publishPending(ctx)
1247- for i, req := range terms {
1250+ issued, err := c.dispatch(ctx, terms)
1251+ if len(terms) > 0 && issued == 0 {
1252+ c.firstTerminalResolved = first
1253+ }
1254+ return err
1255+}
1256+
1257+// dispatch sends reqs to the launcher and reports how many it issued.
1258+// Wayland reads claim tokens only while it knows spawns are pending, so the
1259+// flag is published before the first send. From the first request that is
1260+// not issued (publication failed, or the gate suppressed the send), the
1261+// rest are not sent and their claims are cancelled.
1262+func (c *Core) dispatch(ctx context.Context, reqs []ports.SpawnRequest) (issued int, err error) {
1263+ err = c.publishPending(ctx)
1264+ for i, req := range reqs {
12481265 if err != nil || !c.trySpawn(ctx, req) {
1249- for _, suppressed := range terms[i:] {
1250- c.cancelTerminal(suppressed)
1251- }
1252- if i == 0 {
1253- c.firstTerminalResolved = first
1266+ for _, suppressed := range reqs[i:] {
1267+ c.cancelClaim(suppressed)
12541268 }
1255- return err
1269+ return i, err
12561270 }
12571271 }
1258- return err
1272+ return len(reqs), err
12591273 }
12601274
12611275 // activate brings a window forward for a valid xdg-activation token. The
@@ -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
@@ -1243,19 +1247,29 @@ func (c *Core) spawnEmpty(ctx context.Context) error {
1243 }1247 }
1244 first := c.firstTerminalResolved1248 first := c.firstTerminalResolved
1245 terms := c.fillEmpty()1249 terms := c.fillEmpty()
1246- err := c.publishPending(ctx)1250+ issued, err := c.dispatch(ctx, terms)
1247- for i, req := range terms {1251+ if len(terms) > 0 && issued == 0 {
1252+ c.firstTerminalResolved = first
1253+ }
1254+ return err
1255+}
1256+
1257+// dispatch sends reqs to the launcher and reports how many it issued.
1258+// Wayland reads claim tokens only while it knows spawns are pending, so the
1259+// flag is published before the first send. From the first request that is
1260+// not issued (publication failed, or the gate suppressed the send), the
1261+// rest are not sent and their claims are cancelled.
1262+func (c *Core) dispatch(ctx context.Context, reqs []ports.SpawnRequest) (issued int, err error) {
1263+ err = c.publishPending(ctx)
1264+ for i, req := range reqs {
1248 if err != nil || !c.trySpawn(ctx, req) {1265 if err != nil || !c.trySpawn(ctx, req) {
1249- for _, suppressed := range terms[i:] {1266+ for _, suppressed := range reqs[i:] {
1250- c.cancelTerminal(suppressed)1267+ c.cancelClaim(suppressed)
1251- }
1252- if i == 0 {
1253- c.firstTerminalResolved = first
1254 }1268 }
1255- return err1269+ return i, err
1256 }1270 }
1257 }1271 }
1258- return err1272+ return len(reqs), err
1259 }1273 }
1260 1274
1261 // activate brings a window forward for a valid xdg-activation token. The1275 // activate brings a window forward for a valid xdg-activation token. The
M internal/core/gesture.go +28 -22
@@ -284,27 +284,28 @@ func (c *Core) decide(g *swipeGesture) {
284284 }
285285
286286 // swipeEnd lands the swipe. shown reports that another workspace came on
287-// screen.
288-func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
287+// screen. spawn is the workspace whose empty stash asked for its terminal
288+// (see swipeStash): the caller starts it.
289+func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) {
289290 g := c.swipe
290291 if g != nil && !c.hasScreen(g.screen) {
291292 c.dropSwipe()
292293 }
293294 c.swipe = nil
294295 if g == nil || !c.hasScreen(g.screen) {
295- return false
296+ return false, nil
296297 }
297298 now := c.now()
298299 m := g.screen.mon
299300 switch g.mode {
300301 case swipeNavigation:
301302 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
302- return false
303+ return false, nil
303304 case swipeColumns:
304305 w := g.ws
305306 if g.columnsChanged(m) {
306307 w.view.stop()
307- return false
308+ return false, nil
308309 }
309310 shown := float64(w.View) + w.view.off
310311 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -330,7 +331,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
330331 case swipeWorkspaces:
331332 if g.listChanged(m) {
332333 m.stopSwitch()
333- return false
334+ return false, nil
334335 }
335336 cur := float64(m.Active) + m.switchView.off
336337 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -359,7 +360,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
359360 w := g.ws
360361 if g.stashChanged(m) {
361362 w.stashView.stop()
362- return false
363+ return false, nil
363364 }
364365 drawn := float64(w.stashAt) + w.stashView.off
365366 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -384,17 +385,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
384385 w.stashView.stop()
385386 }
386387 // The stash never brings another workspace on screen.
387- return false
388+ return false, nil
388389 case swipeOverview:
389390 step := g.snap.step(e.Cancelled, e.Time)
390391 if step == 0 {
391- return false
392+ return false, nil
392393 }
393394 if !m.ov.open && (step > 0 || m.Current().stashShown()) {
394395 return c.swipeStash(g, now)
395396 }
396397 if (step < 0) == m.ov.open {
397- return false
398+ return false, nil
398399 }
399400 before := m.Current()
400401 shots := c.snapshot(now)
@@ -402,11 +403,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
402403 c.focusScreen = c.screenIndex(g.screen.name())
403404 c.keyboard.takeBack()
404405 c.transition(shots, now)
405- return m.Current() != before
406+ return m.Current() != before, nil
406407 case swipeDiscrete:
407408 step := g.snap.step(e.Cancelled, e.Time)
408409 if step == 0 {
409- return false
410+ return false, nil
410411 }
411412 w := g.screen.mon.Current()
412413 p := w.policy()
@@ -423,16 +424,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
423424 } else if p.wraps {
424425 // A content swipe cannot escape the last band to another workspace.
425426 if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
426- return false
427+ return false, nil
427428 }
428429 }
429430 // Focus actions act on the focused screen: the swipe's, unless the
430431 // pointer took the focus to another output meanwhile.
431432 if c.cur() != g.screen {
432- return false
433+ return false, nil
433434 }
434435 if c.cur().mon.ov.open {
435- return false
436+ return false, nil
436437 }
437438 before := c.cur().mon.Current()
438439 shots := c.snapshot(now)
@@ -443,26 +444,31 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
443444 c.applyAction(a)
444445 }
445446 c.transition(shots, now)
446- return c.cur().mon.Current() != before
447+ return c.cur().mon.Current() != before, nil
447448 }
448- return shown
449+ return shown, nil
449450 }
450451
451452 // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
452453 // while the stash is shown, with the overview closed, on the swipe's screen
453454 // unless the pointer took the focus to another output meanwhile. It reports
454-// whether the stash showed or hid: an empty stash stays as it is.
455-func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {
455+// whether the stash showed or hid. An empty stash asking for its terminal
456+// (stash.spawn-terminal) shows no workspace, as with the bind: spawn names the
457+// workspace for the caller to start it in.
458+func (c *Core) swipeStash(g *swipeGesture, now time.Time) (shown bool, spawn *Workspace) {
456459 if c.cur() != g.screen {
457- return false
460+ return false, nil
458461 }
459462 w := g.screen.mon.Current()
460463 hidden, over := w.stashHidden, w.stashOver
461464 shots := c.snapshot(now)
462465 c.keyboard.takeBack()
463- c.applyAction(ActionToggleStashVisible)
466+ effect := c.applyAction(ActionToggleStashVisible)
464467 c.transition(shots, now)
465- return w.stashHidden != hidden || w.stashOver != over
468+ if effect.Spawn {
469+ return false, w
470+ }
471+ return w.stashHidden != hidden || w.stashOver != over, nil
466472 }
467473
468474 // swipedWorkspace is the workspace on the swipe's screen, or nil without
@@ -284,27 +284,28 @@ func (c *Core) decide(g *swipeGesture) {
284 }284 }
285 285
286 // swipeEnd lands the swipe. shown reports that another workspace came on286 // swipeEnd lands the swipe. shown reports that another workspace came on
287-// screen.287+// screen. spawn is the workspace whose empty stash asked for its terminal
288-func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {288+// (see swipeStash): the caller starts it.
289+func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) {
289 g := c.swipe290 g := c.swipe
290 if g != nil && !c.hasScreen(g.screen) {291 if g != nil && !c.hasScreen(g.screen) {
291 c.dropSwipe()292 c.dropSwipe()
292 }293 }
293 c.swipe = nil294 c.swipe = nil
294 if g == nil || !c.hasScreen(g.screen) {295 if g == nil || !c.hasScreen(g.screen) {
295- return false296+ return false, nil
296 }297 }
297 now := c.now()298 now := c.now()
298 m := g.screen.mon299 m := g.screen.mon
299 switch g.mode {300 switch g.mode {
300 case swipeNavigation:301 case swipeNavigation:
301 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})302 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
302- return false303+ return false, nil
303 case swipeColumns:304 case swipeColumns:
304 w := g.ws305 w := g.ws
305 if g.columnsChanged(m) {306 if g.columnsChanged(m) {
306 w.view.stop()307 w.view.stop()
307- return false308+ return false, nil
308 }309 }
309 shown := float64(w.View) + w.view.off310 shown := float64(w.View) + w.view.off
310 target, velocity := g.snap.end(e.Cancelled, e.Time)311 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -330,7 +331,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
330 case swipeWorkspaces:331 case swipeWorkspaces:
331 if g.listChanged(m) {332 if g.listChanged(m) {
332 m.stopSwitch()333 m.stopSwitch()
333- return false334+ return false, nil
334 }335 }
335 cur := float64(m.Active) + m.switchView.off336 cur := float64(m.Active) + m.switchView.off
336 target, velocity := g.snap.end(e.Cancelled, e.Time)337 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -359,7 +360,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
359 w := g.ws360 w := g.ws
360 if g.stashChanged(m) {361 if g.stashChanged(m) {
361 w.stashView.stop()362 w.stashView.stop()
362- return false363+ return false, nil
363 }364 }
364 drawn := float64(w.stashAt) + w.stashView.off365 drawn := float64(w.stashAt) + w.stashView.off
365 target, velocity := g.snap.end(e.Cancelled, e.Time)366 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -384,17 +385,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
384 w.stashView.stop()385 w.stashView.stop()
385 }386 }
386 // The stash never brings another workspace on screen.387 // The stash never brings another workspace on screen.
387- return false388+ return false, nil
388 case swipeOverview:389 case swipeOverview:
389 step := g.snap.step(e.Cancelled, e.Time)390 step := g.snap.step(e.Cancelled, e.Time)
390 if step == 0 {391 if step == 0 {
391- return false392+ return false, nil
392 }393 }
393 if !m.ov.open && (step > 0 || m.Current().stashShown()) {394 if !m.ov.open && (step > 0 || m.Current().stashShown()) {
394 return c.swipeStash(g, now)395 return c.swipeStash(g, now)
395 }396 }
396 if (step < 0) == m.ov.open {397 if (step < 0) == m.ov.open {
397- return false398+ return false, nil
398 }399 }
399 before := m.Current()400 before := m.Current()
400 shots := c.snapshot(now)401 shots := c.snapshot(now)
@@ -402,11 +403,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
402 c.focusScreen = c.screenIndex(g.screen.name())403 c.focusScreen = c.screenIndex(g.screen.name())
403 c.keyboard.takeBack()404 c.keyboard.takeBack()
404 c.transition(shots, now)405 c.transition(shots, now)
405- return m.Current() != before406+ return m.Current() != before, nil
406 case swipeDiscrete:407 case swipeDiscrete:
407 step := g.snap.step(e.Cancelled, e.Time)408 step := g.snap.step(e.Cancelled, e.Time)
408 if step == 0 {409 if step == 0 {
409- return false410+ return false, nil
410 }411 }
411 w := g.screen.mon.Current()412 w := g.screen.mon.Current()
412 p := w.policy()413 p := w.policy()
@@ -423,16 +424,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
423 } else if p.wraps {424 } else if p.wraps {
424 // A content swipe cannot escape the last band to another workspace.425 // A content swipe cannot escape the last band to another workspace.
425 if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {426 if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
426- return false427+ return false, nil
427 }428 }
428 }429 }
429 // Focus actions act on the focused screen: the swipe's, unless the430 // Focus actions act on the focused screen: the swipe's, unless the
430 // pointer took the focus to another output meanwhile.431 // pointer took the focus to another output meanwhile.
431 if c.cur() != g.screen {432 if c.cur() != g.screen {
432- return false433+ return false, nil
433 }434 }
434 if c.cur().mon.ov.open {435 if c.cur().mon.ov.open {
435- return false436+ return false, nil
436 }437 }
437 before := c.cur().mon.Current()438 before := c.cur().mon.Current()
438 shots := c.snapshot(now)439 shots := c.snapshot(now)
@@ -443,26 +444,31 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
443 c.applyAction(a)444 c.applyAction(a)
444 }445 }
445 c.transition(shots, now)446 c.transition(shots, now)
446- return c.cur().mon.Current() != before447+ return c.cur().mon.Current() != before, nil
447 }448 }
448- return shown449+ return shown, nil
449 }450 }
450 451
451 // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up452 // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
452 // while the stash is shown, with the overview closed, on the swipe's screen453 // while the stash is shown, with the overview closed, on the swipe's screen
453 // unless the pointer took the focus to another output meanwhile. It reports454 // unless the pointer took the focus to another output meanwhile. It reports
454-// whether the stash showed or hid: an empty stash stays as it is.455+// whether the stash showed or hid. An empty stash asking for its terminal
455-func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {456+// (stash.spawn-terminal) shows no workspace, as with the bind: spawn names the
457+// workspace for the caller to start it in.
458+func (c *Core) swipeStash(g *swipeGesture, now time.Time) (shown bool, spawn *Workspace) {
456 if c.cur() != g.screen {459 if c.cur() != g.screen {
457- return false460+ return false, nil
458 }461 }
459 w := g.screen.mon.Current()462 w := g.screen.mon.Current()
460 hidden, over := w.stashHidden, w.stashOver463 hidden, over := w.stashHidden, w.stashOver
461 shots := c.snapshot(now)464 shots := c.snapshot(now)
462 c.keyboard.takeBack()465 c.keyboard.takeBack()
463- c.applyAction(ActionToggleStashVisible)466+ effect := c.applyAction(ActionToggleStashVisible)
464 c.transition(shots, now)467 c.transition(shots, now)
465- return w.stashHidden != hidden || w.stashOver != over468+ if effect.Spawn {
469+ return false, w
470+ }
471+ return w.stashHidden != hidden || w.stashOver != over, nil
466 }472 }
467 473
468 // swipedWorkspace is the workspace on the swipe's screen, or nil without474 // swipedWorkspace is the workspace on the swipe's screen, or nil without
M internal/core/input.go +61 -14
@@ -49,7 +49,14 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
4949 if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
5050 return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
5151 }
52- return c.slid(ctx, c.swipeEnd(v))
52+ shown, spawn := c.swipeEnd(v)
53+ if spawn != nil {
54+ // As a bind: the terminal starts before the new state is published.
55+ if err := c.runSpawn(ctx, spawn, Effect{Spawn: true, Stash: true}); err != nil {
56+ return err
57+ }
58+ }
59+ return c.slid(ctx, shown)
5360 case ports.PinchBegin:
5461 return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
5562 case ports.PinchUpdate:
@@ -342,19 +349,8 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
342349 return ErrQuit
343350 }
344351 if effect.Spawn {
345- commands := effect.Commands
346- if commands == nil {
347- commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
348- }
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
354- }
355- }
356- if c.inputEpochChanged() {
357- return errSecurityChanged
352+ if err := c.runSpawn(ctx, before, effect); err != nil {
353+ return err
358354 }
359355 }
360356 if c.swipedWorkspace() != swiped {
@@ -372,6 +368,57 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
372368 return c.publish(ctx)
373369 }
374370
371+// runSpawn starts the programs of a spawn effect of an action applied to w:
372+// the given commands, else the configured terminal. A stash effect opens the
373+// windows in the stash of w. It reports errSecurityChanged when the input
374+// epoch changed meanwhile.
375+func (c *Core) runSpawn(ctx context.Context, w *Workspace, effect Effect) error {
376+ commands := effect.Commands
377+ if commands == nil {
378+ commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
379+ }
380+ if effect.Stash {
381+ if err := c.spawnStash(ctx, w, commands, effect.Commands == nil); err != nil {
382+ return err
383+ }
384+ } else {
385+ // Explicit actions are suppressed on a full launcher queue;
386+ // never keep the input owner waiting across an epoch change.
387+ for _, argv := range commands {
388+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
389+ break
390+ }
391+ }
392+ }
393+ if c.inputEpochChanged() {
394+ return errSecurityChanged
395+ }
396+ return nil
397+}
398+
399+// spawnStash runs commands whose windows open in the stash of w, the
400+// workspace the bind acted on. Wayland reads the claim tokens only while it
401+// knows slots are pending, so that is published before the spawn, as
402+// spawnEmpty does. A send the gate suppresses leaves no claim behind. With
403+// terminal set (the terminal of an empty stash), nothing starts while the
404+// previous one is still awaited.
405+func (c *Core) spawnStash(ctx context.Context, w *Workspace, commands [][]string, terminal bool) error {
406+ if c.protectionRequested() {
407+ return nil
408+ }
409+ now := c.now()
410+ c.placement.expireWorkspaceClaims(now)
411+ if terminal && c.placement.workspacePending(w, spawnStash) {
412+ return nil
413+ }
414+ reqs := make([]ports.SpawnRequest, len(commands))
415+ for i, argv := range commands {
416+ reqs[i] = c.placement.request(argv, spawnTarget{kind: spawnStash, ws: w, at: now})
417+ }
418+ _, err := c.dispatch(ctx, reqs)
419+ return err
420+}
421+
375422 // scaleBind steps the focused output's scale and asks for it to be saved.
376423 func (c *Core) scaleBind(ctx context.Context, action Action) error {
377424 dir := 1
@@ -49,7 +49,14 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
49 if c.gesture.on && c.gesture.kind == ports.GestureSwipe {49 if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
50 return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)50 return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
51 }51 }
52- return c.slid(ctx, c.swipeEnd(v))52+ shown, spawn := c.swipeEnd(v)
53+ if spawn != nil {
54+ // As a bind: the terminal starts before the new state is published.
55+ if err := c.runSpawn(ctx, spawn, Effect{Spawn: true, Stash: true}); err != nil {
56+ return err
57+ }
58+ }
59+ return c.slid(ctx, shown)
53 case ports.PinchBegin:60 case ports.PinchBegin:
54 return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)61 return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
55 case ports.PinchUpdate:62 case ports.PinchUpdate:
@@ -342,19 +349,8 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
342 return ErrQuit349 return ErrQuit
343 }350 }
344 if effect.Spawn {351 if effect.Spawn {
345- commands := effect.Commands352+ if err := c.runSpawn(ctx, before, effect); err != nil {
346- if commands == nil {353+ return err
347- commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
348- }
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
354- }
355- }
356- if c.inputEpochChanged() {
357- return errSecurityChanged
358 }354 }
359 }355 }
360 if c.swipedWorkspace() != swiped {356 if c.swipedWorkspace() != swiped {
@@ -372,6 +368,57 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
372 return c.publish(ctx)368 return c.publish(ctx)
373 }369 }
374 370
371+// runSpawn starts the programs of a spawn effect of an action applied to w:
372+// the given commands, else the configured terminal. A stash effect opens the
373+// windows in the stash of w. It reports errSecurityChanged when the input
374+// epoch changed meanwhile.
375+func (c *Core) runSpawn(ctx context.Context, w *Workspace, effect Effect) error {
376+ commands := effect.Commands
377+ if commands == nil {
378+ commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
379+ }
380+ if effect.Stash {
381+ if err := c.spawnStash(ctx, w, commands, effect.Commands == nil); err != nil {
382+ return err
383+ }
384+ } else {
385+ // Explicit actions are suppressed on a full launcher queue;
386+ // never keep the input owner waiting across an epoch change.
387+ for _, argv := range commands {
388+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
389+ break
390+ }
391+ }
392+ }
393+ if c.inputEpochChanged() {
394+ return errSecurityChanged
395+ }
396+ return nil
397+}
398+
399+// spawnStash runs commands whose windows open in the stash of w, the
400+// workspace the bind acted on. Wayland reads the claim tokens only while it
401+// knows slots are pending, so that is published before the spawn, as
402+// spawnEmpty does. A send the gate suppresses leaves no claim behind. With
403+// terminal set (the terminal of an empty stash), nothing starts while the
404+// previous one is still awaited.
405+func (c *Core) spawnStash(ctx context.Context, w *Workspace, commands [][]string, terminal bool) error {
406+ if c.protectionRequested() {
407+ return nil
408+ }
409+ now := c.now()
410+ c.placement.expireWorkspaceClaims(now)
411+ if terminal && c.placement.workspacePending(w, spawnStash) {
412+ return nil
413+ }
414+ reqs := make([]ports.SpawnRequest, len(commands))
415+ for i, argv := range commands {
416+ reqs[i] = c.placement.request(argv, spawnTarget{kind: spawnStash, ws: w, at: now})
417+ }
418+ _, err := c.dispatch(ctx, reqs)
419+ return err
420+}
421+
375 // scaleBind steps the focused output's scale and asks for it to be saved.422 // 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 {423 func (c *Core) scaleBind(ctx context.Context, action Action) error {
377 dir := 1424 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 +44 -21
@@ -15,11 +15,25 @@ type spawnPlacement struct {
1515 slots map[slotKey]string
1616 }
1717
18-// spawnTarget identifies the destination of one pending spawn.
18+// spawnKind tells what a pending spawn is destined for.
19+type spawnKind uint8
20+
21+const (
22+ // spawnSlot fills a slot; the zero value.
23+ spawnSlot spawnKind = iota
24+ // spawnTerminal is an automatic terminal for an empty workspace.
25+ spawnTerminal
26+ // spawnStash is a window opened straight into the stash of ws.
27+ spawnStash
28+)
29+
30+// spawnTarget identifies the destination of one pending spawn. slot is set
31+// for spawnSlot; ws and at for the workspace-bound kinds.
1932 type spawnTarget struct {
20- slot slotKey
21- terminal *Workspace
22- at time.Time
33+ kind spawnKind
34+ slot slotKey
35+ ws *Workspace
36+ at time.Time
2337 }
2438
2539 func newSpawnPlacement() spawnPlacement {
@@ -30,7 +44,7 @@ func newSpawnPlacement() spawnPlacement {
3044 func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {
3145 token := rand.Text()
3246 p.pending[token] = target
33- if target.terminal == nil {
47+ if target.kind == spawnSlot {
3448 p.slots[target.slot] = token
3549 }
3650 return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}
@@ -50,20 +64,20 @@ func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] !=
5064 // anyPending reports whether any spawn still awaits a window.
5165 func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }
5266
53-// terminalPending reports whether a workspace awaits an automatic terminal.
54-func (p *spawnPlacement) terminalPending(w *Workspace) bool {
67+// workspacePending reports whether a workspace awaits a spawn of the given kind.
68+func (p *spawnPlacement) workspacePending(w *Workspace, kind spawnKind) bool {
5569 for _, target := range p.pending {
56- if target.terminal == w {
70+ if target.kind == kind && target.ws == w {
5771 return true
5872 }
5973 }
6074 return false
6175 }
6276
63-// expireTerminals drops terminal claims older than the retry interval.
64-func (p *spawnPlacement) expireTerminals(now time.Time) {
77+// expireWorkspaceClaims drops workspace-bound claims older than the retry interval.
78+func (p *spawnPlacement) expireWorkspaceClaims(now time.Time) {
6579 for token, target := range p.pending {
66- if target.terminal != nil && now.Sub(target.at) > termRetry {
80+ if target.kind != spawnSlot && now.Sub(target.at) > termRetry {
6781 delete(p.pending, token)
6882 }
6983 }
@@ -94,23 +108,32 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
94108 return
95109 }
96110 if v.Slot != "" {
111+ // A claim no window answered in time no longer places one.
112+ p.expireWorkspaceClaims(c.now())
97113 if target, ok := p.pending[v.Slot]; ok {
98- if target.terminal != nil {
114+ switch target.kind {
115+ case spawnTerminal, spawnStash:
99116 delete(p.pending, v.Slot)
100117 for _, s := range c.screens {
101- if s.mon.has(target.terminal) {
102- target.terminal.AddWindow(v.ID)
118+ if s.mon.has(target.ws) {
119+ if target.kind == spawnStash {
120+ target.ws.stashSpawned(v.ID)
121+ } else {
122+ target.ws.AddWindow(v.ID)
123+ }
103124 s.mon.normalize()
104125 return
105126 }
106127 }
107- } else if st := c.slots[target.slot]; st != nil && st.window == 0 {
108- if sc, w := c.byName(target.slot.workspace); w != nil {
109- p.dropSlot(target.slot)
110- st.window = v.ID
111- w.AddSlotWindow(v.ID, target.slot.index, st.cells)
112- sc.mon.normalize()
113- return
128+ case spawnSlot:
129+ if st := c.slots[target.slot]; st != nil && st.window == 0 {
130+ if sc, w := c.byName(target.slot.workspace); w != nil {
131+ p.dropSlot(target.slot)
132+ st.window = v.ID
133+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
134+ sc.mon.normalize()
135+ return
136+ }
114137 }
115138 }
116139 }
@@ -15,11 +15,25 @@ type spawnPlacement struct {
15 slots map[slotKey]string15 slots map[slotKey]string
16 }16 }
17 17
18-// spawnTarget identifies the destination of one pending spawn.18+// spawnKind tells what a pending spawn is destined for.
19+type spawnKind uint8
20+
21+const (
22+ // spawnSlot fills a slot; the zero value.
23+ spawnSlot spawnKind = iota
24+ // spawnTerminal is an automatic terminal for an empty workspace.
25+ spawnTerminal
26+ // spawnStash is a window opened straight into the stash of ws.
27+ spawnStash
28+)
29+
30+// spawnTarget identifies the destination of one pending spawn. slot is set
31+// for spawnSlot; ws and at for the workspace-bound kinds.
19 type spawnTarget struct {32 type spawnTarget struct {
20- slot slotKey33+ kind spawnKind
21- terminal *Workspace34+ slot slotKey
22- at time.Time35+ ws *Workspace
36+ at time.Time
23 }37 }
24 38
25 func newSpawnPlacement() spawnPlacement {39 func newSpawnPlacement() spawnPlacement {
@@ -30,7 +44,7 @@ func newSpawnPlacement() spawnPlacement {
30 func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {44 func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {
31 token := rand.Text()45 token := rand.Text()
32 p.pending[token] = target46 p.pending[token] = target
33- if target.terminal == nil {47+ if target.kind == spawnSlot {
34 p.slots[target.slot] = token48 p.slots[target.slot] = token
35 }49 }
36 return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}50 return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}
@@ -50,20 +64,20 @@ func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] !=
50 // anyPending reports whether any spawn still awaits a window.64 // anyPending reports whether any spawn still awaits a window.
51 func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }65 func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }
52 66
53-// terminalPending reports whether a workspace awaits an automatic terminal.67+// workspacePending reports whether a workspace awaits a spawn of the given kind.
54-func (p *spawnPlacement) terminalPending(w *Workspace) bool {68+func (p *spawnPlacement) workspacePending(w *Workspace, kind spawnKind) bool {
55 for _, target := range p.pending {69 for _, target := range p.pending {
56- if target.terminal == w {70+ if target.kind == kind && target.ws == w {
57 return true71 return true
58 }72 }
59 }73 }
60 return false74 return false
61 }75 }
62 76
63-// expireTerminals drops terminal claims older than the retry interval.77+// expireWorkspaceClaims drops workspace-bound claims older than the retry interval.
64-func (p *spawnPlacement) expireTerminals(now time.Time) {78+func (p *spawnPlacement) expireWorkspaceClaims(now time.Time) {
65 for token, target := range p.pending {79 for token, target := range p.pending {
66- if target.terminal != nil && now.Sub(target.at) > termRetry {80+ if target.kind != spawnSlot && now.Sub(target.at) > termRetry {
67 delete(p.pending, token)81 delete(p.pending, token)
68 }82 }
69 }83 }
@@ -94,23 +108,32 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
94 return108 return
95 }109 }
96 if v.Slot != "" {110 if v.Slot != "" {
111+ // A claim no window answered in time no longer places one.
112+ p.expireWorkspaceClaims(c.now())
97 if target, ok := p.pending[v.Slot]; ok {113 if target, ok := p.pending[v.Slot]; ok {
98- if target.terminal != nil {114+ switch target.kind {
115+ case spawnTerminal, spawnStash:
99 delete(p.pending, v.Slot)116 delete(p.pending, v.Slot)
100 for _, s := range c.screens {117 for _, s := range c.screens {
101- if s.mon.has(target.terminal) {118+ if s.mon.has(target.ws) {
102- target.terminal.AddWindow(v.ID)119+ if target.kind == spawnStash {
120+ target.ws.stashSpawned(v.ID)
121+ } else {
122+ target.ws.AddWindow(v.ID)
123+ }
103 s.mon.normalize()124 s.mon.normalize()
104 return125 return
105 }126 }
106 }127 }
107- } else if st := c.slots[target.slot]; st != nil && st.window == 0 {128+ case spawnSlot:
108- if sc, w := c.byName(target.slot.workspace); w != nil {129+ if st := c.slots[target.slot]; st != nil && st.window == 0 {
109- p.dropSlot(target.slot)130+ if sc, w := c.byName(target.slot.workspace); w != nil {
110- st.window = v.ID131+ p.dropSlot(target.slot)
111- w.AddSlotWindow(v.ID, target.slot.index, st.cells)132+ st.window = v.ID
112- sc.mon.normalize()133+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
113- return134+ sc.mon.normalize()
135+ return
136+ }
114 }137 }
115 }138 }
116 }139 }
M internal/core/spawn_placement_test.go +3 -3
@@ -60,13 +60,13 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
6060 t.Fatal("focus did not move to another workspace")
6161 }
6262 t0 := time.Unix(123, 0)
63- req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0})
63+ req := c.placement.request([]string{"foot"}, spawnTarget{kind: spawnTerminal, ws: target, at: t0})
6464 token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
65- c.placement.expireTerminals(t0.Add(termRetry))
65+ c.placement.expireWorkspaceClaims(t0.Add(termRetry))
6666 if _, ok := c.placement.pending[token]; !ok {
6767 t.Fatal("claim expired at retry boundary")
6868 }
69- c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond))
69+ c.placement.expireWorkspaceClaims(t0.Add(termRetry + time.Nanosecond))
7070 if _, ok := c.placement.pending[token]; ok {
7171 t.Fatal("claim survived past retry boundary")
7272 }
@@ -60,13 +60,13 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
60 t.Fatal("focus did not move to another workspace")60 t.Fatal("focus did not move to another workspace")
61 }61 }
62 t0 := time.Unix(123, 0)62 t0 := time.Unix(123, 0)
63- req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0})63+ req := c.placement.request([]string{"foot"}, spawnTarget{kind: spawnTerminal, ws: target, at: t0})
64 token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")64 token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
65- c.placement.expireTerminals(t0.Add(termRetry))65+ c.placement.expireWorkspaceClaims(t0.Add(termRetry))
66 if _, ok := c.placement.pending[token]; !ok {66 if _, ok := c.placement.pending[token]; !ok {
67 t.Fatal("claim expired at retry boundary")67 t.Fatal("claim expired at retry boundary")
68 }68 }
69- c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond))69+ c.placement.expireWorkspaceClaims(t0.Add(termRetry + time.Nanosecond))
70 if _, ok := c.placement.pending[token]; ok {70 if _, ok := c.placement.pending[token]; ok {
71 t.Fatal("claim survived past retry boundary")71 t.Fatal("claim survived past retry boundary")
72 }72 }
M internal/core/stash.go +16 -0
@@ -81,6 +81,22 @@ 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. A fullscreen stash member, current or waiting for the stash to
87+// show, ends first, so the newcomer is the window on screen. Under a
88+// covering fullscreen window outside the stash, the stash shows over it.
89+func (w *Workspace) stashSpawned(id WindowID) {
90+ if w.fullscreen != 0 && w.stashIndex(w.fullscreen) >= 0 {
91+ w.endFullscreen()
92+ }
93+ w.hiddenFullscreen = 0
94+ w.addStash(Float{ID: id})
95+ if w.cover() != 0 {
96+ w.showStashOver()
97+ }
98+}
99+
84100 // showStash shows the stash and gives it the focus; a member whose
85101 // fullscreen waited while hidden is fullscreen again, selected, as if it
86102 // asked now.
@@ -81,6 +81,22 @@ 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. A fullscreen stash member, current or waiting for the stash to
87+// show, ends first, so the newcomer is the window on screen. Under a
88+// covering fullscreen window outside the stash, the stash shows over it.
89+func (w *Workspace) stashSpawned(id WindowID) {
90+ if w.fullscreen != 0 && w.stashIndex(w.fullscreen) >= 0 {
91+ w.endFullscreen()
92+ }
93+ w.hiddenFullscreen = 0
94+ w.addStash(Float{ID: id})
95+ if w.cover() != 0 {
96+ w.showStashOver()
97+ }
98+}
99+
84 // showStash shows the stash and gives it the focus; a member whose100 // showStash shows the stash and gives it the focus; a member whose
85 // fullscreen waited while hidden is fullscreen again, selected, as if it101 // fullscreen waited while hidden is fullscreen again, selected, as if it
86 // asked now.102 // asked now.
A internal/core/stash_spawn_internal_test.go +274 -0
@@ -0,0 +1,274 @@
1+package core
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "testing/synctest"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// claimBase is the instant of the fixed test clock.
16+var claimBase = time.Unix(1000, 0)
17+
18+// fixedClock is a mock clock at *now, which a test moves by assigning.
19+func fixedClock(t *testing.T, now *time.Time) ports.Clock {
20+ t.Helper()
21+ clock := portsmocks.NewMockClock(t)
22+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return *now }).Maybe()
23+ return clock
24+}
25+
26+func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core {
27+ t.Helper()
28+ var cfg ports.Config
29+ cfg.Keyboard.CmdKey = "alt"
30+ cfg.Layout.MaxColumns = 2
31+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock})
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
36+ return c
37+}
38+
39+func claimToken(req ports.SpawnRequest) string {
40+ return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
41+}
42+
43+// Wayland reads tokens only while slots are pending, so the flag is sent
44+// before the spawn can reach the launcher. In a synctest bubble the worker
45+// is waited on until it blocks sending the flag on the unread command
46+// channel: the spawn channel must still be empty then.
47+func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
48+ synctest.Test(t, func(t *testing.T) {
49+ now := claimBase
50+ commands := make(chan ports.ClientCommand)
51+ spawn := make(chan ports.SpawnRequest, 1)
52+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
53+ ctx, cancel := context.WithCancel(context.Background())
54+ t.Cleanup(cancel)
55+ done := make(chan error, 1)
56+ go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
57+ synctest.Wait()
58+ if len(spawn) != 0 {
59+ t.Fatal("spawned before the pending flag was delivered")
60+ }
61+ select {
62+ case err := <-done:
63+ t.Fatalf("spawnStash returned before the flag was read: %v", err)
64+ default:
65+ }
66+ if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
67+ t.Fatal("first command is not SlotsPending{true}")
68+ }
69+ synctest.Wait()
70+ if err := <-done; err != nil {
71+ t.Fatal(err)
72+ }
73+ if len(spawn) != 1 {
74+ t.Fatal("no spawn after the flag")
75+ }
76+ })
77+}
78+
79+// A send the launcher queue suppresses leaves no claim behind, and the
80+// pending flag falls again at the next publish.
81+func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
82+ now := claimBase
83+ commands := make(chan ports.ClientCommand, 8)
84+ c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
85+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
86+ t.Fatal(err)
87+ }
88+ if c.placement.anyPending() {
89+ t.Fatal("suppressed claims stayed pending")
90+ }
91+ if v := (<-commands).(ports.SlotsPending); !v.Pending {
92+ t.Fatal("pending not announced")
93+ }
94+ if err := c.publishPending(context.Background()); err != nil {
95+ t.Fatal(err)
96+ }
97+ if v := (<-commands).(ports.SlotsPending); v.Pending {
98+ t.Fatal("stale pending flag not republished")
99+ }
100+}
101+
102+// From the first command that is not issued, the rest are not sent and their
103+// claims are cancelled; the issued ones keep theirs.
104+func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
105+ for _, tc := range []struct {
106+ name string
107+ queue int
108+ cancelled bool
109+ issued int
110+ wantErr error
111+ }{
112+ {"queue of one", 1, false, 1, nil},
113+ {"publication fails", 4, true, 0, context.Canceled},
114+ } {
115+ t.Run(tc.name, func(t *testing.T) {
116+ now := claimBase
117+ commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
118+ spawn := make(chan ports.SpawnRequest, tc.queue)
119+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
120+ ctx, cancel := context.WithCancel(context.Background())
121+ t.Cleanup(cancel)
122+ if tc.cancelled {
123+ cancel()
124+ } else {
125+ // An unread unbuffered channel would block the flag: read it.
126+ go func() {
127+ select {
128+ case <-commands:
129+ case <-ctx.Done():
130+ }
131+ }()
132+ }
133+ err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
134+ if !errors.Is(err, tc.wantErr) {
135+ t.Fatalf("error %v, want %v", err, tc.wantErr)
136+ }
137+ if len(spawn) != tc.issued {
138+ t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
139+ }
140+ if len(c.placement.pending) != tc.issued {
141+ t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
142+ }
143+ if tc.issued == 1 {
144+ if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
145+ t.Fatal("the issued command lost its claim")
146+ }
147+ }
148+ })
149+ }
150+}
151+
152+// A claim is placed up to termRetry after it was made, and not a moment
153+// later, even if no publish ran between its age and the window mapping.
154+func TestStashClaimExpiry(t *testing.T) {
155+ for _, tc := range []struct {
156+ name string
157+ age time.Duration
158+ placed bool
159+ }{
160+ {"fresh", 0, true},
161+ {"at the limit", termRetry, true},
162+ {"just after", termRetry + time.Nanosecond, false},
163+ } {
164+ t.Run(tc.name, func(t *testing.T) {
165+ now := claimBase
166+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
167+ m := c.cur().mon
168+ m.AddWindow(1)
169+ target := m.Current()
170+ m.show(m.Workspaces[1])
171+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
172+ now = claimBase.Add(tc.age)
173+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
174+ _, w := c.screenOf(5)
175+ if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
176+ t.Fatalf("placed %t, want %t", placed, tc.placed)
177+ }
178+ })
179+ }
180+}
181+
182+// A workspace that left every screen no longer takes the window.
183+func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
184+ now := claimBase
185+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
186+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
187+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
188+ if _, w := c.screenOf(5); w != c.cur().mon.Current() {
189+ t.Fatal("window did not map onto the current workspace")
190+ }
191+ if c.placement.anyPending() {
192+ t.Fatal("claim kept")
193+ }
194+}
195+
196+// A window opened by a stash spawn is shown, selected and focused in the
197+// stash whatever fullscreen state the workspace and the stash are in.
198+func TestStashSpawnedAlwaysShown(t *testing.T) {
199+ // base has tile 1 and stash [2 3], 3 selected, the stash shown.
200+ base := func() *Workspace {
201+ w := workspace()
202+ w.AddWindow(1)
203+ w.AddWindow(2)
204+ w.AddWindow(3)
205+ w.FocusID(2)
206+ w.ToggleWindowStash()
207+ w.FocusID(3)
208+ w.ToggleWindowStash()
209+ return w
210+ }
211+ shown := func(t *testing.T, w *Workspace) {
212+ t.Helper()
213+ if id, _ := w.Focused(); id != 5 {
214+ t.Fatalf("focused %d, want 5", id)
215+ }
216+ if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
217+ t.Fatalf("new window %+v", p)
218+ }
219+ if w.Stash[w.stashAt].ID != 5 {
220+ t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
221+ }
222+ }
223+
224+ t.Run("covering tile outside the stash", func(t *testing.T) {
225+ w := base()
226+ w.FocusID(1)
227+ w.ToggleFullscreen()
228+ w.stashSpawned(5)
229+ shown(t, w)
230+ if w.cover() != 1 || !w.stashOverCover() {
231+ t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
232+ }
233+ if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
234+ t.Fatalf("tile %+v", p)
235+ }
236+ w.ToggleStashVisible()
237+ if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
238+ t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
239+ }
240+ })
241+
242+ t.Run("fullscreen stash member", func(t *testing.T) {
243+ w := base()
244+ w.ToggleFullscreen() // 3
245+ if w.cover() != 3 {
246+ t.Fatalf("setup: cover %d", w.cover())
247+ }
248+ w.stashSpawned(5)
249+ shown(t, w)
250+ if w.cover() != 0 {
251+ t.Fatalf("cover %d: the stash member still covers", w.cover())
252+ }
253+ if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
254+ t.Fatalf("old member %+v", p)
255+ }
256+ })
257+
258+ t.Run("fullscreen waiting for the stash", func(t *testing.T) {
259+ w := base()
260+ w.ToggleStashVisible() // hidden
261+ w.SetFullscreen(3, true)
262+ if w.hiddenFullscreen != 3 {
263+ t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
264+ }
265+ w.stashSpawned(5)
266+ shown(t, w)
267+ if w.cover() != 0 || w.hiddenFullscreen != 0 {
268+ t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
269+ }
270+ w.ToggleStashVisible()
271+ w.ToggleStashVisible()
272+ shown(t, w)
273+ })
274+}
@@ -0,0 +1,274 @@
1+package core
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "testing/synctest"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// claimBase is the instant of the fixed test clock.
16+var claimBase = time.Unix(1000, 0)
17+
18+// fixedClock is a mock clock at *now, which a test moves by assigning.
19+func fixedClock(t *testing.T, now *time.Time) ports.Clock {
20+ t.Helper()
21+ clock := portsmocks.NewMockClock(t)
22+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return *now }).Maybe()
23+ return clock
24+}
25+
26+func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core {
27+ t.Helper()
28+ var cfg ports.Config
29+ cfg.Keyboard.CmdKey = "alt"
30+ cfg.Layout.MaxColumns = 2
31+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock})
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
36+ return c
37+}
38+
39+func claimToken(req ports.SpawnRequest) string {
40+ return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
41+}
42+
43+// Wayland reads tokens only while slots are pending, so the flag is sent
44+// before the spawn can reach the launcher. In a synctest bubble the worker
45+// is waited on until it blocks sending the flag on the unread command
46+// channel: the spawn channel must still be empty then.
47+func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
48+ synctest.Test(t, func(t *testing.T) {
49+ now := claimBase
50+ commands := make(chan ports.ClientCommand)
51+ spawn := make(chan ports.SpawnRequest, 1)
52+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
53+ ctx, cancel := context.WithCancel(context.Background())
54+ t.Cleanup(cancel)
55+ done := make(chan error, 1)
56+ go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
57+ synctest.Wait()
58+ if len(spawn) != 0 {
59+ t.Fatal("spawned before the pending flag was delivered")
60+ }
61+ select {
62+ case err := <-done:
63+ t.Fatalf("spawnStash returned before the flag was read: %v", err)
64+ default:
65+ }
66+ if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
67+ t.Fatal("first command is not SlotsPending{true}")
68+ }
69+ synctest.Wait()
70+ if err := <-done; err != nil {
71+ t.Fatal(err)
72+ }
73+ if len(spawn) != 1 {
74+ t.Fatal("no spawn after the flag")
75+ }
76+ })
77+}
78+
79+// A send the launcher queue suppresses leaves no claim behind, and the
80+// pending flag falls again at the next publish.
81+func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
82+ now := claimBase
83+ commands := make(chan ports.ClientCommand, 8)
84+ c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
85+ if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
86+ t.Fatal(err)
87+ }
88+ if c.placement.anyPending() {
89+ t.Fatal("suppressed claims stayed pending")
90+ }
91+ if v := (<-commands).(ports.SlotsPending); !v.Pending {
92+ t.Fatal("pending not announced")
93+ }
94+ if err := c.publishPending(context.Background()); err != nil {
95+ t.Fatal(err)
96+ }
97+ if v := (<-commands).(ports.SlotsPending); v.Pending {
98+ t.Fatal("stale pending flag not republished")
99+ }
100+}
101+
102+// From the first command that is not issued, the rest are not sent and their
103+// claims are cancelled; the issued ones keep theirs.
104+func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
105+ for _, tc := range []struct {
106+ name string
107+ queue int
108+ cancelled bool
109+ issued int
110+ wantErr error
111+ }{
112+ {"queue of one", 1, false, 1, nil},
113+ {"publication fails", 4, true, 0, context.Canceled},
114+ } {
115+ t.Run(tc.name, func(t *testing.T) {
116+ now := claimBase
117+ commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
118+ spawn := make(chan ports.SpawnRequest, tc.queue)
119+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
120+ ctx, cancel := context.WithCancel(context.Background())
121+ t.Cleanup(cancel)
122+ if tc.cancelled {
123+ cancel()
124+ } else {
125+ // An unread unbuffered channel would block the flag: read it.
126+ go func() {
127+ select {
128+ case <-commands:
129+ case <-ctx.Done():
130+ }
131+ }()
132+ }
133+ err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
134+ if !errors.Is(err, tc.wantErr) {
135+ t.Fatalf("error %v, want %v", err, tc.wantErr)
136+ }
137+ if len(spawn) != tc.issued {
138+ t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
139+ }
140+ if len(c.placement.pending) != tc.issued {
141+ t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
142+ }
143+ if tc.issued == 1 {
144+ if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
145+ t.Fatal("the issued command lost its claim")
146+ }
147+ }
148+ })
149+ }
150+}
151+
152+// A claim is placed up to termRetry after it was made, and not a moment
153+// later, even if no publish ran between its age and the window mapping.
154+func TestStashClaimExpiry(t *testing.T) {
155+ for _, tc := range []struct {
156+ name string
157+ age time.Duration
158+ placed bool
159+ }{
160+ {"fresh", 0, true},
161+ {"at the limit", termRetry, true},
162+ {"just after", termRetry + time.Nanosecond, false},
163+ } {
164+ t.Run(tc.name, func(t *testing.T) {
165+ now := claimBase
166+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
167+ m := c.cur().mon
168+ m.AddWindow(1)
169+ target := m.Current()
170+ m.show(m.Workspaces[1])
171+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
172+ now = claimBase.Add(tc.age)
173+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
174+ _, w := c.screenOf(5)
175+ if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
176+ t.Fatalf("placed %t, want %t", placed, tc.placed)
177+ }
178+ })
179+ }
180+}
181+
182+// A workspace that left every screen no longer takes the window.
183+func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
184+ now := claimBase
185+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
186+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
187+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
188+ if _, w := c.screenOf(5); w != c.cur().mon.Current() {
189+ t.Fatal("window did not map onto the current workspace")
190+ }
191+ if c.placement.anyPending() {
192+ t.Fatal("claim kept")
193+ }
194+}
195+
196+// A window opened by a stash spawn is shown, selected and focused in the
197+// stash whatever fullscreen state the workspace and the stash are in.
198+func TestStashSpawnedAlwaysShown(t *testing.T) {
199+ // base has tile 1 and stash [2 3], 3 selected, the stash shown.
200+ base := func() *Workspace {
201+ w := workspace()
202+ w.AddWindow(1)
203+ w.AddWindow(2)
204+ w.AddWindow(3)
205+ w.FocusID(2)
206+ w.ToggleWindowStash()
207+ w.FocusID(3)
208+ w.ToggleWindowStash()
209+ return w
210+ }
211+ shown := func(t *testing.T, w *Workspace) {
212+ t.Helper()
213+ if id, _ := w.Focused(); id != 5 {
214+ t.Fatalf("focused %d, want 5", id)
215+ }
216+ if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
217+ t.Fatalf("new window %+v", p)
218+ }
219+ if w.Stash[w.stashAt].ID != 5 {
220+ t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
221+ }
222+ }
223+
224+ t.Run("covering tile outside the stash", func(t *testing.T) {
225+ w := base()
226+ w.FocusID(1)
227+ w.ToggleFullscreen()
228+ w.stashSpawned(5)
229+ shown(t, w)
230+ if w.cover() != 1 || !w.stashOverCover() {
231+ t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
232+ }
233+ if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
234+ t.Fatalf("tile %+v", p)
235+ }
236+ w.ToggleStashVisible()
237+ if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
238+ t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
239+ }
240+ })
241+
242+ t.Run("fullscreen stash member", func(t *testing.T) {
243+ w := base()
244+ w.ToggleFullscreen() // 3
245+ if w.cover() != 3 {
246+ t.Fatalf("setup: cover %d", w.cover())
247+ }
248+ w.stashSpawned(5)
249+ shown(t, w)
250+ if w.cover() != 0 {
251+ t.Fatalf("cover %d: the stash member still covers", w.cover())
252+ }
253+ if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
254+ t.Fatalf("old member %+v", p)
255+ }
256+ })
257+
258+ t.Run("fullscreen waiting for the stash", func(t *testing.T) {
259+ w := base()
260+ w.ToggleStashVisible() // hidden
261+ w.SetFullscreen(3, true)
262+ if w.hiddenFullscreen != 3 {
263+ t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
264+ }
265+ w.stashSpawned(5)
266+ shown(t, w)
267+ if w.cover() != 0 || w.hiddenFullscreen != 0 {
268+ t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
269+ }
270+ w.ToggleStashVisible()
271+ w.ToggleStashVisible()
272+ shown(t, w)
273+ })
274+}
A internal/core/stash_spawn_test.go +228 -0
@@ -0,0 +1,228 @@
1+package core_test
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// stashSpawnRig has a stash bind on Alt+f and, as in the defaults, foot as
12+// the terminal. Tiles map without any automatic terminal.
13+func stashSpawnRig(t *testing.T, edit func(*ports.Config)) *multiRig {
14+ t.Helper()
15+ return startMulti(t, func(c *ports.Config) {
16+ c.Binds["Alt+Ctrl+Shift+f"] = "stash firefox --new-window"
17+ c.Stash.Capture = false
18+ if edit != nil {
19+ edit(c)
20+ }
21+ }, left)
22+}
23+
24+func slotToken(t *testing.T, req ports.SpawnRequest) string {
25+ t.Helper()
26+ if len(req.Env) != 1 || len(req.Env[0]) <= len(ports.SlotEnv)+1 || req.Env[0][:len(ports.SlotEnv)+1] != ports.SlotEnv+"=" {
27+ t.Fatalf("no slot token: %v", req.Env)
28+ }
29+ return req.Env[0][len(ports.SlotEnv)+1:]
30+}
31+
32+// pendingSent drains the command queue and reports the last SlotsPending.
33+func pendingSent(r *multiRig) (pending, sent bool) {
34+ for len(r.commands) > 0 {
35+ if v, ok := (<-r.commands).(ports.SlotsPending); ok {
36+ pending, sent = v.Pending, true
37+ }
38+ }
39+ return pending, sent
40+}
41+
42+func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
43+ for _, s := range set {
44+ for _, w := range s.Windows {
45+ if w.ID == id {
46+ return w, true
47+ }
48+ }
49+ }
50+ return ports.SceneWindow{}, false
51+}
52+
53+// The window of a stash bind opens in the stash of the workspace the key
54+// was pressed on, shown and focused, even when the user moved on before it
55+// mapped; toggle-window-stash then tiles it as a new column.
56+func TestStashBindOpensWindowInStash(t *testing.T) {
57+ r := stashSpawnRig(t, nil)
58+ r.mapWindow(t, 1)
59+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
60+ req := receive(t, r.spawn)
61+ if !slices.Equal(req.Argv, []string{"firefox", "--new-window"}) {
62+ t.Fatalf("argv %v", req.Argv)
63+ }
64+ token := slotToken(t, req)
65+ if pending, sent := pendingSent(r); !sent || !pending {
66+ t.Fatal("SlotsPending{true} was not sent")
67+ }
68+ r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2
69+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
70+ set := receive(t, r.scenes)
71+ if w, ok := sceneWindow(set, 2); ok && !w.Hidden {
72+ t.Fatalf("window shown on the workspace the user moved to: %+v", w)
73+ }
74+ set = r.key(t, "Prior", ports.ModAlt)
75+ w, ok := sceneWindow(set, 2)
76+ if !ok || w.Hidden || !w.Floating || !w.Focused {
77+ t.Fatalf("stashed window %+v", w)
78+ }
79+ if w1, _ := sceneWindow(set, 1); w1.Focused {
80+ t.Fatal("tile kept the focus")
81+ }
82+ if pending, sent := pendingSent(r); !sent || pending {
83+ t.Fatalf("pending flag not cleared: %v %v", pending, sent)
84+ }
85+ // Unstashing tiles it as a column of its own.
86+ set = r.key(t, "s", ports.ModAlt|ports.ModShift)
87+ if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {
88+ t.Fatalf("unstashed window %+v", w)
89+ }
90+ if len(r.spawn) != 0 {
91+ t.Fatal("unexpected spawn")
92+ }
93+}
94+
95+func TestStashBindRunsEveryCommand(t *testing.T) {
96+ r := stashSpawnRig(t, func(c *ports.Config) { c.Binds["Alt+Ctrl+Shift+f"] = "stash one; two" })
97+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
98+ a, b := receive(t, r.spawn), receive(t, r.spawn)
99+ if a.Argv[0] != "one" || b.Argv[0] != "two" || slotToken(t, a) == slotToken(t, b) {
100+ t.Fatal(a, b)
101+ }
102+}
103+
104+// With an empty stash, toggle-stash-visible opens the terminal in it, once
105+// while it is awaited.
106+func TestToggleEmptyStashOpensTerminal(t *testing.T) {
107+ r := stashSpawnRig(t, nil)
108+ r.mapWindow(t, 1)
109+ r.key(t, "s", ports.ModAlt)
110+ req := receive(t, r.spawn)
111+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
112+ t.Fatalf("argv %v", req.Argv)
113+ }
114+ token := slotToken(t, req)
115+ if pending, sent := pendingSent(r); !sent || !pending {
116+ t.Fatal("SlotsPending{true} was not sent")
117+ }
118+ r.key(t, "s", ports.ModAlt)
119+ if len(r.spawn) != 0 {
120+ t.Fatal("second terminal while the first is awaited")
121+ }
122+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
123+ set := receive(t, r.scenes)
124+ if w, ok := sceneWindow(set, 2); !ok || w.Hidden || !w.Floating || !w.Focused {
125+ t.Fatalf("terminal %+v", w)
126+ }
127+ // The stash has a window now: the toggle hides it, no spawn.
128+ set = r.key(t, "s", ports.ModAlt)
129+ if w, _ := sceneWindow(set, 2); !w.Hidden {
130+ t.Fatalf("stash not hidden: %+v", w)
131+ }
132+ if len(r.spawn) != 0 {
133+ t.Fatal("spawned for a stash holding a window")
134+ }
135+}
136+
137+func TestToggleNonEmptyStashDoesNotSpawn(t *testing.T) {
138+ r := stashSpawnRig(t, nil)
139+ r.mapWindow(t, 1)
140+ r.mapWindow(t, 2)
141+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
142+ r.key(t, "s", ports.ModAlt)
143+ r.key(t, "s", ports.ModAlt)
144+ if len(r.spawn) != 0 {
145+ t.Fatal("spawned for a non-empty stash")
146+ }
147+}
148+
149+func TestToggleEmptyStashRespectsOption(t *testing.T) {
150+ r := stashSpawnRig(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
151+ r.mapWindow(t, 1)
152+ r.key(t, "s", ports.ModAlt)
153+ if len(r.spawn) != 0 {
154+ t.Fatal("spawned with stash.spawn-terminal off")
155+ }
156+ r = stashSpawnRig(t, func(c *ports.Config) { c.Terminal.Command = nil })
157+ r.mapWindow(t, 1)
158+ r.key(t, "s", ports.ModAlt)
159+ if len(r.spawn) != 0 {
160+ t.Fatal("spawned without a terminal command")
161+ }
162+}
163+
164+func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) {
165+ r := stashSpawnRig(t, nil)
166+ r.mapWindow(t, 1)
167+ r.key(t, "o", ports.ModAlt)
168+ r.key(t, "s", ports.ModAlt)
169+ if len(r.spawn) != 0 {
170+ t.Fatal("spawned from the overview")
171+ }
172+}
173+
174+// A four-finger swipe down on an empty stash is toggle-stash-visible: with
175+// stash.spawn-terminal it opens the terminal in the stash, once while that
176+// one is awaited, and the window that maps lands in the stash.
177+func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) {
178+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = true })
179+ r.four(t, 0, 40, 10)
180+ req := receive(t, r.spawn)
181+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
182+ t.Fatalf("argv %v", req.Argv)
183+ }
184+ token := slotToken(t, req)
185+ if pending, sent := pendingSent(r.multiRig); !sent || !pending {
186+ t.Fatal("SlotsPending{true} was not sent")
187+ }
188+ r.four(t, 0, 40, 10)
189+ if len(r.spawn) != 0 {
190+ t.Fatal("second terminal while the first is awaited")
191+ }
192+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
193+ if w, ok := sceneWindow(receive(t, r.scenes), 2); !ok || w.Hidden || !w.Floating || !w.Focused {
194+ t.Fatalf("terminal %+v", w)
195+ }
196+}
197+
198+func TestFourFingerSwipeEmptyStashRespectsOption(t *testing.T) {
199+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
200+ r.four(t, 0, 40, 10)
201+ if len(r.spawn) != 0 {
202+ t.Fatal("spawned with stash.spawn-terminal off")
203+ }
204+}
205+
206+// The terminal of a four-finger swipe shows no workspace: the empty slots of
207+// the workspace on screen are not refilled, as with the bind.
208+func TestFourFingerSwipeEmptyStashDoesNotRefillSlots(t *testing.T) {
209+ r := startSlots(t)
210+ r.fill(t)
211+ r.press(t, "d") // dev on screen, its slots filled
212+ r.client <- ports.WindowUnmapped{ID: 2}
213+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
214+ noSpawn(t, r.spawn) // nothing relaunches on its own
215+ var at time.Duration
216+ r.input <- ports.SwipeBegin{Fingers: 4, Time: at}
217+ for range 10 {
218+ at += 8 * time.Millisecond
219+ r.input <- ports.SwipeUpdate{DY: 40, Time: at}
220+ }
221+ r.input <- ports.SwipeEnd{Time: at}
222+ req := receive(t, r.spawn)
223+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
224+ t.Fatalf("argv %v, want the stash terminal", req.Argv)
225+ }
226+ r.sync(t)
227+ noSpawn(t, r.spawn) // the empty slot of dev stays empty
228+}
@@ -0,0 +1,228 @@
1+package core_test
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// stashSpawnRig has a stash bind on Alt+f and, as in the defaults, foot as
12+// the terminal. Tiles map without any automatic terminal.
13+func stashSpawnRig(t *testing.T, edit func(*ports.Config)) *multiRig {
14+ t.Helper()
15+ return startMulti(t, func(c *ports.Config) {
16+ c.Binds["Alt+Ctrl+Shift+f"] = "stash firefox --new-window"
17+ c.Stash.Capture = false
18+ if edit != nil {
19+ edit(c)
20+ }
21+ }, left)
22+}
23+
24+func slotToken(t *testing.T, req ports.SpawnRequest) string {
25+ t.Helper()
26+ if len(req.Env) != 1 || len(req.Env[0]) <= len(ports.SlotEnv)+1 || req.Env[0][:len(ports.SlotEnv)+1] != ports.SlotEnv+"=" {
27+ t.Fatalf("no slot token: %v", req.Env)
28+ }
29+ return req.Env[0][len(ports.SlotEnv)+1:]
30+}
31+
32+// pendingSent drains the command queue and reports the last SlotsPending.
33+func pendingSent(r *multiRig) (pending, sent bool) {
34+ for len(r.commands) > 0 {
35+ if v, ok := (<-r.commands).(ports.SlotsPending); ok {
36+ pending, sent = v.Pending, true
37+ }
38+ }
39+ return pending, sent
40+}
41+
42+func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
43+ for _, s := range set {
44+ for _, w := range s.Windows {
45+ if w.ID == id {
46+ return w, true
47+ }
48+ }
49+ }
50+ return ports.SceneWindow{}, false
51+}
52+
53+// The window of a stash bind opens in the stash of the workspace the key
54+// was pressed on, shown and focused, even when the user moved on before it
55+// mapped; toggle-window-stash then tiles it as a new column.
56+func TestStashBindOpensWindowInStash(t *testing.T) {
57+ r := stashSpawnRig(t, nil)
58+ r.mapWindow(t, 1)
59+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
60+ req := receive(t, r.spawn)
61+ if !slices.Equal(req.Argv, []string{"firefox", "--new-window"}) {
62+ t.Fatalf("argv %v", req.Argv)
63+ }
64+ token := slotToken(t, req)
65+ if pending, sent := pendingSent(r); !sent || !pending {
66+ t.Fatal("SlotsPending{true} was not sent")
67+ }
68+ r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2
69+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
70+ set := receive(t, r.scenes)
71+ if w, ok := sceneWindow(set, 2); ok && !w.Hidden {
72+ t.Fatalf("window shown on the workspace the user moved to: %+v", w)
73+ }
74+ set = r.key(t, "Prior", ports.ModAlt)
75+ w, ok := sceneWindow(set, 2)
76+ if !ok || w.Hidden || !w.Floating || !w.Focused {
77+ t.Fatalf("stashed window %+v", w)
78+ }
79+ if w1, _ := sceneWindow(set, 1); w1.Focused {
80+ t.Fatal("tile kept the focus")
81+ }
82+ if pending, sent := pendingSent(r); !sent || pending {
83+ t.Fatalf("pending flag not cleared: %v %v", pending, sent)
84+ }
85+ // Unstashing tiles it as a column of its own.
86+ set = r.key(t, "s", ports.ModAlt|ports.ModShift)
87+ if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {
88+ t.Fatalf("unstashed window %+v", w)
89+ }
90+ if len(r.spawn) != 0 {
91+ t.Fatal("unexpected spawn")
92+ }
93+}
94+
95+func TestStashBindRunsEveryCommand(t *testing.T) {
96+ r := stashSpawnRig(t, func(c *ports.Config) { c.Binds["Alt+Ctrl+Shift+f"] = "stash one; two" })
97+ r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
98+ a, b := receive(t, r.spawn), receive(t, r.spawn)
99+ if a.Argv[0] != "one" || b.Argv[0] != "two" || slotToken(t, a) == slotToken(t, b) {
100+ t.Fatal(a, b)
101+ }
102+}
103+
104+// With an empty stash, toggle-stash-visible opens the terminal in it, once
105+// while it is awaited.
106+func TestToggleEmptyStashOpensTerminal(t *testing.T) {
107+ r := stashSpawnRig(t, nil)
108+ r.mapWindow(t, 1)
109+ r.key(t, "s", ports.ModAlt)
110+ req := receive(t, r.spawn)
111+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
112+ t.Fatalf("argv %v", req.Argv)
113+ }
114+ token := slotToken(t, req)
115+ if pending, sent := pendingSent(r); !sent || !pending {
116+ t.Fatal("SlotsPending{true} was not sent")
117+ }
118+ r.key(t, "s", ports.ModAlt)
119+ if len(r.spawn) != 0 {
120+ t.Fatal("second terminal while the first is awaited")
121+ }
122+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
123+ set := receive(t, r.scenes)
124+ if w, ok := sceneWindow(set, 2); !ok || w.Hidden || !w.Floating || !w.Focused {
125+ t.Fatalf("terminal %+v", w)
126+ }
127+ // The stash has a window now: the toggle hides it, no spawn.
128+ set = r.key(t, "s", ports.ModAlt)
129+ if w, _ := sceneWindow(set, 2); !w.Hidden {
130+ t.Fatalf("stash not hidden: %+v", w)
131+ }
132+ if len(r.spawn) != 0 {
133+ t.Fatal("spawned for a stash holding a window")
134+ }
135+}
136+
137+func TestToggleNonEmptyStashDoesNotSpawn(t *testing.T) {
138+ r := stashSpawnRig(t, nil)
139+ r.mapWindow(t, 1)
140+ r.mapWindow(t, 2)
141+ r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
142+ r.key(t, "s", ports.ModAlt)
143+ r.key(t, "s", ports.ModAlt)
144+ if len(r.spawn) != 0 {
145+ t.Fatal("spawned for a non-empty stash")
146+ }
147+}
148+
149+func TestToggleEmptyStashRespectsOption(t *testing.T) {
150+ r := stashSpawnRig(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
151+ r.mapWindow(t, 1)
152+ r.key(t, "s", ports.ModAlt)
153+ if len(r.spawn) != 0 {
154+ t.Fatal("spawned with stash.spawn-terminal off")
155+ }
156+ r = stashSpawnRig(t, func(c *ports.Config) { c.Terminal.Command = nil })
157+ r.mapWindow(t, 1)
158+ r.key(t, "s", ports.ModAlt)
159+ if len(r.spawn) != 0 {
160+ t.Fatal("spawned without a terminal command")
161+ }
162+}
163+
164+func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) {
165+ r := stashSpawnRig(t, nil)
166+ r.mapWindow(t, 1)
167+ r.key(t, "o", ports.ModAlt)
168+ r.key(t, "s", ports.ModAlt)
169+ if len(r.spawn) != 0 {
170+ t.Fatal("spawned from the overview")
171+ }
172+}
173+
174+// A four-finger swipe down on an empty stash is toggle-stash-visible: with
175+// stash.spawn-terminal it opens the terminal in the stash, once while that
176+// one is awaited, and the window that maps lands in the stash.
177+func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) {
178+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = true })
179+ r.four(t, 0, 40, 10)
180+ req := receive(t, r.spawn)
181+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
182+ t.Fatalf("argv %v", req.Argv)
183+ }
184+ token := slotToken(t, req)
185+ if pending, sent := pendingSent(r.multiRig); !sent || !pending {
186+ t.Fatal("SlotsPending{true} was not sent")
187+ }
188+ r.four(t, 0, 40, 10)
189+ if len(r.spawn) != 0 {
190+ t.Fatal("second terminal while the first is awaited")
191+ }
192+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
193+ if w, ok := sceneWindow(receive(t, r.scenes), 2); !ok || w.Hidden || !w.Floating || !w.Focused {
194+ t.Fatalf("terminal %+v", w)
195+ }
196+}
197+
198+func TestFourFingerSwipeEmptyStashRespectsOption(t *testing.T) {
199+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
200+ r.four(t, 0, 40, 10)
201+ if len(r.spawn) != 0 {
202+ t.Fatal("spawned with stash.spawn-terminal off")
203+ }
204+}
205+
206+// The terminal of a four-finger swipe shows no workspace: the empty slots of
207+// the workspace on screen are not refilled, as with the bind.
208+func TestFourFingerSwipeEmptyStashDoesNotRefillSlots(t *testing.T) {
209+ r := startSlots(t)
210+ r.fill(t)
211+ r.press(t, "d") // dev on screen, its slots filled
212+ r.client <- ports.WindowUnmapped{ID: 2}
213+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
214+ noSpawn(t, r.spawn) // nothing relaunches on its own
215+ var at time.Duration
216+ r.input <- ports.SwipeBegin{Fingers: 4, Time: at}
217+ for range 10 {
218+ at += 8 * time.Millisecond
219+ r.input <- ports.SwipeUpdate{DY: 40, Time: at}
220+ }
221+ r.input <- ports.SwipeEnd{Time: at}
222+ req := receive(t, r.spawn)
223+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
224+ t.Fatalf("argv %v, want the stash terminal", req.Argv)
225+ }
226+ r.sync(t)
227+ noSpawn(t, r.spawn) // the empty slot of dev stays empty
228+}
M internal/core/stash_swipe_internal_test.go +1 -1
@@ -210,7 +210,7 @@ func TestStashSwipeLandingShowsNoWorkspace(t *testing.T) {
210210 for range 10 {
211211 s.move(-80)
212212 }
213- if shown := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {
213+ if shown, _ := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {
214214 t.Fatal("a stash landing reported a workspace shown")
215215 }
216216 if w.stashAt != 1 {
@@ -210,7 +210,7 @@ func TestStashSwipeLandingShowsNoWorkspace(t *testing.T) {
210 for range 10 {210 for range 10 {
211 s.move(-80)211 s.move(-80)
212 }212 }
213- if shown := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {213+ if shown, _ := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {
214 t.Fatal("a stash landing reported a workspace shown")214 t.Fatal("a stash landing reported a workspace shown")
215 }215 }
216 if w.stashAt != 1 {216 if w.stashAt != 1 {
M internal/core/terminal.go +10 -7
@@ -23,7 +23,7 @@ func (c *Core) keepsTerminal() bool {
2323 // Workspaces with slots are left to them.
2424 func (c *Core) fillEmpty() []ports.SpawnRequest {
2525 now := c.now()
26- c.placement.expireTerminals(now)
26+ c.placement.expireWorkspaceClaims(now)
2727 if !c.keepsTerminal() {
2828 return nil
2929 }
@@ -39,22 +39,25 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
3939 continue
4040 }
4141 }
42- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry {
42+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.workspacePending(w, spawnTerminal) || now.Sub(w.termAt) < termRetry {
4343 continue
4444 }
4545 c.firstTerminalResolved = true
4646 w.termAt = now
47- reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now}))
47+ reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{kind: spawnTerminal, ws: w, at: now}))
4848 }
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.terminal != nil {
57- target.terminal.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 }
@@ -23,7 +23,7 @@ func (c *Core) keepsTerminal() bool {
23 // Workspaces with slots are left to them.23 // Workspaces with slots are left to them.
24 func (c *Core) fillEmpty() []ports.SpawnRequest {24 func (c *Core) fillEmpty() []ports.SpawnRequest {
25 now := c.now()25 now := c.now()
26- c.placement.expireTerminals(now)26+ c.placement.expireWorkspaceClaims(now)
27 if !c.keepsTerminal() {27 if !c.keepsTerminal() {
28 return nil28 return nil
29 }29 }
@@ -39,22 +39,25 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
39 continue39 continue
40 }40 }
41 }41 }
42- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry {42+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.workspacePending(w, spawnTerminal) || now.Sub(w.termAt) < termRetry {
43 continue43 continue
44 }44 }
45 c.firstTerminalResolved = true45 c.firstTerminalResolved = true
46 w.termAt = now46 w.termAt = now
47- reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now}))47+ reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{kind: spawnTerminal, ws: w, at: now}))
48 }48 }
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.terminal != nil {57+ if target, ok := c.placement.pending[token]; ok && target.kind != spawnSlot {
57- target.terminal.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 23

Tags 10

↑↓ moveenter openesc close