feat(core): open the stash terminal from the four-finger swipe
A four-finger swipe down on an empty stash now opens the terminal in it when stash.spawn-terminal is on, like toggle-stash-visible. Bind and swipe share runSpawn, and a second swipe while the terminal is awaited starts nothing.
- commit
- 835c4797b4dd01deea01251cedaef5b485d339e1
- author
- brice · · 2026-10-14 20:36 UTC
- committer
- brice · · 2026-10-14 20:37 UTC
- parent
- ac0ff68
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +100 -45
M docs/config.md +1 -1
| @@ -253,7 +253,7 @@ A cell is `1/max-columns` of the screen width. A column is 1 to 16 cells wide. C | |||
| 253 | 253 | ||
| 254 | 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. | |
| 255 | 255 | ||
| 256 | - | `stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`. | |
| 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 | 257 | ||
| 258 | 258 | ```ini | |
| 259 | 259 | bind.cmd+shift+f = stash firefox | |
| @@ -253,7 +253,7 @@ A cell is `1/max-columns` of the screen width. A column is 1 to 16 cells wide. C | |||||
| 253 | 253 | ||||
| 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. | 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. | ||
| 255 | 255 | ||||
| 256 | - | `stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`. | 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 | 257 | ||||
| 258 | ```ini | 258 | ```ini | ||
| 259 | bind.cmd+shift+f = stash firefox | 259 | bind.cmd+shift+f = stash firefox | ||
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 swipe acted: the stash showed or hid, or an empty stash asked | |
| 456 | + | // for its terminal (stash.spawn-terminal), which spawn names for the caller | |
| 457 | + | // to start, as a bind does. | |
| 458 | + | func (c *Core) swipeStash(g *swipeGesture, now time.Time) (acted 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 true, 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 swipe acted: the stash showed or hid, or an empty stash asked |
| 455 | - | func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool { | 456 | + | // for its terminal (stash.spawn-terminal), which spawn names for the caller |
| 457 | + | // to start, as a bind does. | |||
| 458 | + | func (c *Core) swipeStash(g *swipeGesture, now time.Time) (acted 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 true, 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 +38 -21
| @@ -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,26 +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 | - | switch { | |
| 350 | - | case effect.Stash: | |
| 351 | - | if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil { | |
| 352 | - | return err | |
| 353 | - | } | |
| 354 | - | default: | |
| 355 | - | // Explicit actions are suppressed on a full launcher queue; | |
| 356 | - | // never keep the input owner waiting across an epoch change. | |
| 357 | - | for _, argv := range commands { | |
| 358 | - | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) { | |
| 359 | - | break | |
| 360 | - | } | |
| 361 | - | } | |
| 362 | - | } | |
| 363 | - | if c.inputEpochChanged() { | |
| 364 | - | return errSecurityChanged | |
| 352 | + | if err := c.runSpawn(ctx, before, effect); err != nil { | |
| 353 | + | return err | |
| 365 | 354 | } | |
| 366 | 355 | } | |
| 367 | 356 | if c.swipedWorkspace() != swiped { | |
| @@ -379,6 +368,34 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||
| 379 | 368 | return c.publish(ctx) | |
| 380 | 369 | } | |
| 381 | 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 | + | ||
| 382 | 399 | // spawnStash runs commands whose windows open in the stash of w, the | |
| 383 | 400 | // workspace the bind acted on. Wayland reads the claim tokens only while it | |
| 384 | 401 | // knows slots are pending, so that is published before the spawn, as | |
| @@ -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,26 +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 | - | switch { | |||
| 350 | - | case effect.Stash: | |||
| 351 | - | if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil { | |||
| 352 | - | return err | |||
| 353 | - | } | |||
| 354 | - | default: | |||
| 355 | - | // Explicit actions are suppressed on a full launcher queue; | |||
| 356 | - | // never keep the input owner waiting across an epoch change. | |||
| 357 | - | for _, argv := range commands { | |||
| 358 | - | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) { | |||
| 359 | - | break | |||
| 360 | - | } | |||
| 361 | - | } | |||
| 362 | - | } | |||
| 363 | - | if c.inputEpochChanged() { | |||
| 364 | - | return errSecurityChanged | |||
| 365 | } | 354 | } | ||
| 366 | } | 355 | } | ||
| 367 | if c.swipedWorkspace() != swiped { | 356 | if c.swipedWorkspace() != swiped { | ||
| @@ -379,6 +368,34 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||||
| 379 | return c.publish(ctx) | 368 | return c.publish(ctx) | ||
| 380 | } | 369 | } | ||
| 381 | 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 | + | ||||
| 382 | // spawnStash runs commands whose windows open in the stash of w, the | 399 | // spawnStash runs commands whose windows open in the stash of w, the | ||
| 383 | // workspace the bind acted on. Wayland reads the claim tokens only while it | 400 | // workspace the bind acted on. Wayland reads the claim tokens only while it | ||
| 384 | // knows slots are pending, so that is published before the spawn, as | 401 | // knows slots are pending, so that is published before the spawn, as | ||
M internal/core/stash_spawn_test.go +32 -0
| @@ -169,3 +169,35 @@ func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) { | |||
| 169 | 169 | t.Fatal("spawned from the overview") | |
| 170 | 170 | } | |
| 171 | 171 | } | |
| 172 | + | ||
| 173 | + | // A four-finger swipe down on an empty stash is toggle-stash-visible: with | |
| 174 | + | // stash.spawn-terminal it opens the terminal in the stash, once while that | |
| 175 | + | // one is awaited, and the window that maps lands in the stash. | |
| 176 | + | func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) { | |
| 177 | + | r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = true }) | |
| 178 | + | r.four(t, 0, 40, 10) | |
| 179 | + | req := receive(t, r.spawn) | |
| 180 | + | if len(req.Argv) != 1 || req.Argv[0] != "foot" { | |
| 181 | + | t.Fatalf("argv %v", req.Argv) | |
| 182 | + | } | |
| 183 | + | token := slotToken(t, req) | |
| 184 | + | if pending, sent := pendingSent(r.multiRig); !sent || !pending { | |
| 185 | + | t.Fatal("SlotsPending{true} was not sent") | |
| 186 | + | } | |
| 187 | + | r.four(t, 0, 40, 10) | |
| 188 | + | if len(r.spawn) != 0 { | |
| 189 | + | t.Fatal("second terminal while the first is awaited") | |
| 190 | + | } | |
| 191 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token} | |
| 192 | + | if w, ok := sceneWindow(receive(t, r.scenes), 2); !ok || w.Hidden || !w.Floating || !w.Focused { | |
| 193 | + | t.Fatalf("terminal %+v", w) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestFourFingerSwipeEmptyStashRespectsOption(t *testing.T) { | |
| 198 | + | r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false }) | |
| 199 | + | r.four(t, 0, 40, 10) | |
| 200 | + | if len(r.spawn) != 0 { | |
| 201 | + | t.Fatal("spawned with stash.spawn-terminal off") | |
| 202 | + | } | |
| 203 | + | } | |
| @@ -169,3 +169,35 @@ func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) { | |||||
| 169 | t.Fatal("spawned from the overview") | 169 | t.Fatal("spawned from the overview") | ||
| 170 | } | 170 | } | ||
| 171 | } | 171 | } | ||
| 172 | + | ||||
| 173 | + | // A four-finger swipe down on an empty stash is toggle-stash-visible: with | |||
| 174 | + | // stash.spawn-terminal it opens the terminal in the stash, once while that | |||
| 175 | + | // one is awaited, and the window that maps lands in the stash. | |||
| 176 | + | func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) { | |||
| 177 | + | r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = true }) | |||
| 178 | + | r.four(t, 0, 40, 10) | |||
| 179 | + | req := receive(t, r.spawn) | |||
| 180 | + | if len(req.Argv) != 1 || req.Argv[0] != "foot" { | |||
| 181 | + | t.Fatalf("argv %v", req.Argv) | |||
| 182 | + | } | |||
| 183 | + | token := slotToken(t, req) | |||
| 184 | + | if pending, sent := pendingSent(r.multiRig); !sent || !pending { | |||
| 185 | + | t.Fatal("SlotsPending{true} was not sent") | |||
| 186 | + | } | |||
| 187 | + | r.four(t, 0, 40, 10) | |||
| 188 | + | if len(r.spawn) != 0 { | |||
| 189 | + | t.Fatal("second terminal while the first is awaited") | |||
| 190 | + | } | |||
| 191 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token} | |||
| 192 | + | if w, ok := sceneWindow(receive(t, r.scenes), 2); !ok || w.Hidden || !w.Floating || !w.Focused { | |||
| 193 | + | t.Fatalf("terminal %+v", w) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestFourFingerSwipeEmptyStashRespectsOption(t *testing.T) { | |||
| 198 | + | r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false }) | |||
| 199 | + | r.four(t, 0, 40, 10) | |||
| 200 | + | if len(r.spawn) != 0 { | |||
| 201 | + | t.Fatal("spawned with stash.spawn-terminal off") | |||
| 202 | + | } | |||
| 203 | + | } | |||
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 { | ||