feat(core): animate a workspace moved to another monitor
- commit
- d27f5eca2498a5bc09e9a481d9ef05caf73b959f
- author
- brice · · 2026-10-04 22:42 UTC
- committer
- brice · · 2026-10-04 22:51 UTC
- parent
- ecc78a7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +143 -26
M internal/core/transition.go +37 -19
| @@ -406,6 +406,12 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 406 | 406 | // showed to what the action left, at now. It does nothing with animations | |
| 407 | 407 | // off, with the overview open before or after, or while a swipe follows the | |
| 408 | 408 | // fingers on that screen. Per-window rect motions join the camera here. | |
| 409 | + | // | |
| 410 | + | // A workspace moved to another monitor (move-workspace-to-monitor-*) is | |
| 411 | + | // animated on both screens: the destination's windows slide in from where | |
| 412 | + | // the source drew them, the source slides to its next workspace. The | |
| 413 | + | // focus-monitor-* actions move nothing: no window changes place, so they | |
| 414 | + | // are not animated (the focus pulse marks the new focus). | |
| 409 | 415 | func (c *Core) transition(before []viewShot, now time.Time) { | |
| 410 | 416 | if !c.animOn() { | |
| 411 | 417 | return | |
| @@ -416,7 +422,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||
| 416 | 422 | continue | |
| 417 | 423 | } | |
| 418 | 424 | c.transitionCamera(b, now) | |
| 419 | - | c.transitionRects(b, now) | |
| 425 | + | c.transitionRects(b, before, now) | |
| 420 | 426 | } | |
| 421 | 427 | } | |
| 422 | 428 | ||
| @@ -439,7 +445,7 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) { | |||
| 439 | 445 | // carries over, and the spring starts now like every action's. | |
| 440 | 446 | w.shift = b.view - float64(w.ViewX) | |
| 441 | 447 | w.motion = c.spring(viewSpring(w.shift, b.viewV), now) | |
| 442 | - | case b.ok && m.shown == nil && !c.movedAway(b.sc, b.ws): | |
| 448 | + | case b.ok && m.shown == nil: | |
| 443 | 449 | j := indexOf(b.list, w) | |
| 444 | 450 | if j < 0 { | |
| 445 | 451 | return | |
| @@ -461,23 +467,34 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) { | |||
| 461 | 467 | // transitionCamera left it (the view where it was on screen), so a window | |
| 462 | 468 | // the view scrolls differs only by what the layout itself changed. Windows | |
| 463 | 469 | // that were not drawn before (new, or hidden) or are hidden now get none. | |
| 464 | - | func (c *Core) transitionRects(b *viewShot, now time.Time) { | |
| 470 | + | // | |
| 471 | + | // A workspace that arrived from another monitor starts from the rects its | |
| 472 | + | // source screen drew (found in before), translated to this screen's | |
| 473 | + | // coordinates; a source whose workspace left, or any other change of | |
| 474 | + | // workspace, gets none. | |
| 475 | + | func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |
| 465 | 476 | sc := b.sc | |
| 466 | 477 | m := sc.mon | |
| 478 | + | from, dx, dy := b, 0, 0 | |
| 467 | 479 | if m.Current() != b.ws { | |
| 480 | + | // The motions are those of the workspace this screen left. | |
| 468 | 481 | sc.stopRects() | |
| 469 | - | return | |
| 482 | + | if from = movedFrom(b, before); from == nil { | |
| 483 | + | return | |
| 484 | + | } | |
| 485 | + | dx, dy = from.sc.x-sc.x, from.sc.y-sc.y | |
| 470 | 486 | } | |
| 471 | 487 | for _, p := range m.Layout() { | |
| 472 | 488 | if p.Hidden || p.Preview > 0 { | |
| 473 | 489 | continue | |
| 474 | 490 | } | |
| 475 | - | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | |
| 491 | + | i := slices.IndexFunc(from.rects, func(r rectShot) bool { return r.id == p.ID }) | |
| 476 | 492 | if i < 0 { | |
| 477 | 493 | continue | |
| 478 | 494 | } | |
| 479 | - | old := &b.rects[i] | |
| 480 | - | if old.rect == p.Rect { | |
| 495 | + | old := &from.rects[i] | |
| 496 | + | oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H} | |
| 497 | + | if oldRect == p.Rect { | |
| 481 | 498 | // The settled rect did not change: a running motion goes on | |
| 482 | 499 | // as it is, and there is none to start. | |
| 483 | 500 | continue | |
| @@ -491,25 +508,26 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) { | |||
| 491 | 508 | // following the frame and snaps to the settled size; the fade goes | |
| 492 | 509 | // on. | |
| 493 | 510 | rm.scale = false | |
| 494 | - | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | |
| 495 | - | c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) | |
| 496 | - | c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now) | |
| 497 | - | c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now) | |
| 511 | + | c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now) | |
| 512 | + | c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) | |
| 513 | + | c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now) | |
| 514 | + | c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now) | |
| 498 | 515 | if rm.on() { | |
| 499 | - | sc.rects[p.ID], sc.rectsWS = rm, b.ws | |
| 516 | + | sc.rects[p.ID], sc.rectsWS = rm, m.Current() | |
| 500 | 517 | } else { | |
| 501 | 518 | delete(sc.rects, p.ID) | |
| 502 | 519 | } | |
| 503 | 520 | } | |
| 504 | 521 | } | |
| 505 | 522 | ||
| 506 | - | // movedAway reports whether w, the workspace sc showed, went to another | |
| 507 | - | // screen: it gets no motion on either side. | |
| 508 | - | func (c *Core) movedAway(sc *screen, w *Workspace) bool { | |
| 509 | - | for _, o := range c.screens { | |
| 510 | - | if o != sc && o.mon.has(w) { | |
| 511 | - | return true | |
| 523 | + | // movedFrom is the shot of the other screen that showed the workspace b's | |
| 524 | + | // screen shows now (moved to it by an action), or nil. | |
| 525 | + | func movedFrom(b *viewShot, before []viewShot) *viewShot { | |
| 526 | + | w := b.sc.mon.Current() | |
| 527 | + | for i := range before { | |
| 528 | + | if o := &before[i]; o.ws == w && o.sc != b.sc { | |
| 529 | + | return o | |
| 512 | 530 | } | |
| 513 | 531 | } | |
| 514 | - | return false | |
| 532 | + | return nil | |
| 515 | 533 | } | |
| @@ -406,6 +406,12 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 406 | // showed to what the action left, at now. It does nothing with animations | 406 | // showed to what the action left, at now. It does nothing with animations | ||
| 407 | // off, with the overview open before or after, or while a swipe follows the | 407 | // off, with the overview open before or after, or while a swipe follows the | ||
| 408 | // fingers on that screen. Per-window rect motions join the camera here. | 408 | // fingers on that screen. Per-window rect motions join the camera here. | ||
| 409 | + | // | |||
| 410 | + | // A workspace moved to another monitor (move-workspace-to-monitor-*) is | |||
| 411 | + | // animated on both screens: the destination's windows slide in from where | |||
| 412 | + | // the source drew them, the source slides to its next workspace. The | |||
| 413 | + | // focus-monitor-* actions move nothing: no window changes place, so they | |||
| 414 | + | // are not animated (the focus pulse marks the new focus). | |||
| 409 | func (c *Core) transition(before []viewShot, now time.Time) { | 415 | func (c *Core) transition(before []viewShot, now time.Time) { | ||
| 410 | if !c.animOn() { | 416 | if !c.animOn() { | ||
| 411 | return | 417 | return | ||
| @@ -416,7 +422,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||||
| 416 | continue | 422 | continue | ||
| 417 | } | 423 | } | ||
| 418 | c.transitionCamera(b, now) | 424 | c.transitionCamera(b, now) | ||
| 419 | - | c.transitionRects(b, now) | 425 | + | c.transitionRects(b, before, now) |
| 420 | } | 426 | } | ||
| 421 | } | 427 | } | ||
| 422 | 428 | ||||
| @@ -439,7 +445,7 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) { | |||||
| 439 | // carries over, and the spring starts now like every action's. | 445 | // carries over, and the spring starts now like every action's. | ||
| 440 | w.shift = b.view - float64(w.ViewX) | 446 | w.shift = b.view - float64(w.ViewX) | ||
| 441 | w.motion = c.spring(viewSpring(w.shift, b.viewV), now) | 447 | w.motion = c.spring(viewSpring(w.shift, b.viewV), now) | ||
| 442 | - | case b.ok && m.shown == nil && !c.movedAway(b.sc, b.ws): | 448 | + | case b.ok && m.shown == nil: |
| 443 | j := indexOf(b.list, w) | 449 | j := indexOf(b.list, w) | ||
| 444 | if j < 0 { | 450 | if j < 0 { | ||
| 445 | return | 451 | return | ||
| @@ -461,23 +467,34 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) { | |||||
| 461 | // transitionCamera left it (the view where it was on screen), so a window | 467 | // transitionCamera left it (the view where it was on screen), so a window | ||
| 462 | // the view scrolls differs only by what the layout itself changed. Windows | 468 | // the view scrolls differs only by what the layout itself changed. Windows | ||
| 463 | // that were not drawn before (new, or hidden) or are hidden now get none. | 469 | // that were not drawn before (new, or hidden) or are hidden now get none. | ||
| 464 | - | func (c *Core) transitionRects(b *viewShot, now time.Time) { | 470 | + | // |
| 471 | + | // A workspace that arrived from another monitor starts from the rects its | |||
| 472 | + | // source screen drew (found in before), translated to this screen's | |||
| 473 | + | // coordinates; a source whose workspace left, or any other change of | |||
| 474 | + | // workspace, gets none. | |||
| 475 | + | func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||
| 465 | sc := b.sc | 476 | sc := b.sc | ||
| 466 | m := sc.mon | 477 | m := sc.mon | ||
| 478 | + | from, dx, dy := b, 0, 0 | |||
| 467 | if m.Current() != b.ws { | 479 | if m.Current() != b.ws { | ||
| 480 | + | // The motions are those of the workspace this screen left. | |||
| 468 | sc.stopRects() | 481 | sc.stopRects() | ||
| 469 | - | return | 482 | + | if from = movedFrom(b, before); from == nil { |
| 483 | + | return | |||
| 484 | + | } | |||
| 485 | + | dx, dy = from.sc.x-sc.x, from.sc.y-sc.y | |||
| 470 | } | 486 | } | ||
| 471 | for _, p := range m.Layout() { | 487 | for _, p := range m.Layout() { | ||
| 472 | if p.Hidden || p.Preview > 0 { | 488 | if p.Hidden || p.Preview > 0 { | ||
| 473 | continue | 489 | continue | ||
| 474 | } | 490 | } | ||
| 475 | - | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | 491 | + | i := slices.IndexFunc(from.rects, func(r rectShot) bool { return r.id == p.ID }) |
| 476 | if i < 0 { | 492 | if i < 0 { | ||
| 477 | continue | 493 | continue | ||
| 478 | } | 494 | } | ||
| 479 | - | old := &b.rects[i] | 495 | + | old := &from.rects[i] |
| 480 | - | if old.rect == p.Rect { | 496 | + | oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H} |
| 497 | + | if oldRect == p.Rect { | |||
| 481 | // The settled rect did not change: a running motion goes on | 498 | // The settled rect did not change: a running motion goes on | ||
| 482 | // as it is, and there is none to start. | 499 | // as it is, and there is none to start. | ||
| 483 | continue | 500 | continue | ||
| @@ -491,25 +508,26 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) { | |||||
| 491 | // following the frame and snaps to the settled size; the fade goes | 508 | // following the frame and snaps to the settled size; the fade goes | ||
| 492 | // on. | 509 | // on. | ||
| 493 | rm.scale = false | 510 | rm.scale = false | ||
| 494 | - | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | 511 | + | c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now) |
| 495 | - | c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) | 512 | + | c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) |
| 496 | - | c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now) | 513 | + | c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now) |
| 497 | - | c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now) | 514 | + | c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now) |
| 498 | if rm.on() { | 515 | if rm.on() { | ||
| 499 | - | sc.rects[p.ID], sc.rectsWS = rm, b.ws | 516 | + | sc.rects[p.ID], sc.rectsWS = rm, m.Current() |
| 500 | } else { | 517 | } else { | ||
| 501 | delete(sc.rects, p.ID) | 518 | delete(sc.rects, p.ID) | ||
| 502 | } | 519 | } | ||
| 503 | } | 520 | } | ||
| 504 | } | 521 | } | ||
| 505 | 522 | ||||
| 506 | - | // movedAway reports whether w, the workspace sc showed, went to another | 523 | + | // movedFrom is the shot of the other screen that showed the workspace b's |
| 507 | - | // screen: it gets no motion on either side. | 524 | + | // screen shows now (moved to it by an action), or nil. |
| 508 | - | func (c *Core) movedAway(sc *screen, w *Workspace) bool { | 525 | + | func movedFrom(b *viewShot, before []viewShot) *viewShot { |
| 509 | - | for _, o := range c.screens { | 526 | + | w := b.sc.mon.Current() |
| 510 | - | if o != sc && o.mon.has(w) { | 527 | + | for i := range before { |
| 511 | - | return true | 528 | + | if o := &before[i]; o.ws == w && o.sc != b.sc { |
| 529 | + | return o | |||
| 512 | } | 530 | } | ||
| 513 | } | 531 | } | ||
| 514 | - | return false | 532 | + | return nil |
| 515 | } | 533 | } | ||
M internal/core/transition_test.go +106 -7
| @@ -161,14 +161,113 @@ func TestNoTransitionWithOverview(t *testing.T) { | |||
| 161 | 161 | } | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | - | // A workspace that goes to another monitor gets no rect or switch motion on | |
| 165 | - | // either side. | |
| 166 | - | func TestMovedWorkspaceHasNoMotion(t *testing.T) { | |
| 167 | - | r := startSwipe(t, nil) | |
| 168 | - | twoColumns(t, r) | |
| 164 | + | // movedRig plugs a second 800x600 output right of the first. | |
| 165 | + | func movedRig(t *testing.T, edit func(*ports.Config)) (*swipeRig, ports.OutputInfo) { | |
| 166 | + | t.Helper() | |
| 169 | 167 | other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000} | |
| 170 | - | r.plug(t, other) | |
| 171 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 168 | + | return startSwipeOn(t, edit, wide, other), other | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func outScene(t *testing.T, set []ports.Scene, output string) ports.Scene { | |
| 172 | + | t.Helper() | |
| 173 | + | for _, s := range set { | |
| 174 | + | if s.Output == output { | |
| 175 | + | return s | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | t.Fatalf("no scene for %s", output) | |
| 179 | + | return ports.Scene{} | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // A workspace that goes to another monitor slides in on it from where the | |
| 183 | + | // source drew it, in the destination's coordinates, and settles. | |
| 184 | + | func TestMovedWorkspaceSlidesInOnDestination(t *testing.T) { | |
| 185 | + | r, other := movedRig(t, nil) | |
| 186 | + | twoColumns(t, r) | |
| 187 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 188 | + | dst := outScene(t, set, other.Name) | |
| 189 | + | // DP-2 starts at x=800: the source's 0 and 400 are -800 and -400 there. | |
| 190 | + | if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != -800 || got2.X != -400 { | |
| 191 | + | t.Fatalf("right after the key 1 at %+v and 2 at %+v, want the source's rects (-800, -400)", got1, got2) | |
| 192 | + | } | |
| 193 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name) | |
| 194 | + | if !ok { | |
| 195 | + | t.Fatal("no frame while the windows slide") | |
| 196 | + | } | |
| 197 | + | dst = outScene(t, set, other.Name) | |
| 198 | + | for id, end := range map[ports.WindowID]int{1: 0, 2: 400} { | |
| 199 | + | if got := rectNow(t, dst, id).X; got <= end-800 || got >= end { | |
| 200 | + | t.Fatalf("window %d at %d on the first frame, want between %d and %d", id, got, end-800, end) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | set, _ = r.settleAll(t, wide.Name, other.Name) | |
| 204 | + | dst = outScene(t, set, other.Name) | |
| 205 | + | if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != 0 || got2.X != 400 { | |
| 206 | + | t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2) | |
| 207 | + | } | |
| 208 | + | r.noFrameScene(t, wide.Name, other.Name) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // The source screen slides to the workspace it falls back on when that one | |
| 212 | + | // was in the list it showed, else it changes at once. | |
| 213 | + | func TestMovedWorkspaceSourceSwitchRule(t *testing.T) { | |
| 214 | + | r, other := movedRig(t, nil) | |
| 215 | + | r.workspaces(t) // window 1 on the first workspace, 2 on the second | |
| 216 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 217 | + | src := outScene(t, set, wide.Name) | |
| 218 | + | // The second workspace was below the moved one: it slides up from there. | |
| 219 | + | if got := rectNow(t, src, 2).Y; got != 600 { | |
| 220 | + | t.Fatalf("source shows window 2 at y=%d right after the key, want 600 (sliding in)", got) | |
| 221 | + | } | |
| 222 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name) | |
| 223 | + | if !ok { | |
| 224 | + | t.Fatal("no frame while the source slides") | |
| 225 | + | } | |
| 226 | + | if got := rectNow(t, outScene(t, set, wide.Name), 2).Y; got <= 0 || got >= 600 { | |
| 227 | + | t.Fatalf("window 2 at y=%d on the first frame, want between 0 and 600", got) | |
| 228 | + | } | |
| 229 | + | set, _ = r.settleAll(t, wide.Name, other.Name) | |
| 230 | + | if got := rectNow(t, outScene(t, set, wide.Name), 2); got.Y != 0 { | |
| 231 | + | t.Fatalf("settled window 2 at %+v, want y=0", got) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // A source whose new workspace was not in the list it showed (the moved one | |
| 236 | + | // was alone: a fresh empty one replaces it) changes at once: nothing slides | |
| 237 | + | // in, only the destination's windows move. | |
| 238 | + | func TestMovedWorkspaceSourceWithoutNeighbourIsInstant(t *testing.T) { | |
| 239 | + | r, other := movedRig(t, nil) | |
| 240 | + | twoColumns(t, r) | |
| 241 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 242 | + | if n := len(outScene(t, set, wide.Name).Windows); n != 0 { | |
| 243 | + | t.Fatalf("source draws %d windows right after the key, want none", n) | |
| 244 | + | } | |
| 245 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name) | |
| 246 | + | if !ok { | |
| 247 | + | t.Fatal("no frame while the windows slide") | |
| 248 | + | } | |
| 249 | + | if n := len(outScene(t, set, wide.Name).Windows); n != 0 { | |
| 250 | + | t.Fatalf("source draws %d windows on the first frame, want none", n) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // With animations off the moved workspace lands at once on both screens. | |
| 255 | + | func TestMovedWorkspaceOffIsInstant(t *testing.T) { | |
| 256 | + | r, other := movedRig(t, animationsOff) | |
| 257 | + | twoColumns(t, r) | |
| 258 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 259 | + | dst := outScene(t, set, other.Name) | |
| 260 | + | if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != 0 || got2.X != 400 { | |
| 261 | + | t.Fatalf("1 at %+v and 2 at %+v right after the key, want the settled rects", got1, got2) | |
| 262 | + | } | |
| 263 | + | r.noFrameScene(t, wide.Name, other.Name) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Focusing another monitor moves no window: it is not animated. | |
| 267 | + | func TestFocusMonitorHasNoMotion(t *testing.T) { | |
| 268 | + | r, other := movedRig(t, nil) | |
| 269 | + | twoColumns(t, r) | |
| 270 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 172 | 271 | r.noFrameScene(t, wide.Name, other.Name) | |
| 173 | 272 | } | |
| 174 | 273 | ||
| @@ -161,14 +161,113 @@ func TestNoTransitionWithOverview(t *testing.T) { | |||||
| 161 | } | 161 | } | ||
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
| 164 | - | // A workspace that goes to another monitor gets no rect or switch motion on | 164 | + | // movedRig plugs a second 800x600 output right of the first. |
| 165 | - | // either side. | 165 | + | func movedRig(t *testing.T, edit func(*ports.Config)) (*swipeRig, ports.OutputInfo) { |
| 166 | - | func TestMovedWorkspaceHasNoMotion(t *testing.T) { | 166 | + | t.Helper() |
| 167 | - | r := startSwipe(t, nil) | |||
| 168 | - | twoColumns(t, r) | |||
| 169 | other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000} | 167 | other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000} | ||
| 170 | - | r.plug(t, other) | 168 | + | return startSwipeOn(t, edit, wide, other), other |
| 171 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | 169 | + | } |
| 170 | + | ||||
| 171 | + | func outScene(t *testing.T, set []ports.Scene, output string) ports.Scene { | |||
| 172 | + | t.Helper() | |||
| 173 | + | for _, s := range set { | |||
| 174 | + | if s.Output == output { | |||
| 175 | + | return s | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | t.Fatalf("no scene for %s", output) | |||
| 179 | + | return ports.Scene{} | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // A workspace that goes to another monitor slides in on it from where the | |||
| 183 | + | // source drew it, in the destination's coordinates, and settles. | |||
| 184 | + | func TestMovedWorkspaceSlidesInOnDestination(t *testing.T) { | |||
| 185 | + | r, other := movedRig(t, nil) | |||
| 186 | + | twoColumns(t, r) | |||
| 187 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 188 | + | dst := outScene(t, set, other.Name) | |||
| 189 | + | // DP-2 starts at x=800: the source's 0 and 400 are -800 and -400 there. | |||
| 190 | + | if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != -800 || got2.X != -400 { | |||
| 191 | + | t.Fatalf("right after the key 1 at %+v and 2 at %+v, want the source's rects (-800, -400)", got1, got2) | |||
| 192 | + | } | |||
| 193 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name) | |||
| 194 | + | if !ok { | |||
| 195 | + | t.Fatal("no frame while the windows slide") | |||
| 196 | + | } | |||
| 197 | + | dst = outScene(t, set, other.Name) | |||
| 198 | + | for id, end := range map[ports.WindowID]int{1: 0, 2: 400} { | |||
| 199 | + | if got := rectNow(t, dst, id).X; got <= end-800 || got >= end { | |||
| 200 | + | t.Fatalf("window %d at %d on the first frame, want between %d and %d", id, got, end-800, end) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | set, _ = r.settleAll(t, wide.Name, other.Name) | |||
| 204 | + | dst = outScene(t, set, other.Name) | |||
| 205 | + | if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != 0 || got2.X != 400 { | |||
| 206 | + | t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2) | |||
| 207 | + | } | |||
| 208 | + | r.noFrameScene(t, wide.Name, other.Name) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // The source screen slides to the workspace it falls back on when that one | |||
| 212 | + | // was in the list it showed, else it changes at once. | |||
| 213 | + | func TestMovedWorkspaceSourceSwitchRule(t *testing.T) { | |||
| 214 | + | r, other := movedRig(t, nil) | |||
| 215 | + | r.workspaces(t) // window 1 on the first workspace, 2 on the second | |||
| 216 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 217 | + | src := outScene(t, set, wide.Name) | |||
| 218 | + | // The second workspace was below the moved one: it slides up from there. | |||
| 219 | + | if got := rectNow(t, src, 2).Y; got != 600 { | |||
| 220 | + | t.Fatalf("source shows window 2 at y=%d right after the key, want 600 (sliding in)", got) | |||
| 221 | + | } | |||
| 222 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name) | |||
| 223 | + | if !ok { | |||
| 224 | + | t.Fatal("no frame while the source slides") | |||
| 225 | + | } | |||
| 226 | + | if got := rectNow(t, outScene(t, set, wide.Name), 2).Y; got <= 0 || got >= 600 { | |||
| 227 | + | t.Fatalf("window 2 at y=%d on the first frame, want between 0 and 600", got) | |||
| 228 | + | } | |||
| 229 | + | set, _ = r.settleAll(t, wide.Name, other.Name) | |||
| 230 | + | if got := rectNow(t, outScene(t, set, wide.Name), 2); got.Y != 0 { | |||
| 231 | + | t.Fatalf("settled window 2 at %+v, want y=0", got) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // A source whose new workspace was not in the list it showed (the moved one | |||
| 236 | + | // was alone: a fresh empty one replaces it) changes at once: nothing slides | |||
| 237 | + | // in, only the destination's windows move. | |||
| 238 | + | func TestMovedWorkspaceSourceWithoutNeighbourIsInstant(t *testing.T) { | |||
| 239 | + | r, other := movedRig(t, nil) | |||
| 240 | + | twoColumns(t, r) | |||
| 241 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 242 | + | if n := len(outScene(t, set, wide.Name).Windows); n != 0 { | |||
| 243 | + | t.Fatalf("source draws %d windows right after the key, want none", n) | |||
| 244 | + | } | |||
| 245 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name) | |||
| 246 | + | if !ok { | |||
| 247 | + | t.Fatal("no frame while the windows slide") | |||
| 248 | + | } | |||
| 249 | + | if n := len(outScene(t, set, wide.Name).Windows); n != 0 { | |||
| 250 | + | t.Fatalf("source draws %d windows on the first frame, want none", n) | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // With animations off the moved workspace lands at once on both screens. | |||
| 255 | + | func TestMovedWorkspaceOffIsInstant(t *testing.T) { | |||
| 256 | + | r, other := movedRig(t, animationsOff) | |||
| 257 | + | twoColumns(t, r) | |||
| 258 | + | set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 259 | + | dst := outScene(t, set, other.Name) | |||
| 260 | + | if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != 0 || got2.X != 400 { | |||
| 261 | + | t.Fatalf("1 at %+v and 2 at %+v right after the key, want the settled rects", got1, got2) | |||
| 262 | + | } | |||
| 263 | + | r.noFrameScene(t, wide.Name, other.Name) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // Focusing another monitor moves no window: it is not animated. | |||
| 267 | + | func TestFocusMonitorHasNoMotion(t *testing.T) { | |||
| 268 | + | r, other := movedRig(t, nil) | |||
| 269 | + | twoColumns(t, r) | |||
| 270 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 172 | r.noFrameScene(t, wide.Name, other.Name) | 271 | r.noFrameScene(t, wide.Name, other.Name) | ||
| 173 | } | 272 | } | ||
| 174 | 273 | ||||