detached 937984f

fix(core): keep the destination of a moved workspace off the switch slide, cover named pulls and mixed geometry

commit
937984f7b81c8e1f5c3c1e97ebd633f5e18c4379
author
brice · · 2026-10-04 22:53 UTC
parent
d27f5ec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +137 -8
M internal/core/transition.go +14 -8
@@ -407,11 +407,13 @@ func (c *Core) snapshot(now time.Time) []viewShot {
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.
409409 //
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).
410+// A workspace moved to another monitor (move-workspace-to-monitor-*, or a
411+// named workspace pulled to the focused one by a "workspace <name>" bind
412+// while it shows on another) is animated on both screens: the destination's
413+// windows slide in from where the source drew them (no workspace slide
414+// there), the source slides to its next workspace. The focus-monitor-*
415+// actions move nothing: no window changes place, so they are not animated
416+// (the focus pulse marks the new focus).
415417 func (c *Core) transition(before []viewShot, now time.Time) {
416418 if !c.animOn() {
417419 return
@@ -421,7 +423,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
421423 if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) {
422424 continue
423425 }
424- c.transitionCamera(b, now)
426+ c.transitionCamera(b, before, now)
425427 c.transitionRects(b, before, now)
426428 }
427429 }
@@ -433,7 +435,7 @@ func (c *Core) following(sc *screen) bool {
433435 }
434436
435437 // transitionCamera slides the columns or the monitor from what b showed.
436-func (c *Core) transitionCamera(b *viewShot, now time.Time) {
438+func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
437439 m := b.sc.mon
438440 w := m.Current()
439441 switch {
@@ -445,7 +447,11 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) {
445447 // carries over, and the spring starts now like every action's.
446448 w.shift = b.view - float64(w.ViewX)
447449 w.motion = c.spring(viewSpring(w.shift, b.viewV), now)
448- case b.ok && m.shown == nil:
450+ case b.ok && m.shown == nil && movedFrom(b, before) == nil:
451+ // A screen that received its workspace from another monitor does
452+ // not slide to it: a running slide's list (switchList) may still
453+ // hold the workspace, which would start a slide from nowhere. Only
454+ // its windows move (transitionRects).
449455 j := indexOf(b.list, w)
450456 if j < 0 {
451457 return
@@ -407,11 +407,13 @@ func (c *Core) snapshot(now time.Time) []viewShot {
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 //409 //
410-// A workspace moved to another monitor (move-workspace-to-monitor-*) is410+// A workspace moved to another monitor (move-workspace-to-monitor-*, or a
411-// animated on both screens: the destination's windows slide in from where411+// named workspace pulled to the focused one by a "workspace <name>" bind
412-// the source drew them, the source slides to its next workspace. The412+// while it shows on another) is animated on both screens: the destination's
413-// focus-monitor-* actions move nothing: no window changes place, so they413+// windows slide in from where the source drew them (no workspace slide
414-// are not animated (the focus pulse marks the new focus).414+// there), the source slides to its next workspace. The focus-monitor-*
415+// actions move nothing: no window changes place, so they are not animated
416+// (the focus pulse marks the new focus).
415 func (c *Core) transition(before []viewShot, now time.Time) {417 func (c *Core) transition(before []viewShot, now time.Time) {
416 if !c.animOn() {418 if !c.animOn() {
417 return419 return
@@ -421,7 +423,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
421 if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) {423 if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) {
422 continue424 continue
423 }425 }
424- c.transitionCamera(b, now)426+ c.transitionCamera(b, before, now)
425 c.transitionRects(b, before, now)427 c.transitionRects(b, before, now)
426 }428 }
427 }429 }
@@ -433,7 +435,7 @@ func (c *Core) following(sc *screen) bool {
433 }435 }
434 436
435 // transitionCamera slides the columns or the monitor from what b showed.437 // transitionCamera slides the columns or the monitor from what b showed.
436-func (c *Core) transitionCamera(b *viewShot, now time.Time) {438+func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
437 m := b.sc.mon439 m := b.sc.mon
438 w := m.Current()440 w := m.Current()
439 switch {441 switch {
@@ -445,7 +447,11 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) {
445 // carries over, and the spring starts now like every action's.447 // carries over, and the spring starts now like every action's.
446 w.shift = b.view - float64(w.ViewX)448 w.shift = b.view - float64(w.ViewX)
447 w.motion = c.spring(viewSpring(w.shift, b.viewV), now)449 w.motion = c.spring(viewSpring(w.shift, b.viewV), now)
448- case b.ok && m.shown == nil:450+ case b.ok && m.shown == nil && movedFrom(b, before) == nil:
451+ // A screen that received its workspace from another monitor does
452+ // not slide to it: a running slide's list (switchList) may still
453+ // hold the workspace, which would start a slide from nowhere. Only
454+ // its windows move (transitionRects).
449 j := indexOf(b.list, w)455 j := indexOf(b.list, w)
450 if j < 0 {456 if j < 0 {
451 return457 return
M internal/core/transition_test.go +123 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55 "time"
66
7+ "github.com/bnema/neferwl/internal/core"
78 "github.com/bnema/neferwl/internal/ports"
89 )
910
@@ -263,6 +264,128 @@ func TestMovedWorkspaceOffIsInstant(t *testing.T) {
263264 r.noFrameScene(t, wide.Name, other.Name)
264265 }
265266
267+// A workspace that comes back to the screen it left within the spring of
268+// its first move slides its windows only: the screen's running workspace
269+// slide (its list still holds the workspace) must not start a vertical one.
270+func TestMovedBackWorkspaceHasNoSwitchSlide(t *testing.T) {
271+ r, other := movedRig(t, nil)
272+ twoColumns(t, r)
273+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
274+ // Within the first spring: the source is still sliding.
275+ if _, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name); !ok {
276+ t.Fatal("no frame while the windows slide")
277+ }
278+ set := r.key(t, "Left", ports.ModAlt|ports.ModCtrl|ports.ModShift)
279+ dst := outScene(t, set, wide.Name)
280+ for id := ports.WindowID(1); id <= 2; id++ {
281+ if got := rectNow(t, dst, id); got.Y != 0 {
282+ t.Fatalf("window %d at %+v right after moving back, want y=0 (no vertical slide)", id, got)
283+ }
284+ }
285+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
286+ if !ok {
287+ t.Fatal("no frame while the windows slide back")
288+ }
289+ dst = outScene(t, set, wide.Name)
290+ for id := ports.WindowID(1); id <= 2; id++ {
291+ if got := rectNow(t, dst, id); got.Y != 0 {
292+ t.Fatalf("window %d at %+v on the first frame, want y=0 (rects slide only)", id, got)
293+ }
294+ }
295+ set, _ = r.settleAll(t, wide.Name, other.Name)
296+ dst = outScene(t, set, wide.Name)
297+ if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1 != (ports.Rect{W: 400, H: 600}) || got2 != (ports.Rect{X: 400, W: 400, H: 600}) {
298+ t.Fatalf("settled 1 at %+v and 2 at %+v", got1, got2)
299+ }
300+ r.noFrameScene(t, wide.Name, other.Name)
301+}
302+
303+// The start rects are the source's translated by the difference of the
304+// outputs' positions on both axes, with the source's size; they settle on
305+// the destination's layout.
306+func TestMovedWorkspaceStartsFromSourceGeometry(t *testing.T) {
307+ big := ports.OutputInfo{Name: "DP-2", Width: 1024, Height: 768, RefreshMilli: 60000}
308+ for _, tc := range []struct {
309+ name string
310+ dst ports.OutputInfo
311+ anchor ports.OutputAnchor
312+ key string
313+ start, final [2]ports.Rect
314+ }{
315+ {
316+ name: "right same size", dst: ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000},
317+ anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1"}, key: "Right",
318+ start: [2]ports.Rect{{X: -800, W: 400, H: 600}, {X: -400, W: 400, H: 600}},
319+ final: [2]ports.Rect{{W: 400, H: 600}, {X: 400, W: 400, H: 600}},
320+ },
321+ {
322+ name: "below larger", dst: big,
323+ anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}, key: "Down",
324+ start: [2]ports.Rect{{Y: -600, W: 400, H: 600}, {X: 400, Y: -600, W: 400, H: 600}},
325+ final: [2]ports.Rect{{W: 512, H: 768}, {X: 512, W: 512, H: 768}},
326+ },
327+ } {
328+ t.Run(tc.name, func(t *testing.T) {
329+ r := startSwipeOn(t, func(c *ports.Config) {
330+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Anchor: tc.anchor}}
331+ c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown)
332+ }, wide, tc.dst)
333+ twoColumns(t, r)
334+ dst := outScene(t, r.key(t, tc.key, ports.ModAlt|ports.ModCtrl|ports.ModShift), tc.dst.Name)
335+ for i, id := range []ports.WindowID{1, 2} {
336+ if got := rectNow(t, dst, id); got != tc.start[i] {
337+ t.Fatalf("window %d starts at %+v, want %+v", id, got, tc.start[i])
338+ }
339+ }
340+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, tc.dst.Name)
341+ if !ok {
342+ t.Fatal("no frame while the windows slide")
343+ }
344+ if got := rectNow(t, outScene(t, set, tc.dst.Name), 1); got == tc.start[0] || got == tc.final[0] {
345+ t.Fatalf("window 1 at %+v on the first frame, want it between start and final", got)
346+ }
347+ set, _ = r.settleAll(t, wide.Name, tc.dst.Name)
348+ dst = outScene(t, set, tc.dst.Name)
349+ for i, id := range []ports.WindowID{1, 2} {
350+ if got := rectNow(t, dst, id); got != tc.final[i] {
351+ t.Fatalf("window %d settles at %+v, want %+v", id, got, tc.final[i])
352+ }
353+ }
354+ })
355+ }
356+}
357+
358+// A named workspace pulled to the focused screen from the one showing it
359+// moves like a monitor move: its windows slide in from the source.
360+func TestPulledNamedWorkspaceSlidesIn(t *testing.T) {
361+ r, other := movedRig(t, func(c *ports.Config) {
362+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}}
363+ c.Binds["Alt+w"] = "workspace web"
364+ })
365+ // web shows on DP-2 with a window, then DP-1 pulls it.
366+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
367+ r.key(t, "w", ports.ModAlt)
368+ r.settleAll(t, wide.Name, other.Name)
369+ r.mapWindow(t, 1)
370+ r.settleAll(t, wide.Name, other.Name)
371+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
372+ set := r.key(t, "w", ports.ModAlt)
373+ if got := rectNow(t, outScene(t, set, wide.Name), 1); got.X != 800 {
374+ t.Fatalf("window 1 at %+v right after the pull, want x=800 (where DP-2 drew it)", got)
375+ }
376+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
377+ if !ok {
378+ t.Fatal("no frame while the window slides")
379+ }
380+ if got := rectNow(t, outScene(t, set, wide.Name), 1).X; got <= 0 || got >= 800 {
381+ t.Fatalf("window 1 at x=%d on the first frame, want between 0 and 800", got)
382+ }
383+ set, _ = r.settleAll(t, wide.Name, other.Name)
384+ if got := rectNow(t, outScene(t, set, wide.Name), 1); got != (ports.Rect{W: 800, H: 600}) {
385+ t.Fatalf("settled at %+v", got)
386+ }
387+}
388+
266389 // Focusing another monitor moves no window: it is not animated.
267390 func TestFocusMonitorHasNoMotion(t *testing.T) {
268391 r, other := movedRig(t, nil)
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 "time"5 "time"
6 6
7+ "github.com/bnema/neferwl/internal/core"
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
8 )9 )
9 10
@@ -263,6 +264,128 @@ func TestMovedWorkspaceOffIsInstant(t *testing.T) {
263 r.noFrameScene(t, wide.Name, other.Name)264 r.noFrameScene(t, wide.Name, other.Name)
264 }265 }
265 266
267+// A workspace that comes back to the screen it left within the spring of
268+// its first move slides its windows only: the screen's running workspace
269+// slide (its list still holds the workspace) must not start a vertical one.
270+func TestMovedBackWorkspaceHasNoSwitchSlide(t *testing.T) {
271+ r, other := movedRig(t, nil)
272+ twoColumns(t, r)
273+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
274+ // Within the first spring: the source is still sliding.
275+ if _, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name); !ok {
276+ t.Fatal("no frame while the windows slide")
277+ }
278+ set := r.key(t, "Left", ports.ModAlt|ports.ModCtrl|ports.ModShift)
279+ dst := outScene(t, set, wide.Name)
280+ for id := ports.WindowID(1); id <= 2; id++ {
281+ if got := rectNow(t, dst, id); got.Y != 0 {
282+ t.Fatalf("window %d at %+v right after moving back, want y=0 (no vertical slide)", id, got)
283+ }
284+ }
285+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
286+ if !ok {
287+ t.Fatal("no frame while the windows slide back")
288+ }
289+ dst = outScene(t, set, wide.Name)
290+ for id := ports.WindowID(1); id <= 2; id++ {
291+ if got := rectNow(t, dst, id); got.Y != 0 {
292+ t.Fatalf("window %d at %+v on the first frame, want y=0 (rects slide only)", id, got)
293+ }
294+ }
295+ set, _ = r.settleAll(t, wide.Name, other.Name)
296+ dst = outScene(t, set, wide.Name)
297+ if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1 != (ports.Rect{W: 400, H: 600}) || got2 != (ports.Rect{X: 400, W: 400, H: 600}) {
298+ t.Fatalf("settled 1 at %+v and 2 at %+v", got1, got2)
299+ }
300+ r.noFrameScene(t, wide.Name, other.Name)
301+}
302+
303+// The start rects are the source's translated by the difference of the
304+// outputs' positions on both axes, with the source's size; they settle on
305+// the destination's layout.
306+func TestMovedWorkspaceStartsFromSourceGeometry(t *testing.T) {
307+ big := ports.OutputInfo{Name: "DP-2", Width: 1024, Height: 768, RefreshMilli: 60000}
308+ for _, tc := range []struct {
309+ name string
310+ dst ports.OutputInfo
311+ anchor ports.OutputAnchor
312+ key string
313+ start, final [2]ports.Rect
314+ }{
315+ {
316+ name: "right same size", dst: ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000},
317+ anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1"}, key: "Right",
318+ start: [2]ports.Rect{{X: -800, W: 400, H: 600}, {X: -400, W: 400, H: 600}},
319+ final: [2]ports.Rect{{W: 400, H: 600}, {X: 400, W: 400, H: 600}},
320+ },
321+ {
322+ name: "below larger", dst: big,
323+ anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}, key: "Down",
324+ start: [2]ports.Rect{{Y: -600, W: 400, H: 600}, {X: 400, Y: -600, W: 400, H: 600}},
325+ final: [2]ports.Rect{{W: 512, H: 768}, {X: 512, W: 512, H: 768}},
326+ },
327+ } {
328+ t.Run(tc.name, func(t *testing.T) {
329+ r := startSwipeOn(t, func(c *ports.Config) {
330+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Anchor: tc.anchor}}
331+ c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown)
332+ }, wide, tc.dst)
333+ twoColumns(t, r)
334+ dst := outScene(t, r.key(t, tc.key, ports.ModAlt|ports.ModCtrl|ports.ModShift), tc.dst.Name)
335+ for i, id := range []ports.WindowID{1, 2} {
336+ if got := rectNow(t, dst, id); got != tc.start[i] {
337+ t.Fatalf("window %d starts at %+v, want %+v", id, got, tc.start[i])
338+ }
339+ }
340+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, tc.dst.Name)
341+ if !ok {
342+ t.Fatal("no frame while the windows slide")
343+ }
344+ if got := rectNow(t, outScene(t, set, tc.dst.Name), 1); got == tc.start[0] || got == tc.final[0] {
345+ t.Fatalf("window 1 at %+v on the first frame, want it between start and final", got)
346+ }
347+ set, _ = r.settleAll(t, wide.Name, tc.dst.Name)
348+ dst = outScene(t, set, tc.dst.Name)
349+ for i, id := range []ports.WindowID{1, 2} {
350+ if got := rectNow(t, dst, id); got != tc.final[i] {
351+ t.Fatalf("window %d settles at %+v, want %+v", id, got, tc.final[i])
352+ }
353+ }
354+ })
355+ }
356+}
357+
358+// A named workspace pulled to the focused screen from the one showing it
359+// moves like a monitor move: its windows slide in from the source.
360+func TestPulledNamedWorkspaceSlidesIn(t *testing.T) {
361+ r, other := movedRig(t, func(c *ports.Config) {
362+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}}
363+ c.Binds["Alt+w"] = "workspace web"
364+ })
365+ // web shows on DP-2 with a window, then DP-1 pulls it.
366+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
367+ r.key(t, "w", ports.ModAlt)
368+ r.settleAll(t, wide.Name, other.Name)
369+ r.mapWindow(t, 1)
370+ r.settleAll(t, wide.Name, other.Name)
371+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
372+ set := r.key(t, "w", ports.ModAlt)
373+ if got := rectNow(t, outScene(t, set, wide.Name), 1); got.X != 800 {
374+ t.Fatalf("window 1 at %+v right after the pull, want x=800 (where DP-2 drew it)", got)
375+ }
376+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
377+ if !ok {
378+ t.Fatal("no frame while the window slides")
379+ }
380+ if got := rectNow(t, outScene(t, set, wide.Name), 1).X; got <= 0 || got >= 800 {
381+ t.Fatalf("window 1 at x=%d on the first frame, want between 0 and 800", got)
382+ }
383+ set, _ = r.settleAll(t, wide.Name, other.Name)
384+ if got := rectNow(t, outScene(t, set, wide.Name), 1); got != (ports.Rect{W: 800, H: 600}) {
385+ t.Fatalf("settled at %+v", got)
386+ }
387+}
388+
266 // Focusing another monitor moves no window: it is not animated.389 // Focusing another monitor moves no window: it is not animated.
267 func TestFocusMonitorHasNoMotion(t *testing.T) {390 func TestFocusMonitorHasNoMotion(t *testing.T) {
268 r, other := movedRig(t, nil)391 r, other := movedRig(t, nil)

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