detached d27f5ec

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
406406 // showed to what the action left, at now. It does nothing with animations
407407 // off, with the overview open before or after, or while a swipe follows the
408408 // 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).
409415 func (c *Core) transition(before []viewShot, now time.Time) {
410416 if !c.animOn() {
411417 return
@@ -416,7 +422,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
416422 continue
417423 }
418424 c.transitionCamera(b, now)
419- c.transitionRects(b, now)
425+ c.transitionRects(b, before, now)
420426 }
421427 }
422428
@@ -439,7 +445,7 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) {
439445 // carries over, and the spring starts now like every action's.
440446 w.shift = b.view - float64(w.ViewX)
441447 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:
443449 j := indexOf(b.list, w)
444450 if j < 0 {
445451 return
@@ -461,23 +467,34 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) {
461467 // transitionCamera left it (the view where it was on screen), so a window
462468 // the view scrolls differs only by what the layout itself changed. Windows
463469 // 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) {
465476 sc := b.sc
466477 m := sc.mon
478+ from, dx, dy := b, 0, 0
467479 if m.Current() != b.ws {
480+ // The motions are those of the workspace this screen left.
468481 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
470486 }
471487 for _, p := range m.Layout() {
472488 if p.Hidden || p.Preview > 0 {
473489 continue
474490 }
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 })
476492 if i < 0 {
477493 continue
478494 }
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 {
481498 // The settled rect did not change: a running motion goes on
482499 // as it is, and there is none to start.
483500 continue
@@ -491,25 +508,26 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) {
491508 // following the frame and snaps to the settled size; the fade goes
492509 // on.
493510 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)
498515 if rm.on() {
499- sc.rects[p.ID], sc.rectsWS = rm, b.ws
516+ sc.rects[p.ID], sc.rectsWS = rm, m.Current()
500517 } else {
501518 delete(sc.rects, p.ID)
502519 }
503520 }
504521 }
505522
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
512530 }
513531 }
514- return false
532+ return nil
515533 }
@@ -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 animations406 // 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 the407 // 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 return417 return
@@ -416,7 +422,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
416 continue422 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 return451 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 window467 // 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. Windows468 // 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.sc476 sc := b.sc
466 m := sc.mon477 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- return482+ 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 continue489 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 continue493 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 on498 // 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 continue500 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 goes508 // following the frame and snaps to the settled size; the fade goes
492 // on.509 // on.
493 rm.scale = false510 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.ws516+ 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 another523+// 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 true528+ if o := &before[i]; o.ws == w && o.sc != b.sc {
529+ return o
512 }530 }
513 }531 }
514- return false532+ return nil
515 }533 }
M internal/core/transition_test.go +106 -7
@@ -161,14 +161,113 @@ func TestNoTransitionWithOverview(t *testing.T) {
161161 }
162162 }
163163
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()
169167 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)
172271 r.noFrameScene(t, wide.Name, other.Name)
173272 }
174273
@@ -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 on164+// 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close