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
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 | |||
| 24 | 24 | - production constructor parameters such as `NewRenderer func(w, h) (ports.Renderer, error)`, where tests return a generated mock; | |
| 25 | 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 | 37 | ## Checks before merge | |
| 28 | 38 | ||
| 29 | 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. | |
| @@ -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 merge | 37 | ## 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 | |||
| 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` 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 | 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` 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 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 +23 -9
| @@ -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 | |
| @@ -1243,19 +1247,29 @@ func (c *Core) spawnEmpty(ctx context.Context) error { | |||
| 1243 | 1247 | } | |
| 1244 | 1248 | first := c.firstTerminalResolved | |
| 1245 | 1249 | 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 { | |
| 1248 | 1265 | 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) | |
| 1254 | 1268 | } | |
| 1255 | - | return err | |
| 1269 | + | return i, err | |
| 1256 | 1270 | } | |
| 1257 | 1271 | } | |
| 1258 | - | return err | |
| 1272 | + | return len(reqs), err | |
| 1259 | 1273 | } | |
| 1260 | 1274 | ||
| 1261 | 1275 | // 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 | 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 | ||
| @@ -1243,19 +1247,29 @@ func (c *Core) spawnEmpty(ctx context.Context) error { | |||||
| 1243 | } | 1247 | } | ||
| 1244 | first := c.firstTerminalResolved | 1248 | 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 err | 1269 | + | return i, err |
| 1256 | } | 1270 | } | ||
| 1257 | } | 1271 | } | ||
| 1258 | - | return err | 1272 | + | return len(reqs), err |
| 1259 | } | 1273 | } | ||
| 1260 | 1274 | ||||
| 1261 | // activate brings a window forward for a valid xdg-activation token. The | 1275 | // 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) { | |||
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | 286 | // 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) { | |
| 289 | 290 | g := c.swipe | |
| 290 | 291 | if g != nil && !c.hasScreen(g.screen) { | |
| 291 | 292 | c.dropSwipe() | |
| 292 | 293 | } | |
| 293 | 294 | c.swipe = nil | |
| 294 | 295 | if g == nil || !c.hasScreen(g.screen) { | |
| 295 | - | return false | |
| 296 | + | return false, nil | |
| 296 | 297 | } | |
| 297 | 298 | now := c.now() | |
| 298 | 299 | m := g.screen.mon | |
| 299 | 300 | switch g.mode { | |
| 300 | 301 | case swipeNavigation: | |
| 301 | 302 | c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}}) | |
| 302 | - | return false | |
| 303 | + | return false, nil | |
| 303 | 304 | case swipeColumns: | |
| 304 | 305 | w := g.ws | |
| 305 | 306 | if g.columnsChanged(m) { | |
| 306 | 307 | w.view.stop() | |
| 307 | - | return false | |
| 308 | + | return false, nil | |
| 308 | 309 | } | |
| 309 | 310 | shown := float64(w.View) + w.view.off | |
| 310 | 311 | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| @@ -330,7 +331,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 330 | 331 | case swipeWorkspaces: | |
| 331 | 332 | if g.listChanged(m) { | |
| 332 | 333 | m.stopSwitch() | |
| 333 | - | return false | |
| 334 | + | return false, nil | |
| 334 | 335 | } | |
| 335 | 336 | cur := float64(m.Active) + m.switchView.off | |
| 336 | 337 | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| @@ -359,7 +360,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 359 | 360 | w := g.ws | |
| 360 | 361 | if g.stashChanged(m) { | |
| 361 | 362 | w.stashView.stop() | |
| 362 | - | return false | |
| 363 | + | return false, nil | |
| 363 | 364 | } | |
| 364 | 365 | drawn := float64(w.stashAt) + w.stashView.off | |
| 365 | 366 | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| @@ -384,17 +385,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 384 | 385 | w.stashView.stop() | |
| 385 | 386 | } | |
| 386 | 387 | // The stash never brings another workspace on screen. | |
| 387 | - | return false | |
| 388 | + | return false, nil | |
| 388 | 389 | case swipeOverview: | |
| 389 | 390 | step := g.snap.step(e.Cancelled, e.Time) | |
| 390 | 391 | if step == 0 { | |
| 391 | - | return false | |
| 392 | + | return false, nil | |
| 392 | 393 | } | |
| 393 | 394 | if !m.ov.open && (step > 0 || m.Current().stashShown()) { | |
| 394 | 395 | return c.swipeStash(g, now) | |
| 395 | 396 | } | |
| 396 | 397 | if (step < 0) == m.ov.open { | |
| 397 | - | return false | |
| 398 | + | return false, nil | |
| 398 | 399 | } | |
| 399 | 400 | before := m.Current() | |
| 400 | 401 | shots := c.snapshot(now) | |
| @@ -402,11 +403,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 402 | 403 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 403 | 404 | c.keyboard.takeBack() | |
| 404 | 405 | c.transition(shots, now) | |
| 405 | - | return m.Current() != before | |
| 406 | + | return m.Current() != before, nil | |
| 406 | 407 | case swipeDiscrete: | |
| 407 | 408 | step := g.snap.step(e.Cancelled, e.Time) | |
| 408 | 409 | if step == 0 { | |
| 409 | - | return false | |
| 410 | + | return false, nil | |
| 410 | 411 | } | |
| 411 | 412 | w := g.screen.mon.Current() | |
| 412 | 413 | p := w.policy() | |
| @@ -423,16 +424,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 423 | 424 | } else if p.wraps { | |
| 424 | 425 | // A content swipe cannot escape the last band to another workspace. | |
| 425 | 426 | if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 { | |
| 426 | - | return false | |
| 427 | + | return false, nil | |
| 427 | 428 | } | |
| 428 | 429 | } | |
| 429 | 430 | // Focus actions act on the focused screen: the swipe's, unless the | |
| 430 | 431 | // pointer took the focus to another output meanwhile. | |
| 431 | 432 | if c.cur() != g.screen { | |
| 432 | - | return false | |
| 433 | + | return false, nil | |
| 433 | 434 | } | |
| 434 | 435 | if c.cur().mon.ov.open { | |
| 435 | - | return false | |
| 436 | + | return false, nil | |
| 436 | 437 | } | |
| 437 | 438 | before := c.cur().mon.Current() | |
| 438 | 439 | shots := c.snapshot(now) | |
| @@ -443,26 +444,31 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 443 | 444 | c.applyAction(a) | |
| 444 | 445 | } | |
| 445 | 446 | c.transition(shots, now) | |
| 446 | - | return c.cur().mon.Current() != before | |
| 447 | + | return c.cur().mon.Current() != before, nil | |
| 447 | 448 | } | |
| 448 | - | return shown | |
| 449 | + | return shown, nil | |
| 449 | 450 | } | |
| 450 | 451 | ||
| 451 | 452 | // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up | |
| 452 | 453 | // while the stash is shown, with the overview closed, on the swipe's screen | |
| 453 | 454 | // 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) { | |
| 456 | 459 | if c.cur() != g.screen { | |
| 457 | - | return false | |
| 460 | + | return false, nil | |
| 458 | 461 | } | |
| 459 | 462 | w := g.screen.mon.Current() | |
| 460 | 463 | hidden, over := w.stashHidden, w.stashOver | |
| 461 | 464 | shots := c.snapshot(now) | |
| 462 | 465 | c.keyboard.takeBack() | |
| 463 | - | c.applyAction(ActionToggleStashVisible) | |
| 466 | + | effect := c.applyAction(ActionToggleStashVisible) | |
| 464 | 467 | 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 | |
| 466 | 472 | } | |
| 467 | 473 | ||
| 468 | 474 | // 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 on | 286 | // 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.swipe | 290 | 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 = nil | 294 | c.swipe = nil | ||
| 294 | if g == nil || !c.hasScreen(g.screen) { | 295 | if g == nil || !c.hasScreen(g.screen) { | ||
| 295 | - | return false | 296 | + | return false, nil |
| 296 | } | 297 | } | ||
| 297 | now := c.now() | 298 | now := c.now() | ||
| 298 | m := g.screen.mon | 299 | 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 false | 303 | + | return false, nil |
| 303 | case swipeColumns: | 304 | case swipeColumns: | ||
| 304 | w := g.ws | 305 | w := g.ws | ||
| 305 | if g.columnsChanged(m) { | 306 | if g.columnsChanged(m) { | ||
| 306 | w.view.stop() | 307 | w.view.stop() | ||
| 307 | - | return false | 308 | + | return false, nil |
| 308 | } | 309 | } | ||
| 309 | shown := float64(w.View) + w.view.off | 310 | 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 false | 334 | + | return false, nil |
| 334 | } | 335 | } | ||
| 335 | cur := float64(m.Active) + m.switchView.off | 336 | 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.ws | 360 | w := g.ws | ||
| 360 | if g.stashChanged(m) { | 361 | if g.stashChanged(m) { | ||
| 361 | w.stashView.stop() | 362 | w.stashView.stop() | ||
| 362 | - | return false | 363 | + | return false, nil |
| 363 | } | 364 | } | ||
| 364 | drawn := float64(w.stashAt) + w.stashView.off | 365 | 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 false | 388 | + | 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 false | 392 | + | 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 false | 398 | + | 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() != before | 406 | + | 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 false | 410 | + | 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 false | 427 | + | return false, nil |
| 427 | } | 428 | } | ||
| 428 | } | 429 | } | ||
| 429 | // Focus actions act on the focused screen: the swipe's, unless the | 430 | // 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 false | 433 | + | return false, nil |
| 433 | } | 434 | } | ||
| 434 | if c.cur().mon.ov.open { | 435 | if c.cur().mon.ov.open { | ||
| 435 | - | return false | 436 | + | 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() != before | 447 | + | return c.cur().mon.Current() != before, nil |
| 447 | } | 448 | } | ||
| 448 | - | return shown | 449 | + | return shown, nil |
| 449 | } | 450 | } | ||
| 450 | 451 | ||||
| 451 | // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up | 452 | // 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 screen | 453 | // 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 reports | 454 | // 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 false | 460 | + | return false, nil |
| 458 | } | 461 | } | ||
| 459 | w := g.screen.mon.Current() | 462 | w := g.screen.mon.Current() | ||
| 460 | hidden, over := w.stashHidden, w.stashOver | 463 | 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 != over | 468 | + | 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 without | 474 | // 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 { | |||
| 49 | 49 | if c.gesture.on && c.gesture.kind == ports.GestureSwipe { | |
| 50 | 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 | 60 | case ports.PinchBegin: | |
| 54 | 61 | return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time) | |
| 55 | 62 | case ports.PinchUpdate: | |
| @@ -342,19 +349,8 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||
| 342 | 349 | return ErrQuit | |
| 343 | 350 | } | |
| 344 | 351 | 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 | |
| 358 | 354 | } | |
| 359 | 355 | } | |
| 360 | 356 | if c.swipedWorkspace() != swiped { | |
| @@ -372,6 +368,57 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||
| 372 | 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 | 422 | // scaleBind steps the focused output's scale and asks for it to be saved. | |
| 376 | 423 | func (c *Core) scaleBind(ctx context.Context, action Action) error { | |
| 377 | 424 | 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 ErrQuit | 349 | return ErrQuit | ||
| 343 | } | 350 | } | ||
| 344 | if effect.Spawn { | 351 | if effect.Spawn { | ||
| 345 | - | commands := effect.Commands | 352 | + | 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 := 1 | 424 | 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 +44 -21
| @@ -15,11 +15,25 @@ type spawnPlacement struct { | |||
| 15 | 15 | 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 | 32 | 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 | |
| 23 | 37 | } | |
| 24 | 38 | ||
| 25 | 39 | func newSpawnPlacement() spawnPlacement { | |
| @@ -30,7 +44,7 @@ func newSpawnPlacement() spawnPlacement { | |||
| 30 | 44 | func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest { | |
| 31 | 45 | token := rand.Text() | |
| 32 | 46 | p.pending[token] = target | |
| 33 | - | if target.terminal == nil { | |
| 47 | + | if target.kind == spawnSlot { | |
| 34 | 48 | p.slots[target.slot] = token | |
| 35 | 49 | } | |
| 36 | 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 | 64 | // anyPending reports whether any spawn still awaits a window. | |
| 51 | 65 | func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 } | |
| 52 | 66 | ||
| 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 { | |
| 55 | 69 | for _, target := range p.pending { | |
| 56 | - | if target.terminal == w { | |
| 70 | + | if target.kind == kind && target.ws == w { | |
| 57 | 71 | return true | |
| 58 | 72 | } | |
| 59 | 73 | } | |
| 60 | 74 | return false | |
| 61 | 75 | } | |
| 62 | 76 | ||
| 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) { | |
| 65 | 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 | 81 | delete(p.pending, token) | |
| 68 | 82 | } | |
| 69 | 83 | } | |
| @@ -94,23 +108,32 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 94 | 108 | return | |
| 95 | 109 | } | |
| 96 | 110 | if v.Slot != "" { | |
| 111 | + | // A claim no window answered in time no longer places one. | |
| 112 | + | p.expireWorkspaceClaims(c.now()) | |
| 97 | 113 | if target, ok := p.pending[v.Slot]; ok { | |
| 98 | - | if target.terminal != nil { | |
| 114 | + | switch target.kind { | |
| 115 | + | case spawnTerminal, spawnStash: | |
| 99 | 116 | delete(p.pending, v.Slot) | |
| 100 | 117 | 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 | + | } | |
| 103 | 124 | s.mon.normalize() | |
| 104 | 125 | return | |
| 105 | 126 | } | |
| 106 | 127 | } | |
| 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 | + | } | |
| 114 | 137 | } | |
| 115 | 138 | } | |
| 116 | 139 | } | |
| @@ -15,11 +15,25 @@ type spawnPlacement struct { | |||||
| 15 | slots map[slotKey]string | 15 | 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 slotKey | 33 | + | kind spawnKind |
| 21 | - | terminal *Workspace | 34 | + | slot slotKey |
| 22 | - | at time.Time | 35 | + | 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] = target | 46 | p.pending[token] = target | ||
| 33 | - | if target.terminal == nil { | 47 | + | if target.kind == spawnSlot { |
| 34 | p.slots[target.slot] = token | 48 | 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 true | 71 | return true | ||
| 58 | } | 72 | } | ||
| 59 | } | 73 | } | ||
| 60 | return false | 74 | 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 | return | 108 | 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 | return | 125 | 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.ID | 131 | + | 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 | - | return | 134 | + | 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) { | |||
| 60 | 60 | t.Fatal("focus did not move to another workspace") | |
| 61 | 61 | } | |
| 62 | 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 | 64 | token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | |
| 65 | - | c.placement.expireTerminals(t0.Add(termRetry)) | |
| 65 | + | c.placement.expireWorkspaceClaims(t0.Add(termRetry)) | |
| 66 | 66 | if _, ok := c.placement.pending[token]; !ok { | |
| 67 | 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 | 70 | if _, ok := c.placement.pending[token]; ok { | |
| 71 | 71 | t.Fatal("claim survived past retry boundary") | |
| 72 | 72 | } | |
| @@ -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 { | |||
| 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. 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 | 100 | // showStash shows the stash and gives it the focus; a member whose | |
| 85 | 101 | // fullscreen waited while hidden is fullscreen again, selected, as if it | |
| 86 | 102 | // asked now. | |
| @@ -81,6 +81,22 @@ 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. 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 whose | 100 | // showStash shows the stash and gives it the focus; a member whose | ||
| 85 | // fullscreen waited while hidden is fullscreen again, selected, as if it | 101 | // 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) { | |||
| 210 | 210 | for range 10 { | |
| 211 | 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 | 214 | t.Fatal("a stash landing reported a workspace shown") | |
| 215 | 215 | } | |
| 216 | 216 | 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 { | |||
| 23 | 23 | // Workspaces with slots are left to them. | |
| 24 | 24 | func (c *Core) fillEmpty() []ports.SpawnRequest { | |
| 25 | 25 | now := c.now() | |
| 26 | - | c.placement.expireTerminals(now) | |
| 26 | + | c.placement.expireWorkspaceClaims(now) | |
| 27 | 27 | if !c.keepsTerminal() { | |
| 28 | 28 | return nil | |
| 29 | 29 | } | |
| @@ -39,22 +39,25 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||
| 39 | 39 | 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 | 43 | continue | |
| 44 | 44 | } | |
| 45 | 45 | c.firstTerminalResolved = true | |
| 46 | 46 | 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 | 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.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 | + | } | |
| 58 | 61 | delete(c.placement.pending, token) | |
| 59 | 62 | } | |
| 60 | 63 | } | |
| @@ -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 nil | 28 | return nil | ||
| 29 | } | 29 | } | ||
| @@ -39,22 +39,25 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||||
| 39 | continue | 39 | 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 | continue | 43 | continue | ||
| 44 | } | 44 | } | ||
| 45 | c.firstTerminalResolved = true | 45 | c.firstTerminalResolved = true | ||
| 46 | w.termAt = now | 46 | 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 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.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 { | |||
| 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 { | ||