feat(core): camera transitions for binds, click focus and discrete swipes
- commit
- f2967d7aad272ae972e982bfbdca1a3acd50e668
- author
- brice · · 2026-10-04 16:01 UTC
- committer
- brice · · 2026-10-04 19:08 UTC
- parent
- 724608c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +363 -13
M internal/core/core.go +3 -0
| @@ -133,6 +133,9 @@ type Core struct { | |||
| 133 | 133 | // stepping is the screen a page flip advances (nil: all of them); it is | |
| 134 | 134 | // set while step publishes. | |
| 135 | 135 | stepping *screen | |
| 136 | + | // shots is the snapshot of what the screens show before an action | |
| 137 | + | // (transition.go), reused by every action. | |
| 138 | + | shots []viewShot | |
| 136 | 139 | // motionTime is the time of the last pointer motion; pointerAt is the | |
| 137 | 140 | // last position sent in the pointer's window. | |
| 138 | 141 | motionTime time.Duration | |
| @@ -133,6 +133,9 @@ type Core struct { | |||||
| 133 | // stepping is the screen a page flip advances (nil: all of them); it is | 133 | // stepping is the screen a page flip advances (nil: all of them); it is | ||
| 134 | // set while step publishes. | 134 | // set while step publishes. | ||
| 135 | stepping *screen | 135 | stepping *screen | ||
| 136 | + | // shots is the snapshot of what the screens show before an action | |||
| 137 | + | // (transition.go), reused by every action. | |||
| 138 | + | shots []viewShot | |||
| 136 | // motionTime is the time of the last pointer motion; pointerAt is the | 139 | // motionTime is the time of the last pointer motion; pointerAt is the | ||
| 137 | // last position sent in the pointer's window. | 140 | // last position sent in the pointer's window. | ||
| 138 | motionTime time.Duration | 141 | motionTime time.Duration | ||
M internal/core/core_test.go +3 -1
| @@ -385,6 +385,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 385 | 385 | ||
| 386 | 386 | func TestWorkspaceSwitch(t *testing.T) { | |
| 387 | 387 | cfg := config.Defaults() | |
| 388 | + | cfg.Animations.On = false // the test reads the settled scene right after the key | |
| 388 | 389 | client := make(chan ports.ClientEvent, 8) | |
| 389 | 390 | input := make(chan ports.InputEvent, 8) | |
| 390 | 391 | output := make(chan ports.OutputEvent, 8) | |
| @@ -433,6 +434,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 433 | 434 | ||
| 434 | 435 | func TestClickAfterWorkspaceSwitch(t *testing.T) { | |
| 435 | 436 | cfg := config.Defaults() | |
| 437 | + | cfg.Animations.On = false // the test reads the settled scene right after the key | |
| 436 | 438 | client := make(chan ports.ClientEvent, 8) | |
| 437 | 439 | input := make(chan ports.InputEvent, 8) | |
| 438 | 440 | output := make(chan ports.OutputEvent, 8) | |
| @@ -1022,7 +1024,7 @@ func TestExternalFullscreenInOverview(t *testing.T) { | |||
| 1022 | 1024 | // focus-window-down from a fullscreen tile at the bottom of its column | |
| 1023 | 1025 | // goes to the next workspace and fullscreen stays. | |
| 1024 | 1026 | func TestFocusWindowDownKeepsFullscreen(t *testing.T) { | |
| 1025 | - | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 1027 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed"; c.Animations.On = false }, left) | |
| 1026 | 1028 | r.mapWindow(t, 1) | |
| 1027 | 1029 | r.mapWindow(t, 2) | |
| 1028 | 1030 | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| @@ -385,6 +385,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||||
| 385 | 385 | ||||
| 386 | func TestWorkspaceSwitch(t *testing.T) { | 386 | func TestWorkspaceSwitch(t *testing.T) { | ||
| 387 | cfg := config.Defaults() | 387 | cfg := config.Defaults() | ||
| 388 | + | cfg.Animations.On = false // the test reads the settled scene right after the key | |||
| 388 | client := make(chan ports.ClientEvent, 8) | 389 | client := make(chan ports.ClientEvent, 8) | ||
| 389 | input := make(chan ports.InputEvent, 8) | 390 | input := make(chan ports.InputEvent, 8) | ||
| 390 | output := make(chan ports.OutputEvent, 8) | 391 | output := make(chan ports.OutputEvent, 8) | ||
| @@ -433,6 +434,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||||
| 433 | 434 | ||||
| 434 | func TestClickAfterWorkspaceSwitch(t *testing.T) { | 435 | func TestClickAfterWorkspaceSwitch(t *testing.T) { | ||
| 435 | cfg := config.Defaults() | 436 | cfg := config.Defaults() | ||
| 437 | + | cfg.Animations.On = false // the test reads the settled scene right after the key | |||
| 436 | client := make(chan ports.ClientEvent, 8) | 438 | client := make(chan ports.ClientEvent, 8) | ||
| 437 | input := make(chan ports.InputEvent, 8) | 439 | input := make(chan ports.InputEvent, 8) | ||
| 438 | output := make(chan ports.OutputEvent, 8) | 440 | output := make(chan ports.OutputEvent, 8) | ||
| @@ -1022,7 +1024,7 @@ func TestExternalFullscreenInOverview(t *testing.T) { | |||||
| 1022 | // focus-window-down from a fullscreen tile at the bottom of its column | 1024 | // focus-window-down from a fullscreen tile at the bottom of its column | ||
| 1023 | // goes to the next workspace and fullscreen stays. | 1025 | // goes to the next workspace and fullscreen stays. | ||
| 1024 | func TestFocusWindowDownKeepsFullscreen(t *testing.T) { | 1026 | func TestFocusWindowDownKeepsFullscreen(t *testing.T) { | ||
| 1025 | - | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | 1027 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed"; c.Animations.On = false }, left) |
| 1026 | r.mapWindow(t, 1) | 1028 | r.mapWindow(t, 1) | ||
| 1027 | r.mapWindow(t, 2) | 1029 | r.mapWindow(t, 2) | ||
| 1028 | r.key(t, "f", ports.ModAlt|ports.ModShift) | 1030 | r.key(t, "f", ports.ModAlt|ports.ModShift) | ||
M internal/core/drag_test.go +2 -2
| @@ -484,7 +484,7 @@ func TestLockClearsDrag(t *testing.T) { | |||
| 484 | 484 | } | |
| 485 | 485 | ||
| 486 | 486 | func TestDragEdgeScroll(t *testing.T) { | |
| 487 | - | r := startDragRig(t, nil, dragOut) | |
| 487 | + | r := startDragRig(t, animationsOff, dragOut) | |
| 488 | 488 | for id := ports.WindowID(1); id <= 4; id++ { | |
| 489 | 489 | r.client <- ports.WindowMapped{ID: id} | |
| 490 | 490 | r.settle(t) | |
| @@ -623,7 +623,7 @@ func TestClientDragUsesLastPress(t *testing.T) { | |||
| 623 | 623 | } | |
| 624 | 624 | ||
| 625 | 625 | func TestEdgeScrollPublishesWithoutTarget(t *testing.T) { | |
| 626 | - | r := startDragRig(t, nil, dragOut) | |
| 626 | + | r := startDragRig(t, animationsOff, dragOut) | |
| 627 | 627 | for id := ports.WindowID(1); id <= 4; id++ { | |
| 628 | 628 | r.client <- ports.WindowMapped{ID: id} | |
| 629 | 629 | r.settle(t) | |
| @@ -484,7 +484,7 @@ func TestLockClearsDrag(t *testing.T) { | |||||
| 484 | } | 484 | } | ||
| 485 | 485 | ||||
| 486 | func TestDragEdgeScroll(t *testing.T) { | 486 | func TestDragEdgeScroll(t *testing.T) { | ||
| 487 | - | r := startDragRig(t, nil, dragOut) | 487 | + | r := startDragRig(t, animationsOff, dragOut) |
| 488 | for id := ports.WindowID(1); id <= 4; id++ { | 488 | for id := ports.WindowID(1); id <= 4; id++ { | ||
| 489 | r.client <- ports.WindowMapped{ID: id} | 489 | r.client <- ports.WindowMapped{ID: id} | ||
| 490 | r.settle(t) | 490 | r.settle(t) | ||
| @@ -623,7 +623,7 @@ func TestClientDragUsesLastPress(t *testing.T) { | |||||
| 623 | } | 623 | } | ||
| 624 | 624 | ||||
| 625 | func TestEdgeScrollPublishesWithoutTarget(t *testing.T) { | 625 | func TestEdgeScrollPublishesWithoutTarget(t *testing.T) { | ||
| 626 | - | r := startDragRig(t, nil, dragOut) | 626 | + | r := startDragRig(t, animationsOff, dragOut) |
| 627 | for id := ports.WindowID(1); id <= 4; id++ { | 627 | for id := ports.WindowID(1); id <= 4; id++ { | ||
| 628 | r.client <- ports.WindowMapped{ID: id} | 628 | r.client <- ports.WindowMapped{ID: id} | ||
| 629 | r.settle(t) | 629 | r.settle(t) | ||
M internal/core/gesture.go +2 -0
| @@ -279,11 +279,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 279 | 279 | return false | |
| 280 | 280 | } | |
| 281 | 281 | before := c.cur().mon.Current() | |
| 282 | + | shots := c.snapshot() | |
| 282 | 283 | c.keyboard.takeBack() | |
| 283 | 284 | if mon := c.cur().mon; mon.ov.open { | |
| 284 | 285 | mon.overviewFocus(a) | |
| 285 | 286 | } else { | |
| 286 | 287 | c.applyAction(a) | |
| 288 | + | c.transition(shots, now) | |
| 287 | 289 | } | |
| 288 | 290 | return c.cur().mon.Current() != before | |
| 289 | 291 | } | |
| @@ -279,11 +279,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 279 | return false | 279 | return false | ||
| 280 | } | 280 | } | ||
| 281 | before := c.cur().mon.Current() | 281 | before := c.cur().mon.Current() | ||
| 282 | + | shots := c.snapshot() | |||
| 282 | c.keyboard.takeBack() | 283 | c.keyboard.takeBack() | ||
| 283 | if mon := c.cur().mon; mon.ov.open { | 284 | if mon := c.cur().mon; mon.ov.open { | ||
| 284 | mon.overviewFocus(a) | 285 | mon.overviewFocus(a) | ||
| 285 | } else { | 286 | } else { | ||
| 286 | c.applyAction(a) | 287 | c.applyAction(a) | ||
| 288 | + | c.transition(shots, now) | |||
| 287 | } | 289 | } | ||
| 288 | return c.cur().mon.Current() != before | 290 | return c.cur().mon.Current() != before | ||
| 289 | } | 291 | } | ||
M internal/core/input.go +4 -0
| @@ -125,8 +125,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 125 | 125 | // A click focuses the window and its output. | |
| 126 | 126 | s, w := c.screenOf(id) | |
| 127 | 127 | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | |
| 128 | + | before := c.snapshot() | |
| 128 | 129 | w.Click(id) | |
| 129 | 130 | c.focusScreen = c.screenIndex(s.name()) | |
| 131 | + | c.transition(before, c.now()) | |
| 130 | 132 | if err := c.publish(ctx); err != nil { | |
| 131 | 133 | return err | |
| 132 | 134 | } | |
| @@ -260,6 +262,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 260 | 262 | } | |
| 261 | 263 | before := c.cur().mon.Current() | |
| 262 | 264 | swiped := c.swipedWorkspace() | |
| 265 | + | shots := c.snapshot() | |
| 263 | 266 | c.keyboard.takeBack() // a bind acts on the windows | |
| 264 | 267 | effect := c.applyAction(action) | |
| 265 | 268 | if effect.Quit { | |
| @@ -280,6 +283,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 280 | 283 | if c.swipedWorkspace() != swiped { | |
| 281 | 284 | c.dropSwipe() | |
| 282 | 285 | } | |
| 286 | + | c.transition(shots, c.now()) | |
| 283 | 287 | if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil { | |
| 284 | 288 | return err | |
| 285 | 289 | } | |
| @@ -125,8 +125,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 125 | // A click focuses the window and its output. | 125 | // A click focuses the window and its output. | ||
| 126 | s, w := c.screenOf(id) | 126 | s, w := c.screenOf(id) | ||
| 127 | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | 127 | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | ||
| 128 | + | before := c.snapshot() | |||
| 128 | w.Click(id) | 129 | w.Click(id) | ||
| 129 | c.focusScreen = c.screenIndex(s.name()) | 130 | c.focusScreen = c.screenIndex(s.name()) | ||
| 131 | + | c.transition(before, c.now()) | |||
| 130 | if err := c.publish(ctx); err != nil { | 132 | if err := c.publish(ctx); err != nil { | ||
| 131 | return err | 133 | return err | ||
| 132 | } | 134 | } | ||
| @@ -260,6 +262,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 260 | } | 262 | } | ||
| 261 | before := c.cur().mon.Current() | 263 | before := c.cur().mon.Current() | ||
| 262 | swiped := c.swipedWorkspace() | 264 | swiped := c.swipedWorkspace() | ||
| 265 | + | shots := c.snapshot() | |||
| 263 | c.keyboard.takeBack() // a bind acts on the windows | 266 | c.keyboard.takeBack() // a bind acts on the windows | ||
| 264 | effect := c.applyAction(action) | 267 | effect := c.applyAction(action) | ||
| 265 | if effect.Quit { | 268 | if effect.Quit { | ||
| @@ -280,6 +283,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 280 | if c.swipedWorkspace() != swiped { | 283 | if c.swipedWorkspace() != swiped { | ||
| 281 | c.dropSwipe() | 284 | c.dropSwipe() | ||
| 282 | } | 285 | } | ||
| 286 | + | c.transition(shots, c.now()) | |||
| 283 | if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil { | 287 | if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil { | ||
| 284 | return err | 288 | return err | ||
| 285 | } | 289 | } | ||
M internal/core/screens_test.go +2 -2
| @@ -400,7 +400,7 @@ func TestMonitorActionsFollowGeometry(t *testing.T) { | |||
| 400 | 400 | } | |
| 401 | 401 | ||
| 402 | 402 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | |
| 403 | - | r := startMulti(t, nil, left, right) | |
| 403 | + | r := startMulti(t, animationsOff, left, right) | |
| 404 | 404 | r.mapWindow(t, 1) | |
| 405 | 405 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 406 | 406 | r.mapWindow(t, 2) | |
| @@ -763,7 +763,7 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) { | |||
| 763 | 763 | } | |
| 764 | 764 | ||
| 765 | 765 | func TestStateSnapshot(t *testing.T) { | |
| 766 | - | r := startMulti(t, nil, left, right) | |
| 766 | + | r := startMulti(t, animationsOff, left, right) | |
| 767 | 767 | r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100} | |
| 768 | 768 | receive(t, r.scenes) | |
| 769 | 769 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| @@ -400,7 +400,7 @@ func TestMonitorActionsFollowGeometry(t *testing.T) { | |||||
| 400 | } | 400 | } | ||
| 401 | 401 | ||||
| 402 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | 402 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | ||
| 403 | - | r := startMulti(t, nil, left, right) | 403 | + | r := startMulti(t, animationsOff, left, right) |
| 404 | r.mapWindow(t, 1) | 404 | r.mapWindow(t, 1) | ||
| 405 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | 405 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | ||
| 406 | r.mapWindow(t, 2) | 406 | r.mapWindow(t, 2) | ||
| @@ -763,7 +763,7 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) { | |||||
| 763 | } | 763 | } | ||
| 764 | 764 | ||||
| 765 | func TestStateSnapshot(t *testing.T) { | 765 | func TestStateSnapshot(t *testing.T) { | ||
| 766 | - | r := startMulti(t, nil, left, right) | 766 | + | r := startMulti(t, animationsOff, left, right) |
| 767 | r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100} | 767 | r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100} | ||
| 768 | receive(t, r.scenes) | 768 | receive(t, r.scenes) | ||
| 769 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | 769 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | ||
M internal/core/swipe_test.go +23 -8
| @@ -121,9 +121,19 @@ func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene { | |||
| 121 | 121 | func (r *swipeRig) workspaces(t *testing.T) { | |
| 122 | 122 | t.Helper() | |
| 123 | 123 | r.mapWindow(t, 1) | |
| 124 | - | r.key(t, "Next", ports.ModAlt) | |
| 124 | + | r.keySettled(t, "Next") | |
| 125 | 125 | r.mapWindow(t, 2) | |
| 126 | - | r.key(t, "Prior", ports.ModAlt) | |
| 126 | + | r.keySettled(t, "Prior") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // keySettled presses a workspace key and waits for the slide it starts, so | |
| 130 | + | // the next step begins from the settled view. | |
| 131 | + | func (r *swipeRig) keySettled(t *testing.T, sym string) { | |
| 132 | + | t.Helper() | |
| 133 | + | r.key(t, sym, ports.ModAlt) | |
| 134 | + | if r.cfg.Animations.On { | |
| 135 | + | r.settle(t) | |
| 136 | + | } | |
| 127 | 137 | } | |
| 128 | 138 | ||
| 129 | 139 | func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) { | |
| @@ -324,8 +334,8 @@ func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) { | |||
| 324 | 334 | r := startSwipe(t, nil) | |
| 325 | 335 | r.workspaces(t) | |
| 326 | 336 | // Workspace 3 is the trailing empty one; start there and swipe up to 2. | |
| 327 | - | r.key(t, "Next", ports.ModAlt) | |
| 328 | - | r.key(t, "Next", ports.ModAlt) | |
| 337 | + | r.keySettled(t, "Next") | |
| 338 | + | r.keySettled(t, "Next") | |
| 329 | 339 | r.begin() | |
| 330 | 340 | var s ports.Scene | |
| 331 | 341 | for range 6 { | |
| @@ -382,6 +392,10 @@ func TestSwipeDropsWhenWorkspacesChange(t *testing.T) { | |||
| 382 | 392 | r.move(t, 0, 30) | |
| 383 | 393 | // A key switches workspace mid-swipe: the swipe lets go at once. | |
| 384 | 394 | s := r.key(t, "Next", ports.ModAlt)[0] | |
| 395 | + | if got, ok := rectOf(s, 2); !ok || got.Y == 0 { | |
| 396 | + | t.Fatalf("workspace 2 at %v %t after the key, want the old position", got, ok) | |
| 397 | + | } | |
| 398 | + | s = r.settle(t) | |
| 385 | 399 | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |
| 386 | 400 | t.Fatalf("workspace 2 at %v %t after the key", got, ok) | |
| 387 | 401 | } | |
| @@ -463,7 +477,7 @@ func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) { | |||
| 463 | 477 | } | |
| 464 | 478 | ||
| 465 | 479 | func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) { | |
| 466 | - | r := startSwipe(t, nil) | |
| 480 | + | r := startSwipe(t, animationsOff) | |
| 467 | 481 | before, _ := rectOf(threeColumns(t, r), 2) | |
| 468 | 482 | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | |
| 469 | 483 | // Window 4 on DP-2, so it has a workspace below to switch to. | |
| @@ -554,7 +568,7 @@ func (r *swipeRig) drain() { | |||
| 554 | 568 | } | |
| 555 | 569 | ||
| 556 | 570 | func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | |
| 557 | - | r := startSwipe(t, nil) | |
| 571 | + | r := startSwipe(t, animationsOff) | |
| 558 | 572 | threeColumns(t, r) | |
| 559 | 573 | r.key(t, "Left", ports.ModAlt) | |
| 560 | 574 | s := r.key(t, "Left", ports.ModAlt)[0] | |
| @@ -837,6 +851,7 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) { | |||
| 837 | 851 | for range 3 { | |
| 838 | 852 | r.key(t, "Left", ports.ModAlt) | |
| 839 | 853 | } | |
| 854 | + | r.settle(t) | |
| 840 | 855 | // A flick toward the right lands on a later column: the view moves | |
| 841 | 856 | // right, so column 1 moves left. | |
| 842 | 857 | r.flick(t, 4, 40, 0) | |
| @@ -849,8 +864,8 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) { | |||
| 849 | 864 | t.Fatalf("view did not move right: column 1 at %d", prev.X) | |
| 850 | 865 | } | |
| 851 | 866 | r.key(t, "Right", ports.ModAlt) | |
| 852 | - | // The retargeted spring takes its start time at the first frame, which | |
| 853 | - | // leaves the view where it is; it moves on from the second, never back. | |
| 867 | + | // The retargeted spring keeps the speed of the landing: the view moves | |
| 868 | + | // on from the first frame and never goes back. | |
| 854 | 869 | for i := range 2 { | |
| 855 | 870 | s := r.frame(t, 16*time.Millisecond) | |
| 856 | 871 | got, _ := rectOf(s, 1) | |
| @@ -121,9 +121,19 @@ func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene { | |||||
| 121 | func (r *swipeRig) workspaces(t *testing.T) { | 121 | func (r *swipeRig) workspaces(t *testing.T) { | ||
| 122 | t.Helper() | 122 | t.Helper() | ||
| 123 | r.mapWindow(t, 1) | 123 | r.mapWindow(t, 1) | ||
| 124 | - | r.key(t, "Next", ports.ModAlt) | 124 | + | r.keySettled(t, "Next") |
| 125 | r.mapWindow(t, 2) | 125 | r.mapWindow(t, 2) | ||
| 126 | - | r.key(t, "Prior", ports.ModAlt) | 126 | + | r.keySettled(t, "Prior") |
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // keySettled presses a workspace key and waits for the slide it starts, so | |||
| 130 | + | // the next step begins from the settled view. | |||
| 131 | + | func (r *swipeRig) keySettled(t *testing.T, sym string) { | |||
| 132 | + | t.Helper() | |||
| 133 | + | r.key(t, sym, ports.ModAlt) | |||
| 134 | + | if r.cfg.Animations.On { | |||
| 135 | + | r.settle(t) | |||
| 136 | + | } | |||
| 127 | } | 137 | } | ||
| 128 | 138 | ||||
| 129 | func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) { | 139 | func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) { | ||
| @@ -324,8 +334,8 @@ func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) { | |||||
| 324 | r := startSwipe(t, nil) | 334 | r := startSwipe(t, nil) | ||
| 325 | r.workspaces(t) | 335 | r.workspaces(t) | ||
| 326 | // Workspace 3 is the trailing empty one; start there and swipe up to 2. | 336 | // Workspace 3 is the trailing empty one; start there and swipe up to 2. | ||
| 327 | - | r.key(t, "Next", ports.ModAlt) | 337 | + | r.keySettled(t, "Next") |
| 328 | - | r.key(t, "Next", ports.ModAlt) | 338 | + | r.keySettled(t, "Next") |
| 329 | r.begin() | 339 | r.begin() | ||
| 330 | var s ports.Scene | 340 | var s ports.Scene | ||
| 331 | for range 6 { | 341 | for range 6 { | ||
| @@ -382,6 +392,10 @@ func TestSwipeDropsWhenWorkspacesChange(t *testing.T) { | |||||
| 382 | r.move(t, 0, 30) | 392 | r.move(t, 0, 30) | ||
| 383 | // A key switches workspace mid-swipe: the swipe lets go at once. | 393 | // A key switches workspace mid-swipe: the swipe lets go at once. | ||
| 384 | s := r.key(t, "Next", ports.ModAlt)[0] | 394 | s := r.key(t, "Next", ports.ModAlt)[0] | ||
| 395 | + | if got, ok := rectOf(s, 2); !ok || got.Y == 0 { | |||
| 396 | + | t.Fatalf("workspace 2 at %v %t after the key, want the old position", got, ok) | |||
| 397 | + | } | |||
| 398 | + | s = r.settle(t) | |||
| 385 | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | 399 | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | ||
| 386 | t.Fatalf("workspace 2 at %v %t after the key", got, ok) | 400 | t.Fatalf("workspace 2 at %v %t after the key", got, ok) | ||
| 387 | } | 401 | } | ||
| @@ -463,7 +477,7 @@ func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) { | |||||
| 463 | } | 477 | } | ||
| 464 | 478 | ||||
| 465 | func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) { | 479 | func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) { | ||
| 466 | - | r := startSwipe(t, nil) | 480 | + | r := startSwipe(t, animationsOff) |
| 467 | before, _ := rectOf(threeColumns(t, r), 2) | 481 | before, _ := rectOf(threeColumns(t, r), 2) | ||
| 468 | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | 482 | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | ||
| 469 | // Window 4 on DP-2, so it has a workspace below to switch to. | 483 | // Window 4 on DP-2, so it has a workspace below to switch to. | ||
| @@ -554,7 +568,7 @@ func (r *swipeRig) drain() { | |||||
| 554 | } | 568 | } | ||
| 555 | 569 | ||||
| 556 | func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | 570 | func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | ||
| 557 | - | r := startSwipe(t, nil) | 571 | + | r := startSwipe(t, animationsOff) |
| 558 | threeColumns(t, r) | 572 | threeColumns(t, r) | ||
| 559 | r.key(t, "Left", ports.ModAlt) | 573 | r.key(t, "Left", ports.ModAlt) | ||
| 560 | s := r.key(t, "Left", ports.ModAlt)[0] | 574 | s := r.key(t, "Left", ports.ModAlt)[0] | ||
| @@ -837,6 +851,7 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) { | |||||
| 837 | for range 3 { | 851 | for range 3 { | ||
| 838 | r.key(t, "Left", ports.ModAlt) | 852 | r.key(t, "Left", ports.ModAlt) | ||
| 839 | } | 853 | } | ||
| 854 | + | r.settle(t) | |||
| 840 | // A flick toward the right lands on a later column: the view moves | 855 | // A flick toward the right lands on a later column: the view moves | ||
| 841 | // right, so column 1 moves left. | 856 | // right, so column 1 moves left. | ||
| 842 | r.flick(t, 4, 40, 0) | 857 | r.flick(t, 4, 40, 0) | ||
| @@ -849,8 +864,8 @@ func TestSwipeRetargetKeepsVelocity(t *testing.T) { | |||||
| 849 | t.Fatalf("view did not move right: column 1 at %d", prev.X) | 864 | t.Fatalf("view did not move right: column 1 at %d", prev.X) | ||
| 850 | } | 865 | } | ||
| 851 | r.key(t, "Right", ports.ModAlt) | 866 | r.key(t, "Right", ports.ModAlt) | ||
| 852 | - | // The retargeted spring takes its start time at the first frame, which | 867 | + | // The retargeted spring keeps the speed of the landing: the view moves |
| 853 | - | // leaves the view where it is; it moves on from the second, never back. | 868 | + | // on from the first frame and never goes back. |
| 854 | for i := range 2 { | 869 | for i := range 2 { | ||
| 855 | s := r.frame(t, 16*time.Millisecond) | 870 | s := r.frame(t, 16*time.Millisecond) | ||
| 856 | got, _ := rectOf(s, 1) | 871 | got, _ := rectOf(s, 1) | ||
A internal/core/transition.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Transitions start after a user action (a bind, a click that focuses, a | |
| 9 | + | // discrete swipe step): a snapshot of what each screen shows is taken before | |
| 10 | + | // the action and diffed after it. The state the action leaves is the | |
| 11 | + | // settled one; the transition only adds presentation springs over it | |
| 12 | + | // (Workspace.shift, Monitor.switchOff), starting from what was on screen. | |
| 13 | + | // Nothing here runs for events that come from clients or outputs. | |
| 14 | + | ||
| 15 | + | // viewShot is what one screen showed before an action. | |
| 16 | + | type viewShot struct { | |
| 17 | + | sc *screen | |
| 18 | + | ws *Workspace | |
| 19 | + | // viewX is ws's settled view and view where its columns were on screen | |
| 20 | + | // (viewX plus shift). | |
| 21 | + | viewX int | |
| 22 | + | view float64 | |
| 23 | + | // list and pos are the numbered workspaces and the monitor's fractional | |
| 24 | + | // position in them (index of the current one plus switchOff); ok is | |
| 25 | + | // false when there is none (a shown stash workspace). | |
| 26 | + | list []*Workspace | |
| 27 | + | pos float64 | |
| 28 | + | ok bool | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // snapshot records what every screen without an open overview shows. The | |
| 32 | + | // returned slice and the lists in it belong to Core and are reused by the | |
| 33 | + | // next snapshot: use it before the next action only. | |
| 34 | + | func (c *Core) snapshot() []viewShot { | |
| 35 | + | full := c.shots[:cap(c.shots)] | |
| 36 | + | out := c.shots[:0] | |
| 37 | + | for _, sc := range c.screens { | |
| 38 | + | m := sc.mon | |
| 39 | + | var s viewShot | |
| 40 | + | if len(out) < len(full) { | |
| 41 | + | s.list = full[len(out)].list[:0] | |
| 42 | + | } | |
| 43 | + | s.sc = sc | |
| 44 | + | if !m.ov.open { | |
| 45 | + | w := m.Current() | |
| 46 | + | s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift | |
| 47 | + | if m.shown == nil { | |
| 48 | + | // A landing slide measures in the list it began on. | |
| 49 | + | list := m.Workspaces | |
| 50 | + | if m.switchList != nil { | |
| 51 | + | list = m.switchList | |
| 52 | + | } | |
| 53 | + | if i := indexOf(list, w); i >= 0 { | |
| 54 | + | s.list = append(s.list, list...) | |
| 55 | + | s.pos, s.ok = float64(i)+m.switchOff, true | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | out = append(out, s) | |
| 60 | + | } | |
| 61 | + | for i := len(out); i < len(full); i++ { | |
| 62 | + | full[i].sc, full[i].ws = nil, nil | |
| 63 | + | } | |
| 64 | + | c.shots = out | |
| 65 | + | return out | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // transition starts the springs that take each screen from what before | |
| 69 | + | // showed to what the action left, at now. It does nothing with animations | |
| 70 | + | // off, with the overview open before or after, or while a swipe follows the | |
| 71 | + | // fingers on that screen. Per-window rect motions join the camera here. | |
| 72 | + | func (c *Core) transition(before []viewShot, now time.Time) { | |
| 73 | + | if !c.animOn() { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | for i := range before { | |
| 77 | + | b := &before[i] | |
| 78 | + | if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | c.transitionCamera(b, now) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // following reports whether a swipe moves sc's view with the fingers. | |
| 86 | + | func (c *Core) following(sc *screen) bool { | |
| 87 | + | g := c.swipe | |
| 88 | + | return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // transitionCamera slides the columns or the monitor from what b showed. | |
| 92 | + | func (c *Core) transitionCamera(b *viewShot, now time.Time) { | |
| 93 | + | m := b.sc.mon | |
| 94 | + | w := m.Current() | |
| 95 | + | switch { | |
| 96 | + | case w == b.ws: | |
| 97 | + | if w.ViewX == b.viewX || !w.slidable() { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | // A running landing slide was retargeted by scroll(): its velocity | |
| 101 | + | // carries over, and the spring starts now like every action's. | |
| 102 | + | v := w.motion.velocity() | |
| 103 | + | w.shift = b.view - float64(w.ViewX) | |
| 104 | + | w.motion = c.spring(viewSpring(w.shift, v), now) | |
| 105 | + | case b.ok && m.shown == nil && !c.movedAway(b.sc, b.ws): | |
| 106 | + | j := indexOf(b.list, w) | |
| 107 | + | if j < 0 { | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | off := b.pos - float64(j) | |
| 111 | + | v := m.switchMotion.velocity() | |
| 112 | + | // The snapshot's list is reused by the next action: the slide keeps | |
| 113 | + | // its own copy. | |
| 114 | + | m.switchOff, m.switchList = off, slices.Clone(b.list) | |
| 115 | + | if off == 0 || m.framedSwitch() { | |
| 116 | + | m.stopSwitch() | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | m.switchMotion = c.spring(workspaceSpring(off, v), now) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // movedAway reports whether w, the workspace sc showed, went to another | |
| 124 | + | // screen: it gets no motion on either side. | |
| 125 | + | func (c *Core) movedAway(sc *screen, w *Workspace) bool { | |
| 126 | + | for _, o := range c.screens { | |
| 127 | + | if o != sc && o.mon.has(w) { | |
| 128 | + | return true | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | return false | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Transitions start after a user action (a bind, a click that focuses, a | |||
| 9 | + | // discrete swipe step): a snapshot of what each screen shows is taken before | |||
| 10 | + | // the action and diffed after it. The state the action leaves is the | |||
| 11 | + | // settled one; the transition only adds presentation springs over it | |||
| 12 | + | // (Workspace.shift, Monitor.switchOff), starting from what was on screen. | |||
| 13 | + | // Nothing here runs for events that come from clients or outputs. | |||
| 14 | + | ||||
| 15 | + | // viewShot is what one screen showed before an action. | |||
| 16 | + | type viewShot struct { | |||
| 17 | + | sc *screen | |||
| 18 | + | ws *Workspace | |||
| 19 | + | // viewX is ws's settled view and view where its columns were on screen | |||
| 20 | + | // (viewX plus shift). | |||
| 21 | + | viewX int | |||
| 22 | + | view float64 | |||
| 23 | + | // list and pos are the numbered workspaces and the monitor's fractional | |||
| 24 | + | // position in them (index of the current one plus switchOff); ok is | |||
| 25 | + | // false when there is none (a shown stash workspace). | |||
| 26 | + | list []*Workspace | |||
| 27 | + | pos float64 | |||
| 28 | + | ok bool | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // snapshot records what every screen without an open overview shows. The | |||
| 32 | + | // returned slice and the lists in it belong to Core and are reused by the | |||
| 33 | + | // next snapshot: use it before the next action only. | |||
| 34 | + | func (c *Core) snapshot() []viewShot { | |||
| 35 | + | full := c.shots[:cap(c.shots)] | |||
| 36 | + | out := c.shots[:0] | |||
| 37 | + | for _, sc := range c.screens { | |||
| 38 | + | m := sc.mon | |||
| 39 | + | var s viewShot | |||
| 40 | + | if len(out) < len(full) { | |||
| 41 | + | s.list = full[len(out)].list[:0] | |||
| 42 | + | } | |||
| 43 | + | s.sc = sc | |||
| 44 | + | if !m.ov.open { | |||
| 45 | + | w := m.Current() | |||
| 46 | + | s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift | |||
| 47 | + | if m.shown == nil { | |||
| 48 | + | // A landing slide measures in the list it began on. | |||
| 49 | + | list := m.Workspaces | |||
| 50 | + | if m.switchList != nil { | |||
| 51 | + | list = m.switchList | |||
| 52 | + | } | |||
| 53 | + | if i := indexOf(list, w); i >= 0 { | |||
| 54 | + | s.list = append(s.list, list...) | |||
| 55 | + | s.pos, s.ok = float64(i)+m.switchOff, true | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | out = append(out, s) | |||
| 60 | + | } | |||
| 61 | + | for i := len(out); i < len(full); i++ { | |||
| 62 | + | full[i].sc, full[i].ws = nil, nil | |||
| 63 | + | } | |||
| 64 | + | c.shots = out | |||
| 65 | + | return out | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // transition starts the springs that take each screen from what before | |||
| 69 | + | // showed to what the action left, at now. It does nothing with animations | |||
| 70 | + | // off, with the overview open before or after, or while a swipe follows the | |||
| 71 | + | // fingers on that screen. Per-window rect motions join the camera here. | |||
| 72 | + | func (c *Core) transition(before []viewShot, now time.Time) { | |||
| 73 | + | if !c.animOn() { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | for i := range before { | |||
| 77 | + | b := &before[i] | |||
| 78 | + | if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) { | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | c.transitionCamera(b, now) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // following reports whether a swipe moves sc's view with the fingers. | |||
| 86 | + | func (c *Core) following(sc *screen) bool { | |||
| 87 | + | g := c.swipe | |||
| 88 | + | return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // transitionCamera slides the columns or the monitor from what b showed. | |||
| 92 | + | func (c *Core) transitionCamera(b *viewShot, now time.Time) { | |||
| 93 | + | m := b.sc.mon | |||
| 94 | + | w := m.Current() | |||
| 95 | + | switch { | |||
| 96 | + | case w == b.ws: | |||
| 97 | + | if w.ViewX == b.viewX || !w.slidable() { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | // A running landing slide was retargeted by scroll(): its velocity | |||
| 101 | + | // carries over, and the spring starts now like every action's. | |||
| 102 | + | v := w.motion.velocity() | |||
| 103 | + | w.shift = b.view - float64(w.ViewX) | |||
| 104 | + | w.motion = c.spring(viewSpring(w.shift, v), now) | |||
| 105 | + | case b.ok && m.shown == nil && !c.movedAway(b.sc, b.ws): | |||
| 106 | + | j := indexOf(b.list, w) | |||
| 107 | + | if j < 0 { | |||
| 108 | + | return | |||
| 109 | + | } | |||
| 110 | + | off := b.pos - float64(j) | |||
| 111 | + | v := m.switchMotion.velocity() | |||
| 112 | + | // The snapshot's list is reused by the next action: the slide keeps | |||
| 113 | + | // its own copy. | |||
| 114 | + | m.switchOff, m.switchList = off, slices.Clone(b.list) | |||
| 115 | + | if off == 0 || m.framedSwitch() { | |||
| 116 | + | m.stopSwitch() | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | m.switchMotion = c.spring(workspaceSpring(off, v), now) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // movedAway reports whether w, the workspace sc showed, went to another | |||
| 124 | + | // screen: it gets no motion on either side. | |||
| 125 | + | func (c *Core) movedAway(sc *screen, w *Workspace) bool { | |||
| 126 | + | for _, o := range c.screens { | |||
| 127 | + | if o != sc && o.mon.has(w) { | |||
| 128 | + | return true | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | return false | |||
| 132 | + | } | |||
A internal/core/transition_internal_test.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // The focus pulse waits while a camera transition of a key runs, and starts | |
| 9 | + | // once it settled. | |
| 10 | + | func TestPulseWaitsForCameraTransition(t *testing.T) { | |
| 11 | + | c, ic := pulseCore(t) | |
| 12 | + | m := c.cur().mon | |
| 13 | + | for id := WindowID(3); id <= 4; id++ { | |
| 14 | + | m.AddWindow(id) | |
| 15 | + | } | |
| 16 | + | indicatorScene(t, c) | |
| 17 | + | ic.now = ic.now.Add(pulseSettle + pulseCooldown) | |
| 18 | + | c.pulseTick() | |
| 19 | + | indicatorScene(t, c) | |
| 20 | + | ||
| 21 | + | before := c.snapshot() | |
| 22 | + | for range 3 { | |
| 23 | + | c.applyAction(ActionFocusColumnLeft) | |
| 24 | + | } | |
| 25 | + | c.transition(before, ic.now) | |
| 26 | + | w := m.Current() | |
| 27 | + | if !w.motion.on || m.settled() { | |
| 28 | + | t.Fatal("no camera transition after the focus moved the view") | |
| 29 | + | } | |
| 30 | + | indicatorScene(t, c) | |
| 31 | + | ic.now = ic.now.Add(pulseSettle) | |
| 32 | + | if c.pulseTick() { | |
| 33 | + | t.Fatal("pulse started while the view slides") | |
| 34 | + | } | |
| 35 | + | c.animate(ic.now.Add(5*time.Second), nil) | |
| 36 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 37 | + | if !m.settled() { | |
| 38 | + | t.Fatal("view still slides") | |
| 39 | + | } | |
| 40 | + | ic.now = ic.now.Add(pulseSettle) | |
| 41 | + | if !c.pulseTick() { | |
| 42 | + | t.Fatal("no pulse once the view settled") | |
| 43 | + | } | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // The focus pulse waits while a camera transition of a key runs, and starts | |||
| 9 | + | // once it settled. | |||
| 10 | + | func TestPulseWaitsForCameraTransition(t *testing.T) { | |||
| 11 | + | c, ic := pulseCore(t) | |||
| 12 | + | m := c.cur().mon | |||
| 13 | + | for id := WindowID(3); id <= 4; id++ { | |||
| 14 | + | m.AddWindow(id) | |||
| 15 | + | } | |||
| 16 | + | indicatorScene(t, c) | |||
| 17 | + | ic.now = ic.now.Add(pulseSettle + pulseCooldown) | |||
| 18 | + | c.pulseTick() | |||
| 19 | + | indicatorScene(t, c) | |||
| 20 | + | ||||
| 21 | + | before := c.snapshot() | |||
| 22 | + | for range 3 { | |||
| 23 | + | c.applyAction(ActionFocusColumnLeft) | |||
| 24 | + | } | |||
| 25 | + | c.transition(before, ic.now) | |||
| 26 | + | w := m.Current() | |||
| 27 | + | if !w.motion.on || m.settled() { | |||
| 28 | + | t.Fatal("no camera transition after the focus moved the view") | |||
| 29 | + | } | |||
| 30 | + | indicatorScene(t, c) | |||
| 31 | + | ic.now = ic.now.Add(pulseSettle) | |||
| 32 | + | if c.pulseTick() { | |||
| 33 | + | t.Fatal("pulse started while the view slides") | |||
| 34 | + | } | |||
| 35 | + | c.animate(ic.now.Add(5*time.Second), nil) | |||
| 36 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 37 | + | if !m.settled() { | |||
| 38 | + | t.Fatal("view still slides") | |||
| 39 | + | } | |||
| 40 | + | ic.now = ic.now.Add(pulseSettle) | |||
| 41 | + | if !c.pulseTick() { | |||
| 42 | + | t.Fatal("no pulse once the view settled") | |||
| 43 | + | } | |||
| 44 | + | } | |||
A internal/core/transition_test.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // xOf is the X of window id in s, shown or not. | |
| 11 | + | func xOf(t *testing.T, s ports.Scene, id ports.WindowID) int { | |
| 12 | + | t.Helper() | |
| 13 | + | for _, w := range s.Windows { | |
| 14 | + | if w.ID == id { | |
| 15 | + | return w.Rect.X | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | t.Fatalf("window %d not in the scene", id) | |
| 19 | + | return 0 | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // fourColumns maps four windows of half the width and goes back to the | |
| 23 | + | // first column, settled: the view is at 0. | |
| 24 | + | func fourColumns(t *testing.T, r *swipeRig) { | |
| 25 | + | t.Helper() | |
| 26 | + | for id := ports.WindowID(1); id <= 4; id++ { | |
| 27 | + | r.mapWindow(t, id) | |
| 28 | + | } | |
| 29 | + | for range 3 { | |
| 30 | + | r.key(t, "Left", ports.ModAlt) | |
| 31 | + | } | |
| 32 | + | if r.cfg.Animations.On { | |
| 33 | + | r.settle(t) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // A key that scrolls the view shows the old view right after it, an | |
| 38 | + | // intermediate one on the first frame and the final one once settled. | |
| 39 | + | func TestKeyScrollTransition(t *testing.T) { | |
| 40 | + | r := startSwipe(t, nil) | |
| 41 | + | threeColumns(t, r) | |
| 42 | + | r.key(t, "Left", ports.ModAlt) | |
| 43 | + | r.key(t, "Left", ports.ModAlt) | |
| 44 | + | r.settle(t) | |
| 45 | + | r.key(t, "Right", ports.ModAlt) // 2 is on screen: nothing scrolls | |
| 46 | + | s := r.key(t, "Right", ports.ModAlt)[0] | |
| 47 | + | if got := xOf(t, s, 1); got != 0 { | |
| 48 | + | t.Fatalf("column 1 at %d right after the key, want the old 0", got) | |
| 49 | + | } | |
| 50 | + | got := xOf(t, r.frame(t, 16*time.Millisecond), 1) | |
| 51 | + | if got >= 0 || got <= -400 { | |
| 52 | + | t.Fatalf("column 1 at %d on the first frame, want between 0 and -400", got) | |
| 53 | + | } | |
| 54 | + | if got := xOf(t, r.settle(t), 1); got != -400 { | |
| 55 | + | t.Fatalf("column 1 at %d settled, want -400", got) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Presses during a transition never send the view backward. | |
| 60 | + | func TestChainedKeysNeverJumpBack(t *testing.T) { | |
| 61 | + | r := startSwipe(t, nil) | |
| 62 | + | fourColumns(t, r) | |
| 63 | + | r.key(t, "Right", ports.ModAlt) | |
| 64 | + | r.key(t, "Right", ports.ModAlt) | |
| 65 | + | prev := xOf(t, r.frame(t, 16*time.Millisecond), 1) | |
| 66 | + | r.key(t, "Right", ports.ModAlt) | |
| 67 | + | for i := range 40 { | |
| 68 | + | got := xOf(t, r.frame(t, 16*time.Millisecond), 1) | |
| 69 | + | if got > prev { | |
| 70 | + | t.Fatalf("frame %d: column 1 at %d after %d, the view went back", i, got, prev) | |
| 71 | + | } | |
| 72 | + | prev = got | |
| 73 | + | if got == -800 { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | t.Fatalf("column 1 settled at %d, want -800", prev) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestWorkspaceKeyTransition(t *testing.T) { | |
| 81 | + | r := startSwipe(t, nil) | |
| 82 | + | r.workspaces(t) | |
| 83 | + | s := r.key(t, "Next", ports.ModAlt)[0] | |
| 84 | + | if got, ok := rectOf(s, 1); !ok || got.Y != 0 { | |
| 85 | + | t.Fatalf("workspace 1 at %+v (shown %t) right after the key, want the old position", got, ok) | |
| 86 | + | } | |
| 87 | + | s = r.frame(t, 16*time.Millisecond) | |
| 88 | + | got, ok := rectOf(s, 2) | |
| 89 | + | if !ok || got.Y <= 0 || got.Y >= 600 { | |
| 90 | + | t.Fatalf("workspace 2 at %+v (shown %t) on the first frame, want Y between 0 and 600", got, ok) | |
| 91 | + | } | |
| 92 | + | if got, _ := rectOf(r.settle(t), 2); got.Y != 0 { | |
| 93 | + | t.Fatalf("workspace 2 at %d settled, want 0", got.Y) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // The workspace slide of a key keeps running when the next key goes on. | |
| 98 | + | func TestChainedWorkspaceKeysKeepSliding(t *testing.T) { | |
| 99 | + | r := startSwipe(t, nil) | |
| 100 | + | r.workspaces(t) | |
| 101 | + | r.keySettled(t, "Next") | |
| 102 | + | r.keySettled(t, "Next") | |
| 103 | + | r.mapWindow(t, 3) | |
| 104 | + | r.keySettled(t, "Prior") | |
| 105 | + | r.keySettled(t, "Prior") | |
| 106 | + | r.key(t, "Next", ports.ModAlt) | |
| 107 | + | r.frame(t, 16*time.Millisecond) | |
| 108 | + | r.key(t, "Next", ports.ModAlt) | |
| 109 | + | if got, ok := rectOf(r.settle(t), 3); !ok || got.Y != 0 { | |
| 110 | + | t.Fatalf("workspace 3 at %+v (shown %t) settled", got, ok) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestAnimationsOffKeysLandAtOnce(t *testing.T) { | |
| 115 | + | r := startSwipe(t, animationsOff) | |
| 116 | + | fourColumns(t, r) | |
| 117 | + | r.key(t, "Right", ports.ModAlt) | |
| 118 | + | if got := xOf(t, r.key(t, "Right", ports.ModAlt)[0], 1); got != -400 { | |
| 119 | + | t.Fatalf("column 1 at %d right after the key, want -400", got) | |
| 120 | + | } | |
| 121 | + | r.noFrameScene(t) | |
| 122 | + | ||
| 123 | + | r = startSwipe(t, animationsOff) | |
| 124 | + | r.workspaces(t) | |
| 125 | + | s := r.key(t, "Next", ports.ModAlt)[0] | |
| 126 | + | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |
| 127 | + | t.Fatalf("workspace 2 at %+v (shown %t) right after the key, want the settled rect", got, ok) | |
| 128 | + | } | |
| 129 | + | r.noFrameScene(t) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // With the overview open before or after, nothing slides. | |
| 133 | + | func TestNoTransitionWithOverview(t *testing.T) { | |
| 134 | + | r := startSwipe(t, nil) | |
| 135 | + | r.workspaces(t) | |
| 136 | + | r.key(t, "o", ports.ModAlt) | |
| 137 | + | r.key(t, "o", ports.ModAlt) | |
| 138 | + | r.noFrameScene(t) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // A workspace that goes to another monitor gets no motion on either side. | |
| 142 | + | func TestMovedWorkspaceHasNoMotion(t *testing.T) { | |
| 143 | + | r := startSwipe(t, nil) | |
| 144 | + | r.mapWindow(t, 1) | |
| 145 | + | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | |
| 146 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 147 | + | r.noFrameScene(t) | |
| 148 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // xOf is the X of window id in s, shown or not. | |||
| 11 | + | func xOf(t *testing.T, s ports.Scene, id ports.WindowID) int { | |||
| 12 | + | t.Helper() | |||
| 13 | + | for _, w := range s.Windows { | |||
| 14 | + | if w.ID == id { | |||
| 15 | + | return w.Rect.X | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | t.Fatalf("window %d not in the scene", id) | |||
| 19 | + | return 0 | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // fourColumns maps four windows of half the width and goes back to the | |||
| 23 | + | // first column, settled: the view is at 0. | |||
| 24 | + | func fourColumns(t *testing.T, r *swipeRig) { | |||
| 25 | + | t.Helper() | |||
| 26 | + | for id := ports.WindowID(1); id <= 4; id++ { | |||
| 27 | + | r.mapWindow(t, id) | |||
| 28 | + | } | |||
| 29 | + | for range 3 { | |||
| 30 | + | r.key(t, "Left", ports.ModAlt) | |||
| 31 | + | } | |||
| 32 | + | if r.cfg.Animations.On { | |||
| 33 | + | r.settle(t) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // A key that scrolls the view shows the old view right after it, an | |||
| 38 | + | // intermediate one on the first frame and the final one once settled. | |||
| 39 | + | func TestKeyScrollTransition(t *testing.T) { | |||
| 40 | + | r := startSwipe(t, nil) | |||
| 41 | + | threeColumns(t, r) | |||
| 42 | + | r.key(t, "Left", ports.ModAlt) | |||
| 43 | + | r.key(t, "Left", ports.ModAlt) | |||
| 44 | + | r.settle(t) | |||
| 45 | + | r.key(t, "Right", ports.ModAlt) // 2 is on screen: nothing scrolls | |||
| 46 | + | s := r.key(t, "Right", ports.ModAlt)[0] | |||
| 47 | + | if got := xOf(t, s, 1); got != 0 { | |||
| 48 | + | t.Fatalf("column 1 at %d right after the key, want the old 0", got) | |||
| 49 | + | } | |||
| 50 | + | got := xOf(t, r.frame(t, 16*time.Millisecond), 1) | |||
| 51 | + | if got >= 0 || got <= -400 { | |||
| 52 | + | t.Fatalf("column 1 at %d on the first frame, want between 0 and -400", got) | |||
| 53 | + | } | |||
| 54 | + | if got := xOf(t, r.settle(t), 1); got != -400 { | |||
| 55 | + | t.Fatalf("column 1 at %d settled, want -400", got) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Presses during a transition never send the view backward. | |||
| 60 | + | func TestChainedKeysNeverJumpBack(t *testing.T) { | |||
| 61 | + | r := startSwipe(t, nil) | |||
| 62 | + | fourColumns(t, r) | |||
| 63 | + | r.key(t, "Right", ports.ModAlt) | |||
| 64 | + | r.key(t, "Right", ports.ModAlt) | |||
| 65 | + | prev := xOf(t, r.frame(t, 16*time.Millisecond), 1) | |||
| 66 | + | r.key(t, "Right", ports.ModAlt) | |||
| 67 | + | for i := range 40 { | |||
| 68 | + | got := xOf(t, r.frame(t, 16*time.Millisecond), 1) | |||
| 69 | + | if got > prev { | |||
| 70 | + | t.Fatalf("frame %d: column 1 at %d after %d, the view went back", i, got, prev) | |||
| 71 | + | } | |||
| 72 | + | prev = got | |||
| 73 | + | if got == -800 { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | t.Fatalf("column 1 settled at %d, want -800", prev) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestWorkspaceKeyTransition(t *testing.T) { | |||
| 81 | + | r := startSwipe(t, nil) | |||
| 82 | + | r.workspaces(t) | |||
| 83 | + | s := r.key(t, "Next", ports.ModAlt)[0] | |||
| 84 | + | if got, ok := rectOf(s, 1); !ok || got.Y != 0 { | |||
| 85 | + | t.Fatalf("workspace 1 at %+v (shown %t) right after the key, want the old position", got, ok) | |||
| 86 | + | } | |||
| 87 | + | s = r.frame(t, 16*time.Millisecond) | |||
| 88 | + | got, ok := rectOf(s, 2) | |||
| 89 | + | if !ok || got.Y <= 0 || got.Y >= 600 { | |||
| 90 | + | t.Fatalf("workspace 2 at %+v (shown %t) on the first frame, want Y between 0 and 600", got, ok) | |||
| 91 | + | } | |||
| 92 | + | if got, _ := rectOf(r.settle(t), 2); got.Y != 0 { | |||
| 93 | + | t.Fatalf("workspace 2 at %d settled, want 0", got.Y) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // The workspace slide of a key keeps running when the next key goes on. | |||
| 98 | + | func TestChainedWorkspaceKeysKeepSliding(t *testing.T) { | |||
| 99 | + | r := startSwipe(t, nil) | |||
| 100 | + | r.workspaces(t) | |||
| 101 | + | r.keySettled(t, "Next") | |||
| 102 | + | r.keySettled(t, "Next") | |||
| 103 | + | r.mapWindow(t, 3) | |||
| 104 | + | r.keySettled(t, "Prior") | |||
| 105 | + | r.keySettled(t, "Prior") | |||
| 106 | + | r.key(t, "Next", ports.ModAlt) | |||
| 107 | + | r.frame(t, 16*time.Millisecond) | |||
| 108 | + | r.key(t, "Next", ports.ModAlt) | |||
| 109 | + | if got, ok := rectOf(r.settle(t), 3); !ok || got.Y != 0 { | |||
| 110 | + | t.Fatalf("workspace 3 at %+v (shown %t) settled", got, ok) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestAnimationsOffKeysLandAtOnce(t *testing.T) { | |||
| 115 | + | r := startSwipe(t, animationsOff) | |||
| 116 | + | fourColumns(t, r) | |||
| 117 | + | r.key(t, "Right", ports.ModAlt) | |||
| 118 | + | if got := xOf(t, r.key(t, "Right", ports.ModAlt)[0], 1); got != -400 { | |||
| 119 | + | t.Fatalf("column 1 at %d right after the key, want -400", got) | |||
| 120 | + | } | |||
| 121 | + | r.noFrameScene(t) | |||
| 122 | + | ||||
| 123 | + | r = startSwipe(t, animationsOff) | |||
| 124 | + | r.workspaces(t) | |||
| 125 | + | s := r.key(t, "Next", ports.ModAlt)[0] | |||
| 126 | + | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |||
| 127 | + | t.Fatalf("workspace 2 at %+v (shown %t) right after the key, want the settled rect", got, ok) | |||
| 128 | + | } | |||
| 129 | + | r.noFrameScene(t) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // With the overview open before or after, nothing slides. | |||
| 133 | + | func TestNoTransitionWithOverview(t *testing.T) { | |||
| 134 | + | r := startSwipe(t, nil) | |||
| 135 | + | r.workspaces(t) | |||
| 136 | + | r.key(t, "o", ports.ModAlt) | |||
| 137 | + | r.key(t, "o", ports.ModAlt) | |||
| 138 | + | r.noFrameScene(t) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // A workspace that goes to another monitor gets no motion on either side. | |||
| 142 | + | func TestMovedWorkspaceHasNoMotion(t *testing.T) { | |||
| 143 | + | r := startSwipe(t, nil) | |||
| 144 | + | r.mapWindow(t, 1) | |||
| 145 | + | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | |||
| 146 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 147 | + | r.noFrameScene(t) | |||
| 148 | + | } | |||