feat(core): open apps straight into the stash
Add the 'stash <command>' bind action, which spawns with a SlotEnv claim for the workspace current at key press and puts the mapped window in that workspace's stash, selected and focused. Add stash.spawn-terminal (default on): toggle-stash-visible on an empty stash opens the terminal there, never twice while one is awaited.
- commit
- df18de2d8f42f9b49bcef4248822a69f0b884b34
- author
- brice · · 2026-10-14 20:29 UTC
- parent
- cb04ab1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +449 -20
M README.md +1 -0
| @@ -206,6 +206,7 @@ stash.width = 80 | |||
| 206 | 206 | stash.gap = 2 | |
| 207 | 207 | stash.dim = 0.5 | |
| 208 | 208 | stash.capture = on | |
| 209 | + | stash.spawn-terminal = on | |
| 209 | 210 | ||
| 210 | 211 | # Input | |
| 211 | 212 | touchpad.natural-scroll = off | |
| @@ -206,6 +206,7 @@ stash.width = 80 | |||||
| 206 | stash.gap = 2 | 206 | stash.gap = 2 | ||
| 207 | stash.dim = 0.5 | 207 | stash.dim = 0.5 | ||
| 208 | stash.capture = on | 208 | stash.capture = on | ||
| 209 | + | stash.spawn-terminal = on | |||
| 209 | 210 | ||||
| 210 | # Input | 211 | # Input | ||
| 211 | touchpad.natural-scroll = off | 212 | 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. | |||
| 38 | 38 | | `stash.gap` | `2` | Gap around the selected stashed window, in % (0–10) | | |
| 39 | 39 | | `stash.dim` | `0.5` | Darkening of the stash neighbors, 0 to 1 | | |
| 40 | 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 | 42 | | `touchpad.natural-scroll` | `off` | Content follows the fingers | | |
| 42 | 43 | | `touchpad.tap` | `on` | Tap to click | | |
| 43 | 44 | | `touchpad.accel-speed` | `0` | Pointer speed, `-1` to `1` | | |
| @@ -173,6 +174,7 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | |||
| 173 | 174 | |---|---| | |
| 174 | 175 | | `spawn <command>` | Run a program | | |
| 175 | 176 | | `spawn-terminal` | Run `terminal` | | |
| 177 | + | | `stash <command>` | Run a program and open its window in the [stash](#stash) | | |
| 176 | 178 | | `close-window` | Close the focused window | | |
| 177 | 179 | | `toggle-fullscreen` | Fullscreen the focused window. Nothing shows above it except its own dialogs | | |
| 178 | 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 | 254 | Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it. | |
| 253 | 255 | ||
| 256 | + | `stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`. | |
| 257 | + | ||
| 258 | + | ```ini | |
| 259 | + | bind.cmd+shift+f = stash firefox | |
| 260 | + | ``` | |
| 261 | + | ||
| 262 | + | Apps that hand a launch to an already running instance (single-instance browsers, `footclient`) open their window normally. | |
| 263 | + | ||
| 254 | 264 | ### Free floating windows | |
| 255 | 265 | ||
| 256 | 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. | |
| @@ -38,6 +38,7 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 38 | | `stash.gap` | `2` | Gap around the selected stashed window, in % (0–10) | | 38 | | `stash.gap` | `2` | Gap around the selected stashed window, in % (0–10) | | ||
| 39 | | `stash.dim` | `0.5` | Darkening of the stash neighbors, 0 to 1 | | 39 | | `stash.dim` | `0.5` | Darkening of the stash neighbors, 0 to 1 | | ||
| 40 | | `stash.capture` | `on` | A window opened while the stash shows joins the stash | | 40 | | `stash.capture` | `on` | A window opened while the stash shows joins the stash | | ||
| 41 | + | | `stash.spawn-terminal` | `on` | `toggle-stash-visible` on an empty stash opens `terminal` in it | | |||
| 41 | | `touchpad.natural-scroll` | `off` | Content follows the fingers | | 42 | | `touchpad.natural-scroll` | `off` | Content follows the fingers | | ||
| 42 | | `touchpad.tap` | `on` | Tap to click | | 43 | | `touchpad.tap` | `on` | Tap to click | | ||
| 43 | | `touchpad.accel-speed` | `0` | Pointer speed, `-1` to `1` | | 44 | | `touchpad.accel-speed` | `0` | Pointer speed, `-1` to `1` | | ||
| @@ -173,6 +174,7 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | |||||
| 173 | |---|---| | 174 | |---|---| | ||
| 174 | | `spawn <command>` | Run a program | | 175 | | `spawn <command>` | Run a program | | ||
| 175 | | `spawn-terminal` | Run `terminal` | | 176 | | `spawn-terminal` | Run `terminal` | | ||
| 177 | + | | `stash <command>` | Run a program and open its window in the [stash](#stash) | | |||
| 176 | | `close-window` | Close the focused window | | 178 | | `close-window` | Close the focused window | | ||
| 177 | | `toggle-fullscreen` | Fullscreen the focused window. Nothing shows above it except its own dialogs | | 179 | | `toggle-fullscreen` | Fullscreen the focused window. Nothing shows above it except its own dialogs | | ||
| 178 | | `toggle-floating` | Make the focused window [float](#free-floating-windows), or put it back in its column | | 180 | | `toggle-floating` | Make the focused window [float](#free-floating-windows), or put it back in its column | | ||
| @@ -251,6 +253,14 @@ A cell is `1/max-columns` of the screen width. A column is 1 to 16 cells wide. C | |||||
| 251 | 253 | ||||
| 252 | Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it. | 254 | Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it. | ||
| 253 | 255 | ||||
| 256 | + | `stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`. | |||
| 257 | + | ||||
| 258 | + | ```ini | |||
| 259 | + | bind.cmd+shift+f = stash firefox | |||
| 260 | + | ``` | |||
| 261 | + | ||||
| 262 | + | Apps that hand a launch to an already running instance (single-instance browsers, `footclient`) open their window normally. | |||
| 263 | + | ||||
| 254 | ### Free floating windows | 264 | ### 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 | |||
| 36 | 36 | # Black veil over tiles and lower layers when a float is visible (0 to 1). | |
| 37 | 37 | 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 | 42 | layout.gaps = 0 | |
| 40 | 43 | # Columns sharing the screen equally before the overflow applies: one cell is | |
| 41 | 44 | # 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.3 | 37 | 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 = 0 | 42 | layout.gaps = 0 | ||
| 40 | # Columns sharing the screen equally before the overflow applies: one cell is | 43 | # 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 in | 44 | # 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 { | |||
| 170 | 170 | c.Stash.Gap = 2 | |
| 171 | 171 | c.Stash.Dim = 0.5 | |
| 172 | 172 | c.Stash.Capture = true | |
| 173 | + | c.Stash.SpawnTerminal = true | |
| 173 | 174 | c.Border.Width = 2 | |
| 174 | 175 | c.Border.Active = "#808080" | |
| 175 | 176 | c.Border.Inactive = "#111111" | |
| @@ -760,6 +761,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 760 | 761 | return err | |
| 761 | 762 | } | |
| 762 | 763 | 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 | 770 | case "border.width": | |
| 764 | 771 | n, err := strconv.Atoi(v) | |
| 765 | 772 | if err != nil || n < 0 || n > 32 { | |
| @@ -1207,6 +1214,12 @@ func checkAction(v string) error { | |||
| 1207 | 1214 | } | |
| 1208 | 1215 | 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 | 1223 | if rest, ok := strings.CutPrefix(v, "workspace "); ok { | |
| 1211 | 1224 | if !workspaceName.MatchString(strings.TrimSpace(rest)) { | |
| 1212 | 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 | 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 | 1257 | return nil | |
| 1245 | 1258 | } | |
| @@ -170,6 +170,7 @@ func Defaults() ports.Config { | |||||
| 170 | c.Stash.Gap = 2 | 170 | c.Stash.Gap = 2 | ||
| 171 | c.Stash.Dim = 0.5 | 171 | c.Stash.Dim = 0.5 | ||
| 172 | c.Stash.Capture = true | 172 | c.Stash.Capture = true | ||
| 173 | + | c.Stash.SpawnTerminal = true | |||
| 173 | c.Border.Width = 2 | 174 | 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 err | 761 | return err | ||
| 761 | } | 762 | } | ||
| 762 | c.Stash.Capture = b | 763 | 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 nil | 1215 | 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 nil | 1257 | return nil | ||
| 1245 | } | 1258 | } | ||
M internal/adapters/config/config_test.go +24 -0
| @@ -296,6 +296,8 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 296 | 296 | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | |
| 297 | 297 | {"bind.cmd+x = spawn ", "unknown action"}, | |
| 298 | 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 | 301 | {"startup = ;", "must not be empty"}, | |
| 300 | 302 | } | |
| 301 | 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 | 651 | func TestVRRFlipGap(t *testing.T) { | |
| 628 | 652 | for _, tc := range []struct { | |
| 629 | 653 | 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 string | 653 | value string | ||
M internal/core/action.go +25 -5
| @@ -180,8 +180,17 @@ func NamedArg(a Action) (string, bool) { | |||
| 180 | 180 | const spawnPrefix = "spawn " | |
| 181 | 181 | ||
| 182 | 182 | // 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) | |
| 185 | 194 | if !ok { | |
| 186 | 195 | return nil, false | |
| 187 | 196 | } | |
| @@ -192,8 +201,11 @@ func SpawnCommands(a Action) ([][]string, bool) { | |||
| 192 | 201 | type Effect struct { | |
| 193 | 202 | Spawn bool | |
| 194 | 203 | 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 | |
| 197 | 209 | } | |
| 198 | 210 | ||
| 199 | 211 | // applyAction runs a bind action. Monitor actions change the focused | |
| @@ -365,7 +377,12 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 365 | 377 | m.Current().ToggleWindowStash() | |
| 366 | 378 | return Effect{} | |
| 367 | 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 | 386 | return Effect{} | |
| 370 | 387 | case ActionToggleOverview: | |
| 371 | 388 | m.ToggleOverview() | |
| @@ -433,6 +450,9 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 433 | 450 | if commands, ok := SpawnCommands(a); ok { | |
| 434 | 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 | 456 | if w.freeAction(a) { | |
| 437 | 457 | return Effect{} | |
| 438 | 458 | } | |
| @@ -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, false | 195 | 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 bool | 202 | Spawn bool | ||
| 194 | Commands [][]string // commands to run; nil means the configured terminal | 203 | Commands [][]string // commands to run; nil means the configured terminal | ||
| 195 | - | Close WindowID | 204 | + | // Stash sends the spawned windows to the stash of the workspace the |
| 196 | - | Quit bool | 205 | + | // 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 focused | 211 | // applyAction runs a bind action. Monitor actions change the focused | ||
| @@ -365,7 +377,12 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 365 | m.Current().ToggleWindowStash() | 377 | m.Current().ToggleWindowStash() | ||
| 366 | return Effect{} | 378 | return Effect{} | ||
| 367 | case ActionToggleStashVisible: | 379 | case ActionToggleStashVisible: | ||
| 368 | - | m.Current().ToggleStashVisible() | 380 | + | w := m.Current() |
| 381 | + | // An empty stash has nothing to show: open the terminal in it. | |||
| 382 | + | if len(w.Stash) == 0 && m.stashTerminal { | |||
| 383 | + | return Effect{Spawn: true, Stash: true} | |||
| 384 | + | } | |||
| 385 | + | w.ToggleStashVisible() | |||
| 369 | return Effect{} | 386 | return Effect{} | ||
| 370 | case ActionToggleOverview: | 387 | case ActionToggleOverview: | ||
| 371 | m.ToggleOverview() | 388 | m.ToggleOverview() | ||
| @@ -433,6 +450,9 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 433 | if commands, ok := SpawnCommands(a); ok { | 450 | if commands, ok := SpawnCommands(a); ok { | ||
| 434 | return Effect{Spawn: true, Commands: commands} | 451 | return Effect{Spawn: true, Commands: commands} | ||
| 435 | } | 452 | } | ||
| 453 | + | if commands, ok := StashCommands(a); ok { | |||
| 454 | + | return Effect{Spawn: true, Stash: true, Commands: commands} | |||
| 455 | + | } | |||
| 436 | if w.freeAction(a) { | 456 | if w.freeAction(a) { | ||
| 437 | return Effect{} | 457 | return Effect{} | ||
| 438 | } | 458 | } | ||
M internal/core/core.go +5 -1
| @@ -246,6 +246,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 246 | 246 | binds[b] = Action(a) | |
| 247 | 247 | continue | |
| 248 | 248 | } | |
| 249 | + | if _, ok := StashCommands(Action(a)); ok { | |
| 250 | + | binds[b] = Action(a) | |
| 251 | + | continue | |
| 252 | + | } | |
| 249 | 253 | if Action(a) == ActionScaleUp || Action(a) == ActionScaleDown { | |
| 250 | 254 | binds[b] = Action(a) | |
| 251 | 255 | continue | |
| @@ -1247,7 +1251,7 @@ func (c *Core) spawnEmpty(ctx context.Context) error { | |||
| 1247 | 1251 | for i, req := range terms { | |
| 1248 | 1252 | if err != nil || !c.trySpawn(ctx, req) { | |
| 1249 | 1253 | for _, suppressed := range terms[i:] { | |
| 1250 | - | c.cancelTerminal(suppressed) | |
| 1254 | + | c.cancelClaim(suppressed) | |
| 1251 | 1255 | } | |
| 1252 | 1256 | if i == 0 { | |
| 1253 | 1257 | c.firstTerminalResolved = first | |
| @@ -246,6 +246,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 246 | binds[b] = Action(a) | 246 | binds[b] = Action(a) | ||
| 247 | continue | 247 | 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 | continue | 255 | continue | ||
| @@ -1247,7 +1251,7 @@ func (c *Core) spawnEmpty(ctx context.Context) error { | |||||
| 1247 | for i, req := range terms { | 1251 | for i, req := range terms { | ||
| 1248 | if err != nil || !c.trySpawn(ctx, req) { | 1252 | if err != nil || !c.trySpawn(ctx, req) { | ||
| 1249 | for _, suppressed := range terms[i:] { | 1253 | for _, suppressed := range terms[i:] { | ||
| 1250 | - | c.cancelTerminal(suppressed) | 1254 | + | c.cancelClaim(suppressed) |
| 1251 | } | 1255 | } | ||
| 1252 | if i == 0 { | 1256 | if i == 0 { | ||
| 1253 | c.firstTerminalResolved = first | 1257 | c.firstTerminalResolved = first | ||
M internal/core/input.go +43 -5
| @@ -346,11 +346,18 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||
| 346 | 346 | if commands == nil { | |
| 347 | 347 | commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)} | |
| 348 | 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 | |
| 349 | + | switch { | |
| 350 | + | case effect.Stash: | |
| 351 | + | if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil { | |
| 352 | + | return err | |
| 353 | + | } | |
| 354 | + | default: | |
| 355 | + | // Explicit actions are suppressed on a full launcher queue; | |
| 356 | + | // never keep the input owner waiting across an epoch change. | |
| 357 | + | for _, argv := range commands { | |
| 358 | + | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) { | |
| 359 | + | break | |
| 360 | + | } | |
| 354 | 361 | } | |
| 355 | 362 | } | |
| 356 | 363 | if c.inputEpochChanged() { | |
| @@ -372,6 +379,37 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||
| 372 | 379 | return c.publish(ctx) | |
| 373 | 380 | } | |
| 374 | 381 | ||
| 382 | + | // spawnStash runs commands whose windows open in the stash of w, the | |
| 383 | + | // workspace the bind acted on. Wayland reads the claim tokens only while it | |
| 384 | + | // knows slots are pending, so that is published before the spawn, as | |
| 385 | + | // spawnEmpty does. A send the gate suppresses leaves no claim behind. With | |
| 386 | + | // terminal set (the terminal of an empty stash), nothing starts while the | |
| 387 | + | // previous one is still awaited. | |
| 388 | + | func (c *Core) spawnStash(ctx context.Context, w *Workspace, commands [][]string, terminal bool) error { | |
| 389 | + | if c.protectionRequested() { | |
| 390 | + | return nil | |
| 391 | + | } | |
| 392 | + | now := c.now() | |
| 393 | + | c.placement.expireWorkspaceClaims(now) | |
| 394 | + | if terminal && c.placement.workspacePending(w, spawnStash) { | |
| 395 | + | return nil | |
| 396 | + | } | |
| 397 | + | reqs := make([]ports.SpawnRequest, len(commands)) | |
| 398 | + | for i, argv := range commands { | |
| 399 | + | reqs[i] = c.placement.request(argv, spawnTarget{kind: spawnStash, ws: w, at: now}) | |
| 400 | + | } | |
| 401 | + | err := c.publishPending(ctx) | |
| 402 | + | for i, req := range reqs { | |
| 403 | + | if err != nil || !c.trySpawn(ctx, req) { | |
| 404 | + | for _, suppressed := range reqs[i:] { | |
| 405 | + | c.cancelClaim(suppressed) | |
| 406 | + | } | |
| 407 | + | break | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | return err | |
| 411 | + | } | |
| 412 | + | ||
| 375 | 413 | // scaleBind steps the focused output's scale and asks for it to be saved. | |
| 376 | 414 | func (c *Core) scaleBind(ctx context.Context, action Action) error { | |
| 377 | 415 | dir := 1 | |
| @@ -346,11 +346,18 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||||
| 346 | if commands == nil { | 346 | if commands == nil { | ||
| 347 | commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)} | 347 | commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)} | ||
| 348 | } | 348 | } | ||
| 349 | - | // Explicit actions are suppressed on a full launcher queue; | 349 | + | switch { |
| 350 | - | // never keep the input owner waiting across an epoch change. | 350 | + | case effect.Stash: |
| 351 | - | for _, argv := range commands { | 351 | + | if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil { |
| 352 | - | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) { | 352 | + | return err |
| 353 | - | break | 353 | + | } |
| 354 | + | default: | |||
| 355 | + | // Explicit actions are suppressed on a full launcher queue; | |||
| 356 | + | // never keep the input owner waiting across an epoch change. | |||
| 357 | + | for _, argv := range commands { | |||
| 358 | + | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) { | |||
| 359 | + | break | |||
| 360 | + | } | |||
| 354 | } | 361 | } | ||
| 355 | } | 362 | } | ||
| 356 | if c.inputEpochChanged() { | 363 | if c.inputEpochChanged() { | ||
| @@ -372,6 +379,37 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||||
| 372 | return c.publish(ctx) | 379 | return c.publish(ctx) | ||
| 373 | } | 380 | } | ||
| 374 | 381 | ||||
| 382 | + | // spawnStash runs commands whose windows open in the stash of w, the | |||
| 383 | + | // workspace the bind acted on. Wayland reads the claim tokens only while it | |||
| 384 | + | // knows slots are pending, so that is published before the spawn, as | |||
| 385 | + | // spawnEmpty does. A send the gate suppresses leaves no claim behind. With | |||
| 386 | + | // terminal set (the terminal of an empty stash), nothing starts while the | |||
| 387 | + | // previous one is still awaited. | |||
| 388 | + | func (c *Core) spawnStash(ctx context.Context, w *Workspace, commands [][]string, terminal bool) error { | |||
| 389 | + | if c.protectionRequested() { | |||
| 390 | + | return nil | |||
| 391 | + | } | |||
| 392 | + | now := c.now() | |||
| 393 | + | c.placement.expireWorkspaceClaims(now) | |||
| 394 | + | if terminal && c.placement.workspacePending(w, spawnStash) { | |||
| 395 | + | return nil | |||
| 396 | + | } | |||
| 397 | + | reqs := make([]ports.SpawnRequest, len(commands)) | |||
| 398 | + | for i, argv := range commands { | |||
| 399 | + | reqs[i] = c.placement.request(argv, spawnTarget{kind: spawnStash, ws: w, at: now}) | |||
| 400 | + | } | |||
| 401 | + | err := c.publishPending(ctx) | |||
| 402 | + | for i, req := range reqs { | |||
| 403 | + | if err != nil || !c.trySpawn(ctx, req) { | |||
| 404 | + | for _, suppressed := range reqs[i:] { | |||
| 405 | + | c.cancelClaim(suppressed) | |||
| 406 | + | } | |||
| 407 | + | break | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | return err | |||
| 411 | + | } | |||
| 412 | + | ||||
| 375 | // scaleBind steps the focused output's scale and asks for it to be saved. | 413 | // scaleBind steps the focused output's scale and asks for it to be saved. | ||
| 376 | func (c *Core) scaleBind(ctx context.Context, action Action) error { | 414 | func (c *Core) scaleBind(ctx context.Context, action Action) error { | ||
| 377 | dir := 1 | 415 | dir := 1 | ||
M internal/core/monitor.go +7 -0
| @@ -34,6 +34,9 @@ type Monitor struct { | |||
| 34 | 34 | followMove bool | |
| 35 | 35 | // stashCapture puts new windows in the shown stash (stash.capture). | |
| 36 | 36 | 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 | 40 | // switchView slides the view between numbered workspaces: the view is | |
| 38 | 41 | // at Active+switchView.off, in workspaces. | |
| 39 | 42 | switchView slide | |
| @@ -529,6 +532,10 @@ func (m *Monitor) SetStash(width, gap int) { | |||
| 529 | 532 | // SetStashCapture makes new windows join the shown stash (stash.capture). | |
| 530 | 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 | 539 | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | |
| 533 | 540 | func (m *Monitor) SetFollowMove(on bool) { m.followMove = on } | |
| 534 | 541 | ||
| @@ -34,6 +34,9 @@ type Monitor struct { | |||||
| 34 | followMove bool | 34 | 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 bool | 36 | 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 is | 40 | // 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 slide | 42 | 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) { | |||
| 528 | 528 | m.SetBorder(c.cfg.Border.Width) | |
| 529 | 529 | m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap) | |
| 530 | 530 | m.SetStashCapture(c.cfg.Stash.Capture) | |
| 531 | + | m.SetStashTerminal(c.cfg.Stash.SpawnTerminal && len(c.cfg.Terminal.Command) > 0) | |
| 531 | 532 | m.SetFollowMove(c.cfg.Focus.FollowMove) | |
| 532 | 533 | } | |
| 533 | 534 | ||
| @@ -528,6 +528,7 @@ func (c *Core) settings(m *Monitor) { | |||||
| 528 | m.SetBorder(c.cfg.Border.Width) | 528 | m.SetBorder(c.cfg.Border.Width) | ||
| 529 | m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap) | 529 | m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap) | ||
| 530 | m.SetStashCapture(c.cfg.Stash.Capture) | 530 | m.SetStashCapture(c.cfg.Stash.Capture) | ||
| 531 | + | m.SetStashTerminal(c.cfg.Stash.SpawnTerminal && len(c.cfg.Terminal.Command) > 0) | |||
| 531 | m.SetFollowMove(c.cfg.Focus.FollowMove) | 532 | m.SetFollowMove(c.cfg.Focus.FollowMove) | ||
| 532 | } | 533 | } | ||
| 533 | 534 | ||||
M internal/core/spawn_placement.go +13 -0
| @@ -23,6 +23,8 @@ const ( | |||
| 23 | 23 | spawnSlot spawnKind = iota | |
| 24 | 24 | // spawnTerminal is an automatic terminal for an empty workspace. | |
| 25 | 25 | spawnTerminal | |
| 26 | + | // spawnStash is a window opened straight into the stash of ws. | |
| 27 | + | spawnStash | |
| 26 | 28 | ) | |
| 27 | 29 | ||
| 28 | 30 | // spawnTarget identifies the destination of one pending spawn. slot is set | |
| @@ -106,6 +108,8 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 106 | 108 | return | |
| 107 | 109 | } | |
| 108 | 110 | if v.Slot != "" { | |
| 111 | + | // A claim no window answered in time no longer places one. | |
| 112 | + | p.expireWorkspaceClaims(c.now()) | |
| 109 | 113 | if target, ok := p.pending[v.Slot]; ok { | |
| 110 | 114 | switch target.kind { | |
| 111 | 115 | case spawnTerminal: | |
| @@ -117,6 +121,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 117 | 121 | return | |
| 118 | 122 | } | |
| 119 | 123 | } | |
| 124 | + | case spawnStash: | |
| 125 | + | delete(p.pending, v.Slot) | |
| 126 | + | for _, s := range c.screens { | |
| 127 | + | if s.mon.has(target.ws) { | |
| 128 | + | target.ws.stashSpawned(v.ID) | |
| 129 | + | s.mon.normalize() | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | } | |
| 120 | 133 | case spawnSlot: | |
| 121 | 134 | if st := c.slots[target.slot]; st != nil && st.window == 0 { | |
| 122 | 135 | if sc, w := c.byName(target.slot.workspace); w != nil { | |
| @@ -23,6 +23,8 @@ const ( | |||||
| 23 | spawnSlot spawnKind = iota | 23 | spawnSlot spawnKind = iota | ||
| 24 | // spawnTerminal is an automatic terminal for an empty workspace. | 24 | // spawnTerminal is an automatic terminal for an empty workspace. | ||
| 25 | spawnTerminal | 25 | spawnTerminal | ||
| 26 | + | // spawnStash is a window opened straight into the stash of ws. | |||
| 27 | + | spawnStash | |||
| 26 | ) | 28 | ) | ||
| 27 | 29 | ||||
| 28 | // spawnTarget identifies the destination of one pending spawn. slot is set | 30 | // spawnTarget identifies the destination of one pending spawn. slot is set | ||
| @@ -106,6 +108,8 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 106 | return | 108 | return | ||
| 107 | } | 109 | } | ||
| 108 | if v.Slot != "" { | 110 | if v.Slot != "" { | ||
| 111 | + | // A claim no window answered in time no longer places one. | |||
| 112 | + | p.expireWorkspaceClaims(c.now()) | |||
| 109 | if target, ok := p.pending[v.Slot]; ok { | 113 | if target, ok := p.pending[v.Slot]; ok { | ||
| 110 | switch target.kind { | 114 | switch target.kind { | ||
| 111 | case spawnTerminal: | 115 | case spawnTerminal: | ||
| @@ -117,6 +121,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 117 | return | 121 | return | ||
| 118 | } | 122 | } | ||
| 119 | } | 123 | } | ||
| 124 | + | case spawnStash: | |||
| 125 | + | delete(p.pending, v.Slot) | |||
| 126 | + | for _, s := range c.screens { | |||
| 127 | + | if s.mon.has(target.ws) { | |||
| 128 | + | target.ws.stashSpawned(v.ID) | |||
| 129 | + | s.mon.normalize() | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 120 | case spawnSlot: | 133 | case spawnSlot: | ||
| 121 | if st := c.slots[target.slot]; st != nil && st.window == 0 { | 134 | if st := c.slots[target.slot]; st != nil && st.window == 0 { | ||
| 122 | if sc, w := c.byName(target.slot.workspace); w != nil { | 135 | if sc, w := c.byName(target.slot.workspace); w != nil { | ||
M internal/core/stash.go +10 -0
| @@ -81,6 +81,16 @@ func (w *Workspace) captureStash(id WindowID) bool { | |||
| 81 | 81 | return true | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | + | // stashSpawned puts a window opened by a stash spawn in the stash, selected | |
| 85 | + | // and focused, with no former column: toggle-window-stash tiles it as a new | |
| 86 | + | // column. Under a covering fullscreen window the stash shows over it. | |
| 87 | + | func (w *Workspace) stashSpawned(id WindowID) { | |
| 88 | + | w.addStash(Float{ID: id}) | |
| 89 | + | if w.cover() != 0 && w.stashIndex(w.cover()) < 0 { | |
| 90 | + | w.showStashOver() | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 84 | 94 | // showStash shows the stash and gives it the focus; a member whose | |
| 85 | 95 | // fullscreen waited while hidden is fullscreen again, selected, as if it | |
| 86 | 96 | // asked now. | |
| @@ -81,6 +81,16 @@ func (w *Workspace) captureStash(id WindowID) bool { | |||||
| 81 | return true | 81 | return true | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | + | // stashSpawned puts a window opened by a stash spawn in the stash, selected | |||
| 85 | + | // and focused, with no former column: toggle-window-stash tiles it as a new | |||
| 86 | + | // column. Under a covering fullscreen window the stash shows over it. | |||
| 87 | + | func (w *Workspace) stashSpawned(id WindowID) { | |||
| 88 | + | w.addStash(Float{ID: id}) | |||
| 89 | + | if w.cover() != 0 && w.stashIndex(w.cover()) < 0 { | |||
| 90 | + | w.showStashOver() | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 84 | // showStash shows the stash and gives it the focus; a member whose | 94 | // showStash shows the stash and gives it the focus; a member whose | ||
| 85 | // fullscreen waited while hidden is fullscreen again, selected, as if it | 95 | // fullscreen waited while hidden is fullscreen again, selected, as if it | ||
| 86 | // asked now. | 96 | // asked now. | ||
A internal/core/stash_spawn_internal_test.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core { | |
| 13 | + | t.Helper() | |
| 14 | + | var cfg ports.Config | |
| 15 | + | cfg.Keyboard.CmdKey = "alt" | |
| 16 | + | cfg.Layout.MaxColumns = 2 | |
| 17 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{}) | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |
| 22 | + | return c | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func claimToken(req ports.SpawnRequest) string { | |
| 26 | + | return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Wayland reads tokens only while slots are pending: the flag is sent before | |
| 30 | + | // the spawn can reach the launcher. | |
| 31 | + | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |
| 32 | + | commands := make(chan ports.ClientCommand) // unbuffered: sends are observable | |
| 33 | + | spawn := make(chan ports.SpawnRequest, 1) | |
| 34 | + | c := stashSpawnCore(t, commands, spawn) | |
| 35 | + | done := make(chan error, 1) | |
| 36 | + | go func() { | |
| 37 | + | done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false) | |
| 38 | + | }() | |
| 39 | + | // The send blocks on the unread command: nothing may be spawned yet. | |
| 40 | + | select { | |
| 41 | + | case req := <-spawn: | |
| 42 | + | t.Fatalf("spawned before the pending flag was delivered: %v", req) | |
| 43 | + | case <-time.After(50 * time.Millisecond): | |
| 44 | + | } | |
| 45 | + | select { | |
| 46 | + | case v := <-commands: | |
| 47 | + | if p, ok := v.(ports.SlotsPending); !ok || !p.Pending { | |
| 48 | + | t.Fatalf("command %v", v) | |
| 49 | + | } | |
| 50 | + | case <-time.After(time.Second): | |
| 51 | + | t.Fatal("no SlotsPending") | |
| 52 | + | } | |
| 53 | + | if err := <-done; err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | if len(spawn) != 1 { | |
| 57 | + | t.Fatal("no spawn") | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // A send the launcher queue suppresses leaves no claim behind, and the | |
| 62 | + | // pending flag falls again at the next publish. | |
| 63 | + | func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | |
| 64 | + | commands := make(chan ports.ClientCommand, 8) | |
| 65 | + | c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed | |
| 66 | + | if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | if c.placement.anyPending() { | |
| 70 | + | t.Fatal("suppressed claims stayed pending") | |
| 71 | + | } | |
| 72 | + | if v := (<-commands).(ports.SlotsPending); !v.Pending { | |
| 73 | + | t.Fatal("pending not announced") | |
| 74 | + | } | |
| 75 | + | if err := c.publishPending(context.Background()); err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | if v := (<-commands).(ports.SlotsPending); v.Pending { | |
| 79 | + | t.Fatal("stale pending flag not republished") | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // An unanswered claim expires like a terminal's, even if no publish ran | |
| 84 | + | // between its age and the window mapping. | |
| 85 | + | func TestStashClaimExpires(t *testing.T) { | |
| 86 | + | c := stashSpawnCore(t, nil, nil) | |
| 87 | + | m := c.cur().mon | |
| 88 | + | m.AddWindow(1) | |
| 89 | + | target := m.Current() | |
| 90 | + | m.show(m.Workspaces[1]) | |
| 91 | + | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)}) | |
| 92 | + | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | |
| 93 | + | if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 { | |
| 94 | + | t.Fatal("expired claim still placed the window in the stash") | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // A workspace that left every screen no longer takes the window. | |
| 99 | + | func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) { | |
| 100 | + | c := stashSpawnCore(t, nil, nil) | |
| 101 | + | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()}) | |
| 102 | + | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | |
| 103 | + | if _, w := c.screenOf(5); w != c.cur().mon.Current() { | |
| 104 | + | t.Fatal("window did not map onto the current workspace") | |
| 105 | + | } | |
| 106 | + | if c.placement.anyPending() { | |
| 107 | + | t.Fatal("claim kept") | |
| 108 | + | } | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core { | |||
| 13 | + | t.Helper() | |||
| 14 | + | var cfg ports.Config | |||
| 15 | + | cfg.Keyboard.CmdKey = "alt" | |||
| 16 | + | cfg.Layout.MaxColumns = 2 | |||
| 17 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{}) | |||
| 18 | + | if err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |||
| 22 | + | return c | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func claimToken(req ports.SpawnRequest) string { | |||
| 26 | + | return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Wayland reads tokens only while slots are pending: the flag is sent before | |||
| 30 | + | // the spawn can reach the launcher. | |||
| 31 | + | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |||
| 32 | + | commands := make(chan ports.ClientCommand) // unbuffered: sends are observable | |||
| 33 | + | spawn := make(chan ports.SpawnRequest, 1) | |||
| 34 | + | c := stashSpawnCore(t, commands, spawn) | |||
| 35 | + | done := make(chan error, 1) | |||
| 36 | + | go func() { | |||
| 37 | + | done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false) | |||
| 38 | + | }() | |||
| 39 | + | // The send blocks on the unread command: nothing may be spawned yet. | |||
| 40 | + | select { | |||
| 41 | + | case req := <-spawn: | |||
| 42 | + | t.Fatalf("spawned before the pending flag was delivered: %v", req) | |||
| 43 | + | case <-time.After(50 * time.Millisecond): | |||
| 44 | + | } | |||
| 45 | + | select { | |||
| 46 | + | case v := <-commands: | |||
| 47 | + | if p, ok := v.(ports.SlotsPending); !ok || !p.Pending { | |||
| 48 | + | t.Fatalf("command %v", v) | |||
| 49 | + | } | |||
| 50 | + | case <-time.After(time.Second): | |||
| 51 | + | t.Fatal("no SlotsPending") | |||
| 52 | + | } | |||
| 53 | + | if err := <-done; err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | if len(spawn) != 1 { | |||
| 57 | + | t.Fatal("no spawn") | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // A send the launcher queue suppresses leaves no claim behind, and the | |||
| 62 | + | // pending flag falls again at the next publish. | |||
| 63 | + | func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | |||
| 64 | + | commands := make(chan ports.ClientCommand, 8) | |||
| 65 | + | c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed | |||
| 66 | + | if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil { | |||
| 67 | + | t.Fatal(err) | |||
| 68 | + | } | |||
| 69 | + | if c.placement.anyPending() { | |||
| 70 | + | t.Fatal("suppressed claims stayed pending") | |||
| 71 | + | } | |||
| 72 | + | if v := (<-commands).(ports.SlotsPending); !v.Pending { | |||
| 73 | + | t.Fatal("pending not announced") | |||
| 74 | + | } | |||
| 75 | + | if err := c.publishPending(context.Background()); err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | if v := (<-commands).(ports.SlotsPending); v.Pending { | |||
| 79 | + | t.Fatal("stale pending flag not republished") | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // An unanswered claim expires like a terminal's, even if no publish ran | |||
| 84 | + | // between its age and the window mapping. | |||
| 85 | + | func TestStashClaimExpires(t *testing.T) { | |||
| 86 | + | c := stashSpawnCore(t, nil, nil) | |||
| 87 | + | m := c.cur().mon | |||
| 88 | + | m.AddWindow(1) | |||
| 89 | + | target := m.Current() | |||
| 90 | + | m.show(m.Workspaces[1]) | |||
| 91 | + | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)}) | |||
| 92 | + | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | |||
| 93 | + | if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 { | |||
| 94 | + | t.Fatal("expired claim still placed the window in the stash") | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // A workspace that left every screen no longer takes the window. | |||
| 99 | + | func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) { | |||
| 100 | + | c := stashSpawnCore(t, nil, nil) | |||
| 101 | + | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()}) | |||
| 102 | + | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | |||
| 103 | + | if _, w := c.screenOf(5); w != c.cur().mon.Current() { | |||
| 104 | + | t.Fatal("window did not map onto the current workspace") | |||
| 105 | + | } | |||
| 106 | + | if c.placement.anyPending() { | |||
| 107 | + | t.Fatal("claim kept") | |||
| 108 | + | } | |||
| 109 | + | } | |||
A internal/core/stash_spawn_test.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // stashSpawnRig has a stash bind on Alt+f and, as in the defaults, foot as | |
| 11 | + | // the terminal. Tiles map without any automatic terminal. | |
| 12 | + | func stashSpawnRig(t *testing.T, edit func(*ports.Config)) *multiRig { | |
| 13 | + | t.Helper() | |
| 14 | + | return startMulti(t, func(c *ports.Config) { | |
| 15 | + | c.Binds["Alt+Ctrl+Shift+f"] = "stash firefox --new-window" | |
| 16 | + | c.Stash.Capture = false | |
| 17 | + | if edit != nil { | |
| 18 | + | edit(c) | |
| 19 | + | } | |
| 20 | + | }, left) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func slotToken(t *testing.T, req ports.SpawnRequest) string { | |
| 24 | + | t.Helper() | |
| 25 | + | if len(req.Env) != 1 || len(req.Env[0]) <= len(ports.SlotEnv)+1 || req.Env[0][:len(ports.SlotEnv)+1] != ports.SlotEnv+"=" { | |
| 26 | + | t.Fatalf("no slot token: %v", req.Env) | |
| 27 | + | } | |
| 28 | + | return req.Env[0][len(ports.SlotEnv)+1:] | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // pendingSent drains the command queue and reports the last SlotsPending. | |
| 32 | + | func pendingSent(r *multiRig) (pending, sent bool) { | |
| 33 | + | for len(r.commands) > 0 { | |
| 34 | + | if v, ok := (<-r.commands).(ports.SlotsPending); ok { | |
| 35 | + | pending, sent = v.Pending, true | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return pending, sent | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) { | |
| 42 | + | for _, s := range set { | |
| 43 | + | for _, w := range s.Windows { | |
| 44 | + | if w.ID == id { | |
| 45 | + | return w, true | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | return ports.SceneWindow{}, false | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // The window of a stash bind opens in the stash of the workspace the key | |
| 53 | + | // was pressed on, shown and focused, even when the user moved on before it | |
| 54 | + | // mapped; toggle-window-stash then tiles it as a new column. | |
| 55 | + | func TestStashBindOpensWindowInStash(t *testing.T) { | |
| 56 | + | r := stashSpawnRig(t, nil) | |
| 57 | + | r.mapWindow(t, 1) | |
| 58 | + | r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 59 | + | req := receive(t, r.spawn) | |
| 60 | + | if !slices.Equal(req.Argv, []string{"firefox", "--new-window"}) { | |
| 61 | + | t.Fatalf("argv %v", req.Argv) | |
| 62 | + | } | |
| 63 | + | token := slotToken(t, req) | |
| 64 | + | if pending, sent := pendingSent(r); !sent || !pending { | |
| 65 | + | t.Fatal("SlotsPending{true} was not sent") | |
| 66 | + | } | |
| 67 | + | r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2 | |
| 68 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token} | |
| 69 | + | set := receive(t, r.scenes) | |
| 70 | + | if w, ok := sceneWindow(set, 2); ok && !w.Hidden { | |
| 71 | + | t.Fatalf("window shown on the workspace the user moved to: %+v", w) | |
| 72 | + | } | |
| 73 | + | set = r.key(t, "Prior", ports.ModAlt) | |
| 74 | + | w, ok := sceneWindow(set, 2) | |
| 75 | + | if !ok || w.Hidden || !w.Floating || !w.Focused { | |
| 76 | + | t.Fatalf("stashed window %+v", w) | |
| 77 | + | } | |
| 78 | + | if w1, _ := sceneWindow(set, 1); w1.Focused { | |
| 79 | + | t.Fatal("tile kept the focus") | |
| 80 | + | } | |
| 81 | + | if pending, sent := pendingSent(r); !sent || pending { | |
| 82 | + | t.Fatalf("pending flag not cleared: %v %v", pending, sent) | |
| 83 | + | } | |
| 84 | + | // Unstashing tiles it as a column of its own. | |
| 85 | + | set = r.key(t, "s", ports.ModAlt|ports.ModShift) | |
| 86 | + | if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused { | |
| 87 | + | t.Fatalf("unstashed window %+v", w) | |
| 88 | + | } | |
| 89 | + | if len(r.spawn) != 0 { | |
| 90 | + | t.Fatal("unexpected spawn") | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestStashBindRunsEveryCommand(t *testing.T) { | |
| 95 | + | r := stashSpawnRig(t, func(c *ports.Config) { c.Binds["Alt+Ctrl+Shift+f"] = "stash one; two" }) | |
| 96 | + | r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 97 | + | a, b := receive(t, r.spawn), receive(t, r.spawn) | |
| 98 | + | if a.Argv[0] != "one" || b.Argv[0] != "two" || slotToken(t, a) == slotToken(t, b) { | |
| 99 | + | t.Fatal(a, b) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // With an empty stash, toggle-stash-visible opens the terminal in it, once | |
| 104 | + | // while it is awaited. | |
| 105 | + | func TestToggleEmptyStashOpensTerminal(t *testing.T) { | |
| 106 | + | r := stashSpawnRig(t, nil) | |
| 107 | + | r.mapWindow(t, 1) | |
| 108 | + | r.key(t, "s", ports.ModAlt) | |
| 109 | + | req := receive(t, r.spawn) | |
| 110 | + | if len(req.Argv) != 1 || req.Argv[0] != "foot" { | |
| 111 | + | t.Fatalf("argv %v", req.Argv) | |
| 112 | + | } | |
| 113 | + | token := slotToken(t, req) | |
| 114 | + | if pending, sent := pendingSent(r); !sent || !pending { | |
| 115 | + | t.Fatal("SlotsPending{true} was not sent") | |
| 116 | + | } | |
| 117 | + | r.key(t, "s", ports.ModAlt) | |
| 118 | + | if len(r.spawn) != 0 { | |
| 119 | + | t.Fatal("second terminal while the first is awaited") | |
| 120 | + | } | |
| 121 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token} | |
| 122 | + | set := receive(t, r.scenes) | |
| 123 | + | if w, ok := sceneWindow(set, 2); !ok || w.Hidden || !w.Floating || !w.Focused { | |
| 124 | + | t.Fatalf("terminal %+v", w) | |
| 125 | + | } | |
| 126 | + | // The stash has a window now: the toggle hides it, no spawn. | |
| 127 | + | set = r.key(t, "s", ports.ModAlt) | |
| 128 | + | if w, _ := sceneWindow(set, 2); !w.Hidden { | |
| 129 | + | t.Fatalf("stash not hidden: %+v", w) | |
| 130 | + | } | |
| 131 | + | if len(r.spawn) != 0 { | |
| 132 | + | t.Fatal("spawned for a stash holding a window") | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestToggleNonEmptyStashDoesNotSpawn(t *testing.T) { | |
| 137 | + | r := stashSpawnRig(t, nil) | |
| 138 | + | r.mapWindow(t, 1) | |
| 139 | + | r.mapWindow(t, 2) | |
| 140 | + | r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed | |
| 141 | + | r.key(t, "s", ports.ModAlt) | |
| 142 | + | r.key(t, "s", ports.ModAlt) | |
| 143 | + | if len(r.spawn) != 0 { | |
| 144 | + | t.Fatal("spawned for a non-empty stash") | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestToggleEmptyStashRespectsOption(t *testing.T) { | |
| 149 | + | r := stashSpawnRig(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false }) | |
| 150 | + | r.mapWindow(t, 1) | |
| 151 | + | r.key(t, "s", ports.ModAlt) | |
| 152 | + | if len(r.spawn) != 0 { | |
| 153 | + | t.Fatal("spawned with stash.spawn-terminal off") | |
| 154 | + | } | |
| 155 | + | r = stashSpawnRig(t, func(c *ports.Config) { c.Terminal.Command = nil }) | |
| 156 | + | r.mapWindow(t, 1) | |
| 157 | + | r.key(t, "s", ports.ModAlt) | |
| 158 | + | if len(r.spawn) != 0 { | |
| 159 | + | t.Fatal("spawned without a terminal command") | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) { | |
| 164 | + | r := stashSpawnRig(t, nil) | |
| 165 | + | r.mapWindow(t, 1) | |
| 166 | + | r.key(t, "o", ports.ModAlt) | |
| 167 | + | r.key(t, "s", ports.ModAlt) | |
| 168 | + | if len(r.spawn) != 0 { | |
| 169 | + | t.Fatal("spawned from the overview") | |
| 170 | + | } | |
| 171 | + | } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // stashSpawnRig has a stash bind on Alt+f and, as in the defaults, foot as | |||
| 11 | + | // the terminal. Tiles map without any automatic terminal. | |||
| 12 | + | func stashSpawnRig(t *testing.T, edit func(*ports.Config)) *multiRig { | |||
| 13 | + | t.Helper() | |||
| 14 | + | return startMulti(t, func(c *ports.Config) { | |||
| 15 | + | c.Binds["Alt+Ctrl+Shift+f"] = "stash firefox --new-window" | |||
| 16 | + | c.Stash.Capture = false | |||
| 17 | + | if edit != nil { | |||
| 18 | + | edit(c) | |||
| 19 | + | } | |||
| 20 | + | }, left) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func slotToken(t *testing.T, req ports.SpawnRequest) string { | |||
| 24 | + | t.Helper() | |||
| 25 | + | if len(req.Env) != 1 || len(req.Env[0]) <= len(ports.SlotEnv)+1 || req.Env[0][:len(ports.SlotEnv)+1] != ports.SlotEnv+"=" { | |||
| 26 | + | t.Fatalf("no slot token: %v", req.Env) | |||
| 27 | + | } | |||
| 28 | + | return req.Env[0][len(ports.SlotEnv)+1:] | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // pendingSent drains the command queue and reports the last SlotsPending. | |||
| 32 | + | func pendingSent(r *multiRig) (pending, sent bool) { | |||
| 33 | + | for len(r.commands) > 0 { | |||
| 34 | + | if v, ok := (<-r.commands).(ports.SlotsPending); ok { | |||
| 35 | + | pending, sent = v.Pending, true | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | return pending, sent | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) { | |||
| 42 | + | for _, s := range set { | |||
| 43 | + | for _, w := range s.Windows { | |||
| 44 | + | if w.ID == id { | |||
| 45 | + | return w, true | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | return ports.SceneWindow{}, false | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // The window of a stash bind opens in the stash of the workspace the key | |||
| 53 | + | // was pressed on, shown and focused, even when the user moved on before it | |||
| 54 | + | // mapped; toggle-window-stash then tiles it as a new column. | |||
| 55 | + | func TestStashBindOpensWindowInStash(t *testing.T) { | |||
| 56 | + | r := stashSpawnRig(t, nil) | |||
| 57 | + | r.mapWindow(t, 1) | |||
| 58 | + | r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 59 | + | req := receive(t, r.spawn) | |||
| 60 | + | if !slices.Equal(req.Argv, []string{"firefox", "--new-window"}) { | |||
| 61 | + | t.Fatalf("argv %v", req.Argv) | |||
| 62 | + | } | |||
| 63 | + | token := slotToken(t, req) | |||
| 64 | + | if pending, sent := pendingSent(r); !sent || !pending { | |||
| 65 | + | t.Fatal("SlotsPending{true} was not sent") | |||
| 66 | + | } | |||
| 67 | + | r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2 | |||
| 68 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token} | |||
| 69 | + | set := receive(t, r.scenes) | |||
| 70 | + | if w, ok := sceneWindow(set, 2); ok && !w.Hidden { | |||
| 71 | + | t.Fatalf("window shown on the workspace the user moved to: %+v", w) | |||
| 72 | + | } | |||
| 73 | + | set = r.key(t, "Prior", ports.ModAlt) | |||
| 74 | + | w, ok := sceneWindow(set, 2) | |||
| 75 | + | if !ok || w.Hidden || !w.Floating || !w.Focused { | |||
| 76 | + | t.Fatalf("stashed window %+v", w) | |||
| 77 | + | } | |||
| 78 | + | if w1, _ := sceneWindow(set, 1); w1.Focused { | |||
| 79 | + | t.Fatal("tile kept the focus") | |||
| 80 | + | } | |||
| 81 | + | if pending, sent := pendingSent(r); !sent || pending { | |||
| 82 | + | t.Fatalf("pending flag not cleared: %v %v", pending, sent) | |||
| 83 | + | } | |||
| 84 | + | // Unstashing tiles it as a column of its own. | |||
| 85 | + | set = r.key(t, "s", ports.ModAlt|ports.ModShift) | |||
| 86 | + | if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused { | |||
| 87 | + | t.Fatalf("unstashed window %+v", w) | |||
| 88 | + | } | |||
| 89 | + | if len(r.spawn) != 0 { | |||
| 90 | + | t.Fatal("unexpected spawn") | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestStashBindRunsEveryCommand(t *testing.T) { | |||
| 95 | + | r := stashSpawnRig(t, func(c *ports.Config) { c.Binds["Alt+Ctrl+Shift+f"] = "stash one; two" }) | |||
| 96 | + | r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 97 | + | a, b := receive(t, r.spawn), receive(t, r.spawn) | |||
| 98 | + | if a.Argv[0] != "one" || b.Argv[0] != "two" || slotToken(t, a) == slotToken(t, b) { | |||
| 99 | + | t.Fatal(a, b) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // With an empty stash, toggle-stash-visible opens the terminal in it, once | |||
| 104 | + | // while it is awaited. | |||
| 105 | + | func TestToggleEmptyStashOpensTerminal(t *testing.T) { | |||
| 106 | + | r := stashSpawnRig(t, nil) | |||
| 107 | + | r.mapWindow(t, 1) | |||
| 108 | + | r.key(t, "s", ports.ModAlt) | |||
| 109 | + | req := receive(t, r.spawn) | |||
| 110 | + | if len(req.Argv) != 1 || req.Argv[0] != "foot" { | |||
| 111 | + | t.Fatalf("argv %v", req.Argv) | |||
| 112 | + | } | |||
| 113 | + | token := slotToken(t, req) | |||
| 114 | + | if pending, sent := pendingSent(r); !sent || !pending { | |||
| 115 | + | t.Fatal("SlotsPending{true} was not sent") | |||
| 116 | + | } | |||
| 117 | + | r.key(t, "s", ports.ModAlt) | |||
| 118 | + | if len(r.spawn) != 0 { | |||
| 119 | + | t.Fatal("second terminal while the first is awaited") | |||
| 120 | + | } | |||
| 121 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token} | |||
| 122 | + | set := receive(t, r.scenes) | |||
| 123 | + | if w, ok := sceneWindow(set, 2); !ok || w.Hidden || !w.Floating || !w.Focused { | |||
| 124 | + | t.Fatalf("terminal %+v", w) | |||
| 125 | + | } | |||
| 126 | + | // The stash has a window now: the toggle hides it, no spawn. | |||
| 127 | + | set = r.key(t, "s", ports.ModAlt) | |||
| 128 | + | if w, _ := sceneWindow(set, 2); !w.Hidden { | |||
| 129 | + | t.Fatalf("stash not hidden: %+v", w) | |||
| 130 | + | } | |||
| 131 | + | if len(r.spawn) != 0 { | |||
| 132 | + | t.Fatal("spawned for a stash holding a window") | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestToggleNonEmptyStashDoesNotSpawn(t *testing.T) { | |||
| 137 | + | r := stashSpawnRig(t, nil) | |||
| 138 | + | r.mapWindow(t, 1) | |||
| 139 | + | r.mapWindow(t, 2) | |||
| 140 | + | r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed | |||
| 141 | + | r.key(t, "s", ports.ModAlt) | |||
| 142 | + | r.key(t, "s", ports.ModAlt) | |||
| 143 | + | if len(r.spawn) != 0 { | |||
| 144 | + | t.Fatal("spawned for a non-empty stash") | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestToggleEmptyStashRespectsOption(t *testing.T) { | |||
| 149 | + | r := stashSpawnRig(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false }) | |||
| 150 | + | r.mapWindow(t, 1) | |||
| 151 | + | r.key(t, "s", ports.ModAlt) | |||
| 152 | + | if len(r.spawn) != 0 { | |||
| 153 | + | t.Fatal("spawned with stash.spawn-terminal off") | |||
| 154 | + | } | |||
| 155 | + | r = stashSpawnRig(t, func(c *ports.Config) { c.Terminal.Command = nil }) | |||
| 156 | + | r.mapWindow(t, 1) | |||
| 157 | + | r.key(t, "s", ports.ModAlt) | |||
| 158 | + | if len(r.spawn) != 0 { | |||
| 159 | + | t.Fatal("spawned without a terminal command") | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) { | |||
| 164 | + | r := stashSpawnRig(t, nil) | |||
| 165 | + | r.mapWindow(t, 1) | |||
| 166 | + | r.key(t, "o", ports.ModAlt) | |||
| 167 | + | r.key(t, "s", ports.ModAlt) | |||
| 168 | + | if len(r.spawn) != 0 { | |||
| 169 | + | t.Fatal("spawned from the overview") | |||
| 170 | + | } | |||
| 171 | + | } | |||
M internal/core/terminal.go +7 -4
| @@ -49,12 +49,15 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||
| 49 | 49 | return reqs | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 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) { | |
| 54 | 55 | for _, env := range req.Env { | |
| 55 | 56 | if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok { | |
| 56 | - | if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal { | |
| 57 | - | target.ws.termAt = time.Time{} | |
| 57 | + | if target, ok := c.placement.pending[token]; ok && target.kind != spawnSlot { | |
| 58 | + | if target.kind == spawnTerminal { | |
| 59 | + | target.ws.termAt = time.Time{} | |
| 60 | + | } | |
| 58 | 61 | delete(c.placement.pending, token) | |
| 59 | 62 | } | |
| 60 | 63 | } | |
| @@ -49,12 +49,15 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||||
| 49 | return reqs | 49 | return reqs | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | - | // cancelTerminal removes a claim minted for a send suppressed by the gate. | 52 | + | // cancelClaim removes a workspace claim minted for a send suppressed by the |
| 53 | - | func (c *Core) cancelTerminal(req ports.SpawnRequest) { | 53 | + | // gate. A terminal may be requested again at once. |
| 54 | + | func (c *Core) cancelClaim(req ports.SpawnRequest) { | |||
| 54 | for _, env := range req.Env { | 55 | for _, env := range req.Env { | ||
| 55 | if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok { | 56 | if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok { | ||
| 56 | - | if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal { | 57 | + | if target, ok := c.placement.pending[token]; ok && target.kind != spawnSlot { |
| 57 | - | target.ws.termAt = time.Time{} | 58 | + | if target.kind == spawnTerminal { |
| 59 | + | target.ws.termAt = time.Time{} | |||
| 60 | + | } | |||
| 58 | delete(c.placement.pending, token) | 61 | delete(c.placement.pending, token) | ||
| 59 | } | 62 | } | ||
| 60 | } | 63 | } | ||
M internal/ports/config.go +6 -4
| @@ -121,11 +121,13 @@ type Config struct { | |||
| 121 | 121 | // Width is the width of its selected window (10 to 90) and Gap the | |
| 122 | 122 | // space between it and its neighbors (0 to 10), in percent of the | |
| 123 | 123 | // 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 | 126 | 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 | |
| 129 | 131 | } | |
| 130 | 132 | // Border is drawn inside the window edge; Width 0 disables it. | |
| 131 | 133 | Border struct { | |
| @@ -121,11 +121,13 @@ type Config struct { | |||||
| 121 | // Width is the width of its selected window (10 to 90) and Gap the | 121 | // 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 the | 122 | // 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 new | 123 | // 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 int | 127 | + | Width, Gap int |
| 127 | - | Dim float64 | 128 | + | Dim float64 |
| 128 | - | Capture bool | 129 | + | 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 { | ||